mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 15:57:33 +01:00
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>
This commit is contained in:
1 parent
13459589df
commit
142ea2878d
4 files changed
+93
-7
No files matched your search
+4
-2
@@ -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 composed cat; cat of ship-docsync-failure 38984977679 (bash#12). The Bash grep of ship-docsync-missing-asset 38983306684 (bash#13) is a pure read inside the fixture home, so the shared read-only grammar (GSTA-49) accepts it in known_good_posix (the grammar resolves POSIX paths only and accepts nothing on Windows), as it does the Opus slips wc -l (39173752116) and ls with a glob (effort study t10); its tee, outside-path, recursive-credential and denied-glob mutations stay in known_bad.",
|
||||
"_comment": "Bash commands from docsync E2E runs and their mutations. {repo} is the fixture repo, {other} a directory outside it, {home} the fixture home. known_good: read-only forms the observer must accept. known_bad: forms it must keep rejecting. known_bad also keeps genuine model misses from PR gate runs as negative controls: the composed cat; cat of ship-docsync-failure 38984977679 (bash#12). The Bash grep of ship-docsync-missing-asset 38983306684 (bash#13) is a pure read inside the fixture home, so the shared read-only grammar (GSTA-49) accepts it in known_good_posix (the grammar resolves POSIX paths only and accepts nothing on Windows), as it does the Opus slips wc -l (39173752116) and ls with a glob (effort study t10); its tee, outside-path, recursive-credential and denied-glob mutations stay in known_bad. The narrow pwd/ls/cat/stat/sha256sum forms only read inside the fixture home, never the Claude config dir or actor state (GSTA-49 tightening): cat {home}/cc/.claude.json and cat /etc/hostname are known_bad.",
|
||||
"known_good": [
|
||||
"git status",
|
||||
"git -C {repo} status",
|
||||
@@ -41,6 +41,8 @@
|
||||
"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}/*"
|
||||
"ls -la {home}/*",
|
||||
"cat {home}/cc/.claude.json",
|
||||
"cat /etc/hostname"
|
||||
]
|
||||
}
|
||||
@@ -249,6 +249,14 @@ function docsReadOnlyScope(fixture: ReturnType<typeof fixtureDocs>) {
|
||||
deny: (file: string) => path.basename(file) === 'actor-state.json' || (config !== null && within(file, config)) };
|
||||
}
|
||||
|
||||
/** 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[] = [];
|
||||
@@ -266,7 +274,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;
|
||||
|
||||
@@ -44,6 +44,8 @@ type ToolSpec = {
|
||||
/** 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 = {
|
||||
@@ -88,6 +90,8 @@ export const READ_TOOLS: Readonly<Record<string, ToolSpec>> = {
|
||||
sha1sum: CHECKSUM,
|
||||
sha512sum: CHECKSUM,
|
||||
md5sum: CHECKSUM,
|
||||
echo: { flag: /^-[neE]+$/, literal: true },
|
||||
printf: { flag: /^--$/, literal: true },
|
||||
readlink: { flag: /^-[efmnqsvz]+$/ },
|
||||
realpath: { flag: /^-[eLmPqsz]+$/ },
|
||||
};
|
||||
@@ -104,7 +108,7 @@ 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.`;
|
||||
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; echo and printf with literal arguments only, no printf -v). 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 {
|
||||
@@ -227,6 +231,11 @@ function commandRejection(words: Word[], piped: boolean, cwds: string[], context
|
||||
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 (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[] = [];
|
||||
|
||||
@@ -68,7 +68,8 @@ const ACCEPTED = [
|
||||
'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;',
|
||||
'cd qa-only; wc -l SKILL.md', 'ls docs;', 'echo ----', 'echo -n done', "printf '%s\\n' done", 'printf -- -x',
|
||||
'cat docs/guide.md; echo ----; cat qa-only/SKILL.md', 'echo done | wc -c',
|
||||
];
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('accepted pure local reads', () => {
|
||||
@@ -148,7 +149,17 @@ const REJECTED: Array<[string, string, RegExp]> = [
|
||||
['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', 'tr a-z A-Z', /unlisted tool: tr/],
|
||||
['echo/printf', 'echo hi > docs/new.md', /redirect/],
|
||||
['echo/printf', 'echo hi >> docs/guide.md', /redirect/],
|
||||
['echo/printf', 'echo $(touch x)', /substitution or variable/],
|
||||
['echo/printf', 'echo ' + '\x60' + 'touch x' + '\x60', /substitution or variable/],
|
||||
['echo/printf', 'echo $X', /substitution or variable/],
|
||||
['echo/printf', 'echo ${X}', /substitution or variable/],
|
||||
['echo/printf', 'echo "$X"', /substitution or variable/],
|
||||
['echo/printf', 'printf -v X hi', /unsupported printf option: -v/],
|
||||
['echo/printf', 'echo *', /unquoted glob in literal arguments/],
|
||||
['echo/printf', 'echo hi | tee docs/new.md', /unlisted tool: tee/],
|
||||
['unlisted tool', 'env cat docs/guide.md', /unlisted tool: env/],
|
||||
['unlisted tool', '/bin/cat docs/guide.md', /unlisted tool/],
|
||||
['shell syntax', 'ls &', /background job/],
|
||||
@@ -166,7 +177,7 @@ describe.skipIf(process.platform === 'win32')('rejected classes stay rejected',
|
||||
|
||||
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'])
|
||||
for (const kind of ['write', 'redirect', 'writing pipe', 'substitution', 'network', 'cd out of root', 'path escape', 'sudo', 'unlisted tool', 'echo/printf'])
|
||||
expect(classes.has(kind), kind).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -184,6 +195,43 @@ const DOCSYNC_SLIPS = [
|
||||
['ls -la {home} {home}/.claude/skills/gstack {home}/.claude/skills/gstack/ship',
|
||||
'ls -la {home}/.claude/skills/gstack/ship/sections {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/document-release/*'],
|
||||
];
|
||||
// PR #3275 E2E run 39181697029, slice 3, ship-docsync-current: its whole Bash stream; the composed
|
||||
// cat; echo ----; cat was the only command outside the docsync interface.
|
||||
const DOCSYNC_CURRENT_STREAM = [
|
||||
"ls {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/document-release/sections",
|
||||
"git status",
|
||||
"git merge-base main HEAD",
|
||||
"git ls-files",
|
||||
"{home}/.claude/skills/gstack/bin/gstack-docs-candidate --help",
|
||||
"git diff 40d5e4c2069e23a19017c25494636fb8d0bd4c00 HEAD",
|
||||
"cat personal-note.txt",
|
||||
"git check-ignore -v .qa-state",
|
||||
"{home}/.claude/skills/gstack/bin/gstack-docs-candidate snapshot --out {home}/docs-audit-20261019-r2a1.json --audit-id docs-audit-20261019-r2a1 --mode edit --base 40d5e4c2069e23a19017c25494636fb8d0bd4c00 --docs handbook --docs README.md --docs AGENTS.md --docs CHANGELOG.md --docs TODOS.md",
|
||||
"cat {home}/fixture-interface.md; echo ----; cat {home}/docs-audit-20261019-r2a1.json",
|
||||
"GSTACK_SESSION_KIND=spawned {home}/.claude/skills/gstack/bin/gstack-skill-start --skill document-release --model claude",
|
||||
"git status",
|
||||
"git rev-parse HEAD",
|
||||
"git diff 40d5e4c2069e23a19017c25494636fb8d0bd4c00 HEAD",
|
||||
"git diff --cached",
|
||||
"git diff",
|
||||
"git ls-files -z --cached --others --exclude-standard",
|
||||
"git log 40d5e4c2069e23a19017c25494636fb8d0bd4c00..HEAD --oneline",
|
||||
"git ls-files -s",
|
||||
"git hash-object personal-note.txt",
|
||||
"git check-ignore -v .qa-state",
|
||||
"{home}/.claude/skills/gstack/bin/gstack-skill-end --skill document-release --outcome success --session-id 825-1792371119-d40648a8 --tel-start 1792371119 --used-browse no",
|
||||
"{home}/.claude/skills/gstack/bin/gstack-docs-candidate compare {home}/docs-audit-20261019-r2a1.json",
|
||||
"git status",
|
||||
"git rev-parse HEAD",
|
||||
"cat app.ts",
|
||||
"cat handbook/reference/commands/widget.md.tmpl",
|
||||
"cat README.md",
|
||||
"cat personal-note.txt",
|
||||
"{home}/.claude/skills/gstack/bin/gstack-docs-candidate snapshot --out {home}/docs-audit-20261019-r2a1-post.json --audit-id docs-audit-20261019-r2a1 --mode edit --base 40d5e4c2069e23a19017c25494636fb8d0bd4c00 --docs handbook --docs README.md --docs AGENTS.md --docs CHANGELOG.md --docs TODOS.md",
|
||||
"cat {home}/docs-audit-20261019-r2a1-documentation.md >> {home}/ship-report.md",
|
||||
"bun {home}/fixture-publish.ts",
|
||||
];
|
||||
|
||||
// TM1 focused qa-only-no-fix at e03bfeb: the first Bash call; {dir} is the qa-only fixture root.
|
||||
const QA_ONLY_SLIP = 'wc -l {dir}/qa-only/SKILL.md; ls {dir}/qa-only {dir}/qa-only/sections {dir}/qa/sections {dir}/qa/templates';
|
||||
|
||||
@@ -198,6 +246,25 @@ describe.skipIf(process.platform === 'win32')('free replays of the captured slip
|
||||
for (const command of commands) expect(docsCommandAllowed(command, fixture), command).toBe(true);
|
||||
});
|
||||
|
||||
test('the ship-docsync-current stream of 39181697029 passes the observer with no command failure', () => {
|
||||
const current = fixtureDocs('current');
|
||||
try {
|
||||
const publish = path.join(current.home, 'fixture-publish.ts');
|
||||
const commands = DOCSYNC_CURRENT_STREAM.map(command => command.replaceAll('{home}', current.home));
|
||||
expect(commands.filter(command => !docsCommandAllowed(command, current, [publish]))).toEqual([]);
|
||||
expect(docsToolFailures(nativeResult(commands), current, [publish], true)).toEqual([]);
|
||||
} finally { current.clean(); }
|
||||
});
|
||||
|
||||
test('docsync literal reads stay inside the fixture home and off its credentials (tightening)', () => {
|
||||
const config = path.join(fixture.env.CLAUDE_CONFIG_DIR, '.claude.json');
|
||||
for (const command of ['cat ' + config, 'sha256sum ' + config, 'stat ' + fixture.env.CLAUDE_CONFIG_DIR, 'ls -la ' + fixture.env.CLAUDE_CONFIG_DIR,
|
||||
'cat /etc/hostname', 'ls -la /', 'cat ../../outside.txt', 'cat ' + config + ' 2>/dev/null || true'])
|
||||
expect(docsCommandAllowed(command, fixture), command).toBe(false);
|
||||
for (const command of ['cat README.md', 'ls', 'pwd', 'stat app.ts', 'sha256sum README.md', 'cat ' + path.join(fixture.home, 'candidate.json'), 'ls ..'])
|
||||
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));
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user