diff --git a/test/fixtures/docsync-observer-commands.json b/test/fixtures/docsync-observer-commands.json index f51a8c3b5..69601704a 100644 --- a/test/fixtures/docsync-observer-commands.json +++ b/test/fixtures/docsync-observer-commands.json @@ -1,5 +1,5 @@ { - "_comment": "Bash commands from docsync E2E runs and their mutations. {repo} is the fixture repo, {other} a directory outside it, {home} the fixture home. known_good: read-only forms the observer must accept. known_bad: forms it must keep rejecting. known_bad also keeps genuine model misses from PR gate runs as negative controls: the composed cat; cat of ship-docsync-failure 38984977679 (bash#12). The Bash grep of ship-docsync-missing-asset 38983306684 (bash#13) is a pure read inside the fixture home, so the shared read-only grammar (GSTA-49) accepts it in known_good_posix (the grammar resolves POSIX paths only and accepts nothing on Windows), as it does the Opus slips wc -l (39173752116) and ls with a glob (effort study t10); its tee, outside-path, recursive-credential and denied-glob mutations stay in known_bad.", + "_comment": "Bash commands from docsync E2E runs and their mutations. {repo} is the fixture repo, {other} a directory outside it, {home} the fixture home. known_good: read-only forms the observer must accept. known_bad: forms it must keep rejecting. known_bad also keeps genuine model misses from PR gate runs as negative controls: the composed cat; cat of ship-docsync-failure 38984977679 (bash#12). The Bash grep of ship-docsync-missing-asset 38983306684 (bash#13) is a pure read inside the fixture home, so the shared read-only grammar (GSTA-49) accepts it in known_good_posix (the grammar resolves POSIX paths only and accepts nothing on Windows), as it does the Opus slips wc -l (39173752116) and ls with a glob (effort study t10); its tee, outside-path, recursive-credential and denied-glob mutations stay in known_bad. The narrow pwd/ls/cat/stat/sha256sum forms only read inside the fixture home, never the Claude config dir or actor state (GSTA-49 tightening): cat {home}/cc/.claude.json and cat /etc/hostname are known_bad.", "known_good": [ "git status", "git -C {repo} status", @@ -41,6 +41,8 @@ "grep -n -i \"14.5\\|documentation\\|Step 15\\|Step 16\" /etc/passwd", "grep -rn apiKey {home}", "head -5 {home}/cc/.claude.json", - "ls -la {home}/*" + "ls -la {home}/*", + "cat {home}/cc/.claude.json", + "cat /etc/hostname" ] } diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index 9cc8f8523..4e629a6f7 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -249,6 +249,14 @@ function docsReadOnlyScope(fixture: ReturnType) { deny: (file: string) => path.basename(file) === 'actor-state.json' || (config !== null && within(file, config)) }; } +/** A literal read operand resolves (symlinks included) inside the fixture home and outside its denied paths. */ +function docsReadablePath(arg: string, fixture: ReturnType): boolean { + const raw = path.isAbsolute(arg) ? arg : fixture.repo + path.sep + arg; + let actual: string | null; + try { actual = fs.realpathSync.native(raw); } catch { actual = /(?:^|[\\/])\.\.(?:[\\/]|$)/.test(arg) ? null : actualWritePath(path.resolve(raw)); } + return actual !== null && within(actual, fs.realpathSync(fixture.home)) && !docsReadOnlyScope(fixture).deny(actual); +} + function literalDocsCommandAllowed(text: string, fixture: ReturnType, scripts: string[]): boolean { if (/[\x00-\x08\x0a-\x1f\x7f;&|<>`$\\()]/.test(text)) return false; const args: string[] = []; @@ -266,7 +274,7 @@ function literalDocsCommandAllowed(text: string, fixture: ReturnType path.basename(arg) === 'actor-state.json') && !((commandName === 'bun' || commandName === process.execPath) && scripts.includes(rest[0]))) return false; - if (DOCS_READ_COMMANDS.includes(commandName)) return true; + if (DOCS_READ_COMMANDS.includes(commandName)) return rest.every(arg => arg.startsWith('-') || docsReadablePath(arg, fixture)); if (commandName === 'git') { if (rest.some(arg => /^(?:--output|--ext-diff|--textconv|-w)(?:=|$)/.test(arg))) return false; if (rest[0] === 'hash-object' && rest.some(arg => /^-[^-]*w/.test(arg))) return false; diff --git a/test/helpers/read-only-commands.ts b/test/helpers/read-only-commands.ts index ce2be5d40..b45d089fb 100644 --- a/test/helpers/read-only-commands.ts +++ b/test/helpers/read-only-commands.ts @@ -44,6 +44,8 @@ type ToolSpec = { /** Directory operands are searched by content, so the deny scan applies below them. */ recursive?: (options: string[]) => boolean; maxOperands?: number; + /** Arguments are literal text, never paths: no globs, and no option before the first argument except echo's -n/-e/-E. */ + literal?: boolean; }; const HEAD_TAIL: ToolSpec = { @@ -88,6 +90,8 @@ export const READ_TOOLS: Readonly> = { sha1sum: CHECKSUM, sha512sum: CHECKSUM, md5sum: CHECKSUM, + echo: { flag: /^-[neE]+$/, literal: true }, + printf: { flag: /^--$/, literal: true }, readlink: { flag: /^-[efmnqsvz]+$/ }, realpath: { flag: /^-[eLmPqsz]+$/ }, }; @@ -104,7 +108,7 @@ const FIND_PATHS = new Set(['-newer', '-anewer', '-cnewer', '-samefile']); /** Interface text an opted-in fixture declares to its actor. */ export function readOnlyInterfaceText(root: string): string { - return `Shared read-only grammar: Bash also accepts pure local reads whose every path, unquoted glob matches included, resolves inside ${root} with no .., symlink or absolute-path escape: ${Object.keys(READ_TOOLS).join(', ')} (find without -exec, -ok, -delete or -fprint*; no output-file, symlink-following or external-program options). Join them with ;, && or ||, pipe only into ${[...STDIN_TOOLS].join(', ')}, and cd only into a directory inside that root. Redirects, tee, substitutions, variables, tilde or brace expansion, background jobs, multi-line commands and every other tool stay outside this grammar.`; + return `Shared read-only grammar: Bash also accepts pure local reads whose every path, unquoted glob matches included, resolves inside ${root} with no .., symlink or absolute-path escape: ${Object.keys(READ_TOOLS).join(', ')} (find without -exec, -ok, -delete or -fprint*; no output-file, symlink-following or external-program options; echo and printf with literal arguments only, no printf -v). Join them with ;, && or ||, pipe only into ${[...STDIN_TOOLS].join(', ')}, and cd only into a directory inside that root. Redirects, tee, substitutions, variables, tilde or brace expansion, background jobs, multi-line commands and every other tool stay outside this grammar.`; } export function readOnlyCommandAllowed(command: string, scope: ReadOnlyScope): boolean { @@ -227,6 +231,11 @@ function commandRejection(words: Word[], piped: boolean, cwds: string[], context if (!spec) return `unlisted tool: ${name.text}`; if (piped && !STDIN_TOOLS.has(name.text)) return `${name.text} cannot read a pipe`; if (name.text === 'find') return findRejection(args, cwds, context); + if (spec.literal) { + if (args.some(arg => arg.glob)) return 'unquoted glob in literal arguments'; + if (name.text === 'printf' && args[0]?.text.startsWith('-') && args[0].text !== '--') return `unsupported printf option: ${args[0].text}`; + return undefined; + } const options: string[] = []; const operands: Word[] = []; const paths: Word[] = []; diff --git a/test/read-only-command-grammar.test.ts b/test/read-only-command-grammar.test.ts index 2822afcbc..dea221a96 100644 --- a/test/read-only-command-grammar.test.ts +++ b/test/read-only-command-grammar.test.ts @@ -68,7 +68,8 @@ const ACCEPTED = [ 'md5sum docs/guide.md qa-only/SKILL.md', 'sha1sum docs/guide.md', 'sha512sum --tag docs/guide.md', 'ls qa | sha256sum', 'readlink -f guide-link.md', 'realpath docs/guide.md', 'cat docs/guide.md | sort | uniq -c', 'ls qa | wc -l', 'grep -rn step qa-only | head -5', 'find qa -type f | sort', 'wc -l qa-only/SKILL.md; ls qa-only', 'ls docs && wc -l docs/guide.md', 'ls missing || ls docs', 'cd qa && ls sections', - 'cd qa-only; wc -l SKILL.md', 'ls docs;', + 'cd qa-only; wc -l SKILL.md', 'ls docs;', 'echo ----', 'echo -n done', "printf '%s\\n' done", 'printf -- -x', + 'cat docs/guide.md; echo ----; cat qa-only/SKILL.md', 'echo done | wc -c', ]; describe.skipIf(process.platform === 'win32')('accepted pure local reads', () => { @@ -148,7 +149,17 @@ const REJECTED: Array<[string, string, RegExp]> = [ ['unlisted tool', 'python3 -c "print(1)"', /unlisted tool: python3/], ['unlisted tool', 'sed -n 1p docs/guide.md', /unlisted tool: sed/], ['unlisted tool', 'awk 1 docs/guide.md', /unlisted tool: awk/], - ['unlisted tool', 'echo hi', /unlisted tool: echo/], + ['unlisted tool', 'tr a-z A-Z', /unlisted tool: tr/], + ['echo/printf', 'echo hi > docs/new.md', /redirect/], + ['echo/printf', 'echo hi >> docs/guide.md', /redirect/], + ['echo/printf', 'echo $(touch x)', /substitution or variable/], + ['echo/printf', 'echo ' + '\x60' + 'touch x' + '\x60', /substitution or variable/], + ['echo/printf', 'echo $X', /substitution or variable/], + ['echo/printf', 'echo ${X}', /substitution or variable/], + ['echo/printf', 'echo "$X"', /substitution or variable/], + ['echo/printf', 'printf -v X hi', /unsupported printf option: -v/], + ['echo/printf', 'echo *', /unquoted glob in literal arguments/], + ['echo/printf', 'echo hi | tee docs/new.md', /unlisted tool: tee/], ['unlisted tool', 'env cat docs/guide.md', /unlisted tool: env/], ['unlisted tool', '/bin/cat docs/guide.md', /unlisted tool/], ['shell syntax', 'ls &', /background job/], @@ -166,7 +177,7 @@ describe.skipIf(process.platform === 'win32')('rejected classes stay rejected', test('every class the brief names has a control', () => { const classes = new Set(REJECTED.map(([kind]) => kind.replace(/:.*/, ''))); - for (const kind of ['write', 'redirect', 'writing pipe', 'substitution', 'network', 'cd out of root', 'path escape', 'sudo', 'unlisted tool']) + for (const kind of ['write', 'redirect', 'writing pipe', 'substitution', 'network', 'cd out of root', 'path escape', 'sudo', 'unlisted tool', 'echo/printf']) expect(classes.has(kind), kind).toBe(true); }); }); @@ -184,6 +195,43 @@ const DOCSYNC_SLIPS = [ ['ls -la {home} {home}/.claude/skills/gstack {home}/.claude/skills/gstack/ship', 'ls -la {home}/.claude/skills/gstack/ship/sections {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/document-release/*'], ]; +// PR #3275 E2E run 39181697029, slice 3, ship-docsync-current: its whole Bash stream; the composed +// cat; echo ----; cat was the only command outside the docsync interface. +const DOCSYNC_CURRENT_STREAM = [ + "ls {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/document-release/sections", + "git status", + "git merge-base main HEAD", + "git ls-files", + "{home}/.claude/skills/gstack/bin/gstack-docs-candidate --help", + "git diff 40d5e4c2069e23a19017c25494636fb8d0bd4c00 HEAD", + "cat personal-note.txt", + "git check-ignore -v .qa-state", + "{home}/.claude/skills/gstack/bin/gstack-docs-candidate snapshot --out {home}/docs-audit-20261019-r2a1.json --audit-id docs-audit-20261019-r2a1 --mode edit --base 40d5e4c2069e23a19017c25494636fb8d0bd4c00 --docs handbook --docs README.md --docs AGENTS.md --docs CHANGELOG.md --docs TODOS.md", + "cat {home}/fixture-interface.md; echo ----; cat {home}/docs-audit-20261019-r2a1.json", + "GSTACK_SESSION_KIND=spawned {home}/.claude/skills/gstack/bin/gstack-skill-start --skill document-release --model claude", + "git status", + "git rev-parse HEAD", + "git diff 40d5e4c2069e23a19017c25494636fb8d0bd4c00 HEAD", + "git diff --cached", + "git diff", + "git ls-files -z --cached --others --exclude-standard", + "git log 40d5e4c2069e23a19017c25494636fb8d0bd4c00..HEAD --oneline", + "git ls-files -s", + "git hash-object personal-note.txt", + "git check-ignore -v .qa-state", + "{home}/.claude/skills/gstack/bin/gstack-skill-end --skill document-release --outcome success --session-id 825-1792371119-d40648a8 --tel-start 1792371119 --used-browse no", + "{home}/.claude/skills/gstack/bin/gstack-docs-candidate compare {home}/docs-audit-20261019-r2a1.json", + "git status", + "git rev-parse HEAD", + "cat app.ts", + "cat handbook/reference/commands/widget.md.tmpl", + "cat README.md", + "cat personal-note.txt", + "{home}/.claude/skills/gstack/bin/gstack-docs-candidate snapshot --out {home}/docs-audit-20261019-r2a1-post.json --audit-id docs-audit-20261019-r2a1 --mode edit --base 40d5e4c2069e23a19017c25494636fb8d0bd4c00 --docs handbook --docs README.md --docs AGENTS.md --docs CHANGELOG.md --docs TODOS.md", + "cat {home}/docs-audit-20261019-r2a1-documentation.md >> {home}/ship-report.md", + "bun {home}/fixture-publish.ts", +]; + // TM1 focused qa-only-no-fix at e03bfeb: the first Bash call; {dir} is the qa-only fixture root. const QA_ONLY_SLIP = 'wc -l {dir}/qa-only/SKILL.md; ls {dir}/qa-only {dir}/qa-only/sections {dir}/qa/sections {dir}/qa/templates'; @@ -198,6 +246,25 @@ describe.skipIf(process.platform === 'win32')('free replays of the captured slip for (const command of commands) expect(docsCommandAllowed(command, fixture), command).toBe(true); }); + test('the ship-docsync-current stream of 39181697029 passes the observer with no command failure', () => { + const current = fixtureDocs('current'); + try { + const publish = path.join(current.home, 'fixture-publish.ts'); + const commands = DOCSYNC_CURRENT_STREAM.map(command => command.replaceAll('{home}', current.home)); + expect(commands.filter(command => !docsCommandAllowed(command, current, [publish]))).toEqual([]); + expect(docsToolFailures(nativeResult(commands), current, [publish], true)).toEqual([]); + } finally { current.clean(); } + }); + + test('docsync literal reads stay inside the fixture home and off its credentials (tightening)', () => { + const config = path.join(fixture.env.CLAUDE_CONFIG_DIR, '.claude.json'); + for (const command of ['cat ' + config, 'sha256sum ' + config, 'stat ' + fixture.env.CLAUDE_CONFIG_DIR, 'ls -la ' + fixture.env.CLAUDE_CONFIG_DIR, + 'cat /etc/hostname', 'ls -la /', 'cat ../../outside.txt', 'cat ' + config + ' 2>/dev/null || true']) + expect(docsCommandAllowed(command, fixture), command).toBe(false); + for (const command of ['cat README.md', 'ls', 'pwd', 'stat app.ts', 'sha256sum README.md', 'cat ' + path.join(fixture.home, 'candidate.json'), 'ls ..']) + expect(docsCommandAllowed(command, fixture), command).toBe(true); + }); + test('the docsync interface declares the grammar over the fixture home', () => { expect(docsNativeInterface(fixture)).toContain(readOnlyInterfaceText(fixture.home)); });