fix: remove per-session gate, use permissionDecision deny for every read
The per-session FileReadGate was never requested and broke the cost savings loop — subsequent reads in the same session silently bypassed the timeline, hiding newly created observations. Now the timeline fires on every read that has observations, using the hook contract's permissionDecision: "deny" with the timeline as the reason (exit 0 + JSON) instead of exit code 2 + stderr. - Delete FileReadGate.ts entirely - Remove /api/file-context/gate endpoint from DataRoutes - Switch handler from exit code 2 to permissionDecision: "deny" - Restore permissionDecision fields to HookResult - Eliminate one HTTP round-trip per read (no gate check needed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+103
-103
File diff suppressed because one or more lines are too long
@@ -8,7 +8,6 @@
|
|||||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||||
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||||
import { logger } from '../../utils/logger.js';
|
import { logger } from '../../utils/logger.js';
|
||||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
|
||||||
import { parseJsonArray } from '../../shared/timeline-formatting.js';
|
import { parseJsonArray } from '../../shared/timeline-formatting.js';
|
||||||
import { statSync } from 'fs';
|
import { statSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -212,28 +211,17 @@ export const fileContextHandler: EventHandler = {
|
|||||||
return { continue: true, suppressOutput: true };
|
return { continue: true, suppressOutput: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check the gate: has this file's timeline been shown in this session?
|
// Deny the read with the timeline as the reason — Claude sees the timeline
|
||||||
const gateResponse = await workerHttpRequest('/api/file-context/gate', {
|
// and decides: work from semantic priming, use get_observations(), or ask user to allow read
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ sessionId: input.sessionId, filePath: relativePath, cwd }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (gateResponse.ok) {
|
|
||||||
const gateData = await gateResponse.json() as { firstAttempt: boolean };
|
|
||||||
|
|
||||||
if (gateData.firstAttempt) {
|
|
||||||
// BLOCK: Show timeline, Claude decides whether to re-read or use get_observations()
|
|
||||||
const timeline = formatFileTimeline(dedupedObservations, filePath);
|
const timeline = formatFileTimeline(dedupedObservations, filePath);
|
||||||
return {
|
return {
|
||||||
exitCode: HOOK_EXIT_CODES.BLOCKING_ERROR,
|
hookSpecificOutput: {
|
||||||
stderrMessage: timeline,
|
hookEventName: 'PreToolUse',
|
||||||
|
additionalContext: '',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: timeline,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ALLOW: Second attempt or gate check failed — let the read proceed silently
|
|
||||||
return { continue: true, suppressOutput: true };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('HOOK', 'File context fetch error, skipping', {
|
logger.warn('HOOK', 'File context fetch error, skipping', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface HookResult {
|
|||||||
hookSpecificOutput?: {
|
hookSpecificOutput?: {
|
||||||
hookEventName: string;
|
hookEventName: string;
|
||||||
additionalContext: string;
|
additionalContext: string;
|
||||||
|
permissionDecision?: string;
|
||||||
|
permissionDecisionReason?: string;
|
||||||
};
|
};
|
||||||
systemMessage?: string;
|
systemMessage?: string;
|
||||||
exitCode?: number;
|
exitCode?: number;
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* File Read Gate
|
|
||||||
*
|
|
||||||
* In-memory session-scoped gate tracking which files have had their timeline
|
|
||||||
* injected. Prevents duplicate timeline injections within the same session.
|
|
||||||
* Keys include cwd to prevent worktree collisions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface SessionEntry {
|
|
||||||
files: Set<string>;
|
|
||||||
lastAccess: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
||||||
|
|
||||||
const sessionGates = new Map<string, SessionEntry>();
|
|
||||||
|
|
||||||
let pruneCallCount = 0;
|
|
||||||
const PRUNE_EVERY_N_CALLS = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lazily prune session entries older than the TTL.
|
|
||||||
* Throttled to run every N calls to avoid iterating all sessions on every check.
|
|
||||||
*/
|
|
||||||
function pruneExpiredSessions(): void {
|
|
||||||
pruneCallCount++;
|
|
||||||
if (pruneCallCount < PRUNE_EVERY_N_CALLS) return;
|
|
||||||
pruneCallCount = 0;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, entry] of sessionGates) {
|
|
||||||
if (now - entry.lastAccess > TTL_MS) {
|
|
||||||
sessionGates.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a composite key scoped to session + cwd to prevent worktree collisions.
|
|
||||||
*/
|
|
||||||
function makeKey(sessionId: string, cwd?: string): string {
|
|
||||||
return cwd ? `${sessionId}::${cwd}` : sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this is the first read of a file in a session+cwd scope, and mark it if so.
|
|
||||||
* Returns true if this is the first attempt (file was not previously seen).
|
|
||||||
* Returns false if the file was already seen in this session.
|
|
||||||
*/
|
|
||||||
export function checkAndMark(sessionId: string, filePath: string, cwd?: string): boolean {
|
|
||||||
pruneExpiredSessions();
|
|
||||||
|
|
||||||
const key = makeKey(sessionId, cwd);
|
|
||||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
||||||
|
|
||||||
let entry = sessionGates.get(key);
|
|
||||||
if (!entry) {
|
|
||||||
entry = { files: new Set(), lastAccess: Date.now() };
|
|
||||||
sessionGates.set(key, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh TTL on every access so active sessions don't get re-blocked
|
|
||||||
entry.lastAccess = Date.now();
|
|
||||||
|
|
||||||
if (entry.files.has(normalizedPath)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.files.add(normalizedPath);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all tracked files for a session (e.g., on session end).
|
|
||||||
* Clears all cwd scopes for the given session.
|
|
||||||
*/
|
|
||||||
export function clearSession(sessionId: string): void {
|
|
||||||
for (const key of sessionGates.keys()) {
|
|
||||||
if (key === sessionId || key.startsWith(`${sessionId}::`)) {
|
|
||||||
sessionGates.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ import { SSEBroadcaster } from '../../SSEBroadcaster.js';
|
|||||||
import type { WorkerService } from '../../../worker-service.js';
|
import type { WorkerService } from '../../../worker-service.js';
|
||||||
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
||||||
import { getObservationsByFilePath } from '../../../sqlite/observations/get.js';
|
import { getObservationsByFilePath } from '../../../sqlite/observations/get.js';
|
||||||
import { checkAndMark } from '../../FileReadGate.js';
|
|
||||||
|
|
||||||
export class DataRoutes extends BaseRouteHandler {
|
export class DataRoutes extends BaseRouteHandler {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -63,9 +62,6 @@ export class DataRoutes extends BaseRouteHandler {
|
|||||||
|
|
||||||
// Import endpoint
|
// Import endpoint
|
||||||
app.post('/api/import', this.handleImport.bind(this));
|
app.post('/api/import', this.handleImport.bind(this));
|
||||||
|
|
||||||
// File-context gate
|
|
||||||
app.post('/api/file-context/gate', this.handleFileContextGate.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -502,19 +498,4 @@ export class DataRoutes extends BaseRouteHandler {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a file has already had its timeline injected in this session
|
|
||||||
* POST /api/file-context/gate
|
|
||||||
* Body: { sessionId: string, filePath: string }
|
|
||||||
* Returns: { firstAttempt: boolean }
|
|
||||||
*/
|
|
||||||
private handleFileContextGate = this.wrapHandler((req: Request, res: Response): void => {
|
|
||||||
const { sessionId, filePath, cwd } = req.body;
|
|
||||||
if (!sessionId || !filePath) {
|
|
||||||
this.badRequest(res, 'sessionId and filePath are required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const firstAttempt = checkAndMark(sessionId, filePath, cwd);
|
|
||||||
res.json({ firstAttempt });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user