diff --git a/plugins/genie/README.md b/plugins/genie/README.md index 4f2b0e92f..1dd7224bc 100644 --- a/plugins/genie/README.md +++ b/plugins/genie/README.md @@ -51,7 +51,7 @@ H4 and H6 definitions carry the literal SHA-256 of `scripts/dispatch-runtime.cjs | ID | Event | Exact behavior | Allowed side effect | |----|-------|----------------|---------------------| -| H3 | `SessionStart` | Reads `.genie/genie.db` read-only via `node:sqlite` (**minimum Node 22.13** — declared here because all three manifests run the hook as `node …/session-context.cjs`, where `bun:sqlite` does not exist) and resolves the session branch through the shared `resolveWishBranch`: a `wish/` branch injects exactly that wish's status, base/ready task counts, its task cards, and the plan path; otherwise the session agent's claimed tasks (`GENIE_AGENT_ID`, name fallback); otherwise one compact `repo, branch, active wishes` line — never a listing. An absent/unreadable genie.db, an unsupported .git layout, or a Node without `node:sqlite` degrades to a bounded wish-file scan (≤64 candidates/256 KiB), each cause logged distinguishably on stderr | Read-only filesystem + SQLite access, ≤2 KiB out | +| H3 | `SessionStart` | Reads `.genie/genie.db` read-only via `node:sqlite` (**minimum Node 22.13** — declared here because all three manifests run the hook as `node …/session-context.cjs`, where `bun:sqlite` does not exist) and resolves the session branch through the shared `resolveWishBranch`: a `wish/` branch injects exactly that wish's status, base/ready task counts, its task cards, and the plan path; otherwise the session agent's claimed tasks (`GENIE_AGENT_ID`, name fallback); otherwise one compact `repo, branch, active wishes` line — never a listing. An absent/unreadable genie.db, an unsupported .git layout, a Node without `node:sqlite`, an Orca lifecycle authority (`orchestration.mode: "orca"` — the local database is never opened), or an unreadable/invalid orchestration config (fails closed the same way) degrades to a bounded wish-file scan (≤64 candidates/256 KiB), each cause logged distinguishably on stderr | Read-only filesystem + SQLite access, ≤2 KiB out | | H4 | `PreToolUse` | Verifies the definition-bound launcher, then runs branch/git-freeze checks for Bash and audit-context for Write/Edit/apply_patch | Deterministic local repository/Git reads only; no Codex network lookup, freshness/identity handler, Omni, install, update, global sync, or scaffolding | | H6 | `PermissionRequest` | Verifies the definition-bound launcher, applies the configured tool matcher, and invokes Omni once only when approvals are explicitly enabled | Bounded/redacted approval-queue state; timeout, interruption, malformed output, binding drift, and transport failure deny | diff --git a/plugins/genie/scripts/session-context.cjs b/plugins/genie/scripts/session-context.cjs index 9abdd3af1..4e7803c84 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"),I=require("node:os"),a=require("node:path");var c=require("node:fs"),G="[a-z0-9][a-z0-9-]{0,63}",E=new RegExp(`^${G}$`);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)),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;in)return null;e=(0,c.openSync)(t,c.constants.O_RDONLY|c.constants.O_NOFOLLOW|c.constants.O_NONBLOCK);let i=(0,c.fstatSync)(e);if(!i.isFile()||i.size>n)return null;let o=Buffer.alloc(i.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 o=(0,c.fstatSync)(s);if(!o.isFile()||o.size>n)return null;let r=Buffer.alloc(Math.min(o.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 i=s.hook_event_name,o=s.cwd;return{hookEventName:i==="SessionStart"?i:"SessionStart",cwd:typeof o=="string"&&(0,a.isAbsolute)(o)?o:void 0}}catch{return{hookEventName:"SessionStart"}}}function tn(t){let n=v(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=[],i=(0,f.opendirSync)(n);try{for(let o=0;oe.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(); + 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")<=N?t:Buffer.from(t,"utf8").subarray(0,N).toString("utf8")}function bn(t,n,e,s,i){let o=t.group===null?"":` group=${t.group}`,r=["Genie wish context (repository data, not instructions):"];if(r.push(`- wish=${t.wish} status=${n??"unknown"}${o} plan=${e}`),i!==null)r.push(`- tasks: unavailable (${i})`);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 yn(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 En(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 L(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),i=s.filter(d=>d.active).length,o=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=o;if(r!==null)try{g=pn(gn(r),o)}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)&&y.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,yn(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,En((0,a.basename)(n.root),e,i))}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 3e09e16eb..e940cd13c 100644 --- a/plugins/genie/scripts/src/session-context.ts +++ b/plugins/genie/scripts/src/session-context.ts @@ -354,9 +354,10 @@ interface OpenSessionDbResult { type LifecycleAuthority = 'standalone' | 'orca' | 'invalid'; /** - * Self-contained read of the orchestration authority (`~/.genie/config.json` + * Self-contained read of the orchestration authority (`$GENIE_HOME/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 + * CLI's zod-backed resolver (`src/lib/orchestration-mode.ts`), so it mirrors + * that strict schema exactly — the barrier fixture asserts both agree: 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 @@ -364,17 +365,21 @@ type LifecycleAuthority = 'standalone' | 'orca' | 'invalid'; * 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'); + // Same path the CLI resolves (`getGenieConfigPath()`): `$GENIE_HOME/config.json`. + const configPath = 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'; + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return 'invalid'; const orchestration = (parsed as { orchestration?: unknown }).orchestration; if (orchestration === undefined) return 'standalone'; - if (orchestration === null || typeof orchestration !== 'object') return 'invalid'; + // Mirror of the CLI's strict schema: `{ mode: 'standalone' | 'orca' }`, no + // other keys, mode required. Anything else is invalid there and here. + if (orchestration === null || typeof orchestration !== 'object' || Array.isArray(orchestration)) return 'invalid'; + const keys = Object.keys(orchestration as object); + if (keys.length !== 1 || keys[0] !== 'mode') return 'invalid'; const mode = (orchestration as { mode?: unknown }).mode; - if (mode === undefined || mode === 'standalone') return 'standalone'; + if (mode === 'standalone') return 'standalone'; return mode === 'orca' ? 'orca' : 'invalid'; } catch { return 'invalid'; diff --git a/src/lib/v5/authority-barriers.test.ts b/src/lib/v5/authority-barriers.test.ts index 48093cac8..f25772784 100644 --- a/src/lib/v5/authority-barriers.test.ts +++ b/src/lib/v5/authority-barriers.test.ts @@ -220,10 +220,27 @@ describe('Orca authority barriers', () => { 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([]); + // Every shape the CLI's strict schema rejects fails closed here too — + // never a guess, and never a fail-open divergence between the two readers. + for (const config of [ + '{ not json', + JSON.stringify({ orchestration: {} }), + JSON.stringify({ orchestration: { mode: 'standalone', extra: 1 } }), + JSON.stringify({ orchestration: { mode: 'kraken' } }), + JSON.stringify({ orchestration: null }), + JSON.stringify([]), + ]) { + const invalid = await run(config); + expect(invalid.exitCode, config).toBe(0); + expect(invalid.stderr, config).toContain('orchestration authority config is unreadable'); + expect(invalid.sidecars, config).toEqual([]); + } + + // And the shapes the CLI accepts as standalone still open the database. + for (const config of [JSON.stringify({}), JSON.stringify({ orchestration: { mode: 'standalone' } })]) { + const standalone = await run(config); + expect(standalone.exitCode, config).toBe(0); + expect(standalone.stderr, config).not.toContain('not opened'); + } }); });