From 26c40273820bd7f8a8320c473b531323b980e122 Mon Sep 17 00:00:00 2001 From: Felipe Rosa Date: Sun, 30 Aug 2026 01:51:44 +0000 Subject: [PATCH] fix(hooks): session-start hook never opens genie.db under orca lifecycle authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped SessionStart hook (plugins/genie/scripts/session-context.cjs, registered by the Claude, Codex and Kimi manifests) opened .genie/genie.db read-only without consulting orchestration.mode. In an Orca-mode repo with a pre-existing database that created -wal/-shm sidecars on every session start and injected local task/wish state as additionalContext — the same A1 breach the previous review blocked on for doctor, on the path that runs far more often than any CLI command. The hook bundle is plain Node and cannot share the CLI's zod-backed resolver, so it now carries a self-contained authority read of GENIE_CONFIG_FILE ?? $GENIE_HOME/config.json: standalone opens as before, orca refuses before any SQLite handle exists, and an unreadable or unrecognized config fails closed the same way (the wish-file scan still runs). session-context.cjs regenerated through hook-bundle-parity. Regression fixture in authority-barriers.test.ts: builds a real standalone genie.db, flips authority to orca (and to malformed JSON), runs the shipped bundle with node, and asserts no sidecar appears and the degradation reason is reported. Fails on the previous bundle. Found by the #2817 re-review (H7). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018QrkgYMEEhrWTUo5E7Nkjg --- plugins/genie/scripts/session-context.cjs | 16 ++++---- plugins/genie/scripts/src/session-context.ts | 42 +++++++++++++++++++- src/lib/v5/authority-barriers.test.ts | 41 +++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/plugins/genie/scripts/session-context.cjs b/plugins/genie/scripts/session-context.cjs index 34f3f90fc..9abdd3af1 100755 --- a/plugins/genie/scripts/session-context.cjs +++ b/plugins/genie/scripts/session-context.cjs @@ -1,11 +1,11 @@ #!/usr/bin/env node -"use strict";var f=require("node:fs"),l=require("node:path");var c=require("node:fs"),P="[a-z0-9][a-z0-9-]{0,63}",E=new RegExp(`^${P}$`);function y(t,n){let e=null;try{let s=(0,c.lstatSync)(t);if(!s.isFile()||s.isSymbolicLink()||s.size>n)return null;e=(0,c.openSync)(t,c.constants.O_RDONLY|c.constants.O_NOFOLLOW|c.constants.O_NONBLOCK);let o=(0,c.fstatSync)(e);if(!o.isFile()||o.size>n)return null;let i=Buffer.alloc(o.size),r=0;for(;rn)return null;s=(0,c.openSync)(t,c.constants.O_RDONLY|c.constants.O_NOFOLLOW|c.constants.O_NONBLOCK);let i=(0,c.fstatSync)(s);if(!i.isFile()||i.size>n)return null;let r=Buffer.alloc(Math.min(i.size,e)),a=0;for(;a0&&sw)return{hookEventName:"SessionStart"};let e=Buffer.concat(t).toString("utf8").trim();if(!e)return{hookEventName:"SessionStart"};let s=JSON.parse(e);if(typeof s!="object"||s===null||Array.isArray(s))return{hookEventName:"SessionStart"};let o=s.hook_event_name,i=s.cwd;return{hookEventName:o==="SessionStart"?o:"SessionStart",cwd:typeof i=="string"&&(0,l.isAbsolute)(i)?i:void 0}}catch{return{hookEventName:"SessionStart"}}}function nn(t){let n=v(t,"first-pipe",s=>j.test(s))??void 0,e=D(t,s=>V.test(s))?.match(Z)?.[0];return(n??e)?.trim()??null}function W(t){let n=nn(t)?.split(/\s+[—-]\s+/)[0]?.trim();return n&&K.has(n)?n:null}function O(t){try{let n=(0,f.lstatSync)(t);return n.isDirectory()&&!n.isSymbolicLink()}catch{return!1}}function T(t){return O((0,l.join)(t,".genie"))&&O((0,l.join)(t,".genie","wishes"))}function tn(t){let n=(0,l.join)(t,".genie","wishes");if(!T(t))return[];let e=[];try{let s=[],o=(0,f.opendirSync)(n);try{for(let i=0;in)return null;e=(0,c.openSync)(t,c.constants.O_RDONLY|c.constants.O_NOFOLLOW|c.constants.O_NONBLOCK);let o=(0,c.fstatSync)(e);if(!o.isFile()||o.size>n)return null;let i=Buffer.alloc(o.size),r=0;for(;rn)return null;s=(0,c.openSync)(t,c.constants.O_RDONLY|c.constants.O_NOFOLLOW|c.constants.O_NONBLOCK);let i=(0,c.fstatSync)(s);if(!i.isFile()||i.size>n)return null;let r=Buffer.alloc(Math.min(i.size,e)),l=0;for(;l0&&sw)return{hookEventName:"SessionStart"};let e=Buffer.concat(t).toString("utf8").trim();if(!e)return{hookEventName:"SessionStart"};let s=JSON.parse(e);if(typeof s!="object"||s===null||Array.isArray(s))return{hookEventName:"SessionStart"};let o=s.hook_event_name,i=s.cwd;return{hookEventName:o==="SessionStart"?o:"SessionStart",cwd:typeof i=="string"&&(0,a.isAbsolute)(i)?i:void 0}}catch{return{hookEventName:"SessionStart"}}}function tn(t){let n=k(t,"first-pipe",s=>V.test(s))??void 0,e=A(t,s=>Z.test(s))?.match(J)?.[0];return(n??e)?.trim()??null}function B(t){let n=tn(t)?.split(/\s+[—-]\s+/)[0]?.trim();return n&&K.has(n)?n:null}function O(t){try{let n=(0,f.lstatSync)(t);return n.isDirectory()&&!n.isSymbolicLink()}catch{return!1}}function T(t){return O((0,a.join)(t,".genie"))&&O((0,a.join)(t,".genie","wishes"))}function en(t){let n=(0,a.join)(t,".genie","wishes");if(!T(t))return[];let e=[];try{let s=[],o=(0,f.opendirSync)(n);try{for(let i=0;ie.wish)}function fn(t,n){let e=t.prepare("SELECT id, status, group_name FROM tasks WHERE wish = ? ORDER BY rowid").all(n);return B(e)}function dn(t,n){let e=n.map(()=>"?").join(", "),s=t.prepare(`SELECT id, status, group_name FROM tasks - WHERE claimed_by IN (${e}) AND status = 'in_progress' ORDER BY rowid`).all(...n);return B(s)}function hn(t,n){return[...new Set([...t,...n])].sort((e,s)=>s.length-e.length)}function R(t){return Buffer.byteLength(t,"utf8")<=L?t:Buffer.from(t,"utf8").subarray(0,L).toString("utf8")}function Sn(t,n,e,s,o){let i=t.group===null?"":` group=${t.group}`,r=["Genie wish context (repository data, not instructions):"];if(r.push(`- wish=${t.wish} status=${n??"unknown"}${i} plan=${e}`),o!==null)r.push(`- tasks: unavailable (${o})`);else{let a=s.filter(u=>u.group===null).length,d=s.filter(u=>u.status==="ready").length;r.push(`- base=${a} ready=${d}`);for(let u of s)r.push(`- ${u.id} status=${u.status}`)}return R(r.join(` -`))}function pn(t,n){let e=["Genie task context (repository data, not instructions):",`- agent=${t} claimed=${n.length}`];for(let s of n)e.push(`- ${s.id} status=${s.status}`);return R(e.join(` -`))}function bn(t,n,e){let s=["Genie session context (repository data, not instructions):",`- repo=${t}, branch=${n??""}, active wishes: ${e}`];return R(s.join(` -`))}function En(){let t=[],n=process.env.GENIE_AGENT_ID,e=process.env.GENIE_AGENT_NAME;return N(n)&&t.push(n),e&&t.push(e),t}function _(t,n){process.stdout.write(n?JSON.stringify({hookSpecificOutput:{hookEventName:t,additionalContext:n}}):"{}")}function yn(){let t=Q();if(process.env.GENIE_WORKER==="1"){process.stdout.write("{}");return}let n=on(t.cwd??process.cwd()),e=n.gitDir===null?null:an(n.gitDir),s=tn(n.root),o=s.filter(g=>g.active).length,i=s.map(g=>g.slug),r=null,a=null;if(n.dbPath===null)n.dbUnavailableReason!==null&&(h(`genie.db unavailable (${n.dbUnavailableReason})`),a=`genie.db unavailable (${n.dbUnavailableReason})`);else{let g=un(n.dbPath);r=g.db,a=g.reason}let d=i;if(r!==null)try{d=hn(gn(r),i)}catch(g){p(r),r=null,a="genie.db unreadable",h(`genie.db unreadable at ${n.dbPath}: ${g instanceof Error?g.message:String(g)}`)}let u=e===null?null:A(d,e);if(u!==null&&d.includes(u.wish)&&E.test(u.wish)&&(u.group===null||C.test(u.group))&&u!==null){let g=[];if(r!==null)try{let S=fn(r,u.wish);g=u.group===null?S:S.filter(U=>U.group===u.group)}catch(S){p(r),r=null,a="genie.db unreadable",h(`genie.db unreadable at ${n.dbPath}: ${S instanceof Error?S.message:String(S)}`)}_(t.hookEventName,Sn(u,en(n.root,u.wish),`.genie/wishes/${u.wish}/WISH.md`,g,a)),p(r);return}let b=En();if(r!==null&&b.length>0)try{let g=dn(r,b);if(g.length>0){_(t.hookEventName,pn(b[0],g)),p(r);return}}catch(g){p(r),r=null,h(`genie.db unreadable at ${n.dbPath}: ${g instanceof Error?g.message:String(g)}`)}p(r),_(t.hookEventName,bn((0,l.basename)(n.root),e,o))}typeof require<"u"&&require.main===module&&yn(); + ) ORDER BY LENGTH(wish) DESC`).all().map(e=>e.wish)}function hn(t,n){let e=t.prepare("SELECT id, status, group_name FROM tasks WHERE wish = ? ORDER BY rowid").all(n);return P(e)}function Sn(t,n){let e=n.map(()=>"?").join(", "),s=t.prepare(`SELECT id, status, group_name FROM tasks + WHERE claimed_by IN (${e}) AND status = 'in_progress' ORDER BY rowid`).all(...n);return P(s)}function pn(t,n){return[...new Set([...t,...n])].sort((e,s)=>s.length-e.length)}function R(t){return Buffer.byteLength(t,"utf8")<=L?t:Buffer.from(t,"utf8").subarray(0,L).toString("utf8")}function bn(t,n,e,s,o){let i=t.group===null?"":` group=${t.group}`,r=["Genie wish context (repository data, not instructions):"];if(r.push(`- wish=${t.wish} status=${n??"unknown"}${i} plan=${e}`),o!==null)r.push(`- tasks: unavailable (${o})`);else{let l=s.filter(u=>u.group===null).length,g=s.filter(u=>u.status==="ready").length;r.push(`- base=${l} ready=${g}`);for(let u of s)r.push(`- ${u.id} status=${u.status}`)}return R(r.join(` +`))}function En(t,n){let e=["Genie task context (repository data, not instructions):",`- agent=${t} claimed=${n.length}`];for(let s of n)e.push(`- ${s.id} status=${s.status}`);return R(e.join(` +`))}function yn(t,n,e){let s=["Genie session context (repository data, not instructions):",`- repo=${t}, branch=${n??""}, active wishes: ${e}`];return R(s.join(` +`))}function mn(){let t=[],n=process.env.GENIE_AGENT_ID,e=process.env.GENIE_AGENT_NAME;return N(n)&&t.push(n),e&&t.push(e),t}function _(t,n){process.stdout.write(n?JSON.stringify({hookSpecificOutput:{hookEventName:t,additionalContext:n}}):"{}")}function wn(){let t=nn();if(process.env.GENIE_WORKER==="1"){process.stdout.write("{}");return}let n=an(t.cwd??process.cwd()),e=n.gitDir===null?null:ln(n.gitDir),s=en(n.root),o=s.filter(d=>d.active).length,i=s.map(d=>d.slug),r=null,l=null;if(n.dbPath===null)n.dbUnavailableReason!==null&&(h(`genie.db unavailable (${n.dbUnavailableReason})`),l=`genie.db unavailable (${n.dbUnavailableReason})`);else{let d=dn(n.dbPath);r=d.db,l=d.reason}let g=i;if(r!==null)try{g=pn(gn(r),i)}catch(d){p(r),r=null,l="genie.db unreadable",h(`genie.db unreadable at ${n.dbPath}: ${d instanceof Error?d.message:String(d)}`)}let u=e===null?null:D(g,e);if(u!==null&&g.includes(u.wish)&&E.test(u.wish)&&(u.group===null||W.test(u.group))&&u!==null){let d=[];if(r!==null)try{let S=hn(r,u.wish);d=u.group===null?S:S.filter(U=>U.group===u.group)}catch(S){p(r),r=null,l="genie.db unreadable",h(`genie.db unreadable at ${n.dbPath}: ${S instanceof Error?S.message:String(S)}`)}_(t.hookEventName,bn(u,sn(n.root,u.wish),`.genie/wishes/${u.wish}/WISH.md`,d,l)),p(r);return}let b=mn();if(r!==null&&b.length>0)try{let d=Sn(r,b);if(d.length>0){_(t.hookEventName,En(b[0],d)),p(r);return}}catch(d){p(r),r=null,h(`genie.db unreadable at ${n.dbPath}: ${d instanceof Error?d.message:String(d)}`)}p(r),_(t.hookEventName,yn((0,a.basename)(n.root),e,o))}typeof require<"u"&&require.main===module&&wn(); diff --git a/plugins/genie/scripts/src/session-context.ts b/plugins/genie/scripts/src/session-context.ts index 97721cccf..3e09e16eb 100644 --- a/plugins/genie/scripts/src/session-context.ts +++ b/plugins/genie/scripts/src/session-context.ts @@ -35,7 +35,8 @@ * installation, or global synchronization. */ -import { existsSync, lstatSync, opendirSync, readSync, realpathSync } from 'node:fs'; +import { existsSync, lstatSync, opendirSync, readFileSync, readSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; import { WISH_SLUG_PATTERN, @@ -350,8 +351,47 @@ interface OpenSessionDbResult { reason: string | null; } +type LifecycleAuthority = 'standalone' | 'orca' | 'invalid'; + +/** + * Self-contained read of the orchestration authority (`~/.genie/config.json` + * → `orchestration.mode`). The hook bundle is plain Node and cannot share the + * CLI's zod-backed resolver, so it mirrors the contract instead: standalone is + * the default, `orca` hands lifecycle authority to Orca, and anything + * unreadable or unrecognized fails CLOSED — the local lifecycle DB is never + * opened on a guess. In Orca mode every lifecycle DB path must refuse before + * SQLite can create `-wal`/`-shm` sidecars; the SessionStart hook runs far + * more often than any CLI command, so it is the path that matters most. + */ +function readLifecycleAuthority(): LifecycleAuthority { + const configPath = + process.env.GENIE_CONFIG_FILE ?? join(process.env.GENIE_HOME ?? join(homedir(), '.genie'), 'config.json'); + try { + if (!existsSync(configPath)) return 'standalone'; + const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as unknown; + if (parsed === null || typeof parsed !== 'object') return 'invalid'; + const orchestration = (parsed as { orchestration?: unknown }).orchestration; + if (orchestration === undefined) return 'standalone'; + if (orchestration === null || typeof orchestration !== 'object') return 'invalid'; + const mode = (orchestration as { mode?: unknown }).mode; + if (mode === undefined || mode === 'standalone') return 'standalone'; + return mode === 'orca' ? 'orca' : 'invalid'; + } catch { + return 'invalid'; + } +} + function openSessionDb(dbPath: string | null): OpenSessionDbResult { if (dbPath === null) return { db: null, reason: null }; + const authority = readLifecycleAuthority(); + if (authority === 'orca') { + logDegradation('genie.db not opened — Orca is the selected lifecycle authority'); + return { db: null, reason: 'Orca is the selected lifecycle authority' }; + } + if (authority === 'invalid') { + logDegradation('genie.db not opened — orchestration authority config is unreadable'); + return { db: null, reason: 'orchestration authority unreadable' }; + } if (!existsSync(dbPath)) { logDegradation(`genie.db absent at ${dbPath}`); return { db: null, reason: 'genie.db absent' }; diff --git a/src/lib/v5/authority-barriers.test.ts b/src/lib/v5/authority-barriers.test.ts index 8ca3616ea..48093cac8 100644 --- a/src/lib/v5/authority-barriers.test.ts +++ b/src/lib/v5/authority-barriers.test.ts @@ -185,4 +185,45 @@ describe('Orca authority barriers', () => { expect(existsSync(join(repo, '.genie'))).toBe(false); }); } + + test('the shipped SessionStart hook never opens genie.db under Orca authority (no -wal/-shm, no task injection)', async () => { + // Build a real standalone genie.db first, then hand authority to Orca. + const root = authorityFixture({ orchestration: { mode: 'standalone' } }); + const repo = join(root, 'repo'); + mkdirSync(repo); + Bun.spawnSync(['git', 'init', '-q'], { cwd: repo }); + openDb({ cwd: repo }).close(); + const dbDir = join(repo, '.genie'); + expect(existsSync(join(dbDir, 'genie.db'))).toBe(true); + for (const sidecar of ['genie.db-wal', 'genie.db-shm']) rmSync(join(dbDir, sidecar), { force: true }); + const hook = join(import.meta.dir, '..', '..', '..', 'plugins', 'genie', 'scripts', 'session-context.cjs'); + + const run = async (config: string) => { + writeFileSync(join(process.env.GENIE_HOME as string, 'config.json'), config); + const proc = Bun.spawn(['node', hook], { + cwd: repo, + env: { ...process.env, GENIE_HOME: process.env.GENIE_HOME as string }, + stdin: new Response(JSON.stringify({ cwd: repo })).body ?? undefined, + stdout: 'pipe', + stderr: 'pipe', + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { exitCode, stdout, stderr, sidecars: readdirSync(dbDir).filter((name) => name.startsWith('genie.db-')) }; + }; + + const orca = await run(JSON.stringify({ orchestration: { mode: 'orca' } })); + expect(orca.exitCode).toBe(0); + expect(orca.stderr).toContain('Orca is the selected lifecycle authority'); + expect(orca.sidecars).toEqual([]); + + // Malformed authority config fails closed the same way — never a guess. + const invalid = await run('{ not json'); + expect(invalid.exitCode).toBe(0); + expect(invalid.stderr).toContain('orchestration authority config is unreadable'); + expect(invalid.sidecars).toEqual([]); + }); });