From 6f0fbb6bc9a0fb7a2952fd92c690215aa6d9eb23 Mon Sep 17 00:00:00 2001 From: Jeffrey Montoya Date: Tue, 29 Sep 2026 16:40:18 -0400 Subject: [PATCH] feat(gateguard): keep session-keyed state for 8 idle hours The project-directory fallback keeps the 30-minute window. --- docs/gateguard/design-notes.md | 15 +++++ docs/ja-JP/skills/gateguard/SKILL.md | 2 +- docs/zh-CN/skills/gateguard/SKILL.md | 2 +- manifests/context-packs/skill-triggers@1.json | 2 +- scripts/hooks/gateguard-fact-force.js | 16 +++-- skills/gateguard/SKILL.md | 4 ++ tests/hooks/gateguard-fact-force.test.js | 67 ++++++++++++++++++- 7 files changed, 98 insertions(+), 10 deletions(-) diff --git a/docs/gateguard/design-notes.md b/docs/gateguard/design-notes.md index 695434b20..13265ff7f 100644 --- a/docs/gateguard/design-notes.md +++ b/docs/gateguard/design-notes.md @@ -467,3 +467,18 @@ question still comes before the first command that can change something. The earlier read-only git allowlist, which runs before destructive detection, is unchanged. + +## Idle window + +Session state expires after an idle window. A state key from a real session +id (`session_id`, `CLAUDE_SESSION_ID`, `ECC_SESSION_ID`) or a transcript path +names one conversation, so its window is 8 hours: a break in a long +conversation does not re-ask the checks it already answered. The project +fingerprint fallback (`proj-` keys) can be shared by unrelated sessions in the +same directory, so it keeps 30 minutes. + +Module-load pruning removes a state file idle for twice its key's window, +judged by file name: `state-proj-*` uses 30 minutes, any other key 8 hours. +A session id that itself starts with `proj-` gets the short window for both +expiry and pruning, which can only expire state sooner. Temporary files from interrupted writes +are never loaded and always use the short window. diff --git a/docs/ja-JP/skills/gateguard/SKILL.md b/docs/ja-JP/skills/gateguard/SKILL.md index 8aa5787ff..25844287d 100644 --- a/docs/ja-JP/skills/gateguard/SKILL.md +++ b/docs/ja-JP/skills/gateguard/SKILL.md @@ -116,7 +116,7 @@ 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` に数えます。 +このゲートが発火するまでは、読み取り専用のコマンド(`ls`、`cat`、`rg`、`git status`/`log`/`diff`、`Get-ChildItem` など。リダイレクト、置換、`tee`、`xargs`、未知のコマンドを含まないもの)はゲートを消費せずに通過し `routine_readonly_passes` に数えます。セッション ID またはトランスクリプトパスで識別される状態は 8 時間、プロジェクトディレクトリによるフォールバックは 30 分の無操作で失効します。 ## すでに済んでいるとみなされるもの diff --git a/docs/zh-CN/skills/gateguard/SKILL.md b/docs/zh-CN/skills/gateguard/SKILL.md index e50ff6282..b75dccd21 100644 --- a/docs/zh-CN/skills/gateguard/SKILL.md +++ b/docs/zh-CN/skills/gateguard/SKILL.md @@ -98,7 +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`。 +* **例行 shell 门控与会话:** 门控触发前,只读命令(`ls`、`cat`、`rg`、`git status`/`log`/`diff`、`Get-ChildItem` 等,不含重定向、替换、`tee`、`xargs` 或未知命令)直接放行且不消耗门控,计入 `routine_readonly_passes`;以会话 ID 或 transcript 路径为键的状态空闲 8 小时失效,项目目录回退键仍为 30 分钟。 * **兼容性:** 唯一新增的环境变量是可选的 `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 e00e93c02..f6bd7d7a9 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":"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"} +{"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":"672c16a07a04ec9eb343092c57aa05ec931bacd79fa94977fb572bef2fd816b4","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 bee2a0810..5bec3df59 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -65,8 +65,10 @@ const { const STATE_DIR = process.env.GATEGUARD_STATE_DIR || path.join(process.env.HOME || process.env.USERPROFILE || '/tmp', '.gateguard'); let activeStateFile = null; -// State expires after 30 minutes of inactivity const SESSION_TIMEOUT_MS = 30 * 60 * 1000; +const SESSION_ID_TIMEOUT_MS = 8 * 60 * 60 * 1000; +const PROJECT_KEY_PREFIX = 'proj-'; +let activeIdleWindowMs = SESSION_TIMEOUT_MS; const READ_HEARTBEAT_MS = 60 * 1000; // Maximum checked entries to prevent unbounded growth @@ -1394,10 +1396,16 @@ function resolveSessionKey(data) { return hashSessionKey('proj', path.resolve(projectFingerprint)); } +// see docs/gateguard/design-notes.md#idle-window +function idleWindowForKey(sessionKey) { + return sessionKey.startsWith(PROJECT_KEY_PREFIX) ? SESSION_TIMEOUT_MS : SESSION_ID_TIMEOUT_MS; +} + function getStateFile(data) { if (!activeStateFile) { const sessionKey = resolveSessionKey(data); activeStateFile = path.join(STATE_DIR, `state-${sessionKey}.json`); + activeIdleWindowMs = idleWindowForKey(sessionKey); } return activeStateFile; } @@ -1408,7 +1416,7 @@ function loadState() { if (fs.existsSync(stateFile)) { const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); const lastActive = state.last_active || 0; - if (Date.now() - lastActive > SESSION_TIMEOUT_MS) { + if (Date.now() - lastActive > activeIdleWindowMs) { try { fs.unlinkSync(stateFile); } catch (_) { @@ -1682,7 +1690,6 @@ function isChecked(key) { return found; } -// Prune stale session files older than 1 hour (function pruneStaleFiles() { try { const files = fs.readdirSync(STATE_DIR); @@ -1691,9 +1698,10 @@ function isChecked(key) { const isStateFile = f.startsWith('state-') && (f.endsWith('.json') || f.includes('.json.tmp.')); if (!isStateFile) continue; const fp = path.join(STATE_DIR, f); + const window = f.endsWith('.json') ? idleWindowForKey(f.slice('state-'.length)) : SESSION_TIMEOUT_MS; try { const stat = fs.statSync(fp); - if (now - stat.mtimeMs > SESSION_TIMEOUT_MS * 2) { + if (now - stat.mtimeMs > window * 2) { fs.unlinkSync(fp); } } catch (_) { diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index dce237832..aa14515a8 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -195,6 +195,10 @@ assignment, wrapper (`env`, `sudo`, `xargs`, `sh -c`), unknown command, write/execute option, or PowerShell `$`, `@`, `(`, `{` or `--%` makes the command ordinary. Destructive detection runs first, unchanged. +Session state expires after 8 idle hours when it is keyed by a session id or +transcript path, and after 30 idle minutes when it falls back to the project +directory. + ## Parallel Batches and Partial Application The first-touch gate evaluates each tool call independently. When several diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index c86a9c8cc..e6e8a9e31 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -46,7 +46,7 @@ function writeExpiredState() { fs.mkdirSync(stateDir, { recursive: true }); const expired = { checked: ['some_file.js', '__bash_session__'], - last_active: Date.now() - 31 * 60 * 1000 // 31 minutes ago + last_active: Date.now() - (8 * 60 + 1) * 60 * 1000 }; fs.writeFileSync(stateFile, JSON.stringify(expired), 'utf8'); } catch (_) { @@ -1066,7 +1066,7 @@ function runTests() { // --- Test 6: session state resets after timeout --- if ( - test('session state resets after 30-minute timeout', () => { + test('session state resets after the idle window', () => { writeExpiredState(); const input = { tool_name: 'Edit', @@ -1648,7 +1648,7 @@ function runTests() { fs.writeFileSync(staleFile, JSON.stringify({ checked: [], last_active: Date.now() }), 'utf8'); fs.writeFileSync(freshFile, JSON.stringify({ checked: [], last_active: Date.now() }), 'utf8'); - const staleTime = new Date(Date.now() - 61 * 60 * 1000); + const staleTime = new Date(Date.now() - 17 * 60 * 60 * 1000); fs.utimesSync(staleFile, staleTime, staleTime); const result = runHook({ @@ -6801,6 +6801,67 @@ function runTests() { assert.ok(roReason(routine).includes('current user request')); }); + // --- Idle window follows the session key --- + const idleEnvNoIds = { CLAUDE_SESSION_ID: '', ECC_SESSION_ID: '', CLAUDE_TRANSCRIPT_PATH: '' }; + const idleStateFiles = () => fs.readdirSync(stateDir).filter(f => f.startsWith('state-') && f.endsWith('.json')); + const idleSeed = (fileName, ageMs) => { + fs.mkdirSync(stateDir, { recursive: true }); + const file = path.join(stateDir, fileName); + fs.writeFileSync(file, JSON.stringify({ checked: ['__bash_session__'], last_active: Date.now() - ageMs }), 'utf8'); + return file; + }; + const HOUR = 60 * 60 * 1000; + + c3Case('state keyed by a session id survives two idle hours', () => { + writeState({ checked: ['__bash_session__'], last_active: Date.now() - 2 * HOUR }); + assert.strictEqual(roDecision(roBash('npm test')), undefined); + }); + + c3Case('state keyed by a session id expires after eight idle hours', () => { + writeState({ checked: ['__bash_session__'], last_active: Date.now() - 8 * HOUR - 60 * 1000 }); + assert.strictEqual(roDecision(roBash('npm test')), 'deny'); + }); + + c3Case('state keyed by a transcript path survives two idle hours', () => { + const transcript = path.join(stateDir, 'idle-session.jsonl'); + roBash('npm test', { transcript_path: transcript }, idleEnvNoIds); + const [file] = idleStateFiles(); + assert.ok(/^state-tx-/.test(file), file); + const state = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8')); + fs.writeFileSync(path.join(stateDir, file), JSON.stringify({ ...state, last_active: Date.now() - 2 * HOUR }), 'utf8'); + assert.strictEqual(roDecision(roBash('npm test', { transcript_path: transcript }, idleEnvNoIds)), undefined); + }); + + c3Case('state keyed by the project fallback still expires after 30 idle minutes', () => { + const env = { ...idleEnvNoIds, CLAUDE_PROJECT_DIR: path.join(stateDir, 'idle-project') }; + roBash('npm test', {}, env); + const [file] = idleStateFiles(); + assert.ok(/^state-proj-/.test(file), file); + idleSeed(file, 31 * 60 * 1000); + assert.strictEqual(roDecision(roBash('npm test', {}, env)), 'deny'); + }); + + c3Case('module-load pruning keeps session files for the long window and project files for the short one', () => { + const age = (file, ms) => fs.utimesSync(file, new Date(Date.now() - ms), new Date(Date.now() - ms)); + const recentSession = idleSeed('state-recent-session.json', 0); + const oldSession = idleSeed('state-old-session.json', 0); + const recentTx = idleSeed(`state-tx-${'a'.repeat(24)}.json`, 0); + const oldProj = idleSeed(`state-proj-${'b'.repeat(24)}.json`, 0); + const oldTmp = path.join(stateDir, 'state-recent-session.json.tmp.1.abcd'); + fs.writeFileSync(oldTmp, '{}', 'utf8'); + age(recentSession, 2 * HOUR); + age(recentTx, 2 * HOUR); + age(oldSession, 17 * HOUR); + age(oldProj, 61 * 60 * 1000); + age(oldTmp, 61 * 60 * 1000); + loadDirectHook(); + assert.ok(fs.existsSync(recentSession), 'session file inside the long window is kept'); + assert.ok(fs.existsSync(recentTx), 'transcript file inside the long window is kept'); + assert.ok(!fs.existsSync(oldSession), 'session file past the long window is pruned'); + assert.ok(!fs.existsSync(oldProj), 'project fallback file past the short window is pruned'); + assert.ok(!fs.existsSync(oldTmp), 'stale temp file is pruned on the short window'); + }); + fs.rmSync(f3Base, { recursive: true, force: true }); fs.rmSync(f2Root, { recursive: true, force: true });