feat(gateguard): pass read-only shell commands before the routine gate

The first command that can change something is still gated.
This commit is contained in:
Jeffrey Montoya
2026-09-29 21:50:17 -04:00
parent c3b2a7e627
commit 77ddf9a792
11 changed files with 622 additions and 18 deletions
+52 -1
View File
@@ -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.
+2
View File
@@ -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`)付きで通過させます。判断が曖昧な場合は常に従来どおり拒否します。
+1
View File
@@ -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"`。
## 快速开始
File diff suppressed because one or more lines are too long
+20 -2
View File
@@ -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 };
+186
View File
@@ -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 };
+5
View File
@@ -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,
+14
View File
@@ -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.
+95 -14
View File
@@ -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 });
+237
View File
@@ -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 <<EOF', 'ls >| 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);
+9
View File
@@ -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);