mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
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>
This commit is contained in:
1 parent
8640ab4ff4
commit
2d42e4ece8
10 files changed
+804
-26
No files matched your search
@@ -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] });
|
||||
@@ -211,7 +212,7 @@ function unwrapHarmlessRead(text: string, fixture: ReturnType<typeof fixtureDocs
|
||||
const sameRepo = (value: string) => {
|
||||
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<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,22 @@ export function docsCommandAllowed(command: string, fixture: ReturnType<typeof f
|
||||
return literalDocsCommandAllowed(text, fixture, scripts);
|
||||
}
|
||||
|
||||
/** The shared read-only grammar inside the owned fixture home; private actor state and every Claude config file but settings.json (.claude.json holds the API key) 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) && 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<typeof fixtureDocs>): 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<typeof fixtureDocs>, 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<typeof fixt
|
||||
/[{}]/.test(arg.replace(/(?:\^|@)\{[^{}]*\}/g, ''))))) return false;
|
||||
if (args.some(arg => 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<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)} 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) && !/^(?:<tool_use_error>|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;
|
||||
|
||||
@@ -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<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: /^$/ },
|
||||
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<string> = 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user