mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 15:01:46 +02:00
feat(gateguard): keep session-keyed state for 8 idle hours
The project-directory fallback keeps the 30-minute window.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 分の無操作で失効します。
|
||||
|
||||
## すでに済んでいるとみなされるもの
|
||||
|
||||
|
||||
@@ -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"`。
|
||||
|
||||
## 快速开始
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 (_) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user