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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-19 01:11:44 +00:00
1 parent 8640ab4ff4
commit fcf235548e
5 files changed
+628 -18

No files matched your search

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