Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/t9961-getdb-worktree-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cleocode/core": patch
---

- Route `getDb()` through the T9806 worktree-isolation guard so all ~61 direct core callers are protected (not just the ~28 that route through `openCleoDb`).
140 changes: 140 additions & 0 deletions packages/core/src/store/__tests__/wt-isolation-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Regression suite for T9961 — `getDb()` direct-caller worktree-isolation guard.
*
* T9806 added the worktree-isolation guard inside `openCleoDb('tasks', cwd)`,
* but only ~28 callers route through it. T9961 extracts the guard into
* `worktree-isolation-guard.ts` and calls it from `getDb()` directly, so all
* ~61 direct callers (tasks.find / tasks.show / tasks.list domain handlers)
* benefit automatically.
*
* This test exercises the `getDb()` path directly — verifying the guard fires
* for callers that never touch `openCleoDb`.
*
* @task T9961
* @see T9806 for the `openCleoDb` side (open-cleo-db-worktree-guard.test.ts)
* @saga T9800
* @decision D009
*/

import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getDb, resetDbState } from '../sqlite.js';

/**
* Create a temp directory that simulates a git worktree containing a leaked
* `.cleo/` directory (pre-T9803 install artifact).
*
* Structure:
* <dir>/
* .git ← FILE (gitlink, not a directory) — worktree marker
* .cleo/
* project-info.json
*/
function makeWorktreeFixture(label: string): string {
const dir = join(
tmpdir(),
`cleo-t9961-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
mkdirSync(dir, { recursive: true });
// Write `.git` as a FILE (gitlink) — simulates a `git worktree add` checkout.
writeFileSync(join(dir, '.git'), 'gitdir: /tmp/some-main/.git/worktrees/t9961\n');
// Write a `.cleo/` directory + project-info.json so path resolution (T9803)
// succeeds and we exercise the T9806/T9961 guard layer.
mkdirSync(join(dir, '.cleo'), { recursive: true });
writeFileSync(
join(dir, '.cleo', 'project-info.json'),
JSON.stringify({ projectId: 'wt-fixture-t9961-leaked' }),
);
return dir;
}

function snapshotEnv(): { restore: () => void } {
const saved = process.env['CLEO_ALLOW_WORKTREE_DB_CREATE'];
return {
restore() {
if (saved === undefined) delete process.env['CLEO_ALLOW_WORKTREE_DB_CREATE'];
else process.env['CLEO_ALLOW_WORKTREE_DB_CREATE'] = saved;
},
};
}

describe('getDb() — worktree-isolation guard via direct caller path (T9961 / D009)', () => {
let tempDir: string | undefined;
let restoreEnv: () => void;

beforeEach(() => {
({ restore: restoreEnv } = snapshotEnv());
delete process.env['CLEO_ALLOW_WORKTREE_DB_CREATE'];
tempDir = undefined;
// Reset the sqlite singleton so each test starts fresh
resetDbState();
});

afterEach(() => {
restoreEnv();
resetDbState();
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
/* ignore cleanup errors */
}
}
});

describe('AC-1: getDb() throws E_WT_DB_ISOLATION_VIOLATION for worktree-resident opens', () => {
it('throws when .cleo/ parent is a worktree (gitlink .git file)', async () => {
tempDir = makeWorktreeFixture('direct-caller');
await expect(getDb(tempDir)).rejects.toThrowError(/E_WT_DB_ISOLATION_VIOLATION/);
});

it('error message names the tasks role', async () => {
tempDir = makeWorktreeFixture('role-label');
const err = await getDb(tempDir).catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toMatch(/tasks/);
});
});

describe('AC-2: kill-switch CLEO_ALLOW_WORKTREE_DB_CREATE=1 bypasses guard', () => {
it('does NOT throw E_WT_DB_ISOLATION_VIOLATION when override is set', async () => {
tempDir = makeWorktreeFixture('override');
process.env['CLEO_ALLOW_WORKTREE_DB_CREATE'] = '1';
// The open may still fail for other reasons (migrations in a bare temp
// dir), but it MUST NOT fail with the T9806/T9961 isolation-violation.
const result = await getDb(tempDir).then(
() => null as Error | null,
(e: unknown) => (e instanceof Error ? e : new Error(String(e))),
);
if (result instanceof Error) {
expect(result.message).not.toMatch(/E_WT_DB_ISOLATION_VIOLATION/);
}
// If result is null, the DB opened successfully — no isolation error fired.
});

it('non-"1" values do NOT bypass the guard', async () => {
tempDir = makeWorktreeFixture('override-bad');
process.env['CLEO_ALLOW_WORKTREE_DB_CREATE'] = 'true'; // not literally '1'
await expect(getDb(tempDir)).rejects.toThrowError(/E_WT_DB_ISOLATION_VIOLATION/);
});
});

describe('AC-3: canonical project root (dir .git) is NOT blocked', () => {
it('does not throw for a normal project with .git as a directory', async () => {
// A real project directory has `.git` as a directory, not a gitlink file.
// We cannot easily run the full getDb() in a temp dir (needs migrations),
// but we can confirm the guard logic only activates on gitlink files.
// Tested indirectly: if this test file runs at all from this repo (which
// has `.git` as a directory), the singleton was already opened without
// the guard firing.
//
// Explicit assertion: assertDbPathIsNotWorktreeResident with a dir .git
// must not throw.
const { assertDbPathIsNotWorktreeResident } = await import('../worktree-isolation-guard.js');
// Use process.cwd() — in this repo, .git is a real directory.
expect(() => assertDbPathIsNotWorktreeResident('tasks')).not.toThrow();
});
});
});
65 changes: 2 additions & 63 deletions packages/core/src/store/open-cleo-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,16 @@
* @adr ADR-068, ADR-069
*/

import { existsSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import type { DatabaseSync } from 'node:sqlite';
import { ExitCode } from '@cleocode/contracts';
import { CleoError } from '../errors.js';
import { getCleoDirAbsolute, resolveOrCwd } from '../paths.js';
import { resolveOrCwd } from '../paths.js';
import { getConduitNativeDb } from './conduit-sqlite.js';
import { getNexusDb } from './nexus-sqlite.js';
import { ensureGlobalSignaldockDb, getGlobalSignaldockNativeDb } from './signaldock-sqlite.js';
import { openSkillsDb } from './skills-db.js';
import { getDb as getTasksDb } from './sqlite.js';
import { applyPerfPragmas } from './sqlite-pragmas.js';
import { assertDbPathIsNotWorktreeResident } from './worktree-isolation-guard.js';

/** Canonical roles for the 6 SQLite databases (ADR-068), plus planned llmtxt/docs storage. */
export type CleoDbRole =
Expand Down Expand Up @@ -130,64 +127,6 @@ function isDatabaseSync(db: unknown): db is DatabaseSync {
return Boolean(db && typeof db === 'object' && 'exec' in db && 'prepare' in db);
}

/**
* Worktree-isolation guard for the DB chokepoint (T9806 / council verdict D009).
*
* Defense-in-depth on top of T9803's `getCleoDirAbsolute` THROWS-on-orphan
* fix. After path resolution, this verifies the resolved `.cleo/`'s parent
* directory is the **canonical project root** (i.e. `.git` is a real
* directory) rather than a **git worktree** (i.e. `.git` is a gitlink file).
*
* Refusing worktree-resident opens prevents the residual orphan path where a
* leaked `.cleo/` already exists inside a worktree from a pre-T9803 install:
* T9803 stops NEW creation, T9806 stops re-use of an OLD leak.
*
* Kill-switch: `CLEO_ALLOW_WORKTREE_DB_CREATE=1` bypasses the guard. The
* override is recorded to stderr (caller may pipe to audit log).
*
* @throws `CleoError('E_WT_DB_ISOLATION_VIOLATION')` when:
* - The resolved `.cleo/`'s parent directory contains `.git` as a FILE
* (gitlink — worktree marker), AND
* - `CLEO_ALLOW_WORKTREE_DB_CREATE` is not set to `'1'`.
*
* @internal
*/
function assertDbPathIsNotWorktreeResident(role: CleoDbRole, cwd?: string): void {
let cleoDir: string;
try {
cleoDir = getCleoDirAbsolute(cwd);
} catch {
// T9803 already throws on unresolvable project root. Re-raising here
// would lose context; let the underlying opener surface the original
// error.
return;
}
const projectRoot = dirname(cleoDir);
const projectGit = join(projectRoot, '.git');
let isWorktreeGitlink = false;
try {
isWorktreeGitlink = existsSync(projectGit) && statSync(projectGit).isFile();
} catch {
/* If `.git` itself is missing, this isn't our concern — T9803 will fire. */
}
if (!isWorktreeGitlink) {
return;
}
if (process.env['CLEO_ALLOW_WORKTREE_DB_CREATE'] === '1') {
process.stderr.write(
`[T9806 WT-DB-OVERRIDE] role=${role} path=${cleoDir} reason=CLEO_ALLOW_WORKTREE_DB_CREATE=1\n`,
);
return;
}
throw new CleoError(
ExitCode.CONFIG_ERROR,
`E_WT_DB_ISOLATION_VIOLATION: refusing to open '${role}' DB at ${cleoDir} — parent ${projectRoot} is a git worktree (gitlink). DBs must open against the canonical project root.`,
{
fix: `Run from the canonical project root, OR delete the leaked .cleo/ inside the worktree, OR set CLEO_ALLOW_WORKTREE_DB_CREATE=1 (emergency override, audited).`,
},
);
}

/**
* Open (or create) a CLEO database by canonical role.
*
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/store/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from './migration-manager.js';
import { resolveCorePackageMigrationsFolder } from './resolve-migrations-folder.js';
import { listSqliteBackups } from './sqlite-backup.js';
import { assertDbPathIsNotWorktreeResident } from './worktree-isolation-guard.js';

// node:sqlite access is isolated in the leaf module sqlite-native.ts to prevent
// TDZ circular-import failures in the agent-resolver → dispatch-trace →
Expand Down Expand Up @@ -207,6 +208,11 @@ async function autoRecoverFromBackup(
export async function getDb(cwd?: string): Promise<NodeSQLiteDatabase<typeof schema>> {
const requestedPath = getDbPath(cwd);

// T9961 / T9806: worktree-isolation guard — defense-in-depth for direct
// getDb() callers (61 core handlers) that bypass openCleoDb('tasks', cwd).
// Fires before any DB file is touched, matching the openCleoDb chokepoint.
assertDbPathIsNotWorktreeResident('tasks', cwd);

// T1906: guard against prod-DB writes in test mode
const { assertTestEnv } = await import('./data-accessor.js');
assertTestEnv(requestedPath);
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/store/worktree-isolation-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Worktree-isolation guard for CLEO DB opens (T9806 / T9961 / council verdict D009).
*
* Extracted into a standalone leaf module so it can be imported by both
* `open-cleo-db.ts` (which owns the `CleoDbRole` union and the main chokepoint)
* AND `sqlite.ts` (where `getDb()` lives) — without creating a circular import
* cycle.
*
* Before this extraction, `assertDbPathIsNotWorktreeResident` lived in
* `open-cleo-db.ts`, which imports `getDb` from `sqlite.ts`. If `sqlite.ts`
* had tried to import the guard from `open-cleo-db.ts`, the import cycle would
* have caused a TDZ failure at module-init time.
*
* @task T9961 (extraction), T9806 (original guard)
* @saga T9800
* @decision D009
*/

import { existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { ExitCode } from '@cleocode/contracts';
import { CleoError } from '../errors.js';
import { getCleoDirAbsolute } from '../paths.js';

/**
* Worktree-isolation guard for the DB chokepoint (T9806 / council verdict D009).
*
* Defense-in-depth on top of T9803's `getCleoDirAbsolute` THROWS-on-orphan
* fix. After path resolution, this verifies the resolved `.cleo/`'s parent
* directory is the **canonical project root** (i.e. `.git` is a real
* directory) rather than a **git worktree** (i.e. `.git` is a gitlink file).
*
* Refusing worktree-resident opens prevents the residual orphan path where a
* leaked `.cleo/` already exists inside a worktree from a pre-T9803 install:
* T9803 stops NEW creation, T9806 stops re-use of an OLD leak.
*
* Kill-switch: `CLEO_ALLOW_WORKTREE_DB_CREATE=1` bypasses the guard. The
* override is recorded to stderr (caller may pipe to audit log).
*
* @param role - The DB role label (used in the error message only).
* @param cwd - Optional working directory; defaults to `process.cwd()`.
*
* @throws `CleoError('E_WT_DB_ISOLATION_VIOLATION')` when:
* - The resolved `.cleo/`'s parent directory contains `.git` as a FILE
* (gitlink — worktree marker), AND
* - `CLEO_ALLOW_WORKTREE_DB_CREATE` is not set to `'1'`.
*/
export function assertDbPathIsNotWorktreeResident(role: string, cwd?: string): void {
let cleoDir: string;
try {
cleoDir = getCleoDirAbsolute(cwd);
} catch {
// T9803 already throws on unresolvable project root. Re-raising here
// would lose context; let the underlying opener surface the original
// error.
return;
}
const projectRoot = dirname(cleoDir);
const projectGit = join(projectRoot, '.git');
let isWorktreeGitlink = false;
try {
isWorktreeGitlink = existsSync(projectGit) && statSync(projectGit).isFile();
} catch {
/* If `.git` itself is missing, this isn't our concern — T9803 will fire. */
}
if (!isWorktreeGitlink) {
return;
}
if (process.env['CLEO_ALLOW_WORKTREE_DB_CREATE'] === '1') {
process.stderr.write(
`[T9806 WT-DB-OVERRIDE] role=${role} path=${cleoDir} reason=CLEO_ALLOW_WORKTREE_DB_CREATE=1\n`,
);
return;
}
throw new CleoError(
ExitCode.CONFIG_ERROR,
`E_WT_DB_ISOLATION_VIOLATION: refusing to open '${role}' DB at ${cleoDir} — parent ${projectRoot} is a git worktree (gitlink). DBs must open against the canonical project root.`,
{
fix: `Run from the canonical project root, OR delete the leaked .cleo/ inside the worktree, OR set CLEO_ALLOW_WORKTREE_DB_CREATE=1 (emergency override, audited).`,
},
);
}
Loading