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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-19 02:30:22 +00:00
1 parent 142ea2878d
commit 125721f2ed
4 files changed
+73 -18

No files matched your search

+11 -5
View File
@@ -1,5 +1,5 @@
{
"_comment": "Bash commands from docsync E2E runs and their mutations. {repo} is the fixture repo, {other} a directory outside it, {home} the fixture home. known_good: read-only forms the observer must accept. known_bad: forms it must keep rejecting. known_bad also keeps genuine model misses from PR gate runs as negative controls: the 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.",
"_comment": "Bash commands from docsync E2E runs and their mutations. {repo} is the fixture repo, {other} a directory outside it, {home} the fixture home. known_good: read-only forms the observer must accept. known_bad: forms it must keep rejecting. known_bad also keeps genuine model misses from PR gate runs as negative controls: the composed cat; cat of ship-docsync-failure 38984977679 (bash#12). The Bash grep of ship-docsync-missing-asset 38983306684 (bash#13) is a pure read inside the fixture home, so the shared read-only grammar (GSTA-49) accepts it in known_good_posix (the grammar resolves POSIX paths only and accepts nothing on Windows), as it does the Opus slips wc -l (39173752116) and ls with a glob (effort study t10); its tee, outside-path, recursive-credential and denied-glob mutations stay in known_bad. The narrow pwd/ls/cat/stat/sha256sum forms only read inside the fixture home, never a Claude config file but settings.json, nor actor state (GSTA-49 tightening; the config dir itself stays listable): cat {home}/cc/.claude.json and cat /etc/hostname are known_bad. Fable 5.1 (PR #3288 E2E 39186278010 and a focused rerun): the grammar join applies only to its own reads, so joined Git reads stay known_bad; a trailing ; on a Git read is a harmless wrapper (known_good); a grep paged through sed -n with a line-range print script and an ls of the Claude config dir are known_good_posix; other sed scripts stay known_bad.",
"known_good": [
"git status",
"git -C {repo} status",
@@ -11,12 +11,15 @@
"git status --porcelain 2>/dev/null",
"cat docs/guide.md 2>/dev/null || true",
"git rev-parse HEAD || true",
"ls docs 2>/dev/null"
"ls docs 2>/dev/null",
"git rev-parse HEAD; "
],
"known_good_posix": [
"grep -n -i \"14.5\\|documentation\\|Step 15\\|Step 16\" {home}/.claude/skills/gstack/ship/SKILL.md",
"wc -l {home}/.claude/skills/gstack/ship/SKILL.md",
"ls -la {home}/.claude/skills/gstack/ship/sections {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/document-release/*"
"ls -la {home}/.claude/skills/gstack/ship/sections {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/document-release/*",
"grep -n \"Ship-owned\\|^## \\|^### \\|read-only\\|spawned\" {home}/.claude/skills/gstack/document-release/SKILL.md | sed -n 1,200p",
"cat {home}/candidate.json; echo; ls -la {home}/state {home}/cc {home}/.claude/skills/gstack/document-release {home}/.claude/skills/gstack/ship"
],
"known_bad": [
"git -C {other} status",
@@ -41,8 +44,11 @@
"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}/cc/.claude*",
"cat {home}/cc/.claude.json",
"cat /etc/hostname"
"cat /etc/hostname",
"git rev-parse HEAD; git status",
"sed -n 1w {repo}/out.txt {repo}/README.md",
"sed -i s/a/b/ {repo}/README.md"
]
}
+9 -6
View File
@@ -212,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);
@@ -242,11 +242,12 @@ 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. */
/** 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)) };
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. */
@@ -310,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. ${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.
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.
@@ -398,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;
+10 -2
View File
@@ -65,6 +65,7 @@ export const READ_TOOLS: Readonly<Record<string, ToolSpec>> = {
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]$/,
@@ -97,7 +98,7 @@ export const READ_TOOLS: Readonly<Record<string, ToolSpec>> = {
};
/** 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']);
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', '(', ')', '!', ',']);
@@ -108,7 +109,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; 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.`;
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 {
@@ -231,6 +232,13 @@ 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 (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}`;
+43 -5
View File
@@ -9,7 +9,7 @@ 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 { docsCommandAllowed, docsCompletedRead, docsNativeInterface, docsToolFailures } from './helpers/docsync-observer';
import { readOnlyCommandAllowed, readOnlyCommandRejection, readOnlyInterfaceText, READ_TOOLS, STDIN_TOOLS } from './helpers/read-only-commands';
import type { SkillTestResult } from './helpers/session-runner';
@@ -69,7 +69,7 @@ const ACCEPTED = [
'cat docs/guide.md | sort | uniq -c', 'ls qa | wc -l', 'grep -rn step qa-only | head -5', 'find qa -type f | sort',
'wc -l qa-only/SKILL.md; ls qa-only', 'ls docs && wc -l docs/guide.md', 'ls missing || ls docs', 'cd qa && ls sections',
'cd qa-only; wc -l SKILL.md', 'ls docs;', 'echo ----', 'echo -n done', "printf '%s\\n' done", 'printf -- -x',
'cat docs/guide.md; echo ----; cat qa-only/SKILL.md', 'echo done | wc -c',
'cat docs/guide.md; echo ----; cat qa-only/SKILL.md', 'echo done | wc -c', 'sed -n 1,2p docs/guide.md', 'grep -n step qa-only/SKILL.md | sed -n 1,5p',
];
describe.skipIf(process.platform === 'win32')('accepted pure local reads', () => {
@@ -147,7 +147,12 @@ const REJECTED: Array<[string, string, RegExp]> = [
['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', 'od -c docs/guide.md', /unlisted tool: od/],
['sed', 'sed -i s/a/b/ docs/guide.md', /sed takes only -n and a line-range print script/],
['sed', 'sed -n 1w docs/out.txt docs/guide.md', /sed takes only -n and a line-range print script/],
['sed', 'sed -n -e 1p docs/guide.md', /sed takes only -n and a line-range print script/],
['sed', "sed -n '1p;w docs/out.txt' docs/guide.md", /sed takes only -n and a line-range print script/],
['path escape', 'sed -n 1p escape/secret.txt', /outside root/],
['unlisted tool', 'awk 1 docs/guide.md', /unlisted tool: awk/],
['unlisted tool', 'tr a-z A-Z', /unlisted tool: tr/],
['echo/printf', 'echo hi > docs/new.md', /redirect/],
@@ -258,10 +263,11 @@ describe.skipIf(process.platform === 'win32')('free replays of the captured slip
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,
for (const command of ['cat ' + config, 'sha256sum ' + config, 'ls -la ' + config, 'cat ' + config + '.backup', 'grep -rn apiKey ' + fixture.env.CLAUDE_CONFIG_DIR,
'cat /etc/hostname', 'ls -la /', 'cat ../../outside.txt', 'cat ' + config + ' 2>/dev/null || true'])
expect(docsCommandAllowed(command, fixture), command).toBe(false);
for (const command of ['cat README.md', 'ls', 'pwd', 'stat app.ts', 'sha256sum README.md', 'cat ' + path.join(fixture.home, 'candidate.json'), 'ls ..'])
for (const command of ['cat README.md', 'ls', 'pwd', 'stat app.ts', 'sha256sum README.md', 'cat ' + path.join(fixture.home, 'candidate.json'), 'ls ..',
'ls -la ' + fixture.env.CLAUDE_CONFIG_DIR, 'stat ' + fixture.env.CLAUDE_CONFIG_DIR, 'cat ' + path.join(fixture.env.CLAUDE_CONFIG_DIR, 'settings.json')])
expect(docsCommandAllowed(command, fixture), command).toBe(true);
});
@@ -269,6 +275,38 @@ describe.skipIf(process.platform === 'win32')('free replays of the captured slip
expect(docsNativeInterface(fixture)).toContain(readOnlyInterfaceText(fixture.home));
});
// PR #3288 E2E run 39186278010 (Fable 5.1): the grammar's join invited joined Git reads, which the docsync
// interface keeps separate; the interface now says so, and these reads of the Claude config dir pass.
test('docsync keeps Git reads out of grammar lines and lists the Claude config dir (39186278010)', () => {
expect(docsNativeInterface(fixture)).toContain('not even to another Git read');
for (const command of ['cat {home}/candidate.json; echo; ls -la {home}/state {home}/cc {home}/.claude/skills/gstack/document-release',
'ls {home}/.claude/skills/gstack/ship {home}/state {home}/cc; wc -c {home}/.claude/skills/gstack/ship/sections/documentation.md'])
expect(docsCommandAllowed(command.replaceAll('{home}', fixture.home), fixture), command).toBe(true);
for (const command of ['git status; git branch --show-current; git rev-parse HEAD', 'ls -la {home}/state; git status',
'git ls-files -z --cached --others --exclude-standard | tr \'\\0\' \'\\n\''])
expect(docsCommandAllowed(command.replaceAll('{home}', fixture.home), fixture), command).toBe(false);
});
// Focused Fable rerun of ship-docsync-current and ship-docsync-failure (2026-10-19): a trailing ; on a Git read, a
// grep paged through sed -n, and the phase file read by a composed cat, which the completed-read check must credit.
test('docsync accepts the focused Fable slips and credits a composed cat as a completed read', () => {
const skill = path.join(fixture.home, '.claude/skills/gstack/document-release/SKILL.md');
for (const command of ['git rev-parse HEAD; ', 'grep -n "Ship-owned\\|^## \\|read-only" ' + skill + ' | sed -n 1,200p'])
expect(docsCommandAllowed(command, fixture), command).toBe(true);
for (const command of ['git rev-parse HEAD; git status', 'git commit -m x;', 'sed -n 1w ' + path.join(fixture.repo, 'out.txt') + ' ' + skill])
expect(docsCommandAllowed(command, fixture), command).toBe(false);
const phase = path.join(fixture.home, 'phase.md');
fs.writeFileSync(phase, '# Phase\nstep one\n');
const read = (command: string, output = '# Phase\nstep one\n') =>
docsCompletedRead({ toolCalls: [{ tool: 'Bash', input: { command }, output }] } as unknown as SkillTestResult, phase, fixture);
const invocation = path.join(fixture.home, 'ship-invocation.md');
expect(read('cat ' + invocation + '; echo ----; cat ' + phase + '; echo ----; ls -la ' + fixture.home)).toBe(true);
expect(read('cat ' + phase + ' | head -1')).toBe(false);
expect(read('cd ' + fixture.repo + ' && cat ../phase.md')).toBe(false);
expect(read('cat ' + phase + '; echo ----', '# Phase\n')).toBe(false);
expect(read('cat ' + phase + ' > ' + path.join(fixture.home, 'copy.md'))).toBe(false);
});
test('docsync keeps its git interface and denies credentials and actor state', () => {
for (const command of [
'git status; ls', 'cd repo && git fetch', 'git log | head -5',