v2.0.0-beta: write tools, pending-changes queue, inference loop, API routes

Phase 2 of v2.0. BooCoder is now a functional write-capable chatbot.

Write-path guard: resolveWritePath() uses resolve() (no realpath — files may
not exist for creates) + prefix-check + secret-file deny list (.env, *.pem,
id_rsa*, etc.). 23 unit tests cover traversal attacks.

Pending-changes service: queueEdit/Create/Delete → applyOne/All →
rejectOne/All → rewindOne. Edit diffs stored as JSON {old, new}. All writes
queue before touching disk; apply re-validates the path guard.

5 write tools: edit_file, create_file, delete_file, apply_pending, rewind.
Registered alongside 25 read-only tools from BooChat (30 total, alpha-sorted).
Write tools use a module-level inference context for sql+sessionId injection.

Inference loop via workspace dependency: apps/coder imports
createInferenceRunner, createBroker, ALL_TOOLS from @boocode/server (dist/).
apps/server gains declaration: true + exports map with typed subpath entries.
No code duplication — one inference engine shared by both apps.

API routes: POST /api/sessions/:id/messages (user msg → inference), POST stop,
GET/POST pending-changes CRUD (5 endpoints), WebSocket session streaming.

Dockerfile updated to build apps/server first (coder depends on its .d.ts).
Health endpoint reports tool count: {"ok":true,"db":true,"tools":30}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 01:53:38 +00:00
parent 006226cce5
commit ce31577d1e
23 changed files with 1236 additions and 5 deletions

View File

@@ -0,0 +1,73 @@
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.length === 0) {
throw new WriteGuardError('file path is required');
}
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;
}