diff --git a/test/fixtures/docsync-observer-commands.json b/test/fixtures/docsync-observer-commands.json index 9a228b1a9..8520bc97e 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 Bash grep of ship-docsync-missing-asset 38983306684 (bash#13) and the composed cat; cat of ship-docsync-failure 38984977679 (bash#12).", + "_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, 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.", "known_good": [ "git status", "git -C {repo} status", @@ -11,7 +11,10 @@ "git status --porcelain 2>/dev/null", "cat docs/guide.md 2>/dev/null || true", "git rev-parse HEAD || true", - "ls docs 2>/dev/null" + "ls docs 2>/dev/null", + "grep -n -i \"14.5\\|documentation\\|Step 15\\|Step 16\" {home}/.claude/skills/gstack/ship/SKILL.md", + "wc -l {home}/.claude/skills/gstack/ship/SKILL.md", + "ls -la {home}/.claude/skills/gstack/ship/sections {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/document-release/*" ], "known_bad": [ "git -C {other} status", @@ -31,7 +34,11 @@ "bun arbitrary.ts 2>/dev/null", "git status $EXTRA", "git status\ntrue", - "grep -n -i \"14.5\\|documentation\\|Step 15\\|Step 16\" {home}/.claude/skills/gstack/ship/SKILL.md", - "cat {other}/bash-placeholder 2>/dev/null; cat {home}/state/config.yaml" + "cat {other}/bash-placeholder 2>/dev/null; cat {home}/state/config.yaml", + "grep -n -i \"14.5\\|documentation\\|Step 15\\|Step 16\" {home}/.claude/skills/gstack/ship/SKILL.md | tee {home}/notes.md", + "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}/*" ] } diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index ced112fc0..9cc8f8523 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -6,6 +6,7 @@ import { nativeCalls } from './qa-checkpoint-evidence'; import type { SkillTestResult } from './session-runner'; import { DOC_PATH, type DocsScenario, type fixtureDocs } from './docsync-fixture'; import { sliceBetween } from './skill-fixture'; +import { readOnlyCommandAllowed, readOnlyInterfaceText } from './read-only-commands'; export async function observeDocsWrites(fixture: ReturnType) { return observeQAWrites(fixture.repo, { atomicTargets: [DOC_PATH] }); @@ -226,6 +227,7 @@ function unwrapHarmlessRead(text: string, fixture: ReturnType, scripts: string[] = []): boolean { const text = command.trim(); if (docsPreambleCommands(fixture).some(block => block.trim() === text)) return true; + if (readOnlyCommandAllowed(text, docsReadOnlyScope(fixture))) return true; const insert = /^cat (['"]?)([^\s'"]+)\1 >> (['"]?)([^\s'"]+)\3$/.exec(text); if (insert) return [insert[2], insert[4]].every(file => path.isAbsolute(file) && /\.md$/i.test(file) && !/[\x00-\x1f\x7f;&|<>`$\\()*?\[\]{}~#]/.test(file) && docsPrivateArtifact(file, fixture, scripts)); @@ -240,6 +242,13 @@ export function docsCommandAllowed(command: string, fixture: ReturnType) { + const config = actualWritePath(fixture.env.CLAUDE_CONFIG_DIR); + return { root: fixture.home, cwd: fixture.repo, + deny: (file: string) => path.basename(file) === 'actor-state.json' || (config !== null && within(file, config)) }; +} + function literalDocsCommandAllowed(text: string, fixture: ReturnType, scripts: string[]): boolean { if (/[\x00-\x08\x0a-\x1f\x7f;&|<>`$\\()]/.test(text)) return false; const args: string[] = []; @@ -293,7 +302,7 @@ function literalDocsCommandAllowed(text: string, fixture: ReturnType, 'home' | 'repo' | 'skills'>, scripts: string[] = [], transport = false, insert = false, file?: string): string { const skills = fixture.skills.split(path.sep).join('/'); - return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum, Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch --show-current, describe with only --tags, --always, --abbrev= or --exact-match and at most one revision), the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands. No shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a preview that no permitted Bash command can page. Use Write/Edit for permitted docs and private JSON/Markdown artifacts under ${fixture.home}; do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. + return `Fixture observation interface (applies to parent and every child; ${file ? `saved at ${file}: each child prompt tells the child to Read that file first instead of copying this text` : 'include this interface in child prompts'}): Bash may execute only separate literal pwd, ls, cat, stat, sha256sum, Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch --show-current, describe with only --tags, --always, --abbrev= or --exact-match and at most one revision), the exact generated Preamble block with its spawned prefix, literal installed gstack-skill-start/gstack-skill-end commands for document-release (start requires GSTACK_SESSION_KIND=spawned) or their own --help, or the literal installed ${skills}/bin/gstack-docs-candidate snapshot (its --out a private .json under ${fixture.home}), compare and --help commands. ${readOnlyInterfaceText(fixture.home)} Otherwise no shell composition, custom interpreters, arbitrary scripts, inline eval or memory-mapped writes. The only additional scripts are ${scripts.length ? scripts.join(', ') : 'none'}.${insert ? ` To insert one saved private Markdown artifact into another (a saved section file into a report), Bash may also run the single literal command cat SOURCE.md >> TARGET.md with absolute paths under ${fixture.home}.` : ''} Read/Glob/Grep remain available; Read skill and section files with Read (offset/limit for ranges), because Bash output over 30KB becomes a truncated preview. Use Write/Edit for permitted docs and private JSON/Markdown artifacts under ${fixture.home}; do not rewrite installed skills, config, actor state or scripts. No effects outside the owned fixture. The owner preserves evidence and cleans up. Missing observer coverage blocks acceptance; the Linux kernel monitor covers syscall writes in the product tree, not hostile processes or arbitrary external destinations. The working directory for parent and child Bash calls is already ${fixture.repo}. Run Git reads directly, for example: git status, git diff --cached, git merge-base main HEAD, git rev-parse HEAD. Do not use Git global options such as -C, -c, --git-dir or --work-tree, and do not prepend cd or another shell wrapper. The literal git subcommand must immediately follow git; an absolute owned repository path does not make git -C an allowed command. diff --git a/test/helpers/read-only-commands.ts b/test/helpers/read-only-commands.ts new file mode 100644 index 000000000..ce2be5d40 --- /dev/null +++ b/test/helpers/read-only-commands.ts @@ -0,0 +1,381 @@ +/** + * Shared read-only Bash grammar for paid-eval fixtures (GSTA-49). + * + * A fixture whose contract is not about which reads happen opts in by also + * accepting what this grammar accepts and by declaring readOnlyInterfaceText() + * to its actor. Fixtures whose contract is specifically about which reads + * happen keep their narrow lists. + * + * Accepted: one line of simple commands from READ_TOOLS joined by ;, && or ||, + * where a | pipe may feed only a STDIN_TOOLS filter, plus cd into a directory + * inside the root. Every path operand, path-valued option and unquoted glob + * match must resolve physically (symlinks and .. applied) inside the fixture + * root and outside its denied paths. + * Rejected: every other tool, redirects, substitutions, variables, tilde and + * brace expansion, backgrounding, multi-line input, and the write, exec, + * symlink-following and output-file options of the listed tools. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export type ReadOnlyScope = { + /** Fixture working root: every resolved path must stay inside it. */ + root: string; + /** Starting directory; defaults to the root and must be inside it. */ + cwd?: string; + /** Physical paths that must never be read, listed or searched (credentials, private actor state). */ + deny?: (physical: string) => boolean; +}; + +type Word = { text: string; glob: boolean; quoted: boolean }; +type Token = Word | { op: '|' | ';' | '&&' | '||' }; + +type ToolSpec = { + /** A whole option word that needs no separate value. */ + flag: RegExp; + /** An option word that takes the next word as a non-path value. */ + valued?: RegExp; + /** An option word that takes the next word as a path. */ + pathValued?: RegExp; + /** An option word carrying a path in capture group 1. */ + pathAttached?: RegExp; + /** The first operand is a pattern unless a pattern option was given. */ + pattern?: boolean; + /** Directory operands are searched by content, so the deny scan applies below them. */ + recursive?: (options: string[]) => boolean; + maxOperands?: number; +}; + +const HEAD_TAIL: ToolSpec = { + flag: /^(?:-[qvz]+|-\d+|-[nc][+-]?\d+[a-zA-Z]*|--(?:quiet|silent|verbose|zero-terminated)|--(?:lines|bytes)=[+-]?\d+[a-zA-Z]*)$/, + valued: /^-[nc]$/, +}; +const CHECKSUM: ToolSpec = { flag: /^(?:-[btz]+|--(?:binary|text|tag|zero))$/ }; + +export const READ_TOOLS: Readonly> = { + pwd: { flag: /^-[LP]$/, maxOperands: 0 }, + ls: { flag: /^(?:-[aAbBcCdfFgGhiklmnNopqQrRsStuUvwxX1Z]+|--(?:all|almost-all|human-readable|recursive|reverse|size|inode|directory|classify|full-time|group-directories-first|numeric-uid-gid|no-group|color=never|sort=(?:none|size|time|version|extension|name)|time-style=[\w+%:.-]+))$/ }, + wc: { flag: /^(?:-[clmwL]+|--(?:lines|bytes|chars|words|max-line-length|total=(?:auto|always|only|never)))$/ }, + head: HEAD_TAIL, + tail: HEAD_TAIL, + cat: { flag: /^(?:-[AbeEnstTuv]+|--(?:number|number-nonblank|show-all|show-ends|show-tabs|show-nonprinting|squeeze-blank))$/ }, + stat: { flag: /^(?:-[Lft]+|-c.+|--(?:dereference|file-system|terse)|--(?:format|printf)=.+)$/, valued: /^-c$/ }, + file: { flag: /^(?:-[bhiLkNr0]+|--(?:brief|mime|mime-type|mime-encoding|dereference|no-dereference|keep-going|raw|no-pad))$/ }, + du: { flag: /^(?:-[abchkmsSxl0]+|-d\d+|--(?:all|apparent-size|bytes|total|human-readable|summarize|separate-dirs|one-file-system|count-links|null|si)|--max-depth=\d+|--exclude=.+)$/, valued: /^-d$/ }, + find: { flag: /^$/ }, + grep: { + flag: /^(?:-[EFGPiywxvcLlnhHoqsrabIUzZT]+|-[mABC]\d+|--(?:extended-regexp|fixed-strings|basic-regexp|perl-regexp|ignore-case|no-ignore-case|word-regexp|line-regexp|invert-match|count|files-with-matches|files-without-match|line-number|no-filename|with-filename|only-matching|quiet|silent|no-messages|recursive|text|byte-offset|null|null-data|initial-tab|colou?r(?:=(?:never|auto|always))?)|--(?:max-count|after-context|before-context|context)=\d+|--(?:include|exclude|exclude-dir|label|binary-files)=.+|--directories=(?:read|skip|recurse)|--devices=(?:read|skip))$/, + valued: /^-[mABCdD]$/, + pathValued: /^-f$/, + pathAttached: /^(?:-f|--file=)(.+)$/, + pattern: true, + recursive: options => options.some((option, index) => /^-[^-]*r/.test(option) || option === '--recursive' + || option === '--directories=recurse' || (option === '-d' && options[index + 1] === 'recurse')), + }, + rg: { + flag: /^(?:-[iSsFwxvclnNHIoqauU0.]+|-[mABCdMj]\d+|--(?:hidden|no-ignore|ignore-case|smart-case|case-sensitive|fixed-strings|word-regexp|line-regexp|invert-match|count|count-matches|files|files-with-matches|files-without-match|line-number|no-line-number|with-filename|no-filename|only-matching|quiet|heading|no-heading|json|vimgrep|unrestricted|text|null|no-messages|stats|column|no-config)|--colou?r=(?:never|auto|always)|--sort=(?:none|path|modified|accessed|created)|--(?:max-count|max-depth|context|after-context|before-context|max-columns|threads)=\d+|--(?:glob|iglob|type|type-not|encoding)=.+)$/, + valued: /^(?:-[gtTmABCdMjE]|--(?:glob|iglob|type|type-not|max-count|max-depth|context|after-context|before-context|max-columns|threads|encoding|sort))$/, + pathValued: /^(?:-f|--file)$/, + pathAttached: /^--file=(.+)$/, + pattern: true, + recursive: () => true, + }, + sort: { flag: /^(?:-[bdfgiMhnRrsuVcCzm]+|-[kt].+|--(?:ignore-leading-blanks|dictionary-order|ignore-case|general-numeric-sort|ignore-nonprinting|month-sort|human-numeric-sort|numeric-sort|random-sort|reverse|stable|unique|version-sort|check|merge|zero-terminated)|--(?:key|field-separator)=.+)$/, valued: /^-[kt]$/ }, + uniq: { flag: /^(?:-[cdDiuz]+|-[fsw]\d+|--(?:count|repeated|unique|ignore-case|zero-terminated)|--(?:skip-fields|skip-chars|check-chars)=\d+)$/, valued: /^-[fsw]$/, maxOperands: 1 }, + cut: { flag: /^(?:-[sz]+|-[bcfd].+|--(?:complement|only-delimited|zero-terminated)|--(?:bytes|characters|fields|delimiter|output-delimiter)=.+)$/, valued: /^-[bcfd]$/ }, + nl: { flag: /^(?:-p|-[bdfhilnsvw].+|--no-renumber|--(?:body-numbering|section-delimiter|footer-numbering|header-numbering|line-increment|join-blank-lines|number-format|number-separator|starting-line-number|number-width)=.+)$/, valued: /^-[bdfhilnsvw]$/ }, + sha256sum: CHECKSUM, + sha1sum: CHECKSUM, + sha512sum: CHECKSUM, + md5sum: CHECKSUM, + readlink: { flag: /^-[efmnqsvz]+$/ }, + realpath: { flag: /^-[eLmPqsz]+$/ }, +}; + +/** Tools that may receive a pipe. Each one only prints what it reads. */ +export const STDIN_TOOLS: ReadonlySet = new Set(['wc', 'head', 'tail', 'cat', 'grep', 'rg', 'sort', 'uniq', 'cut', 'nl', 'sha256sum', 'sha1sum', 'sha512sum', 'md5sum']); + +const FIND_FLAGS = new Set(['-print', '-print0', '-prune', '-empty', '-true', '-false', '-a', '-o', '-and', '-or', '-not', + '-readable', '-writable', '-executable', '-depth', '-nowarn', '-xdev', '-mount', '-quit', '-ls', '(', ')', '!', ',']); +const FIND_VALUES = new Set(['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex', '-regextype', + '-type', '-xtype', '-maxdepth', '-mindepth', '-size', '-mtime', '-mmin', '-atime', '-amin', '-ctime', '-cmin', '-perm', + '-user', '-group', '-uid', '-gid', '-links', '-inum', '-lname', '-ilname', '-used', '-printf']); +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.`; +} + +export function readOnlyCommandAllowed(command: string, scope: ReadOnlyScope): boolean { + return readOnlyCommandRejection(command, scope) === undefined; +} + +/** Why the grammar rejects a command, or undefined for an accepted pure local read. */ +export function readOnlyCommandRejection(command: string, scope: ReadOnlyScope): string | undefined { + const tokens = tokenize(command.trim()); + if (typeof tokens === 'string') return tokens; + let root: string; + try { root = fs.realpathSync.native(scope.root); } catch { return 'fixture root does not exist'; } + const start = physicalPath(path.resolve(scope.root, scope.cwd ?? '.')); + if (!start || !inside(start, root)) return 'starting directory outside root'; + const context: Context = { root, deny: scope.deny ?? (() => false) }; + let cwds = [start]; + let pipeline: Word[][] = [[]]; + const finish = (): string | undefined => { + if (pipeline.some(words => !words.length)) return 'empty command'; + if (pipeline.length === 1 && pipeline[0][0].text === 'cd' && !pipeline[0][0].quoted) { + const targets = cdTargets(pipeline[0], cwds, context); + if (typeof targets === 'string') return targets; + cwds = [...new Set([...cwds, ...targets])]; + } else { + for (const [index, words] of pipeline.entries()) { + const rejection = commandRejection(words, index > 0, cwds, context); + if (rejection) return rejection; + } + } + pipeline = [[]]; + }; + for (const [index, token] of tokens.entries()) { + if (!('op' in token)) { pipeline.at(-1)!.push(token); continue; } + if (token.op === '|') { pipeline.push([]); continue; } + if (token.op === ';' && index === tokens.length - 1) break; + const rejection = finish(); + if (rejection) return rejection; + } + return finish(); +} + +function tokenize(command: string): Token[] | string { + if (!command) return 'empty command'; + if (/[\x00-\x08\x0a-\x1f\x7f]/.test(command)) return 'multi-line or control characters'; + const tokens: Token[] = []; + let word: Word | undefined; + const end = () => { if (word) tokens.push(word); word = undefined; }; + const append = (text: string, quoted: boolean) => { + word ??= { text: '', glob: false, quoted: false }; + word.text += text; + word.quoted ||= quoted; + }; + for (let i = 0; i < command.length; i++) { + const c = command[i]; + if (c === ' ' || c === '\t') { end(); continue; } + if (c === '|' || c === '&' || c === ';') { + end(); + const pair = command.slice(i, i + 2); + if (pair === '||' || pair === '&&') { tokens.push({ op: pair }); i++; continue; } + if (c === '&' || pair === '|&' || pair === ';;') return 'background job or unsupported operator'; + tokens.push({ op: c as '|' | ';' }); + continue; + } + if (c === "'") { + const close = command.indexOf("'", i + 1); + if (close < 0) return 'unterminated quote'; + append(command.slice(i + 1, close), true); + i = close; + continue; + } + if (c === '"') { + let text = ''; + for (i++; i < command.length && command[i] !== '"'; i++) { + if (command[i] === '$' || command[i] === '`') return 'substitution or variable'; + if (command[i] === '\\' && /[$`"\\]/.test(command[i + 1] ?? '"')) return 'escape inside double quotes'; + text += command[i]; + } + if (i >= command.length) return 'unterminated quote'; + append(text, true); + continue; + } + if (c === '\\') { + const next = command[i + 1]; + if (word || !next || !'()!'.includes(next) || !/^[ \t]?$/.test(command[i + 2] ?? '')) return 'shell escape'; + tokens.push({ text: next, glob: false, quoted: true }); + i++; + continue; + } + if (c === '$' || c === '`') return 'substitution or variable'; + if (c === '>' || c === '<') return 'redirect'; + if ('(){}'.includes(c)) return 'subshell, grouping or brace expansion'; + if (!word && (c === '~' || c === '#')) return 'tilde expansion or comment'; + if (!/[A-Za-z0-9_./:@%+,=^!*?[\]-]/.test(c)) return `unsupported unquoted character ${JSON.stringify(c)}`; + append(c, false); + if ('*?['.includes(c)) word!.glob = true; + } + end(); + return tokens; +} + +type Context = { root: string; deny: (physical: string) => boolean }; + +function cdTargets(words: Word[], cwds: string[], context: Context): string[] | string { + if (words.length !== 2 || words[1].glob || words[1].text.startsWith('-')) return 'cd needs exactly one literal directory'; + const targets: string[] = []; + for (const cwd of cwds) { + const target = physicalPath(path.isAbsolute(words[1].text) ? words[1].text : `${cwd}/${words[1].text}`); + if (!target || !inside(target, context.root)) return 'cd outside root'; + if (!fs.statSync(target, { throwIfNoEntry: false })?.isDirectory()) return 'cd target is not a directory'; + targets.push(target); + } + return targets; +} + +function commandRejection(words: Word[], piped: boolean, cwds: string[], context: Context): string | undefined { + const [name, ...args] = words; + if (name.quoted || name.glob) return 'quoted or globbed command name'; + if (name.text === 'cd') return 'cd inside a pipeline'; + const spec = READ_TOOLS[name.text]; + 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); + const options: string[] = []; + const operands: Word[] = []; + const paths: Word[] = []; + let patternGiven = false; + let endOfOptions = false; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (endOfOptions || !arg.text.startsWith('-') || arg.text === '-') { operands.push(arg); continue; } + if (arg.text === '--') { endOfOptions = true; continue; } + if (arg.glob) return `globbed option: ${arg.text}`; + options.push(arg.text); + const attached = spec.pathAttached?.exec(arg.text); + if (attached) { paths.push({ text: attached[1], glob: false, quoted: true }); patternGiven = true; continue; } + const patternOption = spec.pattern && /^(?:-e|--regexp)(?:=.+|.+)?$/.exec(arg.text); + if (patternOption && arg.text !== '-e' && arg.text !== '--regexp') { patternGiven = true; continue; } + const takesPath = spec.pathValued?.test(arg.text) ?? false; + if (takesPath || patternOption || spec.valued?.test(arg.text)) { + const value = args[++i]; + if (!value) return `missing value for ${arg.text}`; + if (value.glob) return `unquoted glob in ${arg.text} value`; + options.push(value.text); + if (takesPath) paths.push(value); + if (takesPath || patternOption) patternGiven = true; + continue; + } + if (!spec.flag.test(arg.text)) return `unsupported ${name.text} option: ${arg.text}`; + } + if (spec.pattern && !patternGiven) { + const pattern = operands.shift(); + if (!pattern) return `${name.text} needs a pattern`; + if (pattern.glob) return 'unquoted glob as a pattern'; + } + if (spec.maxOperands !== undefined && operands.length > spec.maxOperands) return `${name.text} takes at most ${spec.maxOperands} operand(s)`; + const recursive = spec.recursive?.(options) ?? false; + if (recursive && !operands.length) operands.push({ text: '.', glob: false, quoted: true }); + return pathsRejection([...operands.filter(operand => operand.text !== '-'), ...paths], cwds, context, recursive); +} + +function findRejection(args: Word[], cwds: string[], context: Context): string | undefined { + let i = args[0]?.text === '-P' ? 1 : 0; + const paths: Word[] = []; + for (; i < args.length && !/^[-(!),]/.test(args[i].text); i++) paths.push(args[i]); + if (!paths.length) paths.push({ text: '.', glob: false, quoted: true }); + for (; i < args.length; i++) { + const arg = args[i]; + if (arg.glob) return `unquoted glob in find expression: ${arg.text}`; + if (FIND_FLAGS.has(arg.text)) continue; + if (!FIND_VALUES.has(arg.text) && !FIND_PATHS.has(arg.text)) return `unsupported find primary: ${arg.text}`; + const value = args[++i]; + if (!value) return `missing value for ${arg.text}`; + if (value.glob) return `unquoted glob in ${arg.text} value`; + if (FIND_PATHS.has(arg.text)) paths.push(value); + } + return pathsRejection(paths, cwds, context, false); +} + +function pathsRejection(words: Word[], cwds: string[], context: Context, recursive: boolean): string | undefined { + for (const cwd of cwds) { + for (const word of words) { + if (!word.text) return 'empty path'; + const candidates = word.glob ? expandGlob(word.text, cwd, context) : [word.text]; + if (typeof candidates === 'string') return candidates; + for (const candidate of candidates) { + if (word.glob && candidate.startsWith('-')) return 'glob expands to an option'; + const physical = physicalPath(path.isAbsolute(candidate) ? candidate : `${cwd}/${candidate}`); + if (!physical || !inside(physical, context.root)) return `path outside root: ${candidate}`; + if (context.deny(physical)) return `denied path: ${candidate}`; + if (recursive && deniedBelow(physical, context)) return `recursive search reaches a denied path: ${candidate}`; + } + } + } +} + +/** Bash pathname expansion without dotglob, globstar or extglob; a pattern with no match stays literal. */ +function expandGlob(pattern: string, cwd: string, context: Context): string[] | string { + const segments = pattern.split('/'); + if (segments.includes('..')) return 'glob with ..'; + const first = segments.findIndex(segment => /[*?[]/.test(segment)); + const prefix = segments.slice(0, first).join('/'); + const base = physicalPath(pattern.startsWith('/') ? prefix || '/' : `${cwd}/${prefix}`); + if (!base || !inside(base, context.root)) return `glob outside root: ${pattern}`; + let matches = [{ shown: prefix, actual: base }]; + for (const segment of segments.slice(first)) { + const regex = globSegment(segment); + matches = matches.flatMap(match => { + const join = (name: string) => ({ shown: match.shown || pattern.startsWith('/') ? `${match.shown}/${name}` : name, actual: path.join(match.actual, name) }); + if (!regex) return segment === '' || fs.lstatSync(path.join(match.actual, segment), { throwIfNoEntry: false }) ? [join(segment)] : []; + let names: string[]; + try { names = fs.readdirSync(match.actual); } catch { return []; } + return names.filter(name => regex.test(name) && (segment.startsWith('.') || !name.startsWith('.'))).sort().map(join); + }); + } + return matches.length ? matches.map(match => match.shown) : [pattern]; +} + +function globSegment(segment: string): RegExp | undefined { + if (!/[*?[]/.test(segment)) return undefined; + let source = ''; + for (let i = 0; i < segment.length; i++) { + const c = segment[i]; + const close = c === '[' ? segment.indexOf(']', i + 2) : -1; + if (c === '*') source += '[^/]*'; + else if (c === '?') source += '[^/]'; + else if (close > 0) { + const body = segment.slice(i + 1, close).replace(/\\/g, '\\\\'); + source += `[${body.startsWith('!') ? '^' + body.slice(1) : body}]`; + i = close; + } else source += c.replace(/[\\^$.|+(){}[\]]/g, '\\$&'); + } + return new RegExp(`^${source}$`); +} + +/** The kernel's view of a path: symlinks resolved in order and .. applied to the resolved parent. */ +function physicalPath(file: string): string | null { + const parts = file.split('/'); + let current = '/'; + for (const [index, part] of parts.entries()) { + if (part === '' || part === '.') continue; + if (part === '..') { current = path.dirname(current); continue; } + const next = path.join(current, part); + const entry = fs.lstatSync(next, { throwIfNoEntry: false }); + if (!entry) { + const rest = parts.slice(index + 1); + return rest.includes('..') ? null : path.join(next, ...rest); + } + if (!entry.isSymbolicLink()) { current = next; continue; } + try { current = fs.realpathSync.native(next); } catch { return null; } + } + return current; +} + +function inside(file: string, root: string): boolean { + return file === root || file.startsWith(root === '/' ? root : root + '/'); +} + +/** True when a content search below directory would reach a denied path (or the tree is too large to prove it would not). */ +function deniedBelow(directory: string, context: Context): boolean { + if (!fs.lstatSync(directory, { throwIfNoEntry: false })?.isDirectory()) return false; + let budget = 20_000; + const pending = [directory]; + while (pending.length) { + const current = pending.pop()!; + let entries: fs.Dirent[]; + try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return true; } + for (const entry of entries) { + const child = path.join(current, entry.name); + if (--budget < 0 || context.deny(child)) return true; + if (entry.isDirectory()) pending.push(child); + } + } + return false; +} diff --git a/test/helpers/touchfiles-data.ts b/test/helpers/touchfiles-data.ts index 619a68ee6..a65b280c9 100644 --- a/test/helpers/touchfiles-data.ts +++ b/test/helpers/touchfiles-data.ts @@ -50,14 +50,14 @@ export const E2E_TOUCHFILES: Record = { 'shared-libs-opportunity-judgment': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts'], 'shared-libs-pr-coverage': ['deslop-shared-libs/**', 'bin/gstack-safe-git', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts', 'test/fixtures/shared-libs-readonly-substitution-ci16358.json', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/shared-libs-plan-actor.ts', 'test/helpers/shared-libs-plan-excerpt.ts'], 'shared-libs-plan-callers': ['test/helpers/shared-libs-plan-actor.ts', 'scripts/resolvers/confidence.ts', 'test/helpers/shared-libs-plan-excerpt.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'deslop-shared-libs/**', 'scripts/resolvers/shared-libs.ts', 'scripts/resolvers/index.ts', 'test/helpers/shared-libs-eval-fixture.ts', 'plan-eng-review/**', 'test/skill-e2e-shared-libs-periodic.test.ts', 'test/fixtures/plan-scope-recovery-av.json', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'test/helpers/e2e-gate.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/agent-sdk-runner.ts', 'test/helpers/llm-judge.ts', 'lib/claude-bin.ts', 'lib/eval-model.ts'], - 'ship-docsync-missing-marker': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-missing-asset': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-launch-failure': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-timeout-unsettled': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-late-result': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-stale-before': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-stale-after': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-recovery': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-missing-marker': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-missing-asset': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-launch-failure': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-timeout-unsettled': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-late-result': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-stale-before': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-stale-after': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-recovery': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble.ts', 'scripts/resolvers/testing.ts', 'scripts/gen-skill-docs.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/qa-functional-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'bin/gstack-context-recovery'], 'review-exploratory-small-cli': ['bin/gstack-qa-evidence', 'lib/qa-evidence.ts', 'lib/fs-atomic.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-evidence.ts', 'bin/gstack-qa-deadline', 'lib/qa-deadline.ts', 'lib/claude-code-windows-job.ts', 'review/**', 'ship/**', 'qa/**', 'scripts/resolvers/qa.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/qa-callers-fixture.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-fixture.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-qa-callers.test.ts', 'scripts/resolvers/testing.ts', 'test/helpers/skill-census.ts'], 'ship-exploratory-small-cli': ['bin/gstack-qa-evidence', 'lib/qa-evidence.ts', 'lib/fs-atomic.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-evidence.ts', 'bin/gstack-qa-deadline', 'lib/qa-deadline.ts', 'lib/claude-code-windows-job.ts', 'review/**', 'ship/**', 'qa/**', 'scripts/resolvers/qa.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/qa-callers-fixture.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-fixture.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-qa-callers.test.ts', 'scripts/resolvers/testing.ts', 'test/helpers/skill-census.ts'], 'ship-exploratory-unavailable': ['bin/gstack-qa-evidence', 'lib/qa-evidence.ts', 'lib/fs-atomic.ts', 'lib/redact-engine.ts', 'lib/redact-patterns.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-evidence.ts', 'bin/gstack-qa-deadline', 'lib/qa-deadline.ts', 'lib/claude-code-windows-job.ts', 'review/**', 'ship/**', 'qa/**', 'scripts/resolvers/qa.ts', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/review-army.ts', 'scripts/resolvers/sections.ts', 'scripts/gen-skill-docs.ts', 'test/helpers/qa-callers-fixture.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-fixture.ts', 'test/helpers/hermetic-skill-runtime.ts', 'test/helpers/session-runner.ts', 'test/skill-e2e-qa-callers.test.ts', 'scripts/resolvers/testing.ts', 'test/helpers/skill-census.ts'], @@ -814,15 +814,15 @@ export const E2E_TOUCHFILES: Record = { 'ship-triage': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'bin/gstack-repo-mode', 'test/skill-e2e-triage.test.ts', 'test/helpers/ship-triage-labels.ts', 'scripts/resolvers/testing.ts' ], - 'ship-docsync-completion': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/docsync-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/testing.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-fixture.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-current': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/docsync-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/testing.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-fixture.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-failure': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/docsync-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/testing.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-fixture.ts', 'bin/gstack-context-recovery'], - 'ship-docsync-store': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/docsync-*.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/testing.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-fixture.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-completion': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/testing.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-fixture.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-current': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/testing.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-fixture.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-failure': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/testing.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-fixture.ts', 'bin/gstack-context-recovery'], + 'ship-docsync-store': ['bin/gstack-docs-candidate', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/**', 'document-release/**', 'test/skill-e2e-ship-docsync.test.ts', 'test/helpers/docsync-*.ts', 'test/helpers/read-only-commands.ts', 'test/helpers/session-runner.ts', 'test/helpers/hermetic-env.ts', 'bin/gstack-skill-start', 'bin/gstack-session-kind', 'scripts/resolvers/sections.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/gen-skill-docs.ts', 'scripts/resolvers/testing.ts', 'test/helpers/qa-checkpoint-evidence.ts', 'test/helpers/qa-functional-observer.ts', 'test/helpers/e2e-gate.ts', 'test/helpers/qa-evidence-producer.ts', 'test/helpers/qa-functional-fixture.ts', 'bin/gstack-context-recovery'], // #2733 behavioral proof: the JSON contract survives a firing gate inside a // spawned-marked subagent. Deps name every behavior under test — the // session-kind override, the skill-start gates, both hooks + the shared // directive, and the AUQ prose rule — so changing any of them selects it. - 'docsync-spawned': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/sections/pr-body.md', + 'docsync-spawned': ['test/helpers/read-only-commands.ts', 'bin/gstack-state-root.sh', 'lib/state-root.ts', 'ship/sections/pr-body.md', 'document-release/**', 'ship/sections/documentation.md', 'ship/sections/documentation.md.tmpl', diff --git a/test/read-only-command-grammar.test.ts b/test/read-only-command-grammar.test.ts new file mode 100644 index 000000000..30fde8a37 --- /dev/null +++ b/test/read-only-command-grammar.test.ts @@ -0,0 +1,213 @@ +/** + * The shared read-only grammar (GSTA-49): accepted pure reads, a negative + * control for every rejected class, and free replays of the captured Opus + * slips through the opted-in docsync observer. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fixtureDocs } from './helpers/docsync-fixture'; +import { docsCommandAllowed, docsNativeInterface, docsToolFailures } from './helpers/docsync-observer'; +import { readOnlyCommandAllowed, readOnlyCommandRejection, readOnlyInterfaceText, READ_TOOLS, STDIN_TOOLS } from './helpers/read-only-commands'; +import type { SkillTestResult } from './helpers/session-runner'; + +let base: string; +let root: string; +let outside: string; +const scope = () => ({ root, deny: (file: string) => path.basename(file) === 'credentials.json' }); + +beforeAll(() => { + base = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ro-grammar-')); + root = path.join(base, 'root'); + outside = path.join(base, 'outside'); + for (const [file, text] of Object.entries({ + 'qa-only/SKILL.md': '# QA only\nstep one\nstep two\n', + 'qa-only/sections/report.md': 'report\n', + 'qa/sections/browser-setup.md': 'setup\n', + 'qa/templates/report.md': 'template\n', + 'docs/guide.md': 'b\na\nb\n', + 'private/credentials.json': '{"token":"secret"}\n', + })) { + fs.mkdirSync(path.join(root, path.dirname(file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'secret.txt'), 'outside\n'); + fs.symlinkSync(outside, path.join(root, 'escape')); + fs.symlinkSync(path.join(root, 'docs/guide.md'), path.join(root, 'guide-link.md')); +}); +afterAll(() => fs.rmSync(base, { recursive: true, force: true })); + +function tree(dir: string): Record { + const out: Record = {}; + for (const name of fs.readdirSync(dir, { recursive: true }).map(String).sort()) { + const file = path.join(dir, name); + const entry = fs.lstatSync(file); + out[name] = entry.isFile() ? fs.readFileSync(file, 'base64') : entry.isSymbolicLink() ? 'link:' + fs.readlinkSync(file) : 'dir'; + } + return out; +} + +const ACCEPTED = [ + 'pwd', 'ls', 'ls -la docs', 'ls -la qa-only/*', 'ls qa-only qa-only/sections qa/sections qa/templates', 'ls -R qa', + 'wc -l docs/guide.md', 'wc -l qa-only/SKILL.md qa-only/sections/*.md', 'head -n 2 docs/guide.md', 'head -2 qa-only/SKILL.md', + 'tail -n +2 docs/guide.md', 'cat -n docs/guide.md', 'cat guide-link.md', 'stat docs/guide.md', 'stat -c %s docs/guide.md', + 'file docs/guide.md', 'du -sh qa', 'du -d 1 .', 'find . -name "*.md" -type f', "find qa -maxdepth 2 -name '*.md' -print", + 'find . -path ./private -prune -o -type f -print', 'grep -n step qa-only/SKILL.md', 'rg -n step qa-only', 'grep -rn template qa', + 'grep -n -i "step\\|report" qa-only/SKILL.md', 'grep -c -e step -e report qa-only/SKILL.md', 'sort docs/guide.md', + 'sort -u docs/guide.md', 'uniq -c docs/guide.md', 'cut -c1-3 docs/guide.md', 'nl -ba docs/guide.md', 'sha256sum docs/guide.md', + '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;', +]; + +describe.skipIf(process.platform === 'win32')('accepted pure local reads', () => { + test('every listed tool has at least one accepted form', () => { + for (const tool of Object.keys(READ_TOOLS)) expect(ACCEPTED.some(command => command.split(/[;&|]+\s*/).some(part => part.trim().split(' ')[0] === tool)), tool).toBe(true); + }); + + test.each(ACCEPTED)('accepts and executes without changing either tree: %s', command => { + expect(readOnlyCommandRejection(command, scope())).toBeUndefined(); + const before = [tree(root), tree(outside)]; + const run = spawnSync('bash', ['-c', command], { cwd: root, encoding: 'utf8', timeout: 10000 }); + expect(run.error).toBeUndefined(); + expect([tree(root), tree(outside)]).toEqual(before); + }); + + test('the declared interface text names every tool and the pipe targets', () => { + const text = readOnlyInterfaceText(root); + for (const tool of [...Object.keys(READ_TOOLS), ...STDIN_TOOLS]) expect(text).toContain(tool); + expect(text).toContain(root); + }); +}); + +// One negative control per rejected class; the reason pins which rule fired. +const REJECTED: Array<[string, string, RegExp]> = [ + ['write', 'rm docs/guide.md', /unlisted tool: rm/], + ['write', 'touch docs/new.md', /unlisted tool: touch/], + ['write', 'sort -o docs/sorted.md docs/guide.md', /unsupported sort option: -o/], + ['write', 'uniq docs/guide.md docs/out.md', /at most 1 operand/], + ['write', 'find . -name "*.md" -delete', /unsupported find primary: -delete/], + ['write', "find . -exec rm '{}' ';'", /unsupported find primary: -exec/], + ['write', "find . -ok cat '{}' ';'", /unsupported find primary: -ok/], + ['write', 'find . -fprint list.txt', /unsupported find primary: -fprint/], + ['write', 'find . -fprintf list.txt %p', /unsupported find primary: -fprintf/], + ['redirect', 'cat docs/guide.md > copy.md', /redirect/], + ['redirect', 'cat docs/guide.md >> copy.md', /redirect/], + ['redirect', 'ls docs 2>/dev/null', /redirect/], + ['redirect', 'wc -l < docs/guide.md', /redirect/], + ['writing pipe', 'cat docs/guide.md | tee copy.md', /unlisted tool: tee/], + ['writing pipe', 'ls | xargs rm', /unlisted tool: xargs/], + ['writing pipe', 'cat docs/guide.md | sh', /unlisted tool: sh/], + ['writing pipe', 'cat docs/guide.md | sort -o copy.md', /unsupported sort option: -o/], + ['writing pipe', 'ls | find . -delete', /find cannot read a pipe/], + ['substitution', 'cat $(ls docs)', /substitution or variable/], + ['substitution', 'cat "$(touch x)"', /substitution or variable/], + ['substitution', 'cat ' + '\x60' + 'ls' + '\x60', /substitution or variable/], + ['substitution', 'cat <(ls)', /redirect/], + ['variable', 'ls $HOME', /substitution or variable/], + ['network', 'curl https://example.com', /unlisted tool: curl/], + ['network', 'wget -q https://example.com', /unlisted tool: wget/], + ['network', 'nc example.com 80', /unlisted tool: nc/], + ['network', 'git fetch origin', /unlisted tool: git/], + ['cd out of root', 'cd /tmp && ls', /cd outside root/], + ['cd out of root', 'cd .. && ls', /cd outside root/], + ['cd out of root', 'cd escape && cat secret.txt', /cd outside root/], + ['cd out of root', 'cd ~ && ls', /tilde/], + ['cd out of root', 'cd - && ls', /cd needs exactly one literal directory/], + ['path escape: ..', 'cat ../outside/secret.txt', /path outside root/], + ['path escape: ..', 'cat docs/../../outside/secret.txt', /path outside root/], + ['path escape: ..', 'grep -rn outside ..', /path outside root/], + ['path escape: symlink', 'cat escape/secret.txt', /path outside root/], + ['path escape: symlink', 'ls escape/*', /glob outside root/], + ['path escape: symlink', 'wc -l escape', /path outside root/], + ['path escape: absolute', 'cat /etc/passwd', /path outside root/], + ['path escape: absolute', 'ls /*', /glob outside root/], + ['path escape: absolute', 'find / -name passwd', /path outside root/], + ['path escape: option value', 'grep -f /etc/passwd docs/guide.md', /path outside root/], + ['path escape: option value', 'find docs -newer /etc/passwd', /path outside root/], + ['denied path', 'cat private/credentials.json', /denied path/], + ['denied path', 'grep -rn token .', /recursive search reaches a denied path/], + ['denied path', 'ls private/*', /denied path/], + ['symlink following', 'grep -R step .', /unsupported grep option: -R/], + ['symlink following', 'find -L . -name x', /unsupported find primary: -L/], + ['symlink following', 'du -L .', /unsupported du option: -L/], + ['external program', 'rg --pre cat step', /unsupported rg option: --pre/], + ['external program', 'rg -z step', /unsupported rg option: -z/], + ['sudo', 'sudo cat docs/guide.md', /unlisted tool: sudo/], + ['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', 'env cat docs/guide.md', /unlisted tool: env/], + ['unlisted tool', '/bin/cat docs/guide.md', /unlisted tool/], + ['shell syntax', 'ls &', /background job/], + ['shell syntax', 'ls\nrm docs/guide.md', /multi-line/], + ['shell syntax', 'cat docs/{guide,other}.md', /brace expansion/], + ['shell syntax', '(ls)', /subshell/], + ['shell syntax', 'tail -f docs/guide.md', /unsupported tail option: -f/], +]; + +describe('rejected classes stay rejected', () => { + test.each(REJECTED)('%s: %s', (_, command, reason) => { + expect(readOnlyCommandRejection(command, scope())).toMatch(reason); + expect(readOnlyCommandAllowed(command, scope())).toBe(false); + }); + + 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']) + expect(classes.has(kind), kind).toBe(true); + }); +}); + +function nativeResult(commands: string[]): SkillTestResult { + return { toolCalls: commands.map(command => ({ tool: 'Bash', input: { command }, output: '' })) } as unknown as SkillTestResult; +} + +// Captured Opus slips. Fixture paths are templated; {home} is the docsync fixture home. +const DOCSYNC_SLIPS = [ + // PR #3275 gate 39173752116, slice 13, ship-docsync-missing-asset: its whole Bash stream. + ['wc -l {home}/.claude/skills/gstack/ship/SKILL.md', + 'ls -la {home}/.claude/skills/gstack/document-release/sections {home}'], + // TM1 effort study at e616a75, ship-docsync-missing-asset t10 (high effort): its Bash stream before the actor call. + ['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/*'], +]; +// 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'; + +describe.skipIf(process.platform === 'win32')('free replays of the captured slips', () => { + let fixture: ReturnType; + beforeAll(() => { fixture = fixtureDocs('current'); }); + afterAll(() => fixture?.clean()); + + test.each(DOCSYNC_SLIPS.map((stream, index) => [index, stream] as const))('docsync stream %i now passes the observer with no command failure', (_, stream) => { + const commands = stream.map(command => command.replaceAll('{home}', fixture.home)); + expect(docsToolFailures(nativeResult(commands), fixture, [], true)).toEqual([]); + for (const command of commands) 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)); + }); + + test('docsync keeps its git interface and denies credentials and actor state', () => { + for (const command of [ + 'git status; ls', 'cd repo && git fetch', 'git log | head -5', + 'wc -l ' + path.join(fixture.home, 'cc/.claude.json'), + 'grep -rn apiKey ' + fixture.home, + 'head ' + path.join(fixture.home, 'actor-state.json'), + 'wc -l /etc/passwd', + 'wc -l ' + path.join(fixture.home, '.claude/skills/gstack/ship/SKILL.md') + ' > ' + path.join(fixture.repo, 'count.txt'), + ]) expect(docsCommandAllowed(command, fixture), command).toBe(false); + }); + + test('the qa-only slip is a pure read inside its fixture root', () => { + expect(readOnlyCommandRejection(QA_ONLY_SLIP.replaceAll('{dir}', root), scope())).toBeUndefined(); + expect(readOnlyCommandRejection(QA_ONLY_SLIP.replaceAll('{dir}', root) + ' > ' + path.join(root, 'out.txt'), scope())).toMatch(/redirect/); + }); +});