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) <noreply@anthropic.com>

* test: add regression test for blocking direct gsd.db writes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tibsfox 2026-04-13 05:14:03 -07:00 committed by GitHub
parent 67d68e2684
commit 05edc2f484
2 changed files with 73 additions and 1 deletions

View file

@ -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'));
});
});

View file

@ -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)