Phase 8 of v2.0. Final hardening pass before production tag. Path-guard fuzz suite (34 tests): traversal attacks (../ all depths, encoded %2e%2e, null bytes, absolute escapes, prefix-without-separator, backslash), secret-file deny list (.env, *.pem, id_rsa*, *.key, credentials.json, *.kdbx, .netrc), valid-path positives, edge cases (empty, whitespace, very long, triple-dot, multiple slashes). write_guard.ts hardened: added null-byte rejection and whitespace-only rejection (previously only checked empty string). Pending-changes integration test skeleton: 4 tests covering the full queue→apply→rewind cycle against a real DB + filesystem. Gated on DATABASE_URL via describe.runIf (same pattern as apps/server's tool_cost_stats.test.ts). Skips cleanly when unset. 57 tests passing (23 existing + 34 fuzz), 4 integration skipped. All builds clean. All services healthy. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
78 lines
2.2 KiB
TypeScript
78 lines
2.2 KiB
TypeScript
import { resolve, sep } from 'node:path';
|
|
|
|
export class WriteGuardError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'WriteGuardError';
|
|
}
|
|
}
|
|
|
|
// Deny list: files that should never be written regardless of path-guard.
|
|
// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns.
|
|
// Full parity with BooChat's deny list is not needed for write-guard because
|
|
// the write tools are intentional (model chose to create/edit); we block only
|
|
// files that are unambiguously secrets.
|
|
const SECRET_PATTERNS: readonly string[] = [
|
|
'.env',
|
|
'.env.local',
|
|
'.env.production',
|
|
'.env.development',
|
|
'.env.staging',
|
|
'id_rsa',
|
|
'id_dsa',
|
|
'id_ecdsa',
|
|
'id_ed25519',
|
|
'*.pem',
|
|
'*.key',
|
|
'*.p12',
|
|
'*.pfx',
|
|
'*.crt',
|
|
'credentials.json',
|
|
'*.kdbx',
|
|
'.netrc',
|
|
];
|
|
|
|
export function isSecretPath(filePath: string): boolean {
|
|
const normalized = filePath.replace(/\\/g, '/');
|
|
const segments = normalized.split('/').filter((s) => s.length > 0);
|
|
if (segments.length === 0) return false;
|
|
const basename = segments[segments.length - 1]!;
|
|
|
|
return SECRET_PATTERNS.some((pattern) => {
|
|
if (pattern.startsWith('*')) {
|
|
return basename.endsWith(pattern.slice(1));
|
|
}
|
|
return basename === pattern;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve and validate a write target path.
|
|
*
|
|
* Key difference from BooChat's pathGuard: no realpath() — the file may not
|
|
* exist yet (creates). Uses resolve() to normalize ../ segments and then
|
|
* checks the result stays within projectRoot.
|
|
*/
|
|
export function resolveWritePath(projectRoot: string, filePath: string): string {
|
|
if (!filePath || filePath.trim().length === 0) {
|
|
throw new WriteGuardError('file path is required');
|
|
}
|
|
|
|
if (filePath.includes('\x00')) {
|
|
throw new WriteGuardError('file path contains null byte');
|
|
}
|
|
|
|
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
|
|
const normalized = resolve(candidate); // normalizes ../ segments
|
|
|
|
if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) {
|
|
throw new WriteGuardError(`path escapes project root: ${filePath}`);
|
|
}
|
|
|
|
if (isSecretPath(normalized)) {
|
|
throw new WriteGuardError(`cannot write to secret file: ${filePath}`);
|
|
}
|
|
|
|
return normalized;
|
|
}
|