mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
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:
1 parent
8640ab4ff4
commit
fcf235548e
5 files changed
+628
-18
No files matched your search
+11
-4
@@ -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}/*"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user