From 77ddf9a792356c76adf89714bc6a92e3feec67bd Mon Sep 17 00:00:00 2001 From: Jeffrey Montoya Date: Tue, 29 Sep 2026 16:38:52 -0400 Subject: [PATCH] feat(gateguard): pass read-only shell commands before the routine gate The first command that can change something is still gated. --- docs/gateguard/design-notes.md | 53 +++- docs/ja-JP/skills/gateguard/SKILL.md | 2 + docs/zh-CN/skills/gateguard/SKILL.md | 1 + manifests/context-packs/skill-triggers@1.json | 2 +- scripts/hooks/gateguard-fact-force.js | 22 +- scripts/lib/gateguard-readonly-shell.js | 186 ++++++++++++++ scripts/lib/gateguard-state.js | 5 + skills/gateguard/SKILL.md | 14 ++ tests/hooks/gateguard-fact-force.test.js | 109 ++++++-- tests/lib/gateguard-readonly-shell.test.js | 237 ++++++++++++++++++ tests/lib/gateguard-state.test.js | 9 + 11 files changed, 622 insertions(+), 18 deletions(-) create mode 100644 scripts/lib/gateguard-readonly-shell.js create mode 100644 tests/lib/gateguard-readonly-shell.test.js diff --git a/docs/gateguard/design-notes.md b/docs/gateguard/design-notes.md index de5e74040..695434b20 100644 --- a/docs/gateguard/design-notes.md +++ b/docs/gateguard/design-notes.md @@ -31,11 +31,17 @@ headings stable, since they are the anchors. - `scripts/lib/gateguard-change-profile.js`: pure analysis of the change text of an Edit/Write/MultiEdit into a bounded change profile (see [Change profile](#change-profile)). +- `scripts/lib/gateguard-readonly-shell.js`: decides whether a Bash or + PowerShell command is allowlisted read-only introspection (see + [Read-only first shell command](#read-only-first-shell-command)). Built by + `createReadOnlyShell()` from the hook's `quoteAwareSegments`, like the + search evidence. ## Fail to deny Every allowance other than a retry of an already-gated target (prior-search -credit, the trivial-edit pass, sibling collapse, the denial cap) is an exception to the first-touch +credit, the trivial-edit pass, sibling collapse, the denial cap, the read-only +first shell command) is an exception to the first-touch or routine denial, so each one falls back to the denial on any doubt: an unreadable transcript, a parse failure, an unresolvable path, or an exception. The lib exports that feed those decisions never throw (they catch and return a @@ -416,3 +422,48 @@ comes from a fixed set; the detail is the tool input, sanitized like a path, whitespace-folded and cut to 60 characters. Sensitive targets never get the line, since no search could have credited them. In a condensed denial the line follows the batch warning. + +## Read-only first shell command + +The routine shell gate asks for the user's request once per session, before +the first Bash or PowerShell command. Sessions usually open with `ls`, +`git status` or a search, which the question adds nothing to, so a command +that is only read-only introspection passes without using the gate up. The +next command that is not read-only still draws the routine denial, so the +question still comes before the first command that can change something. + +- Destructive detection runs first and is unchanged; the check sits after it + and after `GATEGUARD_BASH_ROUTINE_DISABLED`, inside the routine gate, and is + skipped once the gate is checked. A pass returns the input unchanged, + never marks the routine gate checked, and adds one to + `routine_readonly_passes`. +- Character screen, quote-aware: any non-ASCII or control character (other + than tab), an unterminated quote, or a lone `&` rejects. Bash rejects + backslashes anywhere, `` ` `` and `$` outside single quotes, and unquoted + `<`, `>`, `(`, `)`, `{`, `}`, so redirection, here-documents, command, + process and parameter substitution never pass. PowerShell rejects `` ` `` + and `$` outside single quotes and unquoted `<`, `>`, `(`, `)`, `{`, `}`, + `@`, `[`, `]`. Non-ASCII is rejected because PowerShell treats typographic + quotes as quotes, which the segmenter does not. +- The screened command is split by the hook's quote-aware segmenter on `;`, + `|`, `&&` and `||`. PowerShell backslashes are doubled first, since they are + literal there. Every segment must pass. +- Each segment's first word must be an exact allowlisted name with no `=`, + path separator or glob character, so assignments, wrappers (`env`, `sudo`, + `sh -c`, `xargs`), paths and unknown programs reject. Bash: `ls`, `pwd`, + `cat`, `head`, `tail`, `wc`, `grep`, `rg`, `find`, `fd`, `tree`, `git`. + PowerShell (case-insensitive): `Get-ChildItem`, `Get-Content`, + `Select-String`, `Get-Location` and their aliases, `rg`, `git`; `--%` + rejects. +- Options that execute or write reject: `find -exec*`/`-ok*`/`-delete`/ + `-fprint*`/`-fls`, `fd -x`/`-X`/`--exec*` (also inside a short cluster), + `rg --pre*`/`--hostname-bin`, `tree -o`/`-R` (also inside a short cluster). +- `git` takes no global options. The subcommand must be `status`, `log`, + `diff`, `show`, `ls-files`, `rev-parse` or `branch`, and every option must + be in that subcommand's allowlist, so `--output` (and its abbreviations), + `--ext-diff` and `--textconv` reject. `branch` takes no positional + argument, so it only lists. +- Over 4096 characters, a non-string command, or any exception rejects. + +The earlier read-only git allowlist, which runs before destructive detection, +is unchanged. diff --git a/docs/ja-JP/skills/gateguard/SKILL.md b/docs/ja-JP/skills/gateguard/SKILL.md index efc59d77c..8aa5787ff 100644 --- a/docs/ja-JP/skills/gateguard/SKILL.md +++ b/docs/ja-JP/skills/gateguard/SKILL.md @@ -116,6 +116,8 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc 2. What this specific command verifies or produces ``` +このゲートが発火するまでは、読み取り専用のコマンド(`ls`、`cat`、`rg`、`git status`/`log`/`diff`、`Get-ChildItem` など。リダイレクト、置換、`tee`、`xargs`、未知のコマンドを含まないもの)はゲートを消費せずに通過し `routine_readonly_passes` に数えます。 + ## すでに済んでいるとみなされるもの 調査がすでに行われたことがトランスクリプトから確認できる場合、または前回の拒否と同じ回答になる場合、ゲートは拒否の代わりに注記(`additionalContext`)付きで通過させます。判断が曖昧な場合は常に従来どおり拒否します。 diff --git a/docs/zh-CN/skills/gateguard/SKILL.md b/docs/zh-CN/skills/gateguard/SKILL.md index 1ca517049..e50ff6282 100644 --- a/docs/zh-CN/skills/gateguard/SKILL.md +++ b/docs/zh-CN/skills/gateguard/SKILL.md @@ -98,6 +98,7 @@ LLM 的自我评估不起作用。问"你是否违反了任何策略?"答案 * **同回合同级文件合并:** 同一目录、同一类别(仅 code、test、prose)的新文件在同一回合内(完全没有 transcript 路径时为 120 秒内)只拒绝第一个,其余以附注放行。config、instruction,以及任何以 `.` 开头的路径段(点文件和所有点目录)或 8.3 短文件名下的文件从不合并;目录按解析符号链接后的真实位置判定和记录;有 transcript 路径但文件缺失、无法读取或得不到回合 ID 时不合并;编辑从不合并。 * **拒绝上限(可选):** 设置 `GATEGUARD_FACT_FORCE_MAX_DENIALS` 后,会话内首次触碰的拒绝次数达到该值后,新路径直接放行(计入 `cap_allows`)。`GATEGUARD_FACT_FORCE_FULL_DENIALS` 只决定消息详略(消息预算),而此变量决定是否拦截(拒绝预算)。事先搜索抵免和同级合并先于上限判定,不消耗上限;格式错误的值视为无上限,并在 stderr 警告一次。 * **敏感目标:** `.env`/`.env.*`、`*.pem`/`*.key`/`*.p12`/`*.pfx`、`id_rsa*`/`id_ed25519*`/`id_ecdsa*`/`id_dsa*`/`.netrc`/`.pgpass`/`credentials*`/`secrets.*`、任一路径段恰为 `auth`、`authn`、`authz`、`security`、`secrets`、`payment`、`payments`、`billing`、`migrations`,以及 `.github/workflows/` 下的文件,不适用抵免、合并和上限,首次触碰总是拒绝。按字面路径和解析符号链接后的真实位置同时判定(`src/tools -> ../auth` 时 `src/tools/login.py` 也是敏感目标),真实位置无法解析时视为敏感。判定顺序:exempt → subagent → checked → 敏感? → 抵免 → 同级合并 → 上限 → 拒绝。 +* **例行 shell 门控与会话:** 门控触发前,只读命令(`ls`、`cat`、`rg`、`git status`/`log`/`diff`、`Get-ChildItem` 等,不含重定向、替换、`tee`、`xargs` 或未知命令)直接放行且不消耗门控,计入 `routine_readonly_passes`。 * **兼容性:** 唯一新增的环境变量是可选的 `GATEGUARD_FACT_FORCE_MAX_DENIALS`;以前允许的操作不会变为拒绝,且从不返回 `permissionDecision: "allow"`。 ## 快速开始 diff --git a/manifests/context-packs/skill-triggers@1.json b/manifests/context-packs/skill-triggers@1.json index b3921a6d0..e00e93c02 100644 --- a/manifests/context-packs/skill-triggers@1.json +++ b/manifests/context-packs/skill-triggers@1.json @@ -1 +1 @@ -{"coverage":{"skills":293,"withTriggers":32},"generatedAt":"2026-09-24T23:51:22.784Z","id":"skill-triggers@1","model":{"effort":null,"id":"hand-seeded","source":"manual-curation-pending-regeneration"},"registryDigest":"6f59229bb0380819b650579bb0eb3b3896f2cced635d166842996038b01b7a0f","schemaVersion":1,"triggers":{"skill:api-connector-builder":["add api integration","new provider connector","match existing integration pattern"],"skill:api-design":["rest endpoint design","pagination api","status codes","api versioning","rate limiting api","resource naming","filtering api","api error responses","offset pagination","limit query parameter","pagination defaults"],"skill:backend-patterns":["express api","node backend architecture","nextjs api routes","server side patterns","data access layer","static file server","url path handling","file server"],"skill:browser-qa":["deployed feature test","visual regression screenshots","core web vitals check","axe accessibility audit","ship do not ship","staging verification"],"skill:canary-watch":["post deploy monitoring","smoke test url","production url check","console errors production","sse stream check","after deploy verification"],"skill:code-tour":["onboarding walkthrough","explain subsystem","architecture tour","pr walkthrough","rca tour"],"skill:coding-standards":["code review standards","naming conventions","readability review","immutability conventions","fix naming typo","export naming","consistent exports"],"skill:content-hash-cache-pattern":["cache file processing","content addressed cache","sha256 hash cache"],"skill:database-migrations":["zero downtime migration","schema change production","add column large table","backfill data","expand contract","concurrent index","migration rollback","prisma migration","django migration"],"skill:deployment-patterns":["ci cd setup","dockerize app","health checks","rollback strategy","production readiness","deploy pipeline","containerize application"],"skill:design-system":["design tokens","visual consistency audit","css custom properties","ui audit","design system bootstrap"],"skill:django-patterns":["django orm","drf api","django rest framework","django caching","django signals","django middleware"],"skill:django-security":["django authentication","csrf protection","sql injection prevention","xss prevention","django deployment security","role based access control","authorization middleware","permissions checks"],"skill:docker-patterns":["dockerfile review","docker compose setup","container security","multi service orchestration"],"skill:error-handling":["error types","retry logic","circuit breaker","user facing errors","exception handling patterns","typed errors","error boundaries","go error handling","custom error class","error codes","config validation"],"skill:evm-token-decimals":["token decimals","wei conversion","erc20 balance off","bridge token precision"],"skill:frontend-a11y":["aria attributes","screen reader support","focus management","semantic html","form labeling","keyboard navigation react","a11y lint errors"],"skill:git-workflow":["merge vs rebase","commit conventions","resolve merge conflict","branching strategy","clean up commits","pull request cleanup","git history tidy"],"skill:hexagonal-architecture":["ports and adapters","dependency injection boundaries","decouple domain from io"],"skill:kubernetes-patterns":["kubernetes manifests","kubectl debugging","pod probes","k8s rbac","autoscaling config","configmap secrets"],"skill:orch-fix-defect":["fix a bug","broken behavior","regression fix","reproduce bug","defect repair"],"skill:postgres-patterns":["slow postgres query","query optimization","index design","rls policies","supabase schema","postgres indexing","database performance","schema design postgres","postgres driver","node postgres","query planner"],"skill:python-patterns":["pythonic code","pep 8","type hints python","python code review","idiomatic python"],"skill:python-testing":["pytest fixtures","mocking python","parametrized tests","coverage python","tdd python"],"skill:redis-patterns":["cache aside pattern","distributed lock","redis rate limiting","cache invalidation"],"skill:regex-vs-llm-structured-text":["parse invoice","extract receipt data","text extraction pipeline","parse form fields","cheap document parser","extract table data","parse log lines","parse access logs","common log format","log line parsing"],"skill:rust-patterns":["rust ownership","borrow checker","rust error handling","traits rust","rust concurrency","idiomatic rust"],"skill:search-first":["find existing library","npm package research","before writing custom code","evaluate existing tools","add dependency research"],"skill:security-review":["security audit","authentication review","sanitize user input","secrets handling","payment security checklist","prevent injection attacks","secure api endpoints","authn authz review","vulnerability checklist","input validation security","parameterized queries","sql injection"],"skill:security-scan":["audit claude config","claudemd security","mcp server audit","agentshield scan","hook configuration audit","settings json security"],"skill:tdd-workflow":["write test first","failing test","red green refactor","test driven development","regression test first","write a regression test"],"skill:verification-loop":["pre pr checks","verification report","quality gates","build lint test coverage","before creating a pr"]},"triggersDigest":"25b97a9e06fc336c7cf95ab854ed1a41033a54bcd6e1fb1cf69dc906332462aa"} +{"coverage":{"skills":293,"withTriggers":32},"generatedAt":"2026-09-24T23:51:22.784Z","id":"skill-triggers@1","model":{"effort":null,"id":"hand-seeded","source":"manual-curation-pending-regeneration"},"registryDigest":"128108bf6757a91f5e920fd448525efe3259bb47d76984859b76e588213fbaac","schemaVersion":1,"triggers":{"skill:api-connector-builder":["add api integration","new provider connector","match existing integration pattern"],"skill:api-design":["rest endpoint design","pagination api","status codes","api versioning","rate limiting api","resource naming","filtering api","api error responses","offset pagination","limit query parameter","pagination defaults"],"skill:backend-patterns":["express api","node backend architecture","nextjs api routes","server side patterns","data access layer","static file server","url path handling","file server"],"skill:browser-qa":["deployed feature test","visual regression screenshots","core web vitals check","axe accessibility audit","ship do not ship","staging verification"],"skill:canary-watch":["post deploy monitoring","smoke test url","production url check","console errors production","sse stream check","after deploy verification"],"skill:code-tour":["onboarding walkthrough","explain subsystem","architecture tour","pr walkthrough","rca tour"],"skill:coding-standards":["code review standards","naming conventions","readability review","immutability conventions","fix naming typo","export naming","consistent exports"],"skill:content-hash-cache-pattern":["cache file processing","content addressed cache","sha256 hash cache"],"skill:database-migrations":["zero downtime migration","schema change production","add column large table","backfill data","expand contract","concurrent index","migration rollback","prisma migration","django migration"],"skill:deployment-patterns":["ci cd setup","dockerize app","health checks","rollback strategy","production readiness","deploy pipeline","containerize application"],"skill:design-system":["design tokens","visual consistency audit","css custom properties","ui audit","design system bootstrap"],"skill:django-patterns":["django orm","drf api","django rest framework","django caching","django signals","django middleware"],"skill:django-security":["django authentication","csrf protection","sql injection prevention","xss prevention","django deployment security","role based access control","authorization middleware","permissions checks"],"skill:docker-patterns":["dockerfile review","docker compose setup","container security","multi service orchestration"],"skill:error-handling":["error types","retry logic","circuit breaker","user facing errors","exception handling patterns","typed errors","error boundaries","go error handling","custom error class","error codes","config validation"],"skill:evm-token-decimals":["token decimals","wei conversion","erc20 balance off","bridge token precision"],"skill:frontend-a11y":["aria attributes","screen reader support","focus management","semantic html","form labeling","keyboard navigation react","a11y lint errors"],"skill:git-workflow":["merge vs rebase","commit conventions","resolve merge conflict","branching strategy","clean up commits","pull request cleanup","git history tidy"],"skill:hexagonal-architecture":["ports and adapters","dependency injection boundaries","decouple domain from io"],"skill:kubernetes-patterns":["kubernetes manifests","kubectl debugging","pod probes","k8s rbac","autoscaling config","configmap secrets"],"skill:orch-fix-defect":["fix a bug","broken behavior","regression fix","reproduce bug","defect repair"],"skill:postgres-patterns":["slow postgres query","query optimization","index design","rls policies","supabase schema","postgres indexing","database performance","schema design postgres","postgres driver","node postgres","query planner"],"skill:python-patterns":["pythonic code","pep 8","type hints python","python code review","idiomatic python"],"skill:python-testing":["pytest fixtures","mocking python","parametrized tests","coverage python","tdd python"],"skill:redis-patterns":["cache aside pattern","distributed lock","redis rate limiting","cache invalidation"],"skill:regex-vs-llm-structured-text":["parse invoice","extract receipt data","text extraction pipeline","parse form fields","cheap document parser","extract table data","parse log lines","parse access logs","common log format","log line parsing"],"skill:rust-patterns":["rust ownership","borrow checker","rust error handling","traits rust","rust concurrency","idiomatic rust"],"skill:search-first":["find existing library","npm package research","before writing custom code","evaluate existing tools","add dependency research"],"skill:security-review":["security audit","authentication review","sanitize user input","secrets handling","payment security checklist","prevent injection attacks","secure api endpoints","authn authz review","vulnerability checklist","input validation security","parameterized queries","sql injection"],"skill:security-scan":["audit claude config","claudemd security","mcp server audit","agentshield scan","hook configuration audit","settings json security"],"skill:tdd-workflow":["write test first","failing test","red green refactor","test driven development","regression test first","write a regression test"],"skill:verification-loop":["pre pr checks","verification report","quality gates","build lint test coverage","before creating a pr"]},"triggersDigest":"25b97a9e06fc336c7cf95ab854ed1a41033a54bcd6e1fb1cf69dc906332462aa"} diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 2225b56c9..bee2a0810 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -43,11 +43,13 @@ const { const { scanCurrentTurn, createTurnScanner, currentTurnId, transcriptPathFor } = require('../lib/gateguard-turn-scan'); const { createSearchEvidence } = require('../lib/gateguard-search-evidence'); const { profileChange } = require('../lib/gateguard-change-profile'); +const { createReadOnlyShell } = require('../lib/gateguard-readonly-shell'); const { getDenialCount, getCreditedCount, getCapAllowCount, getTrivialAllowCount, + getRoutineReadonlyPassCount, getSiblingAllowCount, getClassCounts, mergeClassCounts, @@ -1453,6 +1455,7 @@ function saveState(state) { let mergedSiblingAllows = getSiblingAllowCount(state); let mergedCapAllows = getCapAllowCount(state); let mergedTrivialAllows = getTrivialAllowCount(state); + let mergedReadonlyPasses = getRoutineReadonlyPassCount(state); try { if (fs.existsSync(stateFile)) { @@ -1471,6 +1474,7 @@ function saveState(state) { mergedSiblingAllows = Math.max(mergedSiblingAllows, getSiblingAllowCount(diskState)); mergedCapAllows = Math.max(mergedCapAllows, getCapAllowCount(diskState)); mergedTrivialAllows = Math.max(mergedTrivialAllows, getTrivialAllowCount(diskState)); + mergedReadonlyPasses = Math.max(mergedReadonlyPasses, getRoutineReadonlyPassCount(diskState)); } } catch (_) { /* ignore malformed or transient disk state */ @@ -1486,7 +1490,8 @@ function saveState(state) { credited_by_class: mergedCreditedByClass, sibling_allows: mergedSiblingAllows, cap_allows: mergedCapAllows, - trivial_allows: mergedTrivialAllows + trivial_allows: mergedTrivialAllows, + routine_readonly_passes: mergedReadonlyPasses }; // Atomic write: temp file + rename prevents partial reads @@ -1636,6 +1641,15 @@ function isTrivialChange(cls, sensitive, profile) { return !sensitive && TRIVIAL_CLASSES.has(cls) && Boolean(profile) && profile.known === true && profile.trivial === true; } +// --- Read-only first shell command --- + +const { isReadOnlyShellCommand } = createReadOnlyShell({ quoteAwareSegments }); + +function countRoutineReadonlyPass() { + const state = loadState(); + return saveState({ ...state, routine_readonly_passes: getRoutineReadonlyPassCount(state) + 1 }); +} + // --- Sibling gates --- const SIBLING_NO_TURN_WINDOW_MS = 120 * 1000; @@ -2153,6 +2167,7 @@ function run(rawInput) { } const edits = toolInput.edits || []; + const notes = []; const trivialKeys = new Set(); for (const edit of edits) { @@ -2226,6 +2241,9 @@ function run(rawInput) { } if (!isChecked(ROUTINE_BASH_SESSION_KEY)) { + if (isReadOnlyShellCommand(toolName, command)) { + return countRoutineReadonlyPass() ? rawInput : allowWithStateWarning(); + } if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { return allowWithStateWarning(); } @@ -2245,4 +2263,4 @@ function run(rawInput) { return rawInput; // allow } -module.exports = { classifyDestructiveCommand, classifyTarget, classifyTargetFor, findCreditingSearch, findClosestMiss, run, scanCurrentTurn }; +module.exports = { classifyDestructiveCommand, classifyTarget, classifyTargetFor, findCreditingSearch, findClosestMiss, isReadOnlyShellCommand, run, scanCurrentTurn }; diff --git a/scripts/lib/gateguard-readonly-shell.js b/scripts/lib/gateguard-readonly-shell.js new file mode 100644 index 000000000..b667f1f2c --- /dev/null +++ b/scripts/lib/gateguard-readonly-shell.js @@ -0,0 +1,186 @@ +'use strict'; + +// see docs/gateguard/design-notes.md#read-only-first-shell-command + +const MAX_COMMAND_CHARS = 4096; + +// --- character screen --- + +const BASH_UNQUOTED_REJECT = new Set(['\\', '`', '$', '<', '>', '(', ')', '{', '}']); +const BASH_DOUBLE_QUOTED_REJECT = new Set(['\\', '`', '$']); +const BASH_SINGLE_QUOTED_REJECT = new Set(['\\']); +const PS_UNQUOTED_REJECT = new Set(['`', '$', '<', '>', '(', ')', '{', '}', '@', '[', ']']); +const PS_DOUBLE_QUOTED_REJECT = new Set(['`', '$']); +const PS_SINGLE_QUOTED_REJECT = new Set(); + +const DIALECTS = Object.freeze({ + Bash: { unquoted: BASH_UNQUOTED_REJECT, double: BASH_DOUBLE_QUOTED_REJECT, single: BASH_SINGLE_QUOTED_REJECT }, + PowerShell: { unquoted: PS_UNQUOTED_REJECT, double: PS_DOUBLE_QUOTED_REJECT, single: PS_SINGLE_QUOTED_REJECT } +}); + +function isPlainAscii(code) { + return code === 0x09 || (code >= 0x20 && code < 0x7f); +} + +function passesCharacterScreen(command, dialect) { + let quote = null; + for (let index = 0; index < command.length; index++) { + const ch = command[index]; + if (!isPlainAscii(ch.charCodeAt(0))) return false; + if (quote === "'") { + if (dialect.single.has(ch)) return false; + if (ch === "'") quote = null; + continue; + } + if (quote === '"') { + if (dialect.double.has(ch)) return false; + if (ch === '"') quote = null; + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + if (dialect.unquoted.has(ch)) return false; + if (ch === '&') { + if (command[index + 1] !== '&') return false; + index++; + } + } + return quote === null; +} + +// --- per-command rules --- + +const anyArgs = () => true; +const noArg = pattern => args => !args.some(arg => pattern.test(arg)); + +const FIND_ACTIONS = /^-(exec|ok|delete|fprint|fls)/; +const FD_EXEC = /^(--exec|-[^-]*[xX])/; +const RG_EXEC = /^--(pre|hostname-bin)/; +const TREE_WRITE = /^(-[^-]*[oR]|--o)/; + +const BASH_COMMANDS = new Map([ + ['ls', anyArgs], + ['pwd', anyArgs], + ['cat', anyArgs], + ['head', anyArgs], + ['tail', anyArgs], + ['wc', anyArgs], + ['grep', anyArgs], + ['rg', noArg(RG_EXEC)], + ['find', noArg(FIND_ACTIONS)], + ['fd', noArg(FD_EXEC)], + ['tree', noArg(TREE_WRITE)], + ['git', isReadOnlyGit] +]); + +const POWERSHELL_COMMANDS = new Map([ + ['get-childitem', anyArgs], + ['gci', anyArgs], + ['dir', anyArgs], + ['ls', anyArgs], + ['get-content', anyArgs], + ['gc', anyArgs], + ['cat', anyArgs], + ['type', anyArgs], + ['select-string', anyArgs], + ['sls', anyArgs], + ['get-location', anyArgs], + ['gl', anyArgs], + ['pwd', anyArgs], + ['rg', noArg(RG_EXEC)], + ['git', isReadOnlyGit] +]); + +// --- git --- + +const GIT_LOG_VALUE_FLAGS = /^--(max-count=\d+|since=.*|until=.*|author=.*|grep=.*|format=.*|pretty=.*|date=.*)$/; +const GIT_DIFF_VALUE_FLAGS = /^(-U\d+|--unified=\d+)$/; + +const GIT_SUBCOMMANDS = new Map([ + ['status', { positional: true, flags: ['--short', '-s', '--branch', '-b', '--porcelain', '--porcelain=v1', '--porcelain=v2', '-uno', '-unormal', '-uall'] }], + [ + 'log', + { + positional: true, + flags: ['--oneline', '--stat', '--name-only', '--name-status', '--graph', '--decorate', '--all', '-p', '--patch', '--no-color', '--reverse', '--first-parent', '--no-merges', '--merges', '--follow', '--abbrev-commit', '-n'], + pattern: value => /^-\d+$/.test(value) || GIT_LOG_VALUE_FLAGS.test(value) + } + ], + [ + 'diff', + { + positional: true, + flags: ['--name-only', '--name-status', '--cached', '--staged', '--stat', '--numstat', '--shortstat', '--no-color', '-w', '--ignore-all-space', '--word-diff', '--merge-base', '--check'], + pattern: value => GIT_DIFF_VALUE_FLAGS.test(value) + } + ], + [ + 'show', + { + positional: true, + flags: ['--stat', '--name-only', '--name-status', '--oneline', '--no-color', '-p', '--patch', '--no-patch', '-s'], + pattern: value => /^--(format|pretty)=/.test(value) + } + ], + ['ls-files', { positional: true, flags: ['--others', '-o', '--exclude-standard', '--cached', '-c', '--modified', '-m', '--deleted', '-d', '--stage', '-s'] }], + ['rev-parse', { positional: true, flags: ['--abbrev-ref', '--show-toplevel', '--git-dir', '--short', '--verify', '--is-inside-work-tree', '--show-prefix', '--symbolic-full-name'] }], + ['branch', { positional: false, flags: ['--show-current', '-a', '--all', '-r', '--remotes', '--list', '-v', '-vv', '--no-color'] }] +]); + +function isReadOnlyGit(args) { + if (args.length === 0) return false; + const rule = GIT_SUBCOMMANDS.get(args[0]); + if (!rule) return false; + let pathspec = false; + for (const arg of args.slice(1)) { + if (pathspec) continue; + if (arg === '--') { + if (!rule.positional) return false; + pathspec = true; + continue; + } + if (arg.startsWith('-')) { + if (rule.flags.includes(arg) || (rule.pattern && rule.pattern(arg))) continue; + return false; + } + if (!rule.positional) return false; + } + return true; +} + +// --- entry --- + +function createReadOnlyShell({ quoteAwareSegments }) { + function segmentsFor(command, toolName) { + return quoteAwareSegments(toolName === 'PowerShell' ? command.replace(/\\/g, '\\\\') : command); + } + + function isReadOnlySegment(words, toolName) { + const [name, ...args] = words; + if (!name || /[=/\\*?[]/.test(name)) return false; + const table = toolName === 'PowerShell' ? POWERSHELL_COMMANDS : BASH_COMMANDS; + const rule = table.get(toolName === 'PowerShell' ? name.toLowerCase() : name); + if (!rule) return false; + if (toolName === 'PowerShell' && args.includes('--%')) return false; + return rule(args); + } + + /** True only when every segment of the command is allowlisted read-only introspection. */ + function isReadOnlyShellCommand(toolName, command) { + try { + const dialect = Object.hasOwn(DIALECTS, toolName) ? DIALECTS[toolName] : null; + if (!dialect || typeof command !== 'string' || command.length > MAX_COMMAND_CHARS) return false; + if (!passesCharacterScreen(command, dialect)) return false; + const segments = segmentsFor(command, toolName); + return segments.length > 0 && segments.every(words => isReadOnlySegment(words, toolName)); + } catch (_) { + return false; + } + } + + return { isReadOnlyShellCommand }; +} + +module.exports = { createReadOnlyShell }; diff --git a/scripts/lib/gateguard-state.js b/scripts/lib/gateguard-state.js index 51338bb85..aafaff09a 100644 --- a/scripts/lib/gateguard-state.js +++ b/scripts/lib/gateguard-state.js @@ -26,6 +26,10 @@ function getTrivialAllowCount(state) { return toCount(state && state.trivial_allows); } +function getRoutineReadonlyPassCount(state) { + return toCount(state && state.routine_readonly_passes); +} + // --- Sibling dir gates --- const MAX_DIR_GATES = 50; @@ -128,6 +132,7 @@ module.exports = { getCreditedCount, getCapAllowCount, getTrivialAllowCount, + getRoutineReadonlyPassCount, getSiblingAllowCount, getClassCounts, mergeClassCounts, diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index ebcc1e2df..dce237832 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -182,6 +182,19 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc 2. What this specific command verifies or produces ``` +Read-only introspection does not use this gate up: until it fires, a command +whose every `;`, `|`, `&&` or `||` segment is `ls`, `pwd`, `cat`, `head`, +`tail`, `wc`, `grep`, `rg`, `find`, `fd`, `tree`, or `git status`/`log`/ +`diff`/`show`/`ls-files`/`rev-parse`/`branch` with read-only options (in +PowerShell: `Get-ChildItem`, `Get-Content`, `Select-String`, `Get-Location`, +their aliases, `rg`, `git`) passes and is counted in +`routine_readonly_passes`. The first other command is still gated. Any +redirection, `tee`, substitution, backtick, variable, environment +assignment, wrapper (`env`, `sudo`, `xargs`, `sh -c`), unknown command, +`find -exec`/`-delete`, `fd -x`, `rg --pre`, `tree -o`, `git` global option or +write/execute option, or PowerShell `$`, `@`, `(`, `{` or `--%` makes the +command ordinary. Destructive detection runs first, unchanged. + ## Parallel Batches and Partial Application The first-touch gate evaluates each tool call independently. When several @@ -455,6 +468,7 @@ the sanitized first file per directory): | `dir_gates` | Per class and directory, the denial that opened a sibling window (turn, time, first file, ordinal); capped at 50 entries | | `cap_allows` | Number of first touches passed through by the denial cap (`GATEGUARD_FACT_FORCE_MAX_DENIALS`); these never count as denials | | `trivial_allows` | Number of comment or whitespace-only edits passed without the first-touch check; these never mark the file checked | +| `routine_readonly_passes` | Number of read-only shell commands passed before the routine shell gate fired; these never mark it checked | Missing or malformed fields load as empty or zero, so older state files keep working. diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 286bcb3ab..c86a9c8cc 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -1039,7 +1039,7 @@ function runTests() { test('denies first routine Bash, allows second', () => { const input = { tool_name: 'Bash', - tool_input: { command: 'ls -la' } + tool_input: { command: 'npm test' } }; // First call: should deny @@ -1406,7 +1406,7 @@ function runTests() { const result = runBashHook({ tool_name: 'Bash', - tool_input: { command: 'pwd' } + tool_input: { command: 'npm test' } }); const output = parseOutput(result.stdout); assert.ok(output, 'should produce valid JSON output'); @@ -1429,7 +1429,7 @@ function runTests() { const input = { session_id: 'raw-session-1234', tool_name: 'Bash', - tool_input: { command: 'ls -la' } + tool_input: { command: 'npm test' } }; const first = runBashHook(input, { @@ -1522,7 +1522,7 @@ function runTests() { const input = { session_id: longSessionId, tool_name: 'Bash', - tool_input: { command: 'ls -la' } + tool_input: { command: 'npm test' } }; const first = runBashHook(input, { @@ -1619,15 +1619,21 @@ function runTests() { // --- Test 23: quoted shell separators are not read-only git bypasses clearState(); if ( - test('does not treat quoted shell separators as read-only git introspection', () => { + test('checks a quoted separator in a git argument as one command after destructive detection', () => { const result = runBashHook({ tool_name: 'Bash', tool_input: { command: 'git show HEAD:"docs/a;b.md"' } }); const output = parseOutput(result.stdout); assert.ok(output, 'should produce valid JSON output'); - assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); - assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current user request')); + assert.ok(!output.hookSpecificOutput, 'read-only first command passes'); + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + assert.strictEqual(state.routine_readonly_passes, 1, 'passed by the read-only check, not the early git allowlist'); + assert.ok(!state.checked.includes('__bash_session__'), 'routine gate stays unchecked'); + + const routine = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'git show HEAD:"docs/a;b.md" > out.txt' } }).stdout); + assert.strictEqual(routine.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok(routine.hookSpecificOutput.permissionDecisionReason.includes('current user request')); }) ) passed++; @@ -1666,7 +1672,7 @@ function runTests() { const input = { transcript_path: path.join(stateDir, 'session.jsonl'), tool_name: 'Bash', - tool_input: { command: 'pwd' } + tool_input: { command: 'npm test' } }; const first = runBashHook(input, { @@ -1703,7 +1709,7 @@ function runTests() { test('uses project directory fallback when no session or transcript id exists', () => { const input = { tool_name: 'Bash', - tool_input: { command: 'pwd' } + tool_input: { command: 'npm test' } }; const fallbackEnv = { CLAUDE_SESSION_ID: '', @@ -1965,7 +1971,7 @@ function runTests() { clearState(); if ( test('routine Bash remains gated in subagent context', () => { - const result = runFreshSessionBash('pwd', { agent_id: 'agent-abc-123' }); + const result = runFreshSessionBash('npm test', { agent_id: 'agent-abc-123' }); const output = parseOutput(result.stdout); assert.ok(output, 'subagent Bash should produce JSON output'); assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); @@ -3061,7 +3067,7 @@ function runTests() { clearState(); if ( test('GATEGUARD_BASH_ROUTINE_DISABLED unset preserves baseline (denies first routine bash)', () => { - const input = { tool_name: 'Bash', tool_input: { command: 'ls -la' } }; + const input = { tool_name: 'Bash', tool_input: { command: 'npm test' } }; const result = runBashHook(input); assert.strictEqual(result.code, 0, 'exit code should be 0'); const output = parseOutput(result.stdout); @@ -3077,7 +3083,7 @@ function runTests() { test('GATEGUARD_BASH_ROUTINE_DISABLED=0 / off / false keeps current behavior', () => { for (const value of ['0', 'false', 'off', '', 'random-value']) { clearState(); - const result = runBashHook({ tool_name: 'Bash', tool_input: { command: 'ls -la' } }, { GATEGUARD_BASH_ROUTINE_DISABLED: value }); + const result = runBashHook({ tool_name: 'Bash', tool_input: { command: 'npm test' } }, { GATEGUARD_BASH_ROUTINE_DISABLED: value }); const output = parseOutput(result.stdout); assert.ok(output, `value="${value}": should produce JSON`); assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny', `value="${value}": routine gate should still fire`); @@ -3145,7 +3151,7 @@ function runTests() { // should fall back to the built-in patterns. A plain `ls` should // therefore hit the routine gate (denied first time) and a // built-in destructive (`rm -rf`) should still fire the destructive gate. - const lsResult = runBashHook({ tool_name: 'Bash', tool_input: { command: 'ls -la' } }, { GATEGUARD_BASH_EXTRA_DESTRUCTIVE: '(unclosed' }); + const lsResult = runBashHook({ tool_name: 'Bash', tool_input: { command: 'npm test' } }, { GATEGUARD_BASH_EXTRA_DESTRUCTIVE: '(unclosed' }); assert.strictEqual(lsResult.code, 0, 'malformed regex must not crash hook'); const lsOutput = parseOutput(lsResult.stdout); assert.ok(lsOutput, 'should produce JSON despite bad env regex'); @@ -6512,7 +6518,7 @@ function runTests() { const env = { ...c4Env, [capEnvName]: '0' }; const destructive = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'rm -rf build' } }, env).stdout); assert.strictEqual(destructive.hookSpecificOutput.permissionDecision, 'deny', 'destructive gate unchanged'); - const routine = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'ls' } }, env).stdout); + const routine = parseOutput(runBashHook({ tool_name: 'Bash', tool_input: { command: 'npm test' } }, env).stdout); assert.strictEqual(routine.hookSpecificOutput.permissionDecision, 'deny', 'routine gate unchanged'); assert.ok(!c3State().cap_allows, 'Bash never counts a cap pass'); }); @@ -6720,6 +6726,81 @@ function runTests() { } }); + // --- Read-only first shell command --- + const roDecision = result => { + const output = parseOutput(result.stdout); + return output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined; + }; + const roReason = result => { + const output = parseOutput(result.stdout); + return output && output.hookSpecificOutput ? String(output.hookSpecificOutput.permissionDecisionReason) : ''; + }; + const roBash = (command, extra = {}, env = {}) => runBashHook({ tool_name: 'Bash', tool_input: { command }, ...extra }, env); + const roPs = command => runPowerShellHook({ tool_name: 'PowerShell', tool_input: { command } }); + + c3Case('a read-only first Bash command passes without using the routine gate', () => { + const first = roBash('ls -la src'); + assert.strictEqual(roDecision(first), undefined, first.stdout); + assert.ok(!first.stdout.includes('permissionDecision'), first.stdout); + const state = c3State(); + assert.strictEqual(state.routine_readonly_passes, 1); + assert.ok(!(state.checked || []).includes('__bash_session__'), 'routine gate must stay unchecked'); + const second = roBash('git status --short | head'); + assert.strictEqual(roDecision(second), undefined, second.stdout); + assert.strictEqual(c3State().routine_readonly_passes, 2); + const routine = roBash('npm test'); + assert.strictEqual(roDecision(routine), 'deny'); + assert.ok(roReason(routine).includes('current user request')); + assert.strictEqual(roDecision(roBash('npm test')), undefined, 'retry after the routine gate passes'); + }); + + c3Case('read-only commands after the routine gate is checked are not counted', () => { + writeState({ checked: ['__bash_session__'], last_active: Date.now() }); + assert.strictEqual(roDecision(roBash('ls')), undefined); + assert.ok(!c3State().routine_readonly_passes, 'no read-only pass counted once the gate is checked'); + }); + + c3Case('a read-only first PowerShell command passes and the next cmdlet is still gated', () => { + assert.strictEqual(roDecision(roPs('Get-ChildItem -Recurse -Filter *.js')), undefined); + const routine = roPs('Get-Date'); + assert.strictEqual(roDecision(routine), 'deny'); + assert.ok(roReason(routine).includes('Before the first PowerShell command')); + }); + + c3Case('commands that write, substitute or run unknown programs still draw the routine gate', () => { + for (const command of ['ls > out.txt', 'ls | tee out.txt', 'cat $(pwd)/a', 'find . -exec ls {} +', 'find . -name x -delete', 'git log | xargs echo', 'FOO=1 ls', 'sed -i s/a/b/ f', 'git commit -m x']) { + clearState(); + const result = roBash(command); + assert.strictEqual(roDecision(result), 'deny', `${command}: ${result.stdout}`); + assert.ok(roReason(result).includes('current user request'), `${command}: routine gate expected`); + assert.ok(!c3State().routine_readonly_passes, `${command}: no read-only pass`); + } + for (const command of ['Get-ChildItem | Out-File a.txt', 'Get-Content a | Set-Content b', 'pwsh -Command Get-ChildItem', 'Get-Content $env:X']) { + clearState(); + assert.strictEqual(roDecision(roPs(command)), 'deny', command); + } + }); + + c3Case('destructive commands are gated before the read-only check', () => { + for (const command of ['find . -name x -exec rm {} +', 'rm -rf build', 'git branch -D old']) { + clearState(); + const result = roBash(command); + assert.strictEqual(roDecision(result), 'deny', command); + assert.ok(roReason(result).includes('Destructive command detected'), `${command}: destructive gate expected`); + } + clearState(); + const ps = roPs('Remove-Item -Recurse -Force C:/tmp/demo'); + assert.strictEqual(roDecision(ps), 'deny'); + assert.ok(roReason(ps).includes('Destructive command detected')); + }); + + c3Case('a read-only first command in a subagent passes and other commands stay gated', () => { + assert.strictEqual(roDecision(roBash('pwd', { agent_id: 'agent-ro' })), undefined); + const routine = roBash('npm test', { agent_id: 'agent-ro' }); + assert.strictEqual(roDecision(routine), 'deny'); + assert.ok(roReason(routine).includes('current user request')); + }); + fs.rmSync(f3Base, { recursive: true, force: true }); fs.rmSync(f2Root, { recursive: true, force: true }); diff --git a/tests/lib/gateguard-readonly-shell.test.js b/tests/lib/gateguard-readonly-shell.test.js new file mode 100644 index 000000000..dee17f78b --- /dev/null +++ b/tests/lib/gateguard-readonly-shell.test.js @@ -0,0 +1,237 @@ +'use strict'; +/** + * Tests for scripts/lib/gateguard-readonly-shell.js. + * + * Run with: node tests/lib/gateguard-readonly-shell.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const hookPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'gateguard-fact-force.js'); + +console.log('=== Testing gateguard-readonly-shell.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +function loadHook() { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gateguard-readonly-shell-state-')); + const savedStateDir = process.env.GATEGUARD_STATE_DIR; + process.env.GATEGUARD_STATE_DIR = stateDir; + try { + return require(hookPath); + } finally { + if (savedStateDir === undefined) delete process.env.GATEGUARD_STATE_DIR; + else process.env.GATEGUARD_STATE_DIR = savedStateDir; + fs.rmSync(stateDir, { recursive: true, force: true }); + } +} + +const { isReadOnlyShellCommand } = loadHook(); + +function expectReadOnly(tool, commands) { + for (const command of commands) { + assert.strictEqual(isReadOnlyShellCommand(tool, command), true, `${tool}: ${command} should be read-only`); + } +} + +function expectNotReadOnly(tool, commands) { + for (const command of commands) { + assert.strictEqual(isReadOnlyShellCommand(tool, command), false, `${tool}: ${command} should not be read-only`); + } +} + +test('plain introspection commands are read-only', () => { + expectReadOnly('Bash', [ + 'ls', + 'ls -la src', + 'pwd', + 'cat README.md', + 'head -n 20 src/a.js', + 'tail -5 log.txt', + 'wc -l src/a.js src/b.js', + 'rg -n "foo bar" src', + "grep -rn 'x|y' .", + 'find . -name "*.js" -type f', + 'fd -e js src', + 'tree -L 2 src' + ]); +}); + +test('read-only git subcommands are read-only', () => { + expectReadOnly('Bash', [ + 'git status', + 'git status --short --branch', + 'git log --oneline -n 5', + 'git log --oneline -20 -- src/a.js', + 'git diff', + 'git diff --cached --stat', + 'git diff HEAD~1 -- src', + 'git show HEAD --stat', + 'git branch --show-current', + 'git branch -a', + 'git rev-parse --show-toplevel', + 'git ls-files src' + ]); +}); + +test('pipes and lists of read-only commands are read-only', () => { + expectReadOnly('Bash', ['ls src | head', 'git log --oneline | head -n 3', 'pwd && ls', 'cat a.txt; wc -l a.txt', 'ls x || ls y']); +}); + +test('PowerShell introspection cmdlets are read-only', () => { + expectReadOnly('PowerShell', [ + 'Get-ChildItem -Recurse -Filter *.js', + 'get-childitem src', + 'gci C:\\repo\\src', + 'Get-Content -Path .\\README.md -TotalCount 20', + 'Select-String -Path src\\*.js -Pattern foo', + 'Get-ChildItem src | Select-String foo', + 'Get-Location', + 'git status --short' + ]); +}); + +test('redirection to a file is not read-only', () => { + expectNotReadOnly('Bash', ['ls > out.txt', 'cat a >> b', 'git log 2>err.txt', 'cat < a.txt', 'cat <| out']); + expectNotReadOnly('PowerShell', ['Get-ChildItem > out.txt', 'Get-Content a 2>&1', 'Get-Content a *> b']); +}); + +test('tee and other writers in a pipe are not read-only', () => { + expectNotReadOnly('Bash', ['ls | tee out.txt', 'cat a | sort -o b', 'git log | xargs rm', 'ls | sh', 'cat a.txt | node']); + expectNotReadOnly('PowerShell', ['Get-ChildItem | Out-File a.txt', 'Get-Content a | Set-Content b', 'Get-ChildItem | Remove-Item', 'Get-Content a | Tee-Object b']); +}); + +test('command substitution, backticks and process substitution are not read-only', () => { + expectNotReadOnly('Bash', ['ls $(pwd)', 'cat `which node`', 'cat <(ls)', 'ls "$(rm -rf x)"', 'cat "`id`"', 'ls $HOME', 'ls "${x:=y}"', 'diff <(ls a) <(ls b)']); + expectNotReadOnly('PowerShell', ['Get-Content $(Remove-Item a)', 'Get-ChildItem $env:TEMP', 'Get-Content "$x"', 'Get-Content @args', 'Get-Content (Get-Item a)']); +}); + +test('find actions that execute, delete or write are not read-only', () => { + expectNotReadOnly('Bash', [ + 'find . -name "*.tmp" -exec echo {} +', + 'find . -execdir ls +', + 'find . -ok echo +', + 'find . -okdir echo +', + 'find . -name x -delete', + 'find . -fprint out.txt', + 'find . -fprintf out.txt %p', + 'find . -fls out.txt' + ]); +}); + +test('fd, rg and tree options that execute or write are not read-only', () => { + expectNotReadOnly('Bash', [ + 'fd -x echo', + 'fd -HX echo', + 'fd --exec echo', + 'fd --exec-batch echo', + 'rg --pre ./script foo', + 'rg --pre=cat foo', + 'rg --hostname-bin=./x foo', + 'tree -o out.txt', + 'tree -ao out.txt', + 'tree -R' + ]); +}); + +test('xargs, sed, awk and other unknown commands are not read-only', () => { + expectNotReadOnly('Bash', ['xargs ls', 'sed -i s/a/b/ f', 'sed -n 1p f', 'awk 1 f', 'echo hi', 'npm test', 'node -e 1', 'sort f', 'less f', 'touch a']); +}); + +test('environment assignments and wrappers are not read-only', () => { + expectNotReadOnly('Bash', ['FOO=1 ls', 'PAGER=sh git log', 'env ls', 'sudo ls', 'command ls', 'nice ls', 'timeout 5 ls', 'exec ls', 'sh -c ls', 'bash -c "ls"']); +}); + +test('commands named by path or quoted into another name are not read-only', () => { + expectNotReadOnly('Bash', ['./ls', '/bin/ls', 'bin/cat a', 'l? a', 'LS']); + expectNotReadOnly('PowerShell', ['Microsoft.PowerShell.Management\\Get-ChildItem', '.\\ls.ps1', '& Get-ChildItem', '. .\\x.ps1']); +}); + +test('git subcommands outside the read-only set are not read-only', () => { + expectNotReadOnly('Bash', [ + 'git commit -m x', + 'git checkout main', + 'git stash', + 'git fetch', + 'git pull', + 'git branch new-feature', + 'git branch -d old', + 'git branch -D old', + 'git branch -m a b', + 'git config user.name x', + 'git grep foo', + 'git remote -v', + 'git tag v1', + 'git worktree list' + ]); +}); + +test('git options that write, execute or reconfigure are not read-only', () => { + expectNotReadOnly('Bash', [ + 'git -c core.pager=sh log', + 'git -C /elsewhere status', + 'git --git-dir=/x log', + 'git -p log', + 'git diff --output=patch.txt', + 'git diff --out=patch.txt', + 'git log --output=log.txt', + 'git diff --ext-diff', + 'git log --ext-diff -p', + 'git diff --textconv', + 'git show --output=x HEAD' + ]); +}); + +test('PowerShell escapes, call operators and stop-parsing are not read-only', () => { + expectNotReadOnly('PowerShell', [ + 'Get-Content a`; Remove-Item b', + 'git log --% ; x', + 'Get-Content a & Remove-Item b', + 'Get-ChildItem {Remove-Item a}', + 'Get-Content [IO.File]::x', + 'pwsh -Command Get-ChildItem', + 'powershell -EncodedCommand AAAA', + 'Invoke-Expression "ls"', + 'Get-ChildItem | ForEach-Object { Remove-Item $_ }', + "Get-Content 'a\u2019; Remove-Item b", + 'Set-Location src', + 'Remove-Item a' + ]); +}); + +test('backslashes, background jobs, newlines and odd characters are not read-only', () => { + expectNotReadOnly('Bash', ['ls a\\;rm b', "cat 'a\\'; rm b", 'ls &', 'ls & rm x', 'ls\nrm x', 'ls\rrm x', 'ls \u200b', 'ls caf\u00e9', 'ls "unterminated', "cat 'unterminated"]); +}); + +test('empty, non-string and oversized commands are not read-only', () => { + assert.strictEqual(isReadOnlyShellCommand('Bash', ''), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', ' '), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', ';'), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', null), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', { toString: () => 'ls' }), false); + assert.strictEqual(isReadOnlyShellCommand('Bash', `ls ${'a'.repeat(5000)}`), false); + assert.strictEqual(isReadOnlyShellCommand('Other', 'ls'), false); +}); + +test('bash-only commands are not read-only under PowerShell and vice versa', () => { + expectNotReadOnly('PowerShell', ['find . -name x', 'tree', 'head a', 'fd x']); + expectNotReadOnly('Bash', ['Get-ChildItem', 'gci', 'Select-String foo']); +}); + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/gateguard-state.test.js b/tests/lib/gateguard-state.test.js index 9510900da..d774cc21d 100644 --- a/tests/lib/gateguard-state.test.js +++ b/tests/lib/gateguard-state.test.js @@ -12,6 +12,7 @@ const { getDenialCount, getCapAllowCount, getTrivialAllowCount, + getRoutineReadonlyPassCount, getClassCounts, mergeClassCounts, incrementClassCount, @@ -45,6 +46,14 @@ test('trivial_allows reads as a non-negative integer', () => { assert.strictEqual(getTrivialAllowCount(null), 0); }); +test('routine_readonly_passes reads as a non-negative integer', () => { + assert.strictEqual(getRoutineReadonlyPassCount({ routine_readonly_passes: 4 }), 4); + for (const bad of [undefined, null, -1, 'x', NaN, Infinity, {}]) { + assert.strictEqual(getRoutineReadonlyPassCount({ routine_readonly_passes: bad }), 0, String(bad)); + } + assert.strictEqual(getRoutineReadonlyPassCount(null), 0); +}); + test('malformed or missing counters read as zero', () => { assert.strictEqual(getDenialCount(null), 0); assert.strictEqual(getDenialCount({ fact_force_denials: 'x' }), 0);