diff --git a/test/fixtures/docsync-observer-commands.json b/test/fixtures/docsync-observer-commands.json index 69601704a..0ec117873 100644 --- a/test/fixtures/docsync-observer-commands.json +++ b/test/fixtures/docsync-observer-commands.json @@ -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" ] } diff --git a/test/helpers/docsync-observer.ts b/test/helpers/docsync-observer.ts index 4e629a6f7..bfdbddbd7 100644 --- a/test/helpers/docsync-observer.ts +++ b/test/helpers/docsync-observer.ts @@ -212,7 +212,7 @@ function unwrapHarmlessRead(text: string, fixture: ReturnType { 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) { 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, '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= 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 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= 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 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) && !/^(?:|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; diff --git a/test/helpers/read-only-commands.ts b/test/helpers/read-only-commands.ts index b45d089fb..c95fe522c 100644 --- a/test/helpers/read-only-commands.ts +++ b/test/helpers/read-only-commands.ts @@ -65,6 +65,7 @@ export const READ_TOOLS: Readonly> = { 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> = { }; /** Tools that may receive a pipe. Each one only prints what it reads. */ -export const STDIN_TOOLS: ReadonlySet = new Set(['wc', 'head', 'tail', 'cat', 'grep', 'rg', 'sort', 'uniq', 'cut', 'nl', 'sha256sum', 'sha1sum', 'sha512sum', 'md5sum']); +export const STDIN_TOOLS: ReadonlySet = 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}`; diff --git a/test/read-only-command-grammar.test.ts b/test/read-only-command-grammar.test.ts index dea221a96..301901a48 100644 --- a/test/read-only-command-grammar.test.ts +++ b/test/read-only-command-grammar.test.ts @@ -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',