diff --git a/src/resources/extensions/gsd/bootstrap/query-tools.ts b/src/resources/extensions/gsd/bootstrap/query-tools.ts index cb4f24929..617a65ee7 100644 --- a/src/resources/extensions/gsd/bootstrap/query-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/query-tools.ts @@ -3,7 +3,6 @@ import { Type } from "@sinclair/typebox"; import type { ExtensionAPI } from "@gsd/pi-coding-agent"; -import { ensureDbOpen } from "./dynamic-tools.js"; import { logWarning } from "../workflow-logger.js"; export function registerQueryTools(pi: ExtensionAPI): void { @@ -26,53 +25,69 @@ export function registerQueryTools(pi: ExtensionAPI): void { }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { try { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { + // Strictly read-only: only use an already-open DB connection. + // Do NOT call ensureDbOpen() — it can create/migrate the DB as a side effect. + const { + isDbAvailable, + getMilestone, + getSliceStatusSummary, + getSliceTaskCounts, + _getAdapter, + } = await import("../gsd-db.js"); + + if (!isDbAvailable()) { return { content: [{ type: "text" as const, text: "Error: GSD database is not available." }], details: { operation: "milestone_status", error: "db_unavailable" } as any, }; } - const { - getMilestone, - getSliceStatusSummary, - getSliceTaskCounts, - } = await import("../gsd-db.js"); + // Wrap all reads in a single transaction for snapshot consistency. + // SQLite WAL mode guarantees reads within a transaction see a single + // consistent snapshot, preventing torn reads from concurrent writes. + const adapter = _getAdapter()!; + adapter.exec("BEGIN"); // eslint-disable-line -- SQLite exec, not child_process + try { + const milestone = getMilestone(params.milestoneId); + if (!milestone) { + adapter.exec("COMMIT"); // eslint-disable-line + return { + content: [{ type: "text" as const, text: `Milestone ${params.milestoneId} not found in database.` }], + details: { operation: "milestone_status", milestoneId: params.milestoneId, found: false } as any, + }; + } - const milestone = getMilestone(params.milestoneId); - if (!milestone) { - return { - content: [{ type: "text" as const, text: `Milestone ${params.milestoneId} not found in database.` }], - details: { operation: "milestone_status", milestoneId: params.milestoneId, found: false } as any, + const sliceStatuses = getSliceStatusSummary(params.milestoneId); + + const slices = sliceStatuses.map((s) => { + const counts = getSliceTaskCounts(params.milestoneId, s.id); + return { + id: s.id, + status: s.status, + taskCounts: counts, + }; + }); + + adapter.exec("COMMIT"); // eslint-disable-line + + const result = { + milestoneId: milestone.id, + title: milestone.title, + status: milestone.status, + createdAt: milestone.created_at, + completedAt: milestone.completed_at, + sliceCount: slices.length, + slices, }; + + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + details: { operation: "milestone_status", milestoneId: milestone.id, sliceCount: slices.length } as any, + }; + } catch (txErr) { + try { adapter.exec("ROLLBACK"); } catch { /* swallow */ } // eslint-disable-line + throw txErr; } - - const sliceStatuses = getSliceStatusSummary(params.milestoneId); - - const slices = sliceStatuses.map((s) => { - const counts = getSliceTaskCounts(params.milestoneId, s.id); - return { - id: s.id, - status: s.status, - taskCounts: counts, - }; - }); - - const result = { - milestoneId: milestone.id, - title: milestone.title, - status: milestone.status, - createdAt: milestone.created_at, - completedAt: milestone.completed_at, - sliceCount: slices.length, - slices, - }; - - return { - content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], - details: { operation: "milestone_status", milestoneId: milestone.id, sliceCount: slices.length } as any, - }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); logWarning("tool", `gsd_milestone_status tool failed: ${msg}`); diff --git a/src/resources/extensions/gsd/tests/db-access-guardrails.test.ts b/src/resources/extensions/gsd/tests/db-access-guardrails.test.ts index eddcb7141..733def9d5 100644 --- a/src/resources/extensions/gsd/tests/db-access-guardrails.test.ts +++ b/src/resources/extensions/gsd/tests/db-access-guardrails.test.ts @@ -80,16 +80,19 @@ test("no prompt file contains an unguarded sqlite3 command invocation", () => { const line = lines[i]; const trimmed = line.trim(); - // Match lines containing sqlite3 or node -e require('better-sqlite3') targeting gsd.db. + // Match lines containing sqlite3 targeting gsd.db in any common form: + // sqlite3 .gsd/gsd.db, sqlite3 ./.gsd/gsd.db, sqlite3 "/path/.gsd/gsd.db", + // sqlite3 -header .gsd/gsd.db, etc. // Guardrail text that says "Never run" or "Do NOT query" is fine — only flag // lines where these appear without a surrounding prohibition keyword. - if (/sqlite3\s+\.?\.?gsd\/gsd\.db/.test(trimmed)) { + if (/sqlite3\b.*gsd\.db/.test(trimmed)) { const context = lines.slice(Math.max(0, i - 3), i + 1).join(" "); if (!/Never|Do NOT|do not|don't|prohibited|forbidden|never run/i.test(context)) { violations.push(`${file}:${i + 1} — unguarded sqlite3 command: ${trimmed}`); } } - if (/node\s+-e\s+.*require\(.*better-sqlite3/.test(trimmed)) { + // Match node -e with better-sqlite3 require in any quoting style + if (/node\s+-e\s+.*(?:require|import).*better-sqlite3/.test(trimmed)) { const context = lines.slice(Math.max(0, i - 3), i + 1).join(" "); if (!/Never|Do NOT|do not|don't|prohibited|forbidden|never run/i.test(context)) { violations.push(`${file}:${i + 1} — unguarded node -e require command: ${trimmed}`);