fix: address PR review feedback — path safety, SQL injection, gate scoping

- Resolve relative filePath against input.cwd before statSync; early-return on ENOENT
- Replace LIKE '%path%' with exact json_each equality to prevent false matches
- Sanitize and parameterize LIMIT to prevent NaN SQL errors
- Fix day-sorting to use earliest epoch in group, not first (specificity-sorted) item
- Use exact path equality in deduplicateObservations instead of substring includes
- Scope FileReadGate by session+cwd to prevent worktree collisions
- Refresh lastAccess TTL on active sessions; throttle prune to every 50 calls
- Type params as (string | number)[] instead of any[]
- Remove unused permissionDecision fields from HookResult

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-06 17:29:59 -07:00
parent a60f79c44d
commit 31910fb265
6 changed files with 175 additions and 142 deletions
+13 -8
View File
@@ -89,7 +89,8 @@ function deduplicateObservations(
const filesRead = parseJsonArray(obs.files_read);
const filesModified = parseJsonArray(obs.files_modified);
const totalFiles = filesRead.length + filesModified.length;
const inModified = filesModified.some(f => f.includes(targetPath) || targetPath.includes(f));
const normalizedTarget = targetPath.replace(/\\/g, '/');
const inModified = filesModified.some(f => f.replace(/\\/g, '/') === normalizedTarget);
let specificityScore = 0;
if (inModified) specificityScore += 2;
@@ -117,10 +118,10 @@ function formatFileTimeline(observations: ObservationRow[], filePath: string): s
byDay.get(day)!.push(obs);
}
// Sort days chronologically
// Sort days chronologically (use earliest observation in each group, not first — which is specificity-sorted)
const sortedDays = Array.from(byDay.entries()).sort((a, b) => {
const aEpoch = a[1][0].created_at_epoch;
const bEpoch = b[1][0].created_at_epoch;
const aEpoch = Math.min(...a[1].map(o => o.created_at_epoch));
const bEpoch = Math.min(...b[1].map(o => o.created_at_epoch));
return aEpoch - bEpoch;
});
@@ -154,12 +155,16 @@ export const fileContextHandler: EventHandler = {
// Skip gate for files below the token-economics threshold — timeline (~370 tokens)
// costs more than reading small files directly.
try {
const stat = statSync(filePath);
const statPath = path.isAbsolute(filePath)
? filePath
: path.resolve(input.cwd || process.cwd(), filePath);
const stat = statSync(statPath);
if (stat.size < FILE_READ_GATE_MIN_BYTES) {
return { continue: true, suppressOutput: true };
}
} catch {
// File not found, symlink error, permission denied — fall through and let gate proceed
} catch (err: any) {
if (err.code === 'ENOENT') return { continue: true, suppressOutput: true };
// Other errors (symlink, permission denied) — fall through and let gate proceed
}
// Check if project is excluded from tracking
@@ -211,7 +216,7 @@ export const fileContextHandler: EventHandler = {
const gateResponse = await workerHttpRequest('/api/file-context/gate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: input.sessionId, filePath: relativePath }),
body: JSON.stringify({ sessionId: input.sessionId, filePath: relativePath, cwd }),
});
if (gateResponse.ok) {