diff --git a/test/docsync-command-grammar.test.ts b/test/docsync-command-grammar.test.ts index 8b24c1e11..c11cc4823 100644 --- a/test/docsync-command-grammar.test.ts +++ b/test/docsync-command-grammar.test.ts @@ -234,16 +234,65 @@ test('the installed docs-candidate helper is admitted only with a private .json expect(docsNativeInterface(fixture)).toContain('compare and --help commands'); }); -// Train gate 39904966815 (ship-docsync-completion, attempt 1) and GSTA-63's focused run joined the candidate's -// --help onto a line of reads. The join stays refused; the own-call rule now sits in the sentence naming --help. -test('a candidate --help joined to reads stays refused, and the interface says so where it names the command', () => { +// Train gate 39904966815 and GSTA-63's focused run joined the candidate's --help onto a line of reads; v1.91.185.0 moved the +// own-call rule into the sentence naming --help and the slip came back four times (JOINED_READ_LINES below). The --help only +// prints usage, so it now joins a line of reads like a Git read; every other own-call command stays alone. +test('a candidate --help joined to reads is a read, and the interface names it as the one exception to the own-call rule', () => { const skills = fixture.skills.split(path.sep).join('/'); - const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${skills}/bin/gstack-docs-candidate --help`; - expect(docsCommandAllowed(joined, fixture)).toBe(false); - expect(docsCommandAllowed(`${skills}/bin/gstack-docs-candidate --help`, fixture)).toBe(true); + const candidate = `${skills}/bin/gstack-docs-candidate`; + const joined = `git diff; git diff --cached; cat options.ts; ls -la handbook handbook/reference handbook/reference/commands; ${candidate} --help`; + expect(docsCommandAllowed(joined, fixture)).toBe(true); + for (const command of [`${candidate} --help`, `${candidate} --help 2>&1`, `git status; ${candidate} snapshot --help`, `${candidate} compare --help 2>/dev/null; git log --oneline -3`, + `${candidate} --help | head -20`]) expect(docsCommandAllowed(command, fixture), command).toBe(true); + const record = path.join(fixture.home, 'c.json'); + for (const command of [ + `git status; ${candidate} snapshot --out ${record} --audit-id a --mode edit --base main`, `git status; ${candidate} compare ${record}`, + `${candidate} --help; git add app.ts`, `${candidate} --help > ${path.join(fixture.home, 'usage.md')}`, `${candidate} --help | sh`, + `echo x | ${candidate} --help`, `git status | ${candidate} --help`, `cd ${fixture.repo} && ${candidate} --help`, `git status; ${candidate} --help snapshot`, + `git status; ${candidate} --version`, `git status; ${candidate} -h`, `git status; ${candidate} --help --exec rm`, + `git status; ~/.claude/skills/gstack/bin/gstack-docs-candidate --help`, `git status; ${path.join(path.dirname(fixture.home), 'bin/gstack-docs-candidate')} --help`, + `git status; ${skills}/bin/gstack-skill-start --help`, `git status; ${skills}/bin/gstack-skill-end --help`, + ]) expect(docsCommandAllowed(command, fixture), command).toBe(false); const sentence = docsNativeInterface(fixture).split(/(?<=\.)\s+(?=[A-Z])/).find(part => part.includes('gstack-docs-candidate snapshot')); expect(sentence).toContain('as its own Bash call, with nothing joined before or after it'); - expect(sentence).toContain('a --help is no exception'); + expect(sentence).not.toContain('a --help is no exception'); + expect(docsNativeInterface(fixture)).toContain(`So may the installed ${candidate} --help, alone or after snapshot or compare, because it only prints usage (the one exception)`); +}); + +// Five red ship-docsync parents, each red only on 'command outside declared docs observation interface', and each with exactly +// one refused line (every other Bash call of the five captures replays clean): four joined the candidate's --help onto a line of +// reads, and gate 40643690306 put `git tag` in one. {home} is the fixture home; the sha is the capture file's sha256 prefix. +const JOINED_READ_LINES: Array<[source: string, scenario: 'current' | 'store', line: string]> = [ + ['screen 40558235014 slice 5 @ 70cedc3d, capture c73fb9d0b1b0', 'current', + 'git diff main HEAD; cat personal-note.txt; ls -la; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['gate 40600731931 slice 4 @ 5fef38a5, capture 606503d8b78a', 'store', + 'cat handbook/reference/commands/widget.md.tmpl; echo ---; cat CHANGELOG.md TODOS.md VERSION package.json AGENTS.md; echo ---; cat personal-note.txt; echo ---; find handbook; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['screen 40627127462 slice 5 @ 76a155d4, capture 67fa4dea377a', 'current', + 'git diff main...HEAD; cat handbook/reference/commands/widget.md.tmpl; ls -la handbook handbook/reference handbook/reference/commands; cat personal-note.txt | head -3; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help'], + ['screen 40627127462 rerun 1 @ 76a155d4, capture 53d06fea1a89', 'current', + 'git diff main HEAD; git rev-parse origin/main; {home}/.claude/skills/gstack/bin/gstack-docs-candidate --help 2>&1'], + ['gate 40643690306 slice 4 @ c188a78c, capture 6ac1902bd83f', 'store', + 'git rev-parse HEAD origin/main; git merge-base main HEAD; git tag; cat {home}/fixture-interface.md | head -5; ls {home}'], +]; + +test.each(JOINED_READ_LINES)('captured joined read line from %s is a declared read', (_source, scenario, line) => { + const owned = fixtureDocs(scenario); + try { + const command = line.replaceAll('{home}', owned.home); + const publish = path.join(owned.home, 'fixture-publish.ts'); + expect(docsCommandAllowed(command, owned, [publish])).toBe(true); + expect(docsToolFailures(nativeResult(command, 'usage: gstack-docs-candidate snapshot ...'), owned, [publish], true)).toEqual([]); + expect(docsCommandAllowed(`${command}; git add app.ts`, owned, [publish])).toBe(false); + } finally { owned.clean(); } +}); + +test('operand-free tag listings are reads, and every tag write stays refused', () => { + for (const listing of ['git tag', 'git tag -l', 'git tag --list', 'git status; git tag; git log --oneline -3']) + expect(docsCommandAllowed(listing, fixture), listing).toBe(true); + for (const write of ['git tag v1', 'git tag -a v1 -m release', 'git tag -d v1', 'git tag -f v1 HEAD~1', 'git tag -s v1', 'git tag -v v1', 'git tag -l "v*"', + 'git tag --contains HEAD', 'git tag -n', 'git tag --sort=-v:refname', 'git tag -l v1', 'git tag; git tag v2', 'git tag | sh']) + expect(docsCommandAllowed(write, fixture), write).toBe(false); + expect(docsNativeInterface(fixture)).toContain('tag listings with no operand and only -l/--list'); }); test('the installed lifecycle helpers are admitted for their own --help, which starts nothing', () => { diff --git a/test/helpers/docsync-interface.ts b/test/helpers/docsync-interface.ts index 41370692d..607dd68ae 100644 --- a/test/helpers/docsync-interface.ts +++ b/test/helpers/docsync-interface.ts @@ -15,13 +15,13 @@ export const docsCapsCommand = (skills: string) => `${skills}/bin/gstack-ship-re export function docsNativeInterface(fixture: Pick, 'home' | 'repo' | 'skills'> & Partial, 'env'>>, scripts: string[] = [], transport = false, insert = false, file?: string): string { const skills = fixture.skills.split(path.sep).join('/'); const state = fixture.env?.GSTACK_HOME ?? path.join(fixture.home, 'state'); - 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 and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), describe with only --tags, --always, --abbrev= or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (a --help is no exception, so never add one to a line of reads): 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, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits) + 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 and Git read commands (status, diff, show, log, ls-files, ls-tree, check-ignore, rev-parse, merge-base, hash-object without -w, branch listings with no operand and only --show-current, -a/--all, -r/--remotes, -v/--verbose, -l/--list, --no-color or --no-abbrev (no --merged, --sort, --format or --contains), tag listings with no operand and only -l/--list, describe with only --tags, --always, --abbrev= or --exact-match and at most one revision). Bash may also run each of the following as its own Bash call, with nothing joined before or after it by ;, &&, || or a pipe (one exception follows the read grammar below): 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, and /ship Step 14.5's three own-tree housekeeping commands exactly as written, each on its own line: the shared .gstack/tmp exclude line (the one &&-joined housekeeping line this interface admits) ${DOCS_TMP_EXCLUDE} then ${DOCS_AUDIT_RESET} and the cap gate, which reads origin/main's policy and whose DOCS_CAP lines and exit status decide it ${docsCapsCommand(skills)} -(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no own-call command ever joins such a line. 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. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); 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. +(no --json, no other base, no other gstack-ship-receipt subcommand). ${readOnlyInterfaceText(fixture.home)} The Git reads above may join such a line too, but not in a line with cd. So may the installed ${skills}/bin/gstack-docs-candidate --help, alone or after snapshot or compare, because it only prints usage (the one exception); neither a joined Git read nor that --help may be fed by a pipe. A for loop over literal words (a lowercase loop variable; words without quotes, globs or $) whose body uses only such reads counts as those reads, one pass per word, wherever it sits in the line. Every read stays off actor-state.json and off ${docsConfigDir(fixture.home)} except its settings.json: that directory holds Claude's credentials and every agent's session transcript, so judge a child only by the output it returns (its echoed SESSION_KIND line included) and by the repository. The additional scripts below also run each as its own Bash call; no other own-call command ever joins such a line. 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. A private JSON/Markdown artifact (the snapshot --out file, saved child output, a section or report file) lives directly under ${fixture.home} or in a new directory you create there, never inside the repository, ${skills}, ${docsConfigDir(fixture.home)}, ${state} (gstack's own state directory, not a place for your files) or ${fixture.home}/remote.git. Use Write/Edit for permitted docs, those private artifacts and Step 14.5's receipt record ${fixture.repo}/${DOCS_AUDIT_RECORD} (the only permitted write under the repository besides the document, excluded from git by the line above); 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/docsync-observer.ts b/test/helpers/docsync-observer.ts index b97312e2b..8125fbe1e 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -322,9 +322,12 @@ function docsUnrollLoops(text: string): string | null { /** * A read-only line the shared grammar cannot see whole: loops are checked as - * their unrolled passes, and each declared Git read joined to grammar reads - * (never after a cd, never fed by a pipe) is checked literally and then stands - * in the line as a neutral read for the grammar. + * their unrolled passes, and each declared Git read or installed candidate + * --help joined to grammar reads (never after a cd, never fed by a pipe) is + * checked literally and then stands in the line as a neutral read for the grammar. + * The candidate's --help prints its usage and returns before it touches Git or a + * file (bin/gstack-docs-candidate main), so joining it reads nothing more (gates + * 40600731931 and 40627127462, screen 40558235014: the parent ended a line of reads with it). */ function docsComposedReadAllowed(text: string, fixture: ReturnType): boolean { const line = docsUnrollLoops(text); @@ -332,13 +335,16 @@ function docsComposedReadAllowed(text: string, fixture: ReturnType line.slice(segment.start, segment.end).trim()); - const git = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) ? [index] : []); - if (!git.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false; + const declared = commands.flatMap((command, index) => /^git(?:\s|$)/.test(command) || command.startsWith(`${candidate} `) ? [index] : []); + if (!declared.length || commands.some(command => /^cd(?:\s|$)/.test(command))) return false; let neutral = line; - for (const index of git.reverse()) { + for (const index of declared.reverse()) { const read = unwrapHarmlessRead(commands[index]!, fixture); - if (segments[index - 1]?.op === '|' || read === null || !/^git\s/.test(read) || !literalDocsCommandAllowed(read, fixture, [])) return false; + const help = read !== null && /^(?:(?:snapshot|compare) )?--help$/.test(read.slice(candidate.length + 1)); + if (segments[index - 1]?.op === '|' || read === null || !(/^git\s/.test(read) || (read.startsWith(`${candidate} `) && help)) + || !literalDocsCommandAllowed(read, fixture, [])) return false; neutral = `${neutral.slice(0, segments[index]!.start)} pwd ${neutral.slice(segments[index]!.end)}`; } return readOnlyCommandAllowed(neutral, docsReadOnlyScope(fixture)); @@ -384,6 +390,9 @@ function literalDocsCommandAllowed(text: string, fixture: ReturnType DOCS_BRANCH_LIST_FLAGS.test(arg)); + // A tag listing reads refs the same way (gate 40643690306: the parent ended a line of reads with `git tag`); + // an operand or any other option creates, deletes, signs or verifies a tag. + if (rest[0] === 'tag') return rest.slice(1).every(arg => arg === '-l' || arg === '--list'); if (rest[0] === 'describe') { const operands = rest.slice(1).filter(arg => !DOCS_DESCRIBE_FLAGS.test(arg)); return operands.length <= 1 && operands.every(arg => !arg.startsWith('-'));