feat(gateguard): name the closest search that did not count

One sanitized line in the denial with a reason code, from the cached turn scan.
This commit is contained in:
Jeffrey Montoya committed 2026-09-29 21:50:15 -04:00
1 parent ad0d9183ff
commit c3b2a7e627
11 files changed
+333 -29

No files matched your search

+34 -1
View File
@@ -19,7 +19,8 @@ headings stable, since they are the anchors.
- `scripts/lib/gateguard-turn-scan.js`: one bounded tail read of the Claude Code
JSONL transcript, walked back to the start of the current turn. Yields the
turn id, the turn's completed non-error search calls (newest first), their
batch ids and the turn's shell commands.
batch ids, the turn's shell commands and its `Read` calls (used only to
explain a denial, never as evidence).
- `scripts/lib/gateguard-search-evidence.js`: decides whether a search in the
current turn covers a target (prior-search credit). Its shell-dependent part
is built by `createSearchEvidence()` from the hook's shell parser, so the
@@ -383,3 +384,35 @@ alone. For a MultiEdit, the denied file's profile covers every entry for that
file (same canonical key); entries for other files do not count. The condensed
denial uses the local-callers hint when `local-callers` is asked, else the
original code hint.
## Closest search that did not count
When a non-sensitive first touch is denied and the turn holds a search (or a
`Read`, or a non-search shell command) that mentions the target but did not
credit it, the denial adds one line naming the closest one, so the agent does
the missing step instead of restating facts:
`Closest search this turn did not count (<tool> <detail>): <why>.` It reuses
the single cached turn scan; no transcript is read again, and nothing is
computed on the credit or allow paths.
A search mentions the target when its stem matches (or, for a generic or short
stem, the bare name of at least three characters appears as a word) or, for a
new-file Write, when it names the target's directory. Reason codes, from
closest to farthest (the closest wins, then the newest):
| Code | Meaning |
|---|---|
| `same-batch` | it would have credited, but was sent in the pending call's own batch |
| `excluded` | an exclusion covers the target, or include globs miss it |
| `out-of-scope` | its path or operands do not contain the target |
| `stdin-only` | a search segment that read piped input or had no tree operand |
| `not-a-search` | a `Read` of the target, or a non-search shell segment naming it |
| `generic-stem` | the target's name is too generic or short to match any search |
`previous-turn` is not reported: the scan stops at the turn boundary, and
reading past it would cost a second, larger scan. Ambiguous shell commands
(substitutions, heredocs, over 8192 characters) give no line. The tool name
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.
+1
View File
@@ -129,6 +129,7 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc
- **範囲:** `Glob`/`Grep`/`LS` は検索したディレクトリ(`path`、なければツールの `cwd`、Glob はパターンのリテラル部分を加える)の内側にある対象だけをクレジットします。対象自体を読むだけのシェルセグメント(例: `ls src/a.py`)や、パス引数がなく再帰でもない・パイプで stdin を読むセグメントは検索とみなしません。`$( )`、バッククォート、`<( )`、`>( )`、ヒアドキュメントを含むコマンドは対象外です。シェルセグメントは、パス引数(なければ `cwd`)の内側にある対象だけをクレジットします(`rg payment docs` は `src/payment.py` をクレジットしません)。引数より前の入力リダイレクト(`rg payment < file`)は stdin とみなし、別の呼び出しで `cd` した後の相対パス引数は数えません。
- **ディレクトリ一致:** まだ存在しないファイルを作成する Write だけが、対象のディレクトリそのものを指す検索でも一致します(Glob の `path` とリテラル接頭辞、Grep/LS の `path`、シェルのパス引数)。シェル引数では grep 系のパターン引数や値を取るフラグの値を除き、今実在するディレクトリだけを数えます。ターン内のいずれかの Bash/PowerShell 呼び出しに `cd`/`pushd`/`Set-Location` などがあれば、そのターンのシェルのディレクトリ一致は無効です。Edit、MultiEdit、既存ファイルを上書きする Write は語幹一致のみです。
- 相対パスはツールの `cwd`、次に `CLAUDE_PROJECT_DIR`、次にプロセスのディレクトリを基準に解決します。トランスクリプトが読めない等の問題があれば拒否に戻ります。
- 機密でない対象が拒否され、ターン内にそのファイルに触れたがクレジットにならなかった呼び出しがある場合、拒否に「Closest search this turn did not count (...)」の 1 行を加え、最も近いものとその理由(同一バッチ、除外、範囲外、stdin、検索ではない `Read` 等、汎用すぎる名前)を示します(詳細はサニタイズして 60 文字まで)。
- **同一ターンの兄弟ファイル作成の集約:** まだ存在しないファイルへの Write(code、test、prose クラスのみ)が拒否された後、**同じクラス・同じディレクトリ**への新規ファイル Write は、同じターン内であれば注記付きで許可されます。ターン ID はターン開始レコード自身の `promptId`、なければその `uuid`、なければそのハッシュです(末尾範囲にターン開始がない場合は、範囲内のユーザーレコードの `promptId` がすべて一致するときだけ使います)。120 秒ルールはトランスクリプトのパスがまったくない場合に限られ、トランスクリプトが存在しない・読めない・ターン ID が得られない場合は集約されません。config・instruction ファイル、`.` で始まるセグメント(ドットファイルとすべてのドットディレクトリ)や 8.3 短縮名を含むパスは集約されません。ディレクトリはシンボリックリンクを解決した実体でも同じ判定を行い、実体のディレクトリをキーにします。Edit/MultiEdit も集約されません。
- **コメント・空白のみの Edit:** 対応言語の機密でない code/test/prose ファイルに対し、コメントと空白だけが変わる Edit(MultiEdit はそのファイルの全エントリ)は注記付きで通過し、`trivial_allows` に数えます。チェック済みにはしないため、コードを変える次の変更は通常どおりゲートされます(Write、Python のインデント変更、複数行文字列などを含む変更は対象外)。
- **正規化パスキー:** 相対パスはツールの `cwd` を基準に解決され、`a.py`、`./a.py`、絶対パスは同じファイルとして扱われ、初回タッチは 1 回だけです。
+1
View File
@@ -94,6 +94,7 @@ LLM 的自我评估不起作用。问"你是否违反了任何策略?"答案
* **按变更内容提问:** 对 code 类目标还会读取变更内容:未触及公开声明的编辑改问"本文件或其模块中依赖所改行为的调用点",不涉及数据的变更省略数据格式问题;无法可靠判断(不支持的扩展名、超过 64 KiB 等)以及敏感目标时仍问原来的四个问题。
* **事先搜索抵免:** 若当前人类回合内已有成功完成(非错误结果)的 `Glob`、`Grep`、`LS` 或 `rg`/`grep`/`find` 等搜索命中该文件,则以附注代替拒绝。`Read` 从不计入;与被门控调用同批次的搜索、重复的 tool_use id、压缩(compaction)之前的搜索均不计入。文件名词干须按单词边界匹配,且目标须位于所搜索目录之内(shell 片段按其路径参数或 `cwd` 判定范围;参数前的输入重定向 `<` 视为读取 stdin)。仅"创建尚不存在的文件"的写入可按目录匹配,且 shell 参数须是真实存在的目录;回合内出现 `cd` 等切换目录命令时不再按 shell 目录匹配。相对路径按工具的 `cwd` 解析。排除规则从不计入:`Grep` 的 `glob` 中以 `!` 开头的项,以及 shell 中 `rg -g '!x'`、`grep --exclude`/`--exclude-dir`、`fd -E`、`ls -I`、`tree -I`、`git ls-files -x`、git `:!x` 路径规格、带 `-not`/`!` 或后接 `-prune` 的 `find -name`/`-path`、PowerShell `-Exclude` 都不作为词干来源;若排除项覆盖目标(文件名、路径上的目录或词干),该搜索完全不能为其抵免(`rg -g '!widget.py' TODO .` 不抵免 `widget.py`,`rg -g '!*.md' widget .` 仍抵免)。含 `--exclude-from`/`--ignore-file` 的片段不抵免;用 `*.py`、`--include`、`find -name` 等文件名 glob 限定的搜索,只有在其匹配目标文件名时词干才计入。
* **仅注释/空白的编辑:** 对受支持语言中非敏感的 code/test/prose 文件,只改动注释和空白的编辑(MultiEdit 须该文件的所有条目都如此)以附注放行并计入 `trivial_allows`,但不标记为已检查,下一次改动代码的编辑仍按首次触碰门控(写入、Python 缩进变化、多行字符串等从不算作此类编辑)。
* **拒绝中指出最接近的未计入搜索:** 非敏感目标被拒绝且本回合有提及该文件却未能抵免的调用时,拒绝消息追加一行"Closest search this turn did not count (...)",说明最接近的一个及原因(同批次、被排除、范围外、读取 stdin、`Read` 等非搜索、文件名过于通用);细节经净化并截断至 60 个字符。
* **同回合同级文件合并:** 同一目录、同一类别(仅 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 → 敏感? → 抵免 → 同级合并 → 上限 → 拒绝。
@@ -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":"34402347e0722c2f86fda65f067dafd4ede2bfa7ef3cf241c23143fd6eb6b49b","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:securityLine truncated
{"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:securityLine truncated
+36 -12
View File
@@ -1869,13 +1869,14 @@ function firstTouchGateMsg(filePath, isWrite, cls, profile) {
* (#2142). Carries the denial ordinal so consecutive denials differ
* textually, and a one-line recovery hint instead of the multi-line block.
*/
function condensedGateMsg(action, filePath, ordinal, cls = 'code', sensitive = false, profile = null) {
function condensedGateMsg(action, filePath, ordinal, cls = 'code', sensitive = false, profile = null, missNote = '') {
const safe = sanitizePath(filePath);
const hint = condensedHintFor(cls, action === 'creation', profile);
return (
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
`${hint} ` +
`${batchSiblingWarning(safe)} ` +
(missNote ? `${missNote} ` : '') +
(sensitive ? `${SENSITIVE_TARGET_NOTE} ` : '') +
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; GATEGUARD_FACT_FORCE_MAX_DENIALS caps denials per session; ECC_GATEGUARD=off disables this gate.)'
);
@@ -1884,19 +1885,21 @@ function condensedGateMsg(action, filePath, ordinal, cls = 'code', sensitive = f
const SENSITIVE_TARGET_NOTE = 'Sensitive target: prior-search credit, sibling collapse, and the denial cap do not apply.';
const RETRY_LINE = 'Present the facts, then retry the same operation.';
function withSensitiveNote(message) {
function withNoteBeforeRetry(message, note) {
if (!note) return message;
const at = message.lastIndexOf(RETRY_LINE);
if (at < 0) return `${message}\n\n${SENSITIVE_TARGET_NOTE}`;
return `${message.slice(0, at)}${SENSITIVE_TARGET_NOTE}\n\n${message.slice(at)}`;
if (at < 0) return `${message}\n\n${note}`;
return `${message.slice(0, at)}${note}\n\n${message.slice(at)}`;
}
function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, profile }) {
function firstTouchDenial(filePath, { isWrite, denials, cls, sensitive, profile, miss }) {
const missNote = sensitive ? '' : closestMissNote(miss);
if (denials > getFullDenialBudget()) {
const action = isWrite ? 'creation' : 'edit';
return denyResult(condensedGateMsg(action, filePath, denials, cls, sensitive, profile), { includeRecoveryHint: false });
return denyResult(condensedGateMsg(action, filePath, denials, cls, sensitive, profile, missNote), { includeRecoveryHint: false });
}
const message = firstTouchGateMsg(filePath, isWrite, cls, profile);
return denyResult(sensitive ? withSensitiveNote(message) : message, {
const message = withNoteBeforeRetry(firstTouchGateMsg(filePath, isWrite, cls, profile), missNote);
return denyResult(sensitive ? withNoteBeforeRetry(message, SENSITIVE_TARGET_NOTE) : message, {
narrowRecoveryHint: EDIT_WRITE_NARROW_RECOVERY_HINT
});
}
@@ -1980,7 +1983,7 @@ function allowWithStateWarning() {
// --- Prior-search credit ---
const CREDIT_DETAIL_MAX_CHARS = 80;
const { findCreditingSearch } = createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS });
const { findCreditingSearch, findClosestMiss } = createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS });
function creditNote(match, filePath) {
const chars = Array.from(sanitizePath(match.detail).replace(/\s+/g, ' '));
@@ -1992,6 +1995,25 @@ function creditNote(match, filePath) {
);
}
// --- Closest search that did not count ---
const MISS_DETAIL_MAX_CHARS = 60;
const MISS_REASON_TEXT = Object.freeze({
'same-batch': 'it was sent in the same batch as this call, so its result was not seen yet',
excluded: 'its filters exclude this file',
'out-of-scope': 'its search path does not contain this file',
'stdin-only': 'it searched piped input, not the tree',
'not-a-search': 'only Glob, Grep, LS and shell search commands count',
'generic-stem': 'this file name is too generic to match a search'
});
function closestMissNote(miss) {
if (!miss || !Object.hasOwn(MISS_REASON_TEXT, miss.reason)) return '';
const chars = Array.from(sanitizePath(miss.detail).replace(/\s+/g, ' ').trim());
const detail = chars.length > MISS_DETAIL_MAX_CHARS ? `${chars.slice(0, MISS_DETAIL_MAX_CHARS - 3).join('')}...` : chars.join('');
return `Closest search this turn did not count (${sanitizePath(miss.name)} ${detail}): ${MISS_REASON_TEXT[miss.reason]}.`;
}
function isMissingOnDisk(resolved) {
try {
fs.lstatSync(resolved);
@@ -2118,7 +2140,8 @@ function run(rawInput) {
// see docs/gateguard/design-notes.md#denial-cap
return rawInput;
}
return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, profile });
const miss = sensitive ? null : findClosestMiss(getTurnScan(), filePath, isNewFile, data);
return firstTouchDenial(filePath, { isWrite: toolName === 'Write', denials, cls, sensitive, profile, miss });
}
return rawInput; // allow
@@ -2166,7 +2189,8 @@ function run(rawInput) {
// see docs/gateguard/design-notes.md#denial-cap
continue;
}
return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, profile });
const miss = sensitive ? null : findClosestMiss(getTurnScan(), filePath, false, data);
return firstTouchDenial(filePath, { isWrite: false, denials, cls, sensitive, profile, miss });
}
}
if (notes.length > 0) {
@@ -2221,4 +2245,4 @@ function run(rawInput) {
return rawInput; // allow
}
module.exports = { classifyDestructiveCommand, classifyTarget, classifyTargetFor, findCreditingSearch, run, scanCurrentTurn };
module.exports = { classifyDestructiveCommand, classifyTarget, classifyTargetFor, findCreditingSearch, findClosestMiss, run, scanCurrentTurn };
+78 -13
View File
@@ -661,9 +661,12 @@ function powershellSearchFilters(kind, args, filters) {
// --- Prior-search credit ---
const MIN_GENERIC_MISS_STEM = 3;
const MISS_RANK = Object.freeze({ 'same-batch': 0, excluded: 1, 'out-of-scope': 2, 'stdin-only': 3, 'not-a-search': 4, 'generic-stem': 5 });
/** Build the prior-search matcher around the hook's shell segmenter. */
function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGMENT_SEPARATORS }) {
function searchEvidence(search, shellDirsTrusted) {
function searchEvidence(search, shellDirsTrusted, misses) {
const input = search.input;
if (search.name === 'Glob') {
if (typeof input.pattern !== 'string' || !input.pattern) return [];
@@ -693,7 +696,7 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
if (search.name === 'LS') {
return stringsOf(input.path).map(dir => ({ texts: [], dirs: [[dir]], scope: [dir], detail: dir }));
}
return shellSearchEvidence(input.command, shellDirsTrusted);
return shellSearchEvidence(input.command, shellDirsTrusted, misses);
}
function segmentPipeFlags(input) {
@@ -728,7 +731,7 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
return flags;
}
function shellSearchEvidence(command, dirsTrusted) {
function shellSearchEvidence(command, dirsTrusted, misses) {
if (typeof command !== 'string' || !command || command.length > MAX_SEARCH_COMMAND_CHARS) return [];
if (AMBIGUOUS_SHELL_PATTERN.test(command)) return [];
const pipeFlags = segmentPipeFlags(command);
@@ -743,18 +746,21 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
base = baseAfterDirectoryChange(base, lead, tokens);
return;
}
if (!SHELL_SEARCH_COMMANDS.has(lead)) return;
let kind = lead;
let args = tokens.slice(1);
if (lead === 'git') {
if (!GIT_SEARCH_SUBCOMMANDS.has(tokens[1])) return;
kind = `git ${tokens[1]}`;
args = tokens.slice(2);
const miss = reason => {
if (misses) misses.push({ texts: [tokens.join(' ')], detail: tokens.join(' '), reason });
};
if (!SHELL_SEARCH_COMMANDS.has(lead) || (lead === 'git' && !GIT_SEARCH_SUBCOMMANDS.has(tokens[1]))) {
miss('not-a-search');
return;
}
const kind = lead === 'git' ? `git ${tokens[1]}` : lead;
const args = tokens.slice(lead === 'git' ? 2 : 1);
const parsed = parseSearchArgs(kind, args);
// see docs/gateguard/design-notes.md#stdin-is-not-a-tree-search
if (parsed.stdin) return;
if (parsed.operands.length === 0 && (!parsed.recursive || pipeFlags[index] !== false)) return;
if (parsed.stdin || (parsed.operands.length === 0 && (!parsed.recursive || pipeFlags[index] !== false))) {
miss('stdin-only');
return;
}
const scopes = shellSearchScopes(parsed, base);
if (scopes.length === 0) return;
const filters = shellSearchFilters(kind, args);
@@ -813,7 +819,66 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
}
}
return { findCreditingSearch };
// --- Closest non-qualifying search ---
// see docs/gateguard/design-notes.md#closest-search-that-did-not-count
function mentionsTarget(texts, stem, rawStem) {
const matcher = stem || rawStem;
return Boolean(matcher) && texts.some(text => typeof text === 'string' && matcher.test(text.toLowerCase()));
}
function itemMiss(item, ctx, stem, rawStem, allowDirMatch, sameBatch) {
if (stem === null) return mentionsTarget(item.texts, null, rawStem) ? 'generic-stem' : null;
const byDir = Boolean(allowDirMatch) && evidenceNamesDir(item, ctx);
const mentioned = mentionsTarget(item.texts, stem, null);
if (!mentioned && !byDir) return null;
const inScope = evidenceInScope(item, ctx);
const admitted = filtersAdmitTarget(item, ctx, stem);
const included = includesAdmitTarget(item, ctx);
if (inScope && admitted && (byDir || included)) return sameBatch ? 'same-batch' : null;
if (!inScope) return 'out-of-scope';
return 'excluded';
}
/** Closest search of the current turn that did not credit the target, with a reason code, else null. */
function findClosestMiss(scan, targetPath, allowDirMatch, data) {
try {
if (!scan || !Array.isArray(scan.searches) || typeof targetPath !== 'string' || !targetPath) return null;
const ctx = dirContext(targetPath, data);
if (!ctx) return null;
const eligible = eligibleStem(targetPath);
const stem = stemMatcher(eligible);
const base = (String(targetPath).split(/[\\/]/).pop() || '').toLowerCase();
const bare = base.includes('.') ? base.slice(0, base.lastIndexOf('.')) : base;
const rawStem = eligible === null && bare.length >= MIN_GENERIC_MISS_STEM ? stemMatcher(bare) : null;
const excluded = excludedBatchId(scan, data);
const shellDirsTrusted = !turnChangesDirectory(scan.shellCommands);
let best = null;
const consider = (name, detail, reason) => {
if (reason && (best === null || MISS_RANK[reason] < MISS_RANK[best.reason])) best = { name, detail, reason };
};
for (const search of scan.searches) {
if (!search || typeof search.messageId !== 'string' || !search.messageId) continue;
const misses = [];
for (const item of searchEvidence(search, shellDirsTrusted, misses)) {
consider(search.name, item.detail, itemMiss(item, ctx, stem, rawStem, allowDirMatch, search.messageId === excluded));
}
for (const miss of misses) {
if (mentionsTarget(miss.texts, stem, rawStem)) consider(search.name, miss.detail, stem === null ? 'generic-stem' : miss.reason);
}
}
for (const read of Array.isArray(scan.reads) ? scan.reads : []) {
if (!read || typeof read.path !== 'string' || !read.path) continue;
const readsTarget = ctx.resolveDir(read.path) === ctx.targetKey || mentionsTarget([read.path], stem, null);
if (readsTarget) consider('Read', read.path, 'not-a-search');
}
return best;
} catch (_) {
return null;
}
}
return { findCreditingSearch, findClosestMiss };
}
module.exports = { createSearchEvidence };
+14 -2
View File
@@ -121,7 +121,8 @@ function scanCurrentTurn(transcriptPath, pendingId = '') {
searches: collectTurnSearches(toolUses, okResultIds, pendingId),
batchIds,
newestMessageId,
shellCommands: collectShellCommands(toolUses)
shellCommands: collectShellCommands(toolUses),
reads: collectTurnReads(toolUses, pendingId)
});
for (let i = lines.length - 1; i >= firstLine; i--) {
@@ -166,7 +167,7 @@ function scanCurrentTurn(transcriptPath, pendingId = '') {
if ((tail.truncated || firstLine > 0) && windowPromptIds.size <= 1) {
return finish(windowPromptIds.size === 1 ? [...windowPromptIds][0] : null);
}
return { turnId: null, searches: [], batchIds: new Map(), newestMessageId: null, shellCommands: [] };
return { turnId: null, searches: [], batchIds: new Map(), newestMessageId: null, shellCommands: [], reads: [] };
}
function collectTurnSearches(toolUsesNewestFirst, okResultIds, pendingId) {
@@ -187,6 +188,17 @@ function collectTurnSearches(toolUsesNewestFirst, okResultIds, pendingId) {
return searches;
}
function collectTurnReads(toolUsesNewestFirst, pendingId) {
const reads = [];
toolUsesNewestFirst
.filter(({ block }) => !pendingId || block.id !== pendingId)
.forEach(({ block }, index) => {
if (block.name !== 'Read' || !isObject(block.input) || typeof block.input.file_path !== 'string') return;
reads.push({ name: 'Read', path: block.input.file_path, callsAgo: index + 1 });
});
return reads;
}
function collectShellCommands(toolUses) {
return toolUses
.filter(({ block }) => SHELL_TOOL_NAMES.has(block.name) && block.input && typeof block.input === 'object')
+8
View File
@@ -358,6 +358,14 @@ An Edit, Write, or MultiEdit entry is allowed, with an
as before.
- **Falls back to deny:** a missing, unreadable, or non-file transcript,
garbage records, or any internal error mean no credit.
- **Closest miss in the denial:** when a non-sensitive first touch is denied
and a call in the turn mentioned the file without crediting it, the denial
adds one line, "Closest search this turn did not count (`<tool>`
`<detail>`): `<why>`.", naming the closest such call and why it failed: sent
in the same batch, its filters exclude the file, its search path does not
contain it, it searched piped input, it was a `Read` or another command
rather than a search, or the file name is too generic to match. The detail
is sanitized and cut to 60 characters; no other transcript text is shown.
### Comment and whitespace-only edits
+109
View File
@@ -5245,6 +5245,115 @@ function runTests() {
}
});
// --- Denials name the closest search that did not count ---
const MISS_PREFIX = 'Closest search this turn did not count';
const missLine = reason => reason.split('\n').find(line => line.startsWith(MISS_PREFIX)) || '';
c3Case('a denial names a search that looked outside the file directory', () => {
const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_o1', 'Grep', { pattern: 'widget_factory', path: `${c3Root}/docs` })]);
const out = c3Edit(`${c3Root}/src/widget_factory.py`, t);
c3AssertDenied(out, 'out of scope');
const line = missLine(out.reason);
assert.ok(line.includes('(Grep widget_factory)'), line || out.reason);
assert.ok(line.includes("its search path does not contain this file"), line);
});
c3Case('a denial names a search whose filters excluded the file', () => {
const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_x1', 'Bash', { command: "rg -g '!*.py' widget_factory ." })]);
const out = c3Edit(`${c3Root}/src/widget_factory.py`, t);
c3AssertDenied(out, 'excluded');
assert.ok(missLine(out.reason).includes('its filters exclude this file'), out.reason);
});
c3Case('a denial names a search sent in the same batch', () => {
const batch = (id, name, input) => ({
type: 'assistant',
uuid: c3NextUuid(),
message: { id: 'msg_b4batch', role: 'assistant', content: [{ type: 'tool_use', id, name, input }] }
});
const t = c3WriteTranscript([
c3Human('x'),
...c3Search('toolu_far', 'Grep', { pattern: 'widget_factory', path: `${c3Root}/docs` }),
batch('toolu_sb', 'Grep', { pattern: 'widget_factory' }),
batch('toolu_se', 'Edit', { file_path: `${c3Root}/src/widget_factory.py` }),
c3ToolResult('toolu_sb')
], { pending: false });
const input = { file_path: `${c3Root}/src/widget_factory.py`, old_string: 'a', new_string: 'b' };
const result = runHook({ tool_name: 'Edit', tool_use_id: 'toolu_se', cwd: c3Root, transcript_path: t, tool_input: input }, c3Env);
const reason = parseOutput(result.stdout).hookSpecificOutput.permissionDecisionReason;
const line = missLine(reason);
assert.ok(line.includes('same batch as this call'), `same batch outranks out of scope: ${line}`);
});
c3Case('a denial names a search that only read piped input', () => {
const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_p1', 'Bash', { command: 'git diff | grep widget_factory' })]);
const out = c3Edit(`${c3Root}/src/widget_factory.py`, t);
c3AssertDenied(out, 'stdin');
assert.ok(missLine(out.reason).includes('it searched piped input, not the tree'), out.reason);
});
c3Case('a denial names a Read or a non-search command of the file', () => {
const read = c3WriteTranscript([
c3Human('x'),
c3ToolUse('toolu_r1', 'Read', { file_path: `${c3Root}/src/widget_factory.py` }),
c3ToolResult('toolu_r1')
]);
const out = c3Edit(`${c3Root}/src/widget_factory.py`, read);
c3AssertDenied(out, 'read');
const line = missLine(out.reason);
assert.ok(line.includes(`(Read ${c3Root}/src/widget_factory.py)`), line || out.reason);
assert.ok(line.includes('only Glob, Grep, LS and shell search commands count'), line);
clearState();
const cat = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_c1', 'Bash', { command: 'cat src/widget_factory.py' })]);
assert.ok(missLine(c3Edit(`${c3Root}/src/widget_factory.py`, cat).reason).includes('(Bash cat src/widget_factory.py)'));
});
c3Case('a denial explains that a generic file name never matches', () => {
const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_g1', 'Grep', { pattern: 'index' })]);
const out = c3Edit(`${c3Root}/src/index.py`, t);
c3AssertDenied(out, 'generic');
assert.ok(missLine(out.reason).includes('this file name is too generic to match a search'), out.reason);
});
c3Case('no closest-search line when nothing in the turn mentions the file', () => {
const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_n1', 'Grep', { pattern: 'unrelated_thing' })]);
const out = c3Edit(`${c3Root}/src/widget_factory.py`, t);
c3AssertDenied(out, 'unrelated');
assert.strictEqual(missLine(out.reason), '');
assert.ok(!out.reason.includes(MISS_PREFIX));
const none = c3Edit(`${c3Root}/src/other_file.py`, undefined);
assert.ok(!none.reason.includes(MISS_PREFIX), 'no transcript, no line');
});
c3Case('the closest-search detail is sanitized and bounded', () => {
const noisy = `widget_factory \u202e\u200b${'z'.repeat(200)}\u0007`;
const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_s1', 'Grep', { pattern: noisy, path: `${c3Root}/docs` })]);
const out = c3Edit(`${c3Root}/src/widget_factory.py`, t);
const line = missLine(out.reason);
assert.ok(line, out.reason);
for (const bad of ['\u202e', '\u200b', '\u0007']) assert.ok(!line.includes(bad), `no U+${bad.codePointAt(0).toString(16)}`);
const detail = line.slice(line.indexOf('(Grep ') + 6, line.indexOf('):'));
assert.ok(Array.from(detail).length <= 60, `detail bounded: ${detail.length}`);
assert.ok(detail.endsWith('...'), 'truncation marked');
});
c3Case('sensitive targets never get a closest-search line', () => {
const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_k1', 'Grep', { pattern: 'login_view', path: `${c3Root}/docs` })]);
const out = c3Edit(`${c3Root}/src/auth/login_view.py`, t);
c3AssertDenied(out, 'sensitive');
assert.ok(!out.reason.includes(MISS_PREFIX), out.reason);
});
c3Case('condensed denials and MultiEdit denials carry the closest-search line', () => {
const t = c3WriteTranscript([c3Human('x'), ...c3Search('toolu_m1', 'Grep', { pattern: 'widget_factory', path: `${c3Root}/docs` })]);
const condensed = c3Edit(`${c3Root}/src/widget_factory.py`, t, { GATEGUARD_FACT_FORCE_FULL_DENIALS: '0' });
assert.ok(!condensed.reason.includes('\n'), 'still one line');
assert.ok(condensed.reason.includes(`${MISS_PREFIX} (Grep widget_factory)`), condensed.reason);
clearState();
const multi = c3Run('MultiEdit', { edits: [{ file_path: `${c3Root}/src/widget_factory.py`, old_string: 'a', new_string: 'b' }] }, t);
assert.ok(missLine(multi.reason).includes('(Grep widget_factory)'), multi.reason);
});
// --- Exclusion globs never earn search credit ---
const r2Target = `${c3Root}/src/widget.py`;
let r2Seq = 0;
@@ -89,6 +89,37 @@ test('credits by Glob literal-prefix directory and stem, folding case only for W
assert.strictEqual(findCreditingSearch(posixScan, '/proj/src/new_file.js', true, { cwd: '/proj' }), null, 'posix is case-sensitive');
});
test('findClosestMiss reports a reason code for the nearest non-qualifying search', () => {
const { findClosestMiss } = loadHook();
const data = { cwd: root };
const target = `${root}/src/widget_factory.py`;
const scanOf = (searches, extra = {}) => ({ turnId: null, searches, batchIds: new Map(), newestMessageId: null, shellCommands: [], reads: [], ...extra });
const s = (name, input, messageId = 'msg_1') => ({ name, input, callsAgo: 1, messageId });
const reason = (scan, allowDir = false, file = target) => (findClosestMiss(scan, file, allowDir, data) || {}).reason || null;
assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory', path: `${root}/docs` })])), 'out-of-scope');
assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory', glob: '!*.py' })])), 'excluded');
assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory', glob: '*.md' })])), 'excluded', 'include globs that miss');
assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory' }, 'msg_b')], { newestMessageId: 'msg_b' })), 'same-batch');
assert.strictEqual(reason(scanOf([s('Bash', { command: 'git diff | grep widget_factory' })])), 'stdin-only');
assert.strictEqual(reason(scanOf([s('Bash', { command: 'cat src/widget_factory.py' })])), 'not-a-search');
assert.strictEqual(reason(scanOf([], { reads: [{ name: 'Read', path: 'src/widget_factory.py', callsAgo: 1 }] })), 'not-a-search');
assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'index' })]), false, `${root}/src/index.py`), 'generic-stem');
assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'unrelated' })])), null);
assert.strictEqual(reason(scanOf([s('Grep', { pattern: 'widget_factory' })])), null, 'a crediting search is not a miss');
assert.strictEqual(reason(scanOf([s('LS', { path: `${root}/src` })]), true, `${root}/src/brand_new.py`), null, 'dir credit is not a miss');
assert.strictEqual(
reason(scanOf([s('Grep', { pattern: 'widget_factory', path: `${root}/docs` }), s('Grep', { pattern: 'widget_factory', glob: '!*.py' })])),
'excluded',
'excluded outranks out-of-scope'
);
const miss = findClosestMiss(scanOf([s('Grep', { pattern: 'widget_factory', path: `${root}/docs` })]), target, false, data);
assert.deepStrictEqual(Object.keys(miss).sort(), ['detail', 'name', 'reason']);
for (const bad of [null, {}, { searches: 'x' }, scanOf([null, { name: 'Grep' }]), scanOf([], { reads: [null, { path: 5 }] })]) {
assert.strictEqual(findClosestMiss(bad, target, false, data), null);
}
assert.strictEqual(findClosestMiss(scanOf([s('Grep', { pattern: 'widget_factory', path: '/elsewhere' })]), '', false, data), null);
});
fs.rmSync(transcriptDir, { recursive: true, force: true });
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
+20
View File
@@ -179,6 +179,26 @@ test('callsAgo counts back from the newest call, skipping the pending call', ()
assert.deepStrictEqual(scan.shellCommands, ['ls']);
});
test('the scan lists the turn Read calls newest first, skipping the pending call', () => {
const t = writeTranscript([
human('go'),
toolUse('toolu_r1', 'Read', { file_path: 'src/a.js' }),
toolResult('toolu_r1'),
...search('toolu_g', 'Grep', { pattern: 'a' }),
toolUse('toolu_r2', 'Read', { file_path: 'src/b.js' }),
toolResult('toolu_r2'),
toolUse('toolu_r3', 'Read', { file_path: 42 })
]);
const scan = scanCurrentTurn(t, `toolu_pending_${seq}`);
assert.deepStrictEqual(scan.reads, [
{ name: 'Read', path: 'src/b.js', callsAgo: 2 },
{ name: 'Read', path: 'src/a.js', callsAgo: 4 }
]);
assert.deepStrictEqual(scan.searches.map(s => s.name), ['Grep'], 'reads are never searches');
const before = writeTranscript([toolUse('toolu_r0', 'Read', { file_path: 'x' }), toolResult('toolu_r0'), human('next')]);
assert.deepStrictEqual(scanCurrentTurn(before).reads, [], 'reads before the turn are not listed');
});
test('no transcript, a directory, or a whole file without a boundary -> no searches', () => {
assert.strictEqual(scanCurrentTurn(''), null);
assert.strictEqual(scanCurrentTurn(path.join(transcriptDir, 'missing.jsonl')), null);