From 05edc2f4840c3daa9bf9086c2a2a63a2b7649966 Mon Sep 17 00:00:00 2001 From: Tibsfox Date: Mon, 13 Apr 2026 05:14:03 -0700 Subject: [PATCH] fix(gsd): block direct writes to gsd.db via hooks to prevent corruption (#3674) * fix(gsd): block direct writes to gsd.db via hooks to prevent corruption When gsd_complete_task tool was unavailable, agents fell back to shell- based sqlite3/sql.js writes to .gsd/gsd.db, corrupting the WAL-backed database. Extend write-intercept to block: - File writes to gsd.db, gsd.db-wal, gsd.db-shm - Bash commands using sqlite3/sql.js/better-sqlite3 targeting gsd.db - Shell redirects/cp/mv targeting gsd.db Closes #3625 Co-Authored-By: Claude Opus 4.6 (1M context) * test: add regression test for blocking direct gsd.db writes Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../gsd/tests/block-db-writes.test.ts | 63 +++++++++++++++++++ .../extensions/gsd/write-intercept.ts | 11 +++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/gsd/tests/block-db-writes.test.ts diff --git a/src/resources/extensions/gsd/tests/block-db-writes.test.ts b/src/resources/extensions/gsd/tests/block-db-writes.test.ts new file mode 100644 index 000000000..72708fb7c --- /dev/null +++ b/src/resources/extensions/gsd/tests/block-db-writes.test.ts @@ -0,0 +1,63 @@ +/** + * Regression test for #3674 — block direct writes to gsd.db + * + * When gsd_complete_task was unavailable, agents fell back to shell-based + * sqlite3 writes, corrupting the WAL-backed database. The fix extends + * write-intercept to block file writes and bash commands targeting gsd.db. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isBlockedStateFile, isBashWriteToStateFile } from '../write-intercept.ts'; + +describe('isBlockedStateFile blocks gsd.db paths (#3674)', () => { + test('blocks .gsd/gsd.db', () => { + assert.ok(isBlockedStateFile('/project/.gsd/gsd.db')); + }); + + test('blocks .gsd/gsd.db-wal', () => { + assert.ok(isBlockedStateFile('/project/.gsd/gsd.db-wal')); + }); + + test('blocks .gsd/gsd.db-shm', () => { + assert.ok(isBlockedStateFile('/project/.gsd/gsd.db-shm')); + }); + + test('blocks resolved symlink path under .gsd/projects/', () => { + assert.ok(isBlockedStateFile('/home/user/.gsd/projects/myproj/gsd.db')); + }); + + test('still blocks STATE.md', () => { + assert.ok(isBlockedStateFile('/project/.gsd/STATE.md')); + }); + + test('does not block other .gsd files', () => { + assert.ok(!isBlockedStateFile('/project/.gsd/DECISIONS.md')); + }); +}); + +describe('isBashWriteToStateFile blocks DB shell commands (#3674)', () => { + test('blocks sqlite3 targeting gsd.db', () => { + assert.ok(isBashWriteToStateFile('sqlite3 .gsd/gsd.db "INSERT INTO ..."')); + }); + + test('blocks better-sqlite3 targeting gsd.db', () => { + assert.ok(isBashWriteToStateFile('node -e "require(\'better-sqlite3\')(\'.gsd/gsd.db\')"')); + }); + + test('blocks shell redirect to gsd.db', () => { + assert.ok(isBashWriteToStateFile('echo data > .gsd/gsd.db')); + }); + + test('blocks cp to gsd.db', () => { + assert.ok(isBashWriteToStateFile('cp backup.db .gsd/gsd.db')); + }); + + test('blocks mv to gsd.db', () => { + assert.ok(isBashWriteToStateFile('mv temp.db .gsd/gsd.db')); + }); + + test('does not block reading gsd.db with cat', () => { + assert.ok(!isBashWriteToStateFile('cat .gsd/gsd.db')); + }); +}); diff --git a/src/resources/extensions/gsd/write-intercept.ts b/src/resources/extensions/gsd/write-intercept.ts index 833cc2023..3846d7a46 100644 --- a/src/resources/extensions/gsd/write-intercept.ts +++ b/src/resources/extensions/gsd/write-intercept.ts @@ -24,6 +24,9 @@ const BLOCKED_PATTERNS: RegExp[] = [ /(^|[/\\])\.gsd[/\\]STATE\.md$/i, // Also match resolved symlink paths under ~/.gsd/projects/ (Pitfall #6) /(^|[/\\])\.gsd[/\\]projects[/\\][^/\\]+[/\\]STATE\.md$/i, + // gsd.db and WAL/SHM files — single-writer WAL connection managed by engine (#3625) + /(^|[/\\])\.gsd[/\\]gsd\.db(-wal|-shm)?$/i, + /(^|[/\\])\.gsd[/\\]projects[/\\][^/\\]+[/\\]gsd\.db(-wal|-shm)?$/i, ]; /** @@ -41,6 +44,12 @@ const BASH_STATE_PATTERNS: RegExp[] = [ /\bsed\b.*-i.*STATE\.md/i, // dd output to STATE.md /\bdd\b.*of=\S*STATE\.md/i, + // Direct DB access via sqlite3/sql.js/better-sqlite3 targeting gsd.db (#3625) + /\b(sqlite3|sql\.js|better-sqlite3|node:sqlite)\b.*gsd\.db/i, + /\bgsd\.db\b.*\b(sqlite3|sql\.js|better-sqlite3)\b/i, + // Shell writes targeting gsd.db files + /[>|]+\s*\S*gsd\.db/i, + /\b(cp|mv|dd)\b.*gsd\.db/i, ]; /** @@ -81,7 +90,7 @@ function matchesBlockedPattern(path: string): boolean { * Error message returned when an agent attempts to directly write an authoritative .gsd/ state file. * Directs the agent to use engine tool calls instead. */ -export const BLOCKED_WRITE_ERROR = `Direct writes to .gsd/STATE.md are blocked. Use engine tool calls instead: +export const BLOCKED_WRITE_ERROR = `Direct writes to .gsd/STATE.md and .gsd/gsd.db are blocked. Use engine tool calls instead: - To complete a task: call gsd_complete_task(milestone_id, slice_id, task_id, summary) - To complete a slice: call gsd_complete_slice(milestone_id, slice_id, summary, uat_result) - To save a decision: call gsd_save_decision(scope, decision, choice, rationale)