From 2d42e4ece834a7fa2c16edda87ec136af6db4c26 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 18 Oct 2026 20:26:31 -0700 Subject: [PATCH] v1.91.143.0 fix(evals): shared read-only command grammar for fixtures; docsync opts in (GSTA-49) (#3288) * fix(evals): shared read-only command grammar for fixtures; docsync opts in (GSTA-49) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(evals): read-only grammar controls are POSIX-only; pin fail-closed on Windows (GSTA-49) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(evals): docsync grammar corpus forms run on POSIX only; Windows fails closed (GSTA-49) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(evals): echo/printf join the read-only grammar; docsync literal reads stay inside the fixture home (GSTA-49) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(evals): docsync keeps Git reads out of grammar lines; sed -n paging, composed-cat completed reads, config dir listable (GSTA-49) Fable 5.1 (CI's eval model) read the grammar's 'Join them with ;, && or ||' as covering Git reads: PR E2E 39186278010 failed 5 docsync cases on 21 joined commands. The interface now says joins cover only the grammar's own reads and Git, Preamble, lifecycle and candidate commands each run as their own Bash call. - read-only grammar: sed -n with a line-range print script (file or pipe) - docsync: a trailing ; on a declared read is a harmless wrapper; the completed- read check credits a cat inside an accepted grammar line (not piped, no cd); the Claude config dir is listable and settings.json readable while .claude.json and every other config file stay denied - focused Fable reruns: ship-docsync-missing-asset and ship-docsync-failure pass Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.143.0 (GSTA-49 shared read-only grammar) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- test/docsync-lifecycle-interface.test.ts | 5 +- test/fixtures/docsync-observer-commands.json | 25 +- test/helpers/docsync-observer.ts | 30 +- test/helpers/read-only-commands.ts | 398 +++++++++++++++++++ test/helpers/touchfiles-data.ts | 26 +- test/read-only-command-grammar.test.ts | 325 +++++++++++++++ 10 files changed, 804 insertions(+), 26 deletions(-) create mode 100644 test/helpers/read-only-commands.ts create mode 100644 test/read-only-command-grammar.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b247d0d57..b9405ec67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.91.143.0] - 2026-10-18 + +**Paid-eval fixtures can share one read-only Bash grammar, and the docs-sync fixture uses it, so a pure read inside the fixture no longer fails a case just because the model joined or piped it.** In four Opus gates, docs-sync and qa-only cases went red on commands like `wc -l `, `ls -la …/*` and `cat a; echo ----; cat b`. Each was a harmless read inside the fixture, but each fixture's hand-kept allowlist rejected it. `test/helpers/read-only-commands.ts` decides once whether a command line only reads inside a fixture root. Fixtures whose contract is about which reads happen keep their narrow lists. On this tree the PR gate's 13 docs-sync cases all pass on Fable 5.1 ($20.62). + +### Added +- **The shared grammar.** It accepts one line of listed read tools (pwd, ls, wc, head, tail, cat, stat, file, du, find, grep, rg, sort, uniq, cut, nl, the checksum tools, readlink, realpath, echo, printf and `sed -n p`), joined with `;`, `&&` or `||`. A pipe may feed only a filter, and `cd` may go only into the root. Every path operand, path-valued option and glob match must resolve, symlinks included, inside the root and outside the fixture's denied paths. It rejects other tools, redirects, substitutions, variables, tilde and brace expansion, background jobs, multi-line input, and the write, exec, symlink-following and output-file options of the listed tools. On Windows it accepts nothing. +- **The docs-sync fixture opts in.** Its declared interface includes the grammar text. Git reads, the Preamble block, lifecycle and candidate commands, and scripts still each run as their own Bash call. The completed-read check credits a `cat` inside an accepted line, and a trailing `;` on a declared read counts as a harmless wrapper. + +### Changed +- **Gate integrity, tightening:** docs-sync's narrow pwd, ls, cat, stat and sha256sum forms used to accept any path. Now each operand must stay inside the fixture home, and actor state and every Claude config file except `settings.json` stay unreadable (`.claude.json` holds the API key). A replay of all 538 docs-sync commands from four Opus gates and 466 from two Fable gates finds none newly rejected. +- **Gate integrity, widening:** 78 negative controls pin each rejected class to its rule. 56 accepted forms run for real and leave both the fixture tree and an outside tree byte-identical. `echo` and `printf` take literal arguments only (no `printf -v`). `sed` takes only `-n` and one line-range print script, so `-i`, `w`, `-e` and multi-command scripts stay rejected. No threshold, kind, trial, behavior assertion or narrow-list allowlist changes. + +### If you… +- **…write a fixture whose contract isn't about which reads happen:** accept `readOnlyCommandAllowed(command, { root, deny })` and declare `readOnlyInterfaceText(root)` to the actor. + ## [1.91.141.0] - 2026-10-18 **Daily `/cso` reports now leave out candidates without demonstrated in-scope impact.** The daily rubric already required an attacker-controlled entrypoint, a security-boundary crossing, demonstrated impact, and challenged protective controls. It now says what to omit: candidates whose impact needs code outside the snapshot or a future change, or that expose nothing sensitive. Those stay coverage evidence instead of supported findings. Comprehensive reports are unchanged and keep unresolved candidates as labeled hypotheses. In the third paid runtime qualification, daily precision was 47/53, below the 0.95 bar, and four of its five false-positive classes were findings of this kind. In a preregistered 12-cell pilot, 5 of the 6 false positives disappeared, every seeded issue was kept, and no new false positive appeared. Daily reports also drop low and informational real issues: 4 of the 13 pilot controls were lost this way. Garry accepted that trade-off. Comprehensive mode still reports them. diff --git a/VERSION b/VERSION index 639e36f47..b8efe5d73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.141.0 +1.91.143.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index b539c3953..f09fde826 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.141.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.143.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/package.json b/package.json index a665af9bd..3cba5dfca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.141", + "version": "1.91.143", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/docsync-lifecycle-interface.test.ts b/test/docsync-lifecycle-interface.test.ts index 9ca4c99a1..a8028988c 100644 --- a/test/docsync-lifecycle-interface.test.ts +++ b/test/docsync-lifecycle-interface.test.ts @@ -121,12 +121,15 @@ test('Git guidance uses the existing working directory; --git-dir and --work-tre describe('stored docsync observer commands', () => { const stored = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/docsync-observer-commands.json'), 'utf8')) as - { known_good: string[]; known_bad: string[] }; + { known_good: string[]; known_good_posix: string[]; known_bad: string[] }; const fill = (command: string) => command.replaceAll('{repo}', fixture.repo).replaceAll('{other}', path.dirname(fixture.repo)) .replaceAll('{home}', fixture.home); test.each(stored.known_good)('harmless read form is accepted: %s', command => { expect(docsCommandAllowed(fill(command), fixture)).toBe(true); }); + test.skipIf(process.platform === 'win32').each(stored.known_good_posix)('shared read-only grammar form is accepted: %s', command => { + expect(docsCommandAllowed(fill(command), fixture)).toBe(true); + }); test.each(stored.known_bad)('known-bad form stays rejected: %s', command => { expect(docsCommandAllowed(fill(command), fixture)).toBe(false); }); diff --git a/test/fixtures/docsync-observer-commands.json b/test/fixtures/docsync-observer-commands.json index 9a228b1a9..0ec117873 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 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 a Claude config file but settings.json, nor actor state (GSTA-49 tightening; the config dir itself stays listable): cat {home}/cc/.claude.json and cat /etc/hostname are known_bad. Fable 5.1 (PR #3288 E2E 39186278010 and a focused rerun): the grammar join applies only to its own reads, so joined Git reads stay known_bad; a trailing ; on a Git read is a harmless wrapper (known_good); a grep paged through sed -n with a line-range print script and an ls of the Claude config dir are known_good_posix; other sed scripts stay known_bad.", "known_good": [ "git status", "git -C {repo} status", @@ -11,7 +11,15 @@ "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", + "git rev-parse HEAD; " + ], + "known_good_posix": [ + "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/*", + "grep -n \"Ship-owned\\|^## \\|^### \\|read-only\\|spawned\" {home}/.claude/skills/gstack/document-release/SKILL.md | sed -n 1,200p", + "cat {home}/candidate.json; echo; ls -la {home}/state {home}/cc {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/ship" ], "known_bad": [ "git -C {other} status", @@ -31,7 +39,16 @@ "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}/cc/.claude*", + "cat {home}/cc/.claude.json", + "cat /etc/hostname", + "git rev-parse HEAD; git status", + "sed -n 1w {repo}/out.txt {repo}/README.md", + "sed -i s/a/b/ {repo}/README.md" ] } diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index ced112fc0..bfdbddbd7 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] }); @@ -211,7 +212,7 @@ function unwrapHarmlessRead(text: string, fixture: ReturnType { try { return fs.realpathSync(unquote(value)) === fs.realpathSync(fixture.repo); } catch { return false; } }; - let out = text.replace(/\s+\|\|\s+true$/, '').replace(/\s+2>\s*(?:\/dev\/null|&1)(?=\s|$)/g, '').trim(); + let out = text.replace(/\s*;$/, '').replace(/\s+\|\|\s+true$/, '').replace(/\s+2>\s*(?:\/dev\/null|&1)(?=\s|$)/g, '').trim(); const cd = /^cd\s+('[^']*'|"[^"]*"|\S+)\s+&&\s+/.exec(out); if (cd && sameRepo(cd[1]!)) out = out.slice(cd[0].length); const globals = /^git((?:\s+(?:--no-pager|-C\s+(?:'[^']*'|"[^"]*"|\S+)|-c\s+(?:'[^']*'|"[^"]*"|\S+)))+)(?=\s)/.exec(out); @@ -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,22 @@ 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) && file !== config && file !== path.join(config, 'settings.json')) }; +} + +/** 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[] = []; @@ -257,7 +275,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; @@ -293,7 +311,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)} Git reads, the Preamble block, lifecycle and candidate commands and scripts never join such a line: run each as its own Bash call, never joined to another command with ;, &&, || or a pipe, not even to another Git read. 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. @@ -381,8 +399,10 @@ export function docsCompletedRead(result: SkillTestResult, file: string, fixture if (options.beforeFirstEdit && ['Write', 'Edit'].includes(call.tool) && resolve(call.input?.file_path ?? '') === target) break; const read = call.tool === 'Read' && resolve(call.input?.file_path ?? '') === target; const command = String(call.input?.command ?? '').trim(); - const catArgs = /^cat\s+/.test(command) && !/[\n\r;&|<>`$\\(){}]/.test(command) - ? command.match(/'[^']*'|"[^"]*"|[^\s'"]+/g)?.slice(1).map(arg => /^['"]/.test(arg) ? arg.slice(1, -1) : arg) ?? [] : []; + const literal = /^cat\s+/.test(command) && !/[\n\r;&|<>`$\\(){}]/.test(command); + const grammar = !literal && !/(?:^|[;&|]\s*)cd\s/.test(command) && readOnlyCommandAllowed(command, docsReadOnlyScope(fixture)); + const catArgs = !literal && !grammar ? [] : command.split(/\s*(?:;|&&|\|\|)\s*/).filter(part => /^cat\s/.test(part) && !part.includes('|')) + .flatMap(part => part.match(/'[^']*'|"[^"]*"|[^\s'"]+/g)?.slice(1).map(arg => /^['"]/.test(arg) ? arg.slice(1, -1) : arg) ?? []); const cat = call.tool === 'Bash' && catArgs.some(arg => resolve(arg) === target); if ((read || cat) && !/^(?:|Error(?: reading file|:)|Exit code [1-9]\d*\b)/i.test(call.output.trimStart()) && call.output.replace(/^\s*\d+(?:→|\t)/gm, '').includes(source)) return true; diff --git a/test/helpers/read-only-commands.ts b/test/helpers/read-only-commands.ts new file mode 100644 index 000000000..c95fe522c --- /dev/null +++ b/test/helpers/read-only-commands.ts @@ -0,0 +1,398 @@ +/** + * 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; + /** 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 = { + 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: /^$/ }, + sed: { 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, + echo: { flag: /^-[neE]+$/, literal: true }, + printf: { flag: /^--$/, literal: true }, + 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', 'sed']); + +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*; sed only as -n with a line-range print script such as sed -n 1,200p; no output-file, symlink-following or external-program options; echo and printf with literal arguments only, no printf -v). Join only these reads 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); + if (name.text === 'sed') { + const start = args.findIndex(arg => !/^(?:-n|--quiet|--silent)$/.test(arg.text)); + const [script, ...files] = start < 0 ? [] : args.slice(start); + if (!script || !/^(?:\d+|\$)(?:,(?:\d+|\$))?p$/.test(script.text) || files.some(file => file.text.startsWith('-'))) + return 'sed takes only -n and a line-range print script'; + return pathsRejection(files, cwds, context, false); + } + 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[] = []; + 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..301901a48 --- /dev/null +++ b/test/read-only-command-grammar.test.ts @@ -0,0 +1,325 @@ +/** + * 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, docsCompletedRead, 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(() => { + if (process.platform === 'win32') return; + 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(() => { if (base) fs.rmSync(base, { recursive: true, force: true }); }); + +// Paid fixtures run on Linux; the grammar resolves POSIX paths only, so on Windows it accepts nothing. +test.if(process.platform === 'win32')('the grammar fails closed on Windows', () => { + const root = fs.realpathSync(os.tmpdir()); + for (const command of ['pwd', 'ls', 'wc -l x']) expect(readOnlyCommandAllowed(command, { root })).toBe(false); +}); + +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;', 'echo ----', 'echo -n done', "printf '%s\\n' done", 'printf -- -x', + 'cat docs/guide.md; echo ----; cat qa-only/SKILL.md', 'echo done | wc -c', 'sed -n 1,2p docs/guide.md', 'grep -n step qa-only/SKILL.md | sed -n 1,5p', +]; + +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', 'od -c docs/guide.md', /unlisted tool: od/], + ['sed', 'sed -i s/a/b/ docs/guide.md', /sed takes only -n and a line-range print script/], + ['sed', 'sed -n 1w docs/out.txt docs/guide.md', /sed takes only -n and a line-range print script/], + ['sed', 'sed -n -e 1p docs/guide.md', /sed takes only -n and a line-range print script/], + ['sed', "sed -n '1p;w docs/out.txt' docs/guide.md", /sed takes only -n and a line-range print script/], + ['path escape', 'sed -n 1p escape/secret.txt', /outside root/], + ['unlisted tool', 'awk 1 docs/guide.md', /unlisted tool: awk/], + ['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/], + ['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.skipIf(process.platform === 'win32')('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', 'echo/printf']) + 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/*'], +]; +// 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'; + +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 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, 'ls -la ' + config, 'cat ' + config + '.backup', 'grep -rn apiKey ' + 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 ..', + 'ls -la ' + fixture.env.CLAUDE_CONFIG_DIR, 'stat ' + fixture.env.CLAUDE_CONFIG_DIR, 'cat ' + path.join(fixture.env.CLAUDE_CONFIG_DIR, 'settings.json')]) + 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)); + }); + + // PR #3288 E2E run 39186278010 (Fable 5.1): the grammar's join invited joined Git reads, which the docsync + // interface keeps separate; the interface now says so, and these reads of the Claude config dir pass. + test('docsync keeps Git reads out of grammar lines and lists the Claude config dir (39186278010)', () => { + expect(docsNativeInterface(fixture)).toContain('not even to another Git read'); + for (const command of ['cat {home}/candidate.json; echo; ls -la {home}/state {home}/cc {home}/.claude/skills/gstack/document-release', + 'ls {home}/.claude/skills/gstack/ship {home}/state {home}/cc; wc -c {home}/.claude/skills/gstack/ship/sections/documentation.md']) + expect(docsCommandAllowed(command.replaceAll('{home}', fixture.home), fixture), command).toBe(true); + for (const command of ['git status; git branch --show-current; git rev-parse HEAD', 'ls -la {home}/state; git status', + 'git ls-files -z --cached --others --exclude-standard | tr \'\\0\' \'\\n\'']) + expect(docsCommandAllowed(command.replaceAll('{home}', fixture.home), fixture), command).toBe(false); + }); + + // Focused Fable rerun of ship-docsync-current and ship-docsync-failure (2026-10-19): a trailing ; on a Git read, a + // grep paged through sed -n, and the phase file read by a composed cat, which the completed-read check must credit. + test('docsync accepts the focused Fable slips and credits a composed cat as a completed read', () => { + const skill = path.join(fixture.home, '.claude/skills/gstack/document-release/SKILL.md'); + for (const command of ['git rev-parse HEAD; ', 'grep -n "Ship-owned\\|^## \\|read-only" ' + skill + ' | sed -n 1,200p']) + expect(docsCommandAllowed(command, fixture), command).toBe(true); + for (const command of ['git rev-parse HEAD; git status', 'git commit -m x;', 'sed -n 1w ' + path.join(fixture.repo, 'out.txt') + ' ' + skill]) + expect(docsCommandAllowed(command, fixture), command).toBe(false); + const phase = path.join(fixture.home, 'phase.md'); + fs.writeFileSync(phase, '# Phase\nstep one\n'); + const read = (command: string, output = '# Phase\nstep one\n') => + docsCompletedRead({ toolCalls: [{ tool: 'Bash', input: { command }, output }] } as unknown as SkillTestResult, phase, fixture); + const invocation = path.join(fixture.home, 'ship-invocation.md'); + expect(read('cat ' + invocation + '; echo ----; cat ' + phase + '; echo ----; ls -la ' + fixture.home)).toBe(true); + expect(read('cat ' + phase + ' | head -1')).toBe(false); + expect(read('cd ' + fixture.repo + ' && cat ../phase.md')).toBe(false); + expect(read('cat ' + phase + '; echo ----', '# Phase\n')).toBe(false); + expect(read('cat ' + phase + ' > ' + path.join(fixture.home, 'copy.md'))).toBe(false); + }); + + 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/); + }); +});