Skip to content

Commit ef8b35f

Browse files
committed
fix: enforce workspace boundaries for agent tools, block critical compliance turns
- Add 'workspace' PolicyKind with default_deny_workspace_global rule (priority 150) - Enforce workspace policy in validateToolCall for file tools and shell - Sandbox shell tool cwd to agent workspace by default, validate via resolveWorkspacePath - Block subsequent turns when compliance classifies previous turn as critical risk - Migration 056 widens policy_rules CHECK constraint and inserts default deny rule - 9 tests covering policy kind, deny/allow rules, validateToolCall blocking, shell workspace param
1 parent e35fe9b commit ef8b35f

10 files changed

Lines changed: 463 additions & 20 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ Versioning: [Semantic Versioning](https://semver.org/)
3535
and the database, including retroactive cleanup of entries leaked by prior test runs.
3636
(`tests/mcp_test.ts`)
3737

38+
- **Agent workspace boundary escape** — non-admin agents could bypass workspace isolation
39+
by specifying `workspace: "global"` in file tools (`file_list`, `file_tree`) and shell
40+
commands, accessing the host filesystem outside their agent workspace directory. Added a
41+
new `workspace` `PolicyKind` with a `default_deny_workspace_global` deny rule (priority 150,
42+
migration 056) that blocks `workspace:global` access unless an explicit allow rule exists.
43+
The shell tool now defaults to agent workspace and validates `cwd` via
44+
`resolveWorkspacePath`. When compliance classifies a turn as `critical` risk, subsequent
45+
turns in that session are now blocked rather than only logged.
46+
(`src/security/policy.ts`, `src/security/validator.ts`, `packages/gate/src/security/policy.ts`,
47+
`packages/gate/src/security/validator.ts`, `src/tools/builtin/shell.ts`,
48+
`src/pipeline/builtin.ts`, migration `056_workspace_policy.sql`,
49+
`tests/workspace_policy_test.ts`)
50+
3851
### Changed
3952

4053
- **Gateway health-retry endpoint now functional** — `POST /api/mcp-gateway/health-retry`
@@ -268,6 +281,24 @@ Versioning: [Semantic Versioning](https://semver.org/)
268281
real CodeFile containers rather than unrelated symbol nodes.
269282
(`src/codegraph/sync.ts`, `src/codegraph/resolver.ts`)
270283

284+
- **Database corruption defenses** — four-layer protection against SQLite corruption from
285+
concurrent server instances, crashes, and unclean shutdowns:
286+
1. **Pre-start integrity check** — `runMigrations()` now runs `PRAGMA integrity_check` on all
287+
5 databases before applying migrations. If corruption is detected, the server refuses to start
288+
with a clear error message pointing to the backups directory.
289+
2. **Single-instance PID lock** — a `server.pid` file is checked before any initialization.
290+
If another cortex server process is alive and holding the lock, the new instance exits
291+
immediately with the running PID. Stale PID files from dead processes are cleaned up
292+
automatically.
293+
3. **WAL checkpoint on graceful shutdown** — the shutdown handler now checkpoints all databases
294+
with `PRAGMA wal_checkpoint(TRUNCATE)`, ensuring WAL data is merged into the main database
295+
file before exit.
296+
4. **Enhanced `tryRecover()`** — recovery uses `PRAGMA integrity_check` instead of `SELECT 1`
297+
for corruption detection, catches `malformed database schema` errors, verifies restored
298+
backups pass integrity checks before accepting them, and falls through to older backups when
299+
the latest is also corrupted. Recovery instructions are printed when no healthy backup exists.
300+
(`src/db/migrate.ts`, `src/server/server.ts`)
301+
271302
## [0.53.1] - 2026-06-24
272303

273304
### Fixed

‎packages/gate/src/security/policy.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { getCoreDb } from '../../../../src/db/client.ts';
22
import type { InValue } from 'npm:@libsql/client';
33

4-
export type PolicyKind = 'tool' | 'shell' | 'domain' | 'capability' | 'path' | 'computer';
4+
export type PolicyKind =
5+
| 'tool'
6+
| 'shell'
7+
| 'domain'
8+
| 'capability'
9+
| 'path'
10+
| 'computer'
11+
| 'workspace';
512
export type PolicyEffect = 'allow' | 'deny';
613

714
export interface PolicyRule {

‎packages/gate/src/security/validator.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ export async function validateToolCall(
5454
}
5555

5656
if (toolName === 'shell' || toolName === 'code_exec') {
57+
const wsArg = args.workspace;
58+
if (wsArg === 'global' || wsArg === 'agent') {
59+
const wsDecision = await checkPolicy('workspace', String(wsArg));
60+
if (!wsDecision.allowed) {
61+
await logEvent({
62+
event_type: 'policy_check',
63+
session_id: sessionId,
64+
actor: 'validator',
65+
action: `workspace:${toolName}`,
66+
summary: `Workspace access denied: ${wsArg}`,
67+
started_at: new Date().toISOString(),
68+
payload: { tool: toolName, workspace: wsArg, rule: wsDecision.rule?.id },
69+
});
70+
return { allowed: false, reason: wsDecision.reason };
71+
}
72+
}
73+
5774
const command = String(args.command ?? args.code ?? '');
5875
const shellDecision = await checkPolicy('shell', command);
5976

@@ -128,8 +145,26 @@ export async function validateToolCall(
128145
}
129146
}
130147

131-
// Path-based policy check for file tools
148+
// Workspace policy check for file tools with workspace parameter
132149
if (FILE_TOOLS.has(toolName)) {
150+
const workspaceArg = args.workspace;
151+
if (workspaceArg === 'global' || workspaceArg === 'agent') {
152+
const wsValue = String(workspaceArg);
153+
const wsDecision = await checkPolicy('workspace', wsValue);
154+
if (!wsDecision.allowed) {
155+
await logEvent({
156+
event_type: 'policy_check',
157+
session_id: sessionId,
158+
actor: 'validator',
159+
action: `workspace:${toolName}`,
160+
summary: `Workspace access denied: ${wsValue}`,
161+
started_at: new Date().toISOString(),
162+
payload: { tool: toolName, workspace: wsValue, rule: wsDecision.rule?.id },
163+
});
164+
return { allowed: false, reason: wsDecision.reason };
165+
}
166+
}
167+
133168
const pathArg = args.path ?? args.source ?? args.pattern ?? '';
134169
if (typeof pathArg === 'string' && pathArg) {
135170
const pathDecision = await checkPolicy('path', pathArg);

‎src/db/migrate.ts‎

Lines changed: 125 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const DB_FILES = ['cortex.db', 'memory.db', 'lens.db', 'vault.db', 'plugins.db']
8383

8484
const MAX_BACKUPS = 5;
8585

86-
async function checkpointWal(db: Db): Promise<void> {
86+
export async function checkpointWal(db: Db): Promise<void> {
8787
try {
8888
await db.run('PRAGMA wal_checkpoint(TRUNCATE)');
8989
} catch {
@@ -145,29 +145,40 @@ async function pruneBackups(): Promise<void> {
145145
async function tryRecover(dbPath: string): Promise<boolean> {
146146
if (!await exists(dbPath)) return false;
147147

148-
// Try opening the DB. SQLite auto-recovers WAL from unclean shutdown.
149-
// Only treat as corrupt if it throws SQLITE_CORRUPT.
150148
let isCorrupt = false;
149+
let corruptReason = '';
151150
try {
152151
const testClient = createClient({ url: `file:${dbPath}` });
153-
await testClient.execute('SELECT 1');
152+
const result = await testClient.execute('PRAGMA integrity_check');
153+
const rows = result.rows as unknown as Array<{ integrity_check: string }>;
154+
for (const row of rows) {
155+
if (row.integrity_check !== 'ok') {
156+
isCorrupt = true;
157+
corruptReason = row.integrity_check;
158+
break;
159+
}
160+
}
154161
testClient.close();
155162
} catch (e) {
163+
const msg = (e as Error).message ?? '';
156164
if (
157-
(e as Error).message?.includes('SQLITE_CORRUPT') ||
158-
(e as Error).message?.includes('disk image is malformed')
165+
msg.includes('SQLITE_CORRUPT') ||
166+
msg.includes('disk image is malformed') ||
167+
msg.includes('malformed database schema')
159168
) {
160-
console.log(` ⚠ ${basename(dbPath)} is corrupted (SQLITE_CORRUPT) — will recover`);
169+
console.log(` ⚠ ${basename(dbPath)} is corrupted (${msg.slice(0, 80)}) — will recover`);
161170
isCorrupt = true;
171+
corruptReason = msg;
162172
} else {
163-
// Other errors (permission, etc.) — don't try to recover
164-
console.error(` ✗ ${basename(dbPath)} open failed: ${(e as Error).message}`);
173+
console.error(` ✗ ${basename(dbPath)} open failed: ${msg}`);
165174
return false;
166175
}
167176
}
168177

169178
if (!isCorrupt) return false;
170179

180+
console.log(` ⚠ ${basename(dbPath)} integrity failure: ${corruptReason.slice(0, 120)}`);
181+
171182
const entries: Deno.DirEntry[] = [];
172183
for await (const entry of Deno.readDir(PATHS.backupsDir)) {
173184
if (entry.isDirectory) entries.push(entry);
@@ -199,18 +210,80 @@ async function tryRecover(dbPath: string): Promise<boolean> {
199210
}
200211
}
201212

202-
return true;
213+
// Verify restored backup passes integrity check
214+
try {
215+
const verifyClient = createClient({ url: `file:${dbPath}` });
216+
const verifyResult = await verifyClient.execute('PRAGMA integrity_check');
217+
const verifyRows = verifyResult.rows as unknown as Array<{ integrity_check: string }>;
218+
const verifyOk = verifyRows.length === 1 && verifyRows[0].integrity_check === 'ok';
219+
verifyClient.close();
220+
if (verifyOk) {
221+
console.log(` ✓ ${basename(dbPath)} restored from backup — integrity check passed`);
222+
return true;
223+
} else {
224+
console.warn(
225+
` ⚠ Restored ${basename(dbPath)} still has integrity issues — trying older backup`,
226+
);
227+
}
228+
} catch {
229+
console.warn(
230+
` ⚠ Restored ${basename(dbPath)} failed to open — trying older backup`,
231+
);
232+
}
203233
}
204234

205235
if (isCorrupt) {
206236
console.error(
207-
` ✗ ${basename(dbPath)} is corrupted and no backup exists — manual recovery required`,
237+
` ✗ ${basename(dbPath)} has corruption and no healthy backup found.`,
238+
);
239+
console.error(
240+
` To recover manually:`,
241+
);
242+
if (await exists(PATHS.backupsDir)) {
243+
console.error(` 1. Find a healthy backup in ${PATHS.backupsDir}`);
244+
console.error(` 2. Copy cortex.db from the backup into ${PATHS.dataDir}`);
245+
console.error(` 3. Remove any <dbname>-wal and <dbname>-shm files`);
246+
console.error(` 4. Restart the server`);
247+
}
248+
console.error(
249+
` If no healthy backup exists, delete the corrupted database to start fresh.`,
208250
);
209251
}
210252

211253
return false;
212254
}
213255

256+
async function integrityCheck(db: Db, label: string): Promise<string[]> {
257+
try {
258+
const rows = await db.all<{ integrity_check: string }>('PRAGMA integrity_check');
259+
const errors: string[] = [];
260+
for (const row of rows) {
261+
const val = row.integrity_check;
262+
if (val !== 'ok') errors.push(val);
263+
}
264+
return errors;
265+
} catch (e) {
266+
return [`${label}: integrity_check failed — ${(e as Error).message}`];
267+
}
268+
}
269+
270+
async function checkAllDatabases(
271+
dbs: Map<string, Db>,
272+
labels: Map<string, string>,
273+
): Promise<boolean> {
274+
let allOk = true;
275+
for (const [name, db] of dbs) {
276+
const label = labels.get(name) ?? name;
277+
const errors = await integrityCheck(db, label);
278+
if (errors.length > 0) {
279+
allOk = false;
280+
console.error(` ✗ ${label} integrity errors:`);
281+
for (const err of errors) console.error(` • ${err}`);
282+
}
283+
}
284+
return allOk;
285+
}
286+
214287
export async function runMigrations(): Promise<void> {
215288
await ensureDir(PATHS.dataDir);
216289
await ensureDir(PATHS.sessionsDir);
@@ -230,6 +303,34 @@ export async function runMigrations(): Promise<void> {
230303
const vaultDb = await getVaultDb();
231304
const pluginsDb = await getPluginsDb();
232305

306+
const integrityLabels = new Map([
307+
['cortex.db', 'cortex.db'],
308+
['memory.db', 'memory.db'],
309+
['lens.db', 'lens.db'],
310+
['vault.db', 'vault.db'],
311+
['plugins.db', 'plugins.db'],
312+
]);
313+
const integrityDbs = new Map<string, Db>([
314+
['cortex.db', coreDb],
315+
['memory.db', memoryDb],
316+
['lens.db', lensDb],
317+
['vault.db', vaultDb],
318+
['plugins.db', pluginsDb],
319+
]);
320+
321+
if (!await checkAllDatabases(integrityDbs, integrityLabels)) {
322+
console.error(
323+
'\n Database corruption detected. The server cannot start with corrupted databases.',
324+
);
325+
console.error(
326+
' To recover: restore from backups in ' + PATHS.backupsDir,
327+
);
328+
console.error(
329+
' Backups are created automatically before each migration run.',
330+
);
331+
Deno.exit(1);
332+
}
333+
233334
const dbMap = new Map<string, Db>([
234335
['cortex.db', coreDb],
235336
['memory.db', memoryDb],
@@ -443,6 +544,11 @@ export async function runMigrations(): Promise<void> {
443544
sqlFile: '055_mcp_gateway_approvals.sql',
444545
label: 'cortex.db (MCP gateway approvals)',
445546
},
547+
{
548+
db: coreDb,
549+
sqlFile: '056_workspace_policy.sql',
550+
label: 'cortex.db (workspace boundary policy)',
551+
},
446552
];
447553

448554
for (const { db, sqlFile, label } of targets) {
@@ -471,6 +577,14 @@ export async function runMigrations(): Promise<void> {
471577
// Run sensitivity backfill if needed (one-time after adding sensitivity columns)
472578
const { runBackfill } = await import('../security/backfill.ts');
473579
await runBackfill();
580+
581+
// Final health check — warn (don't abort) on post-migration issues
582+
const postCheckOk = await checkAllDatabases(integrityDbs, integrityLabels);
583+
if (!postCheckOk) {
584+
console.warn(
585+
' ⚠ Post-migration integrity issues detected. Some features may be degraded.',
586+
);
587+
}
474588
}
475589

476590
export async function createAutoAdmin(
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- Migration 056: Workspace boundary policy rules (cortex.db)
2+
-- 1. Widen the CHECK constraint on policy_rules.kind to include 'workspace'
3+
-- 2. Add default deny rule for workspace:global access
4+
5+
-- Widen constraint via table rebuild (SQLite lacks ALTER TABLE DROP CONSTRAINT)
6+
CREATE TABLE IF NOT EXISTS policy_rules_new (
7+
id TEXT PRIMARY KEY,
8+
kind TEXT NOT NULL CHECK (kind IN ('tool', 'shell', 'domain', 'capability', 'path', 'computer', 'workspace')),
9+
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
10+
pattern TEXT NOT NULL,
11+
reason TEXT,
12+
priority INTEGER NOT NULL DEFAULT 100,
13+
enabled INTEGER NOT NULL DEFAULT 1,
14+
node_id TEXT,
15+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
16+
);
17+
18+
INSERT OR IGNORE INTO policy_rules_new SELECT * FROM policy_rules;
19+
20+
DROP TABLE policy_rules;
21+
ALTER TABLE policy_rules_new RENAME TO policy_rules;
22+
23+
CREATE INDEX IF NOT EXISTS idx_policy_rules_kind ON policy_rules(kind);
24+
CREATE INDEX IF NOT EXISTS idx_policy_rules_enabled ON policy_rules(enabled);
25+
26+
-- Add default deny rule for workspace:global (priority 150 between deny rules ~1-10 and default_allow_tools at 200)
27+
INSERT OR IGNORE INTO policy_rules (id, kind, effect, pattern, reason, priority, enabled, node_id, created_at)
28+
VALUES ('default_deny_workspace_global', 'workspace', 'deny', 'global',
29+
'Global workspace access denied by default — requires explicit allow rule', 150, 1, NULL, datetime('now'));

0 commit comments

Comments
 (0)