@letrquan/book 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +2747 -40
- package/README.md +132 -1311
- package/dist/additional-roots-H7UNAEHS.js +31 -0
- package/dist/{app-MOXXWEOY.js → app-GE4OHJOC.js} +5740 -4045
- package/dist/app-GE4OHJOC.js.map +1 -0
- package/dist/{chunk-5GDZ22YP.js → chunk-224FJZLH.js} +99 -108
- package/dist/chunk-224FJZLH.js.map +1 -0
- package/dist/chunk-2AAOF5B5.js +28 -0
- package/dist/chunk-2AAOF5B5.js.map +1 -0
- package/dist/{chunk-CAGOB2N4.js → chunk-4AS7LKKQ.js} +6 -4
- package/dist/chunk-4AS7LKKQ.js.map +1 -0
- package/dist/chunk-525CAZPY.js +120 -0
- package/dist/chunk-525CAZPY.js.map +1 -0
- package/dist/{chunk-URLQAXTR.js → chunk-56SIEMDX.js} +2 -2
- package/dist/chunk-56SIEMDX.js.map +1 -0
- package/dist/{chunk-XIFFTATU.js → chunk-5YHTKWJN.js} +5420 -4031
- package/dist/chunk-5YHTKWJN.js.map +1 -0
- package/dist/chunk-62QL5QUP.js +283 -0
- package/dist/chunk-62QL5QUP.js.map +1 -0
- package/dist/{chunk-KWHQ2XXL.js → chunk-67UZEWWF.js} +3 -3
- package/dist/chunk-67UZEWWF.js.map +1 -0
- package/dist/chunk-6OQQOMZR.js +785 -0
- package/dist/chunk-6OQQOMZR.js.map +1 -0
- package/dist/{chunk-GTVB4HEM.js → chunk-AQEQX4FF.js} +344 -315
- package/dist/chunk-AQEQX4FF.js.map +1 -0
- package/dist/chunk-C7ADUNPR.js +273 -0
- package/dist/chunk-C7ADUNPR.js.map +1 -0
- package/dist/{chunk-E75IPST5.js → chunk-EFJ2NH6E.js} +12 -58
- package/dist/chunk-EFJ2NH6E.js.map +1 -0
- package/dist/{chunk-5KLVH3PY.js → chunk-HQI7NYGX.js} +5 -3
- package/dist/chunk-HQI7NYGX.js.map +1 -0
- package/dist/chunk-IME7R2UI.js +6541 -0
- package/dist/chunk-IME7R2UI.js.map +1 -0
- package/dist/chunk-J4F2QLM2.js +273 -0
- package/dist/chunk-J4F2QLM2.js.map +1 -0
- package/dist/{chunk-MAQXCUR4.js → chunk-J62VZYOB.js} +444 -112
- package/dist/chunk-J62VZYOB.js.map +1 -0
- package/dist/chunk-JTPOI3KU.js +826 -0
- package/dist/chunk-JTPOI3KU.js.map +1 -0
- package/dist/{chunk-WKBO5R4O.js → chunk-KBQS35OV.js} +2 -2
- package/dist/chunk-KBQS35OV.js.map +1 -0
- package/dist/chunk-KXAYUIKP.js +90 -0
- package/dist/chunk-KXAYUIKP.js.map +1 -0
- package/dist/{chunk-VZQXX3WW.js → chunk-QYBD22H5.js} +1 -1
- package/dist/chunk-QYBD22H5.js.map +1 -0
- package/dist/{chunk-V4BOYR52.js → chunk-RFJ6Q6LE.js} +763 -198
- package/dist/chunk-RFJ6Q6LE.js.map +1 -0
- package/dist/{chunk-7N4J557C.js → chunk-RT5SATF7.js} +1 -1
- package/dist/chunk-RT5SATF7.js.map +1 -0
- package/dist/{chunk-C7D4NJ2D.js → chunk-S4JMID4G.js} +68 -26
- package/dist/chunk-S4JMID4G.js.map +1 -0
- package/dist/{chunk-ANSJPYGI.js → chunk-SOW4WRQP.js} +3 -3
- package/dist/chunk-SOW4WRQP.js.map +1 -0
- package/dist/{chunk-J7MIZ5CA.js → chunk-TLX3H2CL.js} +518 -79
- package/dist/chunk-TLX3H2CL.js.map +1 -0
- package/dist/{chunk-DGMQEI6Y.js → chunk-VPNLCMVR.js} +22 -9
- package/dist/chunk-VPNLCMVR.js.map +1 -0
- package/dist/{chunk-BTZZUCLI.js → chunk-W3XFHEF4.js} +173 -29
- package/dist/chunk-W3XFHEF4.js.map +1 -0
- package/dist/{chunk-574C6MQR.js → chunk-X4NJT436.js} +130 -60
- package/dist/chunk-X4NJT436.js.map +1 -0
- package/dist/chunk-X66I2NQO.js +137 -0
- package/dist/chunk-X66I2NQO.js.map +1 -0
- package/dist/chunk-XHAO3K7I.js +18 -0
- package/dist/chunk-XHAO3K7I.js.map +1 -0
- package/dist/{chunk-3U5IBM24.js → chunk-Y2VHN7TC.js} +2 -2
- package/dist/chunk-Y2VHN7TC.js.map +1 -0
- package/dist/chunk-YC6W4EJH.js +947 -0
- package/dist/chunk-YC6W4EJH.js.map +1 -0
- package/dist/{chunk-24AXE6SP.js → chunk-ZPSZZUWF.js} +1 -1
- package/dist/chunk-ZPSZZUWF.js.map +1 -0
- package/dist/{command-approvals-6Y57VCWZ.js → command-approvals-VBIU6BLM.js} +6 -5
- package/dist/context-CBOBDY5Q.js +38 -0
- package/dist/{hook-approvals-CTUFKEJT.js → hook-approvals-ZVT4V7IM.js} +5 -5
- package/dist/index.js +996 -155
- package/dist/index.js.map +1 -1
- package/dist/{interactive-assets-CCAAWVKH.js → interactive-assets-T4I4SEHU.js} +13 -9
- package/dist/interactive-assets-T4I4SEHU.js.map +1 -0
- package/dist/job-runner.js +159 -111
- package/dist/job-runner.js.map +1 -1
- package/dist/job-supervisor.d.ts +2 -0
- package/dist/job-supervisor.js +112 -0
- package/dist/job-supervisor.js.map +1 -0
- package/dist/loader-S77OVTHH.js +18 -0
- package/dist/{mcp-approvals-WNPH65BK.js → mcp-approvals-HJ2VDYMC.js} +5 -5
- package/dist/{mcp-config-YIJIES2C.js → mcp-config-RNB5LEVE.js} +3 -3
- package/dist/memory-store-DZUT5HUH.js +75 -0
- package/dist/{model-window-store-YFVNXCIP.js → model-window-store-MTSQ5JDJ.js} +8 -4
- package/dist/openai-compatible-2OB54YST.js +14 -0
- package/dist/{permission-approvals-76PVREFD.js → permission-approvals-YLV6UC5W.js} +5 -5
- package/dist/permissions-DHOKF3W2.js +45 -0
- package/dist/sandbox-WRYTZH3D.js +41 -0
- package/dist/{scrollback-LUKLIBVI.js → scrollback-35ME2GFX.js} +32 -22
- package/dist/scrollback-35ME2GFX.js.map +1 -0
- package/dist/sdk.d.ts +2219 -2361
- package/dist/sdk.js +53 -38
- package/dist/sdk.js.map +1 -1
- package/dist/settings-loader-JUNPGAAM.js +35 -0
- package/dist/settings-loader-JUNPGAAM.js.map +1 -0
- package/dist/{shell-selection-V6QAYXPX.js → shell-selection-CDHGJ4GC.js} +2 -2
- package/dist/shell-selection-CDHGJ4GC.js.map +1 -0
- package/package.json +23 -22
- package/dist/app-MOXXWEOY.js.map +0 -1
- package/dist/chunk-24AXE6SP.js.map +0 -1
- package/dist/chunk-3U5IBM24.js.map +0 -1
- package/dist/chunk-574C6MQR.js.map +0 -1
- package/dist/chunk-5GDZ22YP.js.map +0 -1
- package/dist/chunk-5KLVH3PY.js.map +0 -1
- package/dist/chunk-7N4J557C.js.map +0 -1
- package/dist/chunk-A7JLBK2Y.js +0 -3506
- package/dist/chunk-A7JLBK2Y.js.map +0 -1
- package/dist/chunk-ANSJPYGI.js.map +0 -1
- package/dist/chunk-BTZZUCLI.js.map +0 -1
- package/dist/chunk-C7D4NJ2D.js.map +0 -1
- package/dist/chunk-CAGOB2N4.js.map +0 -1
- package/dist/chunk-DGMQEI6Y.js.map +0 -1
- package/dist/chunk-E75IPST5.js.map +0 -1
- package/dist/chunk-EYGD6X2T.js +0 -180
- package/dist/chunk-EYGD6X2T.js.map +0 -1
- package/dist/chunk-GTVB4HEM.js.map +0 -1
- package/dist/chunk-J7MIZ5CA.js.map +0 -1
- package/dist/chunk-KWHQ2XXL.js.map +0 -1
- package/dist/chunk-L6LK2QIQ.js +0 -657
- package/dist/chunk-L6LK2QIQ.js.map +0 -1
- package/dist/chunk-MAQXCUR4.js.map +0 -1
- package/dist/chunk-S4XL7HOM.js +0 -402
- package/dist/chunk-S4XL7HOM.js.map +0 -1
- package/dist/chunk-URLQAXTR.js.map +0 -1
- package/dist/chunk-V4BOYR52.js.map +0 -1
- package/dist/chunk-VZQXX3WW.js.map +0 -1
- package/dist/chunk-WKBO5R4O.js.map +0 -1
- package/dist/chunk-XIFFTATU.js.map +0 -1
- package/dist/context-ZC5IFEAX.js +0 -25
- package/dist/interactive-assets-CCAAWVKH.js.map +0 -1
- package/dist/loader-4IDS2RIE.js +0 -15
- package/dist/openai-compatible-RHZUBHV6.js +0 -10
- package/dist/permissions-CHCKTFQQ.js +0 -28
- package/dist/sandbox-CNVAQDA7.js +0 -23
- package/dist/scrollback-LUKLIBVI.js.map +0 -1
- package/dist/settings-loader-M2QP5TX2.js +0 -21
- package/patches/ink+6.8.0.patch +0 -13
- package/scripts/apply-ink-patch.mjs +0 -29
- package/scripts/ink-patch.mjs +0 -59
- package/scripts/verify-ink-patch.mjs +0 -11
- /package/dist/{command-approvals-6Y57VCWZ.js.map → additional-roots-H7UNAEHS.js.map} +0 -0
- /package/dist/{context-ZC5IFEAX.js.map → command-approvals-VBIU6BLM.js.map} +0 -0
- /package/dist/{hook-approvals-CTUFKEJT.js.map → context-CBOBDY5Q.js.map} +0 -0
- /package/dist/{loader-4IDS2RIE.js.map → hook-approvals-ZVT4V7IM.js.map} +0 -0
- /package/dist/{mcp-approvals-WNPH65BK.js.map → loader-S77OVTHH.js.map} +0 -0
- /package/dist/{mcp-config-YIJIES2C.js.map → mcp-approvals-HJ2VDYMC.js.map} +0 -0
- /package/dist/{model-window-store-YFVNXCIP.js.map → mcp-config-RNB5LEVE.js.map} +0 -0
- /package/dist/{openai-compatible-RHZUBHV6.js.map → memory-store-DZUT5HUH.js.map} +0 -0
- /package/dist/{permission-approvals-76PVREFD.js.map → model-window-store-MTSQ5JDJ.js.map} +0 -0
- /package/dist/{permissions-CHCKTFQQ.js.map → openai-compatible-2OB54YST.js.map} +0 -0
- /package/dist/{sandbox-CNVAQDA7.js.map → permission-approvals-YLV6UC5W.js.map} +0 -0
- /package/dist/{settings-loader-M2QP5TX2.js.map → permissions-DHOKF3W2.js.map} +0 -0
- /package/dist/{shell-selection-V6QAYXPX.js.map → sandbox-WRYTZH3D.js.map} +0 -0
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/hook-approvals.ts"],"sourcesContent":["/**\r\n * Trust decisions for hook entries declared by a repository.\r\n *\r\n * A hook entry is a shell command the repository can get Book to execute at any\r\n * lifecycle event — on every prompt, around every tool call, at session start.\r\n * Once merged into resolved settings an entry carries no provenance, so nothing\r\n * downstream can tell a repository's hook from the user's own.\r\n *\r\n * Project-declared hooks therefore require a one-time per-workspace decision,\r\n * keyed by a fingerprint of the full entry (event, matcher, command, env) and\r\n * recorded in the user-global trust store, outside the workspace, where nothing\r\n * the repository ships can reach it (see `workspace-trust.ts`). Any change to\r\n * an entry produces a new fingerprint and reverts it to untrusted.\r\n *\r\n * User-global and local-layer hooks need no gate: those layers were written by\r\n * or for the user.\r\n */\r\nimport { createHash } from 'crypto';\r\nimport { HOOK_EVENTS, type HookEntry, type HookEvent, type ProjectHookChoice } from './settings.js';\r\nimport { updateWorkspaceTrust } from './workspace-trust.js';\r\n\r\n/** Every project-declared hook entry, paired with the event that triggers it. */\r\nexport interface DeclaredHook {\r\n event: HookEvent;\r\n entry: HookEntry;\r\n}\r\n\r\n/** Stable digest of everything the user is asked to trust about a hook. */\r\nexport function hookFingerprint(event: HookEvent, entry: HookEntry): string {\r\n const sortedEnv = Object.fromEntries(\r\n Object.entries(entry.env ?? {}).sort(([a], [b]) => a.localeCompare(b)),\r\n );\r\n const canonical = JSON.stringify({\r\n event,\r\n matcher: entry.matcher ?? null,\r\n command: entry.command,\r\n env: sortedEnv,\r\n });\r\n return createHash('sha256').update(canonical).digest('hex').slice(0, 16);\r\n}\r\n\r\n/** Flatten a settings document into its declared hook entries, event paired. */\r\nexport function collectDeclaredHooks(\r\n settings:\r\n | {\r\n hooks?: Partial<Record<HookEvent, HookEntry[]>> & { projectEntries?: unknown };\r\n }\r\n | null\r\n | undefined,\r\n): DeclaredHook[] {\r\n const declared: DeclaredHook[] = [];\r\n for (const event of HOOK_EVENTS) {\r\n for (const entry of settings?.hooks?.[event] ?? []) {\r\n declared.push({ event, entry });\r\n }\r\n }\r\n return declared;\r\n}\r\n\r\nexport type ProjectHookState = ProjectHookChoice | 'unknown';\r\n\r\nexport function evaluateProjectHook(\r\n store: Record<string, ProjectHookChoice> | undefined,\r\n fingerprint: string,\r\n): ProjectHookState {\r\n return store?.[fingerprint] ?? 'unknown';\r\n}\r\n\r\nexport interface ProjectHookPartition {\r\n /** Approved by the user: these join the resolved hooks. */\r\n approved: DeclaredHook[];\r\n /** No decision recorded: withheld until the user makes one. */\r\n pending: DeclaredHook[];\r\n /** Explicitly refused: withheld, and not re-offered. */\r\n rejected: DeclaredHook[];\r\n}\r\n\r\nexport function partitionProjectHooks(\r\n hooks: readonly DeclaredHook[],\r\n store: Record<string, ProjectHookChoice> | undefined,\r\n): ProjectHookPartition {\r\n const partition: ProjectHookPartition = { approved: [], pending: [], rejected: [] };\r\n for (const hook of hooks) {\r\n const state = evaluateProjectHook(store, hookFingerprint(hook.event, hook.entry));\r\n if (state === 'approved') partition.approved.push(hook);\r\n else if (state === 'rejected') partition.rejected.push(hook);\r\n else partition.pending.push(hook);\r\n }\r\n return partition;\r\n}\r\n\r\n/**\r\n * Render repository-authored text for a terminal that is about to ask the user\r\n * to trust it.\r\n *\r\n * A hook command and its environment values are written by the party being\r\n * gated. Printed raw, a value carrying newlines or ANSI escapes can forge\r\n * further lines of the report — including an `[x] …` line for a hook that is\r\n * nothing of the kind. Control characters become visible escapes, and an\r\n * over-long value is truncated so one entry cannot push the rest of the\r\n * disclosure off screen.\r\n */\r\nexport function renderUntrustedText(value: string, maxLength = 200): string {\r\n const escaped = value.replace(/[\\u0000-\\u001f\\u007f-\\u009f]/g, (character) => {\r\n if (character === '\\n') return '\\\\n';\r\n if (character === '\\r') return '\\\\r';\r\n if (character === '\\t') return '\\\\t';\r\n return `\\\\x${character.codePointAt(0)!.toString(16).padStart(2, '0')}`;\r\n });\r\n return escaped.length > maxLength ? `${escaped.slice(0, maxLength)}…` : escaped;\r\n}\r\n\r\n/**\r\n * Everything the fingerprint covers, rendered for a trust decision.\r\n *\r\n * The fingerprint digests event, matcher, command *and* env, so a disclosure\r\n * showing only the command understates what approval grants: `npm test` with\r\n * `NODE_OPTIONS=--require ./payload.js` reads as harmless and is not. Two\r\n * entries differing only in matcher or env are also indistinguishable without\r\n * these lines.\r\n */\r\nexport function describeDeclaredHook(hook: DeclaredHook): { headline: string; details: string[] } {\r\n const details = [`fingerprint: ${hookFingerprint(hook.event, hook.entry)}`];\r\n if (hook.entry.matcher !== undefined) {\r\n details.push(`matcher: ${renderUntrustedText(hook.entry.matcher)}`);\r\n }\r\n for (const [key, value] of Object.entries(hook.entry.env ?? {}).sort(([a], [b]) =>\r\n a.localeCompare(b),\r\n )) {\r\n details.push(`env: ${renderUntrustedText(key, 64)}=${renderUntrustedText(value)}`);\r\n }\r\n return { headline: `${hook.event}: ${renderUntrustedText(hook.entry.command)}`, details };\r\n}\r\n\r\n/**\r\n * Record an approve/reject decision for one hook, leaving every other recorded\r\n * decision — in this workspace and in every other — untouched.\r\n */\r\nexport function persistProjectHookChoice(\r\n workspace: string,\r\n fingerprint: string,\r\n choice: ProjectHookChoice,\r\n options: { trustStorePath?: string } = {},\r\n): { ok: boolean; error?: string } {\r\n return updateWorkspaceTrust(\r\n workspace,\r\n (trust) => {\r\n trust.hookEntries[fingerprint] = choice;\r\n },\r\n options.trustStorePath,\r\n );\r\n}\r\n"],"mappings":";;;;;;;;AAiBA,SAAS,kBAAkB;AAWpB,SAAS,gBAAgB,OAAkB,OAA0B;AAC1E,QAAM,YAAY,OAAO;AAAA,IACvB,OAAO,QAAQ,MAAM,OAAO,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AAAA,EACvE;AACA,QAAM,YAAY,KAAK,UAAU;AAAA,IAC/B;AAAA,IACA,SAAS,MAAM,WAAW;AAAA,IAC1B,SAAS,MAAM;AAAA,IACf,KAAK;AAAA,EACP,CAAC;AACD,SAAO,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AACzE;AAGO,SAAS,qBACd,UAMgB;AAChB,QAAM,WAA2B,CAAC;AAClC,aAAW,SAAS,aAAa;AAC/B,eAAW,SAAS,UAAU,QAAQ,KAAK,KAAK,CAAC,GAAG;AAClD,eAAS,KAAK,EAAE,OAAO,MAAM,CAAC;AAAA,IAChC;AAAA,EACF;AACA,SAAO;AACT;AAIO,SAAS,oBACd,OACA,aACkB;AAClB,SAAO,QAAQ,WAAW,KAAK;AACjC;AAWO,SAAS,sBACd,OACA,OACsB;AACtB,QAAM,YAAkC,EAAE,UAAU,CAAC,GAAG,SAAS,CAAC,GAAG,UAAU,CAAC,EAAE;AAClF,aAAW,QAAQ,OAAO;AACxB,UAAM,QAAQ,oBAAoB,OAAO,gBAAgB,KAAK,OAAO,KAAK,KAAK,CAAC;AAChF,QAAI,UAAU,WAAY,WAAU,SAAS,KAAK,IAAI;AAAA,aAC7C,UAAU,WAAY,WAAU,SAAS,KAAK,IAAI;AAAA,QACtD,WAAU,QAAQ,KAAK,IAAI;AAAA,EAClC;AACA,SAAO;AACT;AAaO,SAAS,oBAAoB,OAAe,YAAY,KAAa;AAC1E,QAAM,UAAU,MAAM,QAAQ,iCAAiC,CAAC,cAAc;AAC5E,QAAI,cAAc,KAAM,QAAO;AAC/B,QAAI,cAAc,KAAM,QAAO;AAC/B,QAAI,cAAc,IAAM,QAAO;AAC/B,WAAO,MAAM,UAAU,YAAY,CAAC,EAAG,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC;AAAA,EACtE,CAAC;AACD,SAAO,QAAQ,SAAS,YAAY,GAAG,QAAQ,MAAM,GAAG,SAAS,CAAC,WAAM;AAC1E;AAWO,SAAS,qBAAqB,MAA6D;AAChG,QAAM,UAAU,CAAC,gBAAgB,gBAAgB,KAAK,OAAO,KAAK,KAAK,CAAC,EAAE;AAC1E,MAAI,KAAK,MAAM,YAAY,QAAW;AACpC,YAAQ,KAAK,gBAAgB,oBAAoB,KAAK,MAAM,OAAO,CAAC,EAAE;AAAA,EACxE;AACA,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAK,MAAM,OAAO,CAAC,CAAC,EAAE;AAAA,IAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAC3E,EAAE,cAAc,CAAC;AAAA,EACnB,GAAG;AACD,YAAQ,KAAK,gBAAgB,oBAAoB,KAAK,EAAE,CAAC,IAAI,oBAAoB,KAAK,CAAC,EAAE;AAAA,EAC3F;AACA,SAAO,EAAE,UAAU,GAAG,KAAK,KAAK,KAAK,oBAAoB,KAAK,MAAM,OAAO,CAAC,IAAI,QAAQ;AAC1F;AAMO,SAAS,yBACd,WACA,aACA,QACA,UAAuC,CAAC,GACP;AACjC,SAAO;AAAA,IACL;AAAA,IACA,CAAC,UAAU;AACT,YAAM,YAAY,WAAW,IAAI;AAAA,IACnC;AAAA,IACA,QAAQ;AAAA,EACV;AACF;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/settings-loader.ts"],"sourcesContent":["import { readFileSync, existsSync } from 'fs';\r\nimport { join, normalize } from 'path';\r\nimport {\r\n bookSettingsSchema,\r\n DEFAULT_SETTINGS,\r\n HOOK_EVENTS,\r\n type BookSettings,\r\n type HookEntry,\r\n type HookEvent,\r\n type ResolvedSettings,\r\n} from './settings.js';\r\nimport { SettingsRepository, writeFileAtomic } from './settings-repository.js';\r\nimport { resolveBookHome } from './book-home.js';\r\nimport { partitionProjectAllowRules } from './permission-approvals.js';\r\nimport { collectDeclaredHooks, partitionProjectHooks } from './hook-approvals.js';\r\nimport { defaultTrustStorePath, loadWorkspaceTrust } from './workspace-trust.js';\r\nimport { normalizeRemovedSettings } from './settings-removed.js';\r\n\r\nconst LEGACY_PERMISSIONS_MIGRATION_VERSION = 1;\r\n\r\n/**\r\n * Deep-merge two settings objects. For arrays, concatenate (used for\r\n * permission rules and additionalDirectories). For objects, merge recursively.\r\n * For scalars, the override wins.\r\n */\r\nconst CONCATENATED_ARRAY_PATHS = new Set([\r\n 'permissions.allow',\r\n 'permissions.ask',\r\n 'permissions.deny',\r\n ...[\r\n 'SessionStart',\r\n 'SessionEnd',\r\n 'UserPromptSubmit',\r\n 'PreToolUse',\r\n 'PostToolUse',\r\n 'Stop',\r\n 'PreCompact',\r\n 'PostCompact',\r\n 'SubagentStart',\r\n 'SubagentStop',\r\n ].map((event) => `hooks.${event}`),\r\n]);\r\n\r\nfunction mergeObject(\r\n base: Record<string, unknown>,\r\n override: Record<string, unknown>,\r\n prefix = '',\r\n): Record<string, unknown> {\r\n const result = structuredClone(base);\r\n\r\n for (const [key, value] of Object.entries(override)) {\r\n if (value === undefined) continue;\r\n const path = prefix ? `${prefix}.${key}` : key;\r\n const existing = result[key];\r\n\r\n if (Array.isArray(value)) {\r\n if (path === 'additionalDirectories') {\r\n const combined = [...(Array.isArray(existing) ? existing : []), ...value].map((entry) =>\r\n normalize(String(entry)),\r\n );\r\n result[key] = [...new Set(combined)];\r\n } else if (CONCATENATED_ARRAY_PATHS.has(path)) {\r\n result[key] = [...(Array.isArray(existing) ? existing : []), ...value];\r\n } else {\r\n result[key] = structuredClone(value);\r\n }\r\n } else if (\r\n typeof value === 'object' &&\r\n value !== null &&\r\n typeof existing === 'object' &&\r\n existing !== null &&\r\n !Array.isArray(existing)\r\n ) {\r\n result[key] = mergeObject(\r\n existing as Record<string, unknown>,\r\n value as Record<string, unknown>,\r\n path,\r\n );\r\n } else {\r\n result[key] = value;\r\n }\r\n }\r\n\r\n return result;\r\n}\r\n\r\n/**\r\n * How much authority a settings layer carries.\r\n *\r\n * - `trusted` — the user's own global file, or a path they passed on the CLI.\r\n * - `local` — `<workspace>/.book/settings.local.json`: gitignored and normally\r\n * written by the user, or by Book on their behalf.\r\n * - `repository` — `<workspace>/.book/settings.json`: checked in and controlled\r\n * by whoever wrote the repository. It may never grant itself trust.\r\n */\r\nexport type SettingsLayerTrust = 'trusted' | 'local' | 'repository';\r\n\r\n/**\r\n * Settings paths that a workspace file may not supply for itself. Most record\r\n * a decision the user made *about* repository-controlled input; honouring one\r\n * from the workspace would let a clone approve itself. Experimental capability\r\n * flags are included because merely opening a clone must not opt the user into\r\n * unstable runtime behavior.\r\n *\r\n * The local layer is no safer than the checked-in one here. `.gitignore` does\r\n * not stop a *tracked* file from reaching a clone, so a repository that\r\n * force-adds `.book/settings.local.json` ships its own approvals with it. Both\r\n * workspace layers are therefore stripped. Trust decisions come from the\r\n * user-global store; experimental flags come from a trusted user-global or\r\n * explicit settings document (or from a process environment opt-in).\r\n */\r\nconst WORKSPACE_FORBIDDEN_PATHS: ReadonlyArray<readonly [string, string]> = [\r\n ['mcp', 'projectServers'],\r\n ['permissions', 'projectAllowRules'],\r\n ['hooks', 'projectEntries'],\r\n ['commands', 'projectCommands'],\r\n];\r\n\r\nfunction stripPaths(\r\n settings: Partial<BookSettings>,\r\n paths: ReadonlyArray<readonly [string, string]>,\r\n): void {\r\n for (const [parent, key] of paths) {\r\n const container = (settings as Record<string, unknown>)[parent];\r\n if (container && typeof container === 'object' && !Array.isArray(container)) {\r\n delete (container as Record<string, unknown>)[key];\r\n }\r\n }\r\n}\r\n\r\nfunction sanitizeLayer(\r\n settings: Partial<BookSettings>,\r\n trust: SettingsLayerTrust,\r\n): Partial<BookSettings> {\r\n if (trust === 'trusted') return settings;\r\n const sanitized = structuredClone(settings);\r\n // Project/local settings cannot opt a session into the most permissive mode.\r\n if (sanitized.defaultMode === 'bypassPermissions') delete sanitized.defaultMode;\r\n // `shell` names the program every Bash command is handed to. A clone that\r\n // could point it at a binary it ships would run that binary on the first\r\n // command, so the key is honoured from trusted layers only.\r\n delete sanitized.shell;\r\n stripPaths(sanitized, WORKSPACE_FORBIDDEN_PATHS);\r\n return sanitized;\r\n}\r\n\r\nfunction mergeLayer(\r\n resolved: ResolvedSettings,\r\n layer: Partial<BookSettings>,\r\n trust: SettingsLayerTrust,\r\n): ResolvedSettings {\r\n const candidate = sanitizeLayer(layer, trust);\r\n // A trusted global safety ceiling cannot be disabled by a lower-trust layer.\r\n if (\r\n resolved.disableBypassPermissionsMode === true &&\r\n candidate.disableBypassPermissionsMode === false\r\n ) {\r\n candidate.disableBypassPermissionsMode = true;\r\n }\r\n return mergeSettings(resolved, candidate);\r\n}\r\n\r\nexport function mergeSettings(\r\n base: ResolvedSettings,\r\n override: Partial<BookSettings>,\r\n): ResolvedSettings {\r\n return mergeObject(\r\n base as unknown as Record<string, unknown>,\r\n override as Record<string, unknown>,\r\n ) as unknown as ResolvedSettings;\r\n}\r\n\r\n/**\r\n * Load and validate a single settings.json file. Returns null if the file\r\n * doesn't exist. Throws on parse/validation errors.\r\n */\r\nfunction loadSettingsFile(path: string): BookSettings | null {\r\n if (!existsSync(path)) return null;\r\n const raw = readFileSync(path, 'utf-8');\r\n let parsed: unknown;\r\n try {\r\n parsed = JSON.parse(raw);\r\n } catch (e) {\r\n throw new Error(\r\n `Invalid JSON in settings file: ${path}\\n${e instanceof Error ? e.message : String(e)}`,\r\n );\r\n }\r\n // A setting that was removed must not brick a working install: an obsolete\r\n // value of a surviving key would otherwise fail the whole document and stop\r\n // Book from starting, with a schema dump that names no remedy. Removed keys\r\n // are reported by `book doctor`, which reads the file itself.\r\n parsed = normalizeRemovedSettings(parsed);\r\n const result = bookSettingsSchema.safeParse(parsed);\r\n if (!result.success) throw new Error(`Invalid settings in ${path}:\\n${result.error.message}`);\r\n // Resolution owns defaults. Returning the source document preserves the distinction\r\n // between an omitted field and a field explicitly set to an empty collection.\r\n return parsed as BookSettings;\r\n}\r\n\r\n/**\r\n * Resolve settings from all scopes: user → project → local.\r\n * Priority: Local > Project > User.\r\n *\r\n * @param workspace - Project root directory\r\n * @param overridePath - Optional path to an ad-hoc settings file (--settings flag)\r\n * @returns Fully resolved settings with all defaults filled\r\n */\r\nexport interface SettingsResolutionPaths {\r\n home?: string;\r\n userSettingsPath?: string;\r\n projectSettingsPath?: string;\r\n localSettingsPath?: string;\r\n trustStorePath?: string;\r\n}\r\n\r\n/**\r\n * Every settings file this resolution would read, in layer order.\r\n *\r\n * Exported so callers that need to inspect the files themselves — `book doctor`\r\n * reporting removed keys, the credential error checking for a stale `auth`\r\n * block — resolve the same paths the loader does rather than rebuilding them.\r\n */\r\nexport function settingsLayerPaths(\r\n workspace: string,\r\n overridePath?: string,\r\n paths: SettingsResolutionPaths = {},\r\n): string[] {\r\n const userPath =\r\n paths.userSettingsPath ??\r\n (paths.home\r\n ? join(paths.home, '.book', 'settings.json')\r\n : join(resolveBookHome(), 'settings.json'));\r\n const layers = [\r\n userPath,\r\n paths.projectSettingsPath ?? join(workspace, '.book', 'settings.json'),\r\n paths.localSettingsPath ?? join(workspace, '.book', 'settings.local.json'),\r\n ];\r\n if (overridePath) layers.push(overridePath);\r\n return layers;\r\n}\r\n\r\nexport function resolveSettings(\r\n workspace: string,\r\n overridePath?: string,\r\n paths: SettingsResolutionPaths = {},\r\n): ResolvedSettings {\r\n let resolved = structuredClone(DEFAULT_SETTINGS);\r\n\r\n // Layer 1: User settings (~/.book/settings.json)\r\n const userPath =\r\n paths.userSettingsPath ??\r\n (paths.home\r\n ? join(paths.home, '.book', 'settings.json')\r\n : join(resolveBookHome(), 'settings.json'));\r\n const user = loadSettingsFile(userPath);\r\n if (user) resolved = mergeLayer(resolved, user, 'trusted');\r\n\r\n // Layer 2: Project settings (<workspace>/.book/settings.json)\r\n const projectPath = paths.projectSettingsPath ?? join(workspace, '.book', 'settings.json');\r\n const project = loadSettingsFile(projectPath);\r\n // `allow` rules from the repository layer are held back rather than merged:\r\n // they only widen authority, and the decisions that release them live in the\r\n // local layer, which has not been merged yet.\r\n const declaredProjectAllow = project?.permissions?.allow ?? [];\r\n // Project-declared hook entries are held back the same way: each one is a\r\n // shell command the repository would otherwise get Book to run.\r\n const declaredProjectHooks = collectDeclaredHooks(project);\r\n // Released hooks belong between the user and local layers, matching the\r\n // relative merge order ungated layers still produce.\r\n const userHookCounts = Object.fromEntries(\r\n HOOK_EVENTS.map((event) => [event, resolved.hooks[event].length]),\r\n ) as Record<HookEvent, number>;\r\n if (project) {\r\n let withheld: BookSettings = project;\r\n if (declaredProjectAllow.length > 0) {\r\n withheld = { ...withheld, permissions: { ...withheld.permissions, allow: [] } };\r\n }\r\n if (declaredProjectHooks.length > 0) {\r\n withheld = { ...withheld, hooks: structuredClone(DEFAULT_SETTINGS.hooks) };\r\n }\r\n resolved = mergeLayer(resolved, withheld, 'repository');\r\n }\r\n\r\n // Layer 3: Local settings (<workspace>/.book/settings.local.json)\r\n const localPath = paths.localSettingsPath ?? join(workspace, '.book', 'settings.local.json');\r\n const local = loadSettingsFile(localPath);\r\n if (local) resolved = mergeLayer(resolved, local, 'local');\r\n\r\n // Layer 4 (optional): Ad-hoc override (--settings flag)\r\n if (overridePath) {\r\n const override = loadSettingsFile(overridePath);\r\n if (override) resolved = mergeLayer(resolved, override, 'trusted');\r\n }\r\n\r\n // Trust decisions come from outside the workspace, so a repository cannot\r\n // ship its own. Layers the user controls may still carry them — the global\r\n // file and an explicit `--settings` path are trusted for everything else —\r\n // but the store has the final say on any key it records.\r\n const trust = loadWorkspaceTrust(\r\n workspace,\r\n paths.trustStorePath ?? defaultTrustStorePath(paths.home),\r\n );\r\n resolved.permissions.projectAllowRules = {\r\n ...resolved.permissions.projectAllowRules,\r\n ...trust.permissionAllowRules,\r\n };\r\n resolved.mcp.projectServers = { ...resolved.mcp.projectServers, ...trust.mcpServers };\r\n resolved.hooks.projectEntries = { ...resolved.hooks.projectEntries, ...trust.hookEntries };\r\n resolved.commands.projectCommands = {\r\n ...resolved.commands.projectCommands,\r\n ...trust.projectCommands,\r\n };\r\n\r\n // Every decision source is in place now, so the withheld repository rules can\r\n // be released — approved ones only.\r\n if (declaredProjectAllow.length > 0) {\r\n const { approved } = partitionProjectAllowRules(\r\n declaredProjectAllow,\r\n resolved.permissions?.projectAllowRules,\r\n );\r\n if (approved.length > 0) {\r\n resolved.permissions.allow = [...(resolved.permissions.allow ?? []), ...approved];\r\n }\r\n }\r\n\r\n if (declaredProjectHooks.length > 0) {\r\n const { approved } = partitionProjectHooks(\r\n declaredProjectHooks,\r\n resolved.hooks?.projectEntries,\r\n );\r\n const approvedByEvent = new Map<HookEvent, HookEntry[]>();\r\n for (const { event, entry } of approved) {\r\n const entries = approvedByEvent.get(event) ?? [];\r\n entries.push(entry);\r\n approvedByEvent.set(event, entries);\r\n }\r\n for (const [event, entries] of approvedByEvent) {\r\n resolved.hooks[event].splice(userHookCounts[event], 0, ...entries);\r\n }\r\n }\r\n\r\n const settings = bookSettingsSchema.parse(resolved) as ResolvedSettings;\r\n return settings;\r\n}\r\n\r\nexport { loadSettingsFile };\r\n\r\n/**\r\n * Migrate rules from the legacy ~/.book/permissions.json into the local\r\n * settings file (<workspace>/.book/settings.local.json). Runs once on first\r\n * load when the legacy file exists and the local settings don't have rules yet.\r\n *\r\n * @param home - User home directory; injectable for isolated migration tests\r\n * @param validatedSettings - Already-resolved settings from the startup preflight\r\n * @returns true if migration occurred, false otherwise\r\n */\r\nexport function migrateLegacyPermissions(\r\n workspace: string,\r\n home?: string,\r\n validatedSettings?: ResolvedSettings,\r\n): boolean {\r\n // Direct callers must observe the same fail-before-storage boundary as the\r\n // normal startup path. Internal callers pass already-resolved settings to\r\n // avoid reading the layers twice on the common off path.\r\n if (!validatedSettings) {\r\n resolveSettings(workspace, undefined, home ? { home } : undefined);\r\n }\r\n\r\n const legacyPath = join(home ? join(home, '.book') : resolveBookHome(), 'permissions.json');\r\n if (!existsSync(legacyPath)) return false;\r\n const markerPath = join(workspace, '.book', 'migrations.json');\r\n try {\r\n const marker = JSON.parse(readFileSync(markerPath, 'utf-8')) as {\r\n legacyPermissions?: number;\r\n };\r\n if ((marker.legacyPermissions ?? 0) >= LEGACY_PERMISSIONS_MIGRATION_VERSION) return false;\r\n } catch {\r\n // Missing or malformed markers are safely rebuilt after a successful migration.\r\n }\r\n\r\n let legacyRules: Array<{ toolName: string; pattern?: string; effect: string }> = [];\r\n try {\r\n const raw = readFileSync(legacyPath, 'utf-8');\r\n const parsed = JSON.parse(raw) as {\r\n rules: Array<{ toolName: string; pattern?: string; effect: string }>;\r\n };\r\n legacyRules = parsed.rules ?? [];\r\n } catch {\r\n return false; // corrupt — leave the legacy file alone\r\n }\r\n\r\n if (legacyRules.length === 0) return false;\r\n\r\n const localDir = join(workspace, '.book');\r\n const localPath = join(localDir, 'settings.local.json');\r\n const result = new SettingsRepository(localPath).update((existing) => {\r\n const permissions = (existing.permissions ?? {}) as {\r\n allow?: string[];\r\n ask?: string[];\r\n deny?: string[];\r\n };\r\n for (const key of ['allow', 'ask', 'deny'] as const) {\r\n if (!Array.isArray(permissions[key])) permissions[key] = [];\r\n }\r\n\r\n for (const rule of legacyRules) {\r\n const specifier = rule.pattern ? `${rule.toolName}(${rule.pattern})` : rule.toolName;\r\n const effect = rule.effect as 'allow' | 'ask' | 'deny';\r\n if (!permissions[effect]!.includes(specifier)) permissions[effect]!.push(specifier);\r\n }\r\n existing.permissions = permissions;\r\n });\r\n if (!result.ok) return false;\r\n\r\n writeFileAtomic(\r\n markerPath,\r\n `${JSON.stringify({ legacyPermissions: LEGACY_PERMISSIONS_MIGRATION_VERSION }, null, 2)}\\n`,\r\n );\r\n if (!result.changed) return false;\r\n\r\n console.warn(\r\n `⚠ Migrated ${legacyRules.length} permission rule(s) from ~/.book/permissions.json to ${localPath}. ` +\r\n 'Delete the legacy file after verifying the migration.',\r\n );\r\n return true;\r\n}\r\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cli/ink-scroll-renderer.ts","../src/cli/frame-buffer.ts"],"sourcesContent":["import { createRequire } from 'node:module';\r\nimport { dirname, join } from 'node:path';\r\nimport { pathToFileURL } from 'node:url';\r\n\r\nexport interface TranscriptScrollHint {\r\n top: number;\r\n bottom: number;\r\n delta: number;\r\n}\r\n\r\ninterface InkStream {\r\n isTTY?: boolean;\r\n rows?: number;\r\n write: (data: string) => unknown;\r\n}\r\n\r\ninterface LogUpdateRenderer {\r\n (output: string): boolean | void;\r\n clear: () => void;\r\n done: () => void;\r\n sync: (output: string) => void;\r\n setCursorPosition: (position: unknown) => void;\r\n isCursorDirty: () => boolean;\r\n willRender: (output: string) => boolean;\r\n}\r\n\r\ninterface LogUpdateModule {\r\n create: (stream: InkStream, options?: { incremental?: boolean }) => LogUpdateRenderer;\r\n}\r\n\r\nlet pendingHint: TranscriptScrollHint | null = null;\r\nlet rendererInstalled = false;\r\n\r\nexport function isInkScrollRendererEnabled(value?: string): boolean {\r\n return value === 'on';\r\n}\r\n\r\nexport function setTranscriptScrollHint(hint: TranscriptScrollHint): void {\r\n if (!Number.isFinite(hint.delta) || hint.delta === 0) return;\r\n if (\r\n pendingHint &&\r\n pendingHint.top === hint.top &&\r\n pendingHint.bottom === hint.bottom &&\r\n Math.sign(pendingHint.delta) === Math.sign(hint.delta)\r\n ) {\r\n pendingHint = { ...pendingHint, delta: pendingHint.delta + hint.delta };\r\n return;\r\n }\r\n pendingHint = { ...hint };\r\n}\r\n\r\nfunction splitFrame(frame: string): string[] {\r\n return frame.endsWith('\\n') ? frame.slice(0, -1).split('\\n') : frame.split('\\n');\r\n}\r\n\r\nfunction getFrameEndRow(frame: string, visibleLineCount: number): number {\r\n return visibleLineCount + (frame.endsWith('\\n') ? 1 : 0);\r\n}\r\n\r\nfunction cursorTo(row: number): string {\r\n return `\\x1b[${Math.max(1, Math.floor(row))};1H`;\r\n}\r\n\r\nfunction setScrollRegion(top: number, bottom: number): string {\r\n return `\\x1b[${Math.max(1, Math.floor(top))};${Math.max(1, Math.floor(bottom))}r`;\r\n}\r\n\r\nexport function isPureVerticalShift(\r\n previousLines: readonly string[],\r\n nextLines: readonly string[],\r\n hint: TranscriptScrollHint,\r\n): boolean {\r\n if (previousLines.length !== nextLines.length) return false;\r\n const top = Math.floor(hint.top) - 1;\r\n const bottom = Math.floor(hint.bottom);\r\n const delta = Math.trunc(hint.delta);\r\n if (top < 0 || bottom > nextLines.length || bottom - top < 2) return false;\r\n if (Math.abs(delta) >= bottom - top) return false;\r\n\r\n for (let row = 0; row < top; row++) {\r\n if (previousLines[row] !== nextLines[row]) return false;\r\n }\r\n for (let row = bottom; row < nextLines.length; row++) {\r\n if (previousLines[row] !== nextLines[row]) return false;\r\n }\r\n\r\n if (delta > 0) {\r\n for (let row = top; row < bottom - delta; row++) {\r\n if (nextLines[row] !== previousLines[row + delta]) return false;\r\n }\r\n } else {\r\n const distance = Math.abs(delta);\r\n for (let row = top + distance; row < bottom; row++) {\r\n if (nextLines[row] !== previousLines[row - distance]) return false;\r\n }\r\n }\r\n return true;\r\n}\r\n\r\nfunction writeExposedRows(\r\n stream: InkStream,\r\n lines: readonly string[],\r\n top: number,\r\n bottom: number,\r\n delta: number,\r\n): void {\r\n const distance = Math.abs(delta);\r\n const first = delta > 0 ? bottom - distance : top;\r\n for (let offset = 0; offset < distance; offset++) {\r\n const row = first + offset;\r\n stream.write(cursorTo(row + 1) + (lines[row] ?? '') + '\\x1b[K');\r\n }\r\n}\r\n\r\nfunction createScrollAwareRenderer(\r\n stream: InkStream,\r\n options: { incremental?: boolean } | undefined,\r\n createBase: LogUpdateModule['create'],\r\n): LogUpdateRenderer {\r\n const base = createBase(stream, options);\r\n let previousFrame = '';\r\n\r\n const render = ((output: string) => {\r\n const hint = pendingHint;\r\n pendingHint = null;\r\n const previousLines = splitFrame(previousFrame);\r\n const nextLines = splitFrame(output);\r\n const terminalRows = stream.rows ?? 0;\r\n const frameEndRow = getFrameEndRow(output, nextLines.length);\r\n const canScroll =\r\n Boolean(stream.isTTY) &&\r\n terminalRows > 0 &&\r\n frameEndRow <= terminalRows &&\r\n previousFrame.length > 0 &&\r\n hint !== null &&\r\n hint.bottom <= nextLines.length &&\r\n isPureVerticalShift(previousLines, nextLines, hint);\r\n\r\n if (canScroll && hint) {\r\n const top = Math.floor(hint.top);\r\n const bottom = Math.floor(hint.bottom);\r\n const delta = Math.trunc(hint.delta);\r\n stream.write('\\x1b[?25l' + setScrollRegion(top, bottom));\r\n stream.write(`\\x1b[${Math.abs(delta)}${delta > 0 ? 'S' : 'T'}`);\r\n writeExposedRows(stream, nextLines, top - 1, bottom, delta);\r\n // Ink's sync() positions the input cursor relative to the frame bottom.\r\n // Re-anchor there first so its cursor suffix cannot repaint the prompt on\r\n // a transcript row after a terminal-native scroll.\r\n stream.write(setScrollRegion(1, terminalRows) + cursorTo(frameEndRow));\r\n previousFrame = output;\r\n base.sync(output);\r\n return true;\r\n }\r\n\r\n previousFrame = output;\r\n return base(output);\r\n }) as LogUpdateRenderer;\r\n\r\n render.clear = () => {\r\n pendingHint = null;\r\n previousFrame = '';\r\n base.clear();\r\n };\r\n render.done = () => {\r\n pendingHint = null;\r\n previousFrame = '';\r\n base.done();\r\n };\r\n render.sync = (output: string) => {\r\n pendingHint = null;\r\n previousFrame = output;\r\n base.sync(output);\r\n };\r\n render.setCursorPosition = (position: unknown) => base.setCursorPosition(position);\r\n render.isCursorDirty = () => base.isCursorDirty();\r\n render.willRender = (output: string) => {\r\n const shouldRender = base.willRender(output);\r\n if (!shouldRender) pendingHint = null;\r\n return shouldRender;\r\n };\r\n return render;\r\n}\r\n\r\nexport async function installInkScrollRenderer(enabled?: boolean): Promise<void> {\r\n if (rendererInstalled) return;\r\n // DECSTBM/CSI S/T rendering remains available for profiling, but Windows\r\n // ConPTY can corrupt fixed footer rows after a later incremental repaint.\r\n if (!(enabled ?? isInkScrollRendererEnabled(process.env.BOOK_SCROLL_RENDERER))) return;\r\n rendererInstalled = true;\r\n\r\n try {\r\n const require = createRequire(import.meta.url);\r\n const inkEntry = require.resolve('ink');\r\n const logUpdateUrl = pathToFileURL(join(dirname(inkEntry), 'log-update.js')).href;\r\n const module = (await import(logUpdateUrl)) as { default: LogUpdateModule };\r\n const logUpdate = module.default;\r\n const createBase = logUpdate.create;\r\n logUpdate.create = (stream, options) => createScrollAwareRenderer(stream, options, createBase);\r\n } catch {\r\n // Keep Ink's built-in renderer when its internal module layout changes.\r\n }\r\n}\r\n","import { createRequire } from 'node:module';\r\nimport { dirname, join } from 'node:path';\r\nimport { pathToFileURL } from 'node:url';\r\n\r\nexport interface FrameCursorPosition {\r\n x: number;\r\n y: number;\r\n}\r\n\r\ninterface InkStream {\r\n isTTY?: boolean;\r\n rows?: number;\r\n write: (data: string) => unknown;\r\n}\r\n\r\ninterface LogUpdateRenderer {\r\n (output: string): boolean | void;\r\n clear: () => void;\r\n done: () => void;\r\n sync: (output: string) => void;\r\n setCursorPosition: (position: unknown) => void;\r\n isCursorDirty: () => boolean;\r\n willRender: (output: string) => boolean;\r\n}\r\n\r\ninterface LogUpdateModule {\r\n create: (stream: InkStream, options?: { incremental?: boolean }) => LogUpdateRenderer;\r\n}\r\n\r\nlet latestFrame = '';\r\nlet lastCursorPosition: FrameCursorPosition | null = null;\r\nlet captureInstalled = false;\r\nconst frameUpdateListeners = new Set<() => void>();\r\n\r\nfunction notifyFrameUpdate(): void {\r\n for (const listener of frameUpdateListeners) {\r\n try {\r\n listener();\r\n } catch {\r\n // A terminal overlay must never break Ink's renderer.\r\n }\r\n }\r\n}\r\n\r\nfunction splitFrame(frame: string): string[] {\r\n return frame.endsWith('\\n') ? frame.slice(0, -1).split('\\n') : frame.split('\\n');\r\n}\r\n\r\nfunction toCursorPosition(position: unknown): FrameCursorPosition | null {\r\n if (!position || typeof position !== 'object') return null;\r\n const candidate = position as { x?: unknown; y?: unknown };\r\n if (typeof candidate.x !== 'number' || typeof candidate.y !== 'number') return null;\r\n return { x: candidate.x, y: candidate.y };\r\n}\r\n\r\n/** Lines of the most recently rendered full-screen frame. */\r\nexport function getFrameLines(): string[] {\r\n return latestFrame.length === 0 ? [] : splitFrame(latestFrame);\r\n}\r\n\r\n/** Last cursor position Ink asked the renderer to restore, if any. */\r\nexport function getLastCursorPosition(): FrameCursorPosition | null {\r\n return lastCursorPosition;\r\n}\r\n\r\n/** Subscribe to completed Ink frame writes, used to preserve terminal overlays. */\r\nexport function subscribeToFrameUpdates(listener: () => void): () => void {\r\n frameUpdateListeners.add(listener);\r\n return () => frameUpdateListeners.delete(listener);\r\n}\r\n\r\n/** Override the captured snapshot. Used by focused selection tests. */\r\nexport function setFrameSnapshotForTesting(\r\n frame: string | null,\r\n cursor: FrameCursorPosition | null = null,\r\n): void {\r\n latestFrame = frame ?? '';\r\n lastCursorPosition = cursor;\r\n}\r\n\r\nexport function createFrameCapturingRenderer(\r\n stream: InkStream,\r\n options: { incremental?: boolean } | undefined,\r\n createBase: LogUpdateModule['create'],\r\n): LogUpdateRenderer {\r\n const base = createBase(stream, options);\r\n\r\n const render = ((output: string) => {\r\n latestFrame = output;\r\n const rendered = base(output);\r\n if (rendered !== false) notifyFrameUpdate();\r\n return rendered;\r\n }) as LogUpdateRenderer;\r\n\r\n render.clear = () => {\r\n latestFrame = '';\r\n base.clear();\r\n };\r\n render.done = () => {\r\n latestFrame = '';\r\n lastCursorPosition = null;\r\n base.done();\r\n };\r\n render.sync = (output: string) => {\r\n latestFrame = output;\r\n base.sync(output);\r\n notifyFrameUpdate();\r\n };\r\n render.setCursorPosition = (position: unknown) => {\r\n lastCursorPosition = toCursorPosition(position);\r\n base.setCursorPosition(position);\r\n };\r\n render.isCursorDirty = () => base.isCursorDirty();\r\n render.willRender = (output: string) => base.willRender(output);\r\n return render;\r\n}\r\n\r\n/** Capture Ink's latest full frame so mouse selection can extract visible text. */\r\nexport async function installFrameCapture(): Promise<void> {\r\n if (captureInstalled) return;\r\n captureInstalled = true;\r\n\r\n try {\r\n const require = createRequire(import.meta.url);\r\n const inkEntry = require.resolve('ink');\r\n const logUpdateUrl = pathToFileURL(join(dirname(inkEntry), 'log-update.js')).href;\r\n const module = (await import(logUpdateUrl)) as { default: LogUpdateModule };\r\n const logUpdate = module.default;\r\n const createBase = logUpdate.create;\r\n logUpdate.create = (stream, options) =>\r\n createFrameCapturingRenderer(stream, options, createBase);\r\n } catch {\r\n // Keep the TUI usable if Ink changes its private renderer layout.\r\n }\r\n}\r\n"],"mappings":";AAAA,SAAS,qBAAqB;AAC9B,SAAS,SAAS,YAAY;AAC9B,SAAS,qBAAqB;AA4B9B,IAAI,cAA2C;AAC/C,IAAI,oBAAoB;AAEjB,SAAS,2BAA2B,OAAyB;AAClE,SAAO,UAAU;AACnB;AAEO,SAAS,wBAAwB,MAAkC;AACxE,MAAI,CAAC,OAAO,SAAS,KAAK,KAAK,KAAK,KAAK,UAAU,EAAG;AACtD,MACE,eACA,YAAY,QAAQ,KAAK,OACzB,YAAY,WAAW,KAAK,UAC5B,KAAK,KAAK,YAAY,KAAK,MAAM,KAAK,KAAK,KAAK,KAAK,GACrD;AACA,kBAAc,EAAE,GAAG,aAAa,OAAO,YAAY,QAAQ,KAAK,MAAM;AACtE;AAAA,EACF;AACA,gBAAc,EAAE,GAAG,KAAK;AAC1B;AAEA,SAAS,WAAW,OAAyB;AAC3C,SAAO,MAAM,SAAS,IAAI,IAAI,MAAM,MAAM,GAAG,EAAE,EAAE,MAAM,IAAI,IAAI,MAAM,MAAM,IAAI;AACjF;AAEA,SAAS,eAAe,OAAe,kBAAkC;AACvE,SAAO,oBAAoB,MAAM,SAAS,IAAI,IAAI,IAAI;AACxD;AAEA,SAAS,SAAS,KAAqB;AACrC,SAAO,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC;AAC7C;AAEA,SAAS,gBAAgB,KAAa,QAAwB;AAC5D,SAAO,QAAQ,KAAK,IAAI,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC,IAAI,KAAK,IAAI,GAAG,KAAK,MAAM,MAAM,CAAC,CAAC;AAChF;AAEO,SAAS,oBACd,eACA,WACA,MACS;AACT,MAAI,cAAc,WAAW,UAAU,OAAQ,QAAO;AACtD,QAAM,MAAM,KAAK,MAAM,KAAK,GAAG,IAAI;AACnC,QAAM,SAAS,KAAK,MAAM,KAAK,MAAM;AACrC,QAAM,QAAQ,KAAK,MAAM,KAAK,KAAK;AACnC,MAAI,MAAM,KAAK,SAAS,UAAU,UAAU,SAAS,MAAM,EAAG,QAAO;AACrE,MAAI,KAAK,IAAI,KAAK,KAAK,SAAS,IAAK,QAAO;AAE5C,WAAS,MAAM,GAAG,MAAM,KAAK,OAAO;AAClC,QAAI,cAAc,GAAG,MAAM,UAAU,GAAG,EAAG,QAAO;AAAA,EACpD;AACA,WAAS,MAAM,QAAQ,MAAM,UAAU,QAAQ,OAAO;AACpD,QAAI,cAAc,GAAG,MAAM,UAAU,GAAG,EAAG,QAAO;AAAA,EACpD;AAEA,MAAI,QAAQ,GAAG;AACb,aAAS,MAAM,KAAK,MAAM,SAAS,OAAO,OAAO;AAC/C,UAAI,UAAU,GAAG,MAAM,cAAc,MAAM,KAAK,EAAG,QAAO;AAAA,IAC5D;AAAA,EACF,OAAO;AACL,UAAM,WAAW,KAAK,IAAI,KAAK;AAC/B,aAAS,MAAM,MAAM,UAAU,MAAM,QAAQ,OAAO;AAClD,UAAI,UAAU,GAAG,MAAM,cAAc,MAAM,QAAQ,EAAG,QAAO;AAAA,IAC/D;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,iBACP,QACA,OACA,KACA,QACA,OACM;AACN,QAAM,WAAW,KAAK,IAAI,KAAK;AAC/B,QAAM,QAAQ,QAAQ,IAAI,SAAS,WAAW;AAC9C,WAAS,SAAS,GAAG,SAAS,UAAU,UAAU;AAChD,UAAM,MAAM,QAAQ;AACpB,WAAO,MAAM,SAAS,MAAM,CAAC,KAAK,MAAM,GAAG,KAAK,MAAM,QAAQ;AAAA,EAChE;AACF;AAEA,SAAS,0BACP,QACA,SACA,YACmB;AACnB,QAAM,OAAO,WAAW,QAAQ,OAAO;AACvC,MAAI,gBAAgB;AAEpB,QAAM,UAAU,CAAC,WAAmB;AAClC,UAAM,OAAO;AACb,kBAAc;AACd,UAAM,gBAAgB,WAAW,aAAa;AAC9C,UAAM,YAAY,WAAW,MAAM;AACnC,UAAM,eAAe,OAAO,QAAQ;AACpC,UAAM,cAAc,eAAe,QAAQ,UAAU,MAAM;AAC3D,UAAM,YACJ,QAAQ,OAAO,KAAK,KACpB,eAAe,KACf,eAAe,gBACf,cAAc,SAAS,KACvB,SAAS,QACT,KAAK,UAAU,UAAU,UACzB,oBAAoB,eAAe,WAAW,IAAI;AAEpD,QAAI,aAAa,MAAM;AACrB,YAAM,MAAM,KAAK,MAAM,KAAK,GAAG;AAC/B,YAAM,SAAS,KAAK,MAAM,KAAK,MAAM;AACrC,YAAM,QAAQ,KAAK,MAAM,KAAK,KAAK;AACnC,aAAO,MAAM,cAAc,gBAAgB,KAAK,MAAM,CAAC;AACvD,aAAO,MAAM,QAAQ,KAAK,IAAI,KAAK,CAAC,GAAG,QAAQ,IAAI,MAAM,GAAG,EAAE;AAC9D,uBAAiB,QAAQ,WAAW,MAAM,GAAG,QAAQ,KAAK;AAI1D,aAAO,MAAM,gBAAgB,GAAG,YAAY,IAAI,SAAS,WAAW,CAAC;AACrE,sBAAgB;AAChB,WAAK,KAAK,MAAM;AAChB,aAAO;AAAA,IACT;AAEA,oBAAgB;AAChB,WAAO,KAAK,MAAM;AAAA,EACpB;AAEA,SAAO,QAAQ,MAAM;AACnB,kBAAc;AACd,oBAAgB;AAChB,SAAK,MAAM;AAAA,EACb;AACA,SAAO,OAAO,MAAM;AAClB,kBAAc;AACd,oBAAgB;AAChB,SAAK,KAAK;AAAA,EACZ;AACA,SAAO,OAAO,CAAC,WAAmB;AAChC,kBAAc;AACd,oBAAgB;AAChB,SAAK,KAAK,MAAM;AAAA,EAClB;AACA,SAAO,oBAAoB,CAAC,aAAsB,KAAK,kBAAkB,QAAQ;AACjF,SAAO,gBAAgB,MAAM,KAAK,cAAc;AAChD,SAAO,aAAa,CAAC,WAAmB;AACtC,UAAM,eAAe,KAAK,WAAW,MAAM;AAC3C,QAAI,CAAC,aAAc,eAAc;AACjC,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAEA,eAAsB,yBAAyB,SAAkC;AAC/E,MAAI,kBAAmB;AAGvB,MAAI,EAAE,WAAW,2BAA2B,QAAQ,IAAI,oBAAoB,GAAI;AAChF,sBAAoB;AAEpB,MAAI;AACF,UAAMA,WAAU,cAAc,YAAY,GAAG;AAC7C,UAAM,WAAWA,SAAQ,QAAQ,KAAK;AACtC,UAAM,eAAe,cAAc,KAAK,QAAQ,QAAQ,GAAG,eAAe,CAAC,EAAE;AAC7E,UAAM,SAAU,MAAM,OAAO;AAC7B,UAAM,YAAY,OAAO;AACzB,UAAM,aAAa,UAAU;AAC7B,cAAU,SAAS,CAAC,QAAQ,YAAY,0BAA0B,QAAQ,SAAS,UAAU;AAAA,EAC/F,QAAQ;AAAA,EAER;AACF;;;ACzMA,SAAS,iBAAAC,sBAAqB;AAC9B,SAAS,WAAAC,UAAS,QAAAC,aAAY;AAC9B,SAAS,iBAAAC,sBAAqB;AA2B9B,IAAI,cAAc;AAClB,IAAI,qBAAiD;AACrD,IAAI,mBAAmB;AACvB,IAAM,uBAAuB,oBAAI,IAAgB;AAEjD,SAAS,oBAA0B;AACjC,aAAW,YAAY,sBAAsB;AAC3C,QAAI;AACF,eAAS;AAAA,IACX,QAAQ;AAAA,IAER;AAAA,EACF;AACF;AAEA,SAASC,YAAW,OAAyB;AAC3C,SAAO,MAAM,SAAS,IAAI,IAAI,MAAM,MAAM,GAAG,EAAE,EAAE,MAAM,IAAI,IAAI,MAAM,MAAM,IAAI;AACjF;AAEA,SAAS,iBAAiB,UAA+C;AACvE,MAAI,CAAC,YAAY,OAAO,aAAa,SAAU,QAAO;AACtD,QAAM,YAAY;AAClB,MAAI,OAAO,UAAU,MAAM,YAAY,OAAO,UAAU,MAAM,SAAU,QAAO;AAC/E,SAAO,EAAE,GAAG,UAAU,GAAG,GAAG,UAAU,EAAE;AAC1C;AAGO,SAAS,gBAA0B;AACxC,SAAO,YAAY,WAAW,IAAI,CAAC,IAAIA,YAAW,WAAW;AAC/D;AAGO,SAAS,wBAAoD;AAClE,SAAO;AACT;AAGO,SAAS,wBAAwB,UAAkC;AACxE,uBAAqB,IAAI,QAAQ;AACjC,SAAO,MAAM,qBAAqB,OAAO,QAAQ;AACnD;AAWO,SAAS,6BACd,QACA,SACA,YACmB;AACnB,QAAM,OAAO,WAAW,QAAQ,OAAO;AAEvC,QAAM,UAAU,CAAC,WAAmB;AAClC,kBAAc;AACd,UAAM,WAAW,KAAK,MAAM;AAC5B,QAAI,aAAa,MAAO,mBAAkB;AAC1C,WAAO;AAAA,EACT;AAEA,SAAO,QAAQ,MAAM;AACnB,kBAAc;AACd,SAAK,MAAM;AAAA,EACb;AACA,SAAO,OAAO,MAAM;AAClB,kBAAc;AACd,yBAAqB;AACrB,SAAK,KAAK;AAAA,EACZ;AACA,SAAO,OAAO,CAAC,WAAmB;AAChC,kBAAc;AACd,SAAK,KAAK,MAAM;AAChB,sBAAkB;AAAA,EACpB;AACA,SAAO,oBAAoB,CAAC,aAAsB;AAChD,yBAAqB,iBAAiB,QAAQ;AAC9C,SAAK,kBAAkB,QAAQ;AAAA,EACjC;AACA,SAAO,gBAAgB,MAAM,KAAK,cAAc;AAChD,SAAO,aAAa,CAAC,WAAmB,KAAK,WAAW,MAAM;AAC9D,SAAO;AACT;AAGA,eAAsB,sBAAqC;AACzD,MAAI,iBAAkB;AACtB,qBAAmB;AAEnB,MAAI;AACF,UAAMC,WAAUC,eAAc,YAAY,GAAG;AAC7C,UAAM,WAAWD,SAAQ,QAAQ,KAAK;AACtC,UAAM,eAAeE,eAAcC,MAAKC,SAAQ,QAAQ,GAAG,eAAe,CAAC,EAAE;AAC7E,UAAM,SAAU,MAAM,OAAO;AAC7B,UAAM,YAAY,OAAO;AACzB,UAAM,aAAa,UAAU;AAC7B,cAAU,SAAS,CAAC,QAAQ,YAC1B,6BAA6B,QAAQ,SAAS,UAAU;AAAA,EAC5D,QAAQ;AAAA,EAER;AACF;","names":["require","createRequire","dirname","join","pathToFileURL","splitFrame","require","createRequire","pathToFileURL","join","dirname"]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/model-window-store.ts"],"sourcesContent":["import { existsSync, readFileSync } from 'node:fs';\r\nimport { join } from 'node:path';\r\nimport { z } from 'zod';\r\nimport { resolveBookHome } from './book-home.js';\r\nimport { writeFileAtomic } from './settings-repository.js';\r\n\r\nexport const MODEL_WINDOW_STORE_VERSION = 1;\r\n\r\n/**\r\n * Fraction of the refused prompt history recorded as the learned window ceiling.\r\n *\r\n * When a provider rejects a request for exceeding the context limit, the history\r\n * tokens at that moment represent a size that was REFUSED, not a size the provider\r\n * accepts. Recording the refused size directly would set a ceiling larger than the\r\n * true context window (e.g. true window 100k, refused history 150k -> ceiling 150k),\r\n * causing repeated user-visible failures. Applying a safety margin ensures the\r\n * recorded ceiling is strictly below the refused size and converges rapidly to a\r\n * viable operating limit.\r\n */\r\nexport const LEARNED_WINDOW_SAFETY_MARGIN = 0.8;\r\n\r\n/**\r\n * Minimum context window ceiling recorded from an overflow refusal.\r\n *\r\n * Sane coding sessions require at least 16k tokens for tools, system prompt,\r\n * and working context. Any refusal below this floor is far more likely to be an\r\n * upstream proxy error (e.g. 413 Request Entity Too Large) or bad telemetry\r\n * than a real context limit. Refusals below this floor are ignored rather than\r\n * recorded, preventing a transient error from permanently bricking the model.\r\n */\r\nexport const MIN_LEARNED_CONTEXT_WINDOW = 16_384;\r\n\r\nexport interface LearnedModelWindowEntry {\r\n contextWindow: number;\r\n learnedAt: number;\r\n}\r\n\r\nexport interface ModelWindowStoreData {\r\n version: number;\r\n models: Record<string, LearnedModelWindowEntry>;\r\n}\r\n\r\nexport interface ModelWindowStoreOptions {\r\n /** Explicit BOOK_HOME directory root (used by tests to isolate storage). */\r\n home?: string;\r\n /** Explicit file path override (used by tests). */\r\n path?: string;\r\n /** Optional clock override for timestamps (used by tests). */\r\n now?: () => number;\r\n}\r\n\r\nexport interface ModelWindowStore {\r\n get(model: string): number | undefined;\r\n getEntry(model: string): LearnedModelWindowEntry | undefined;\r\n ratchet(model: string, ceiling: number): boolean;\r\n all(): Record<string, LearnedModelWindowEntry>;\r\n}\r\n\r\nconst learnedModelWindowEntrySchema = z.object({\r\n contextWindow: z.number().int().positive(),\r\n learnedAt: z.number().int().nonnegative(),\r\n});\r\n\r\nconst looseModelWindowStoreSchema = z.object({\r\n version: z.number().int().positive().default(MODEL_WINDOW_STORE_VERSION),\r\n models: z.record(z.unknown()).default({}),\r\n});\r\n\r\nfunction emptyStore(): ModelWindowStoreData {\r\n return { version: MODEL_WINDOW_STORE_VERSION, models: {} };\r\n}\r\n\r\n/**\r\n * Resolved location for the user-global learned-window store.\r\n *\r\n * This file lives under BOOK_HOME (e.g. ~/.book/model-windows.json), NEVER inside\r\n * the workspace. A repository must never be able to declare or tamper with\r\n * learned window ceilings for itself.\r\n */\r\nexport function defaultModelWindowStorePath(home?: string): string {\r\n return home ? join(home, 'model-windows.json') : join(resolveBookHome(), 'model-windows.json');\r\n}\r\n\r\n/**\r\n * Read the learned-window store.\r\n * Tolerates missing, empty, or corrupt files by returning an empty store.\r\n * Never throws into a run.\r\n */\r\nexport function readModelWindowStore(options?: ModelWindowStoreOptions): ModelWindowStoreData {\r\n const filePath = options?.path ?? defaultModelWindowStorePath(options?.home);\r\n if (!existsSync(filePath)) {\r\n return emptyStore();\r\n }\r\n try {\r\n const raw = readFileSync(filePath, 'utf-8').trim();\r\n if (!raw) return emptyStore();\r\n const parsed = JSON.parse(raw);\r\n const validated = looseModelWindowStoreSchema.safeParse(parsed);\r\n if (!validated.success) {\r\n return emptyStore();\r\n }\r\n const models: Record<string, LearnedModelWindowEntry> = {};\r\n for (const [key, value] of Object.entries(validated.data.models)) {\r\n const entryResult = learnedModelWindowEntrySchema.safeParse(value);\r\n if (entryResult.success) {\r\n models[key] = entryResult.data;\r\n }\r\n }\r\n return {\r\n version: validated.data.version,\r\n models,\r\n };\r\n } catch {\r\n return emptyStore();\r\n }\r\n}\r\n\r\n/**\r\n * Atomically persist the learned-window store.\r\n * Never throws into a run.\r\n */\r\nexport function writeModelWindowStore(\r\n data: ModelWindowStoreData,\r\n options?: ModelWindowStoreOptions,\r\n): boolean {\r\n if (data.version > MODEL_WINDOW_STORE_VERSION) {\r\n return false;\r\n }\r\n const filePath = options?.path ?? defaultModelWindowStorePath(options?.home);\r\n try {\r\n if (existsSync(filePath)) {\r\n const existing = readModelWindowStore(options);\r\n if (existing.version > MODEL_WINDOW_STORE_VERSION) {\r\n return false;\r\n }\r\n }\r\n const serialized = JSON.stringify(data, null, 2) + '\\n';\r\n writeFileAtomic(filePath, serialized);\r\n return true;\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\n/**\r\n * Records a learned context window ceiling for a model.\r\n *\r\n * Ratchet, downward only:\r\n * - If no entry exists for the model, records the new ceiling.\r\n * - If an entry exists with a larger ceiling, lowers it.\r\n * - If an entry exists with an equal or smaller ceiling, changes nothing.\r\n *\r\n * Monotonicity is strict: a later overflow at a smaller size lowers it further;\r\n * an overflow at a larger size does not raise it.\r\n *\r\n * Returns true if the store was updated with a new ceiling, false otherwise.\r\n */\r\nexport function ratchetModelWindow(\r\n model: string,\r\n ceiling: number,\r\n options?: ModelWindowStoreOptions,\r\n): boolean {\r\n return createModelWindowStore(options).ratchet(model, ceiling);\r\n}\r\n\r\n/**\r\n * File-backed implementation of ModelWindowStore.\r\n * Delegates to createModelWindowStore for closure-based, freeze-safe caching.\r\n */\r\nexport class FileModelWindowStore implements ModelWindowStore {\r\n private readonly inner: ModelWindowStore;\r\n\r\n constructor(options?: ModelWindowStoreOptions) {\r\n this.inner = createModelWindowStore(options);\r\n }\r\n\r\n get(model: string): number | undefined {\r\n return this.inner.get(model);\r\n }\r\n\r\n getEntry(model: string): LearnedModelWindowEntry | undefined {\r\n return this.inner.getEntry(model);\r\n }\r\n\r\n ratchet(model: string, ceiling: number): boolean {\r\n return this.inner.ratchet(model, ceiling);\r\n }\r\n\r\n all(): Record<string, LearnedModelWindowEntry> {\r\n return this.inner.all();\r\n }\r\n}\r\n\r\n/**\r\n * In-memory implementation of ModelWindowStore, useful for unit tests.\r\n */\r\nexport class MemoryModelWindowStore implements ModelWindowStore {\r\n private readonly models = new Map<string, LearnedModelWindowEntry>();\r\n private readonly now: () => number;\r\n\r\n constructor(initial?: Record<string, LearnedModelWindowEntry>, now = Date.now) {\r\n this.now = now;\r\n if (initial) {\r\n for (const [k, v] of Object.entries(initial)) {\r\n this.models.set(k, { ...v });\r\n }\r\n }\r\n }\r\n\r\n get(model: string): number | undefined {\r\n return this.models.get(model)?.contextWindow;\r\n }\r\n\r\n getEntry(model: string): LearnedModelWindowEntry | undefined {\r\n const entry = this.models.get(model);\r\n return entry ? { ...entry } : undefined;\r\n }\r\n\r\n ratchet(model: string, ceiling: number): boolean {\r\n if (\r\n !model ||\r\n typeof ceiling !== 'number' ||\r\n !Number.isFinite(ceiling) ||\r\n ceiling < MIN_LEARNED_CONTEXT_WINDOW\r\n ) {\r\n return false;\r\n }\r\n const normalized = Math.floor(ceiling);\r\n const existing = this.models.get(model);\r\n if (existing && existing.contextWindow <= normalized) {\r\n return false;\r\n }\r\n this.models.set(model, { contextWindow: normalized, learnedAt: this.now() });\r\n return true;\r\n }\r\n\r\n all(): Record<string, LearnedModelWindowEntry> {\r\n const result: Record<string, LearnedModelWindowEntry> = {};\r\n for (const [k, v] of this.models.entries()) {\r\n result[k] = { ...v };\r\n }\r\n return result;\r\n }\r\n}\r\n\r\n/**\r\n * Create a freeze-safe ModelWindowStore instance.\r\n *\r\n * Cache state is held in a closure rather than as object properties, ensuring\r\n * that Object.freeze / deepFreeze(config) does not break subsequent memoization.\r\n */\r\nexport function createModelWindowStore(options?: ModelWindowStoreOptions): ModelWindowStore {\r\n let cachedData: ModelWindowStoreData | undefined;\r\n\r\n function load(): ModelWindowStoreData {\r\n if (cachedData === undefined) {\r\n cachedData = readModelWindowStore(options);\r\n }\r\n return cachedData;\r\n }\r\n\r\n return {\r\n get(model: string): number | undefined {\r\n if (!model) return undefined;\r\n return load().models[model]?.contextWindow;\r\n },\r\n\r\n getEntry(model: string): LearnedModelWindowEntry | undefined {\r\n if (!model) return undefined;\r\n const entry = load().models[model];\r\n return entry ? { ...entry } : undefined;\r\n },\r\n\r\n ratchet(model: string, ceiling: number): boolean {\r\n if (\r\n !model ||\r\n typeof ceiling !== 'number' ||\r\n !Number.isFinite(ceiling) ||\r\n ceiling < MIN_LEARNED_CONTEXT_WINDOW\r\n ) {\r\n return false;\r\n }\r\n const fresh = readModelWindowStore(options);\r\n if (fresh.version > MODEL_WINDOW_STORE_VERSION) {\r\n return false;\r\n }\r\n const normalizedCeiling = Math.floor(ceiling);\r\n const existingEntry = fresh.models[model];\r\n const targetCeiling = existingEntry\r\n ? Math.min(existingEntry.contextWindow, normalizedCeiling)\r\n : normalizedCeiling;\r\n\r\n if (existingEntry && existingEntry.contextWindow === targetCeiling) {\r\n // If the merged value equals what is already on disk, write nothing and return false.\r\n return false;\r\n }\r\n\r\n const now = options?.now ? options.now() : Date.now();\r\n const updated: ModelWindowStoreData = {\r\n version: fresh.version,\r\n models: {\r\n ...fresh.models,\r\n [model]: {\r\n contextWindow: targetCeiling,\r\n learnedAt: now,\r\n },\r\n },\r\n };\r\n const written = writeModelWindowStore(updated, options);\r\n if (written) {\r\n cachedData = updated;\r\n return true;\r\n }\r\n return false;\r\n },\r\n\r\n all(): Record<string, LearnedModelWindowEntry> {\r\n const models = load().models;\r\n const result: Record<string, LearnedModelWindowEntry> = {};\r\n for (const [k, v] of Object.entries(models)) {\r\n result[k] = { ...v };\r\n }\r\n return result;\r\n },\r\n };\r\n}\r\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/workspace-trust.ts"],"sourcesContent":["/**\r\n * Trust decisions the user makes about repository-controlled input.\r\n *\r\n * Book gates four kinds of repository-declared configuration behind a one-time\r\n * per-workspace decision: `permissions.allow` rules, MCP servers, hook entries,\r\n * and shell substitution in a project `.book/commands/*.md` body. Each gate is\r\n * only as strong as the store its decisions live in.\r\n *\r\n * That store used to be `<workspace>/.book/settings.local.json`, on the\r\n * reasoning that the file is gitignored and only Book writes it. `.gitignore`\r\n * does not stop a *tracked* file from reaching a clone: `git add -f\r\n * .book/settings.local.json` ships it with the repository, and every\r\n * fingerprint the store is keyed by is a digest of configuration the repository\r\n * already controls. A hostile project could therefore precompute its own\r\n * approvals and arrive pre-trusted, releasing its hooks on first run.\r\n *\r\n * Approval data lives outside the workspace instead, in\r\n * `<BOOK_HOME>/trust.json`, keyed by absolute workspace path. Nothing a\r\n * repository can write reaches it, so the gated party cannot self-approve\r\n * whatever it does to the files it ships.\r\n *\r\n * Reads are fail-closed: an unparseable or off-schema store resolves to \"no\r\n * decisions recorded\", which withholds the gated input rather than releasing\r\n * it. Writes refuse to touch a store they could not parse, so a corrupt or\r\n * newer-versioned file is reported rather than silently overwritten.\r\n */\r\nimport { existsSync, readFileSync } from 'fs';\r\nimport { join, resolve } from 'path';\r\nimport { z } from 'zod';\r\nimport { resolveBookHome } from './book-home.js';\r\nimport { writeFileAtomic } from './settings-repository.js';\r\nimport {\r\n mcpProjectServerChoiceSchema,\r\n projectAllowRuleChoiceSchema,\r\n projectCommandChoiceSchema,\r\n projectHookChoiceSchema,\r\n} from './settings.js';\r\n\r\n/**\r\n * Bumped only for a format change that older Book versions cannot read.\r\n *\r\n * v2 added `projectCommands`. The addition is *readable* by a v1 build, which\r\n * is exactly the problem: `updateWorkspaceTrust` writes the schema-parsed\r\n * value, and `z.object` strips unknown keys, so a v1 build recording any hook,\r\n * rule, or MCP decision for a workspace would silently drop that workspace's\r\n * command approvals on the way out. Refusing the whole store is the louder and\r\n * safer failure — a v1 build reports it as unreadable, withholds every gated\r\n * declaration, and declines to write, rather than quietly erasing decisions.\r\n */\r\nexport const TRUST_STORE_VERSION = 2;\r\n\r\nconst workspaceTrustSchema = z.object({\r\n /** Keyed by the exact rule text the project layer declared. */\r\n permissionAllowRules: z.record(projectAllowRuleChoiceSchema).default({}),\r\n /** Keyed by server name, carrying the fingerprint the decision was made against. */\r\n mcpServers: z.record(mcpProjectServerChoiceSchema).default({}),\r\n /** Keyed by a fingerprint of `{ event, matcher, command, env }`. */\r\n hookEntries: z.record(projectHookChoiceSchema).default({}),\r\n /** Keyed by command name, carrying the fingerprint of the shell its body runs. */\r\n projectCommands: z.record(projectCommandChoiceSchema).default({}),\r\n});\r\n\r\nexport type WorkspaceTrust = z.infer<typeof workspaceTrustSchema>;\r\n\r\nexport function emptyWorkspaceTrust(): WorkspaceTrust {\r\n return { permissionAllowRules: {}, mcpServers: {}, hookEntries: {}, projectCommands: {} };\r\n}\r\n\r\n/** Location of the user-global trust store. */\r\nexport function defaultTrustStorePath(home?: string): string {\r\n return home ? join(home, '.book', 'trust.json') : join(resolveBookHome(), 'trust.json');\r\n}\r\n\r\nfunction workspaceKey(workspace: string): string {\r\n const absolute = resolve(workspace);\r\n // Windows paths are case-insensitive: without folding, `D:\\repo` and `d:\\repo`\r\n // would accumulate two independent sets of decisions for one directory, and a\r\n // decision recorded under one spelling would read back as \"not decided\".\r\n return process.platform === 'win32' ? absolute.toLowerCase() : absolute;\r\n}\r\n\r\ninterface RawTrustStore {\r\n /** Other workspaces' entries, preserved verbatim so a write never drops them. */\r\n workspaces: Record<string, unknown>;\r\n}\r\n\r\ninterface RawTrustStoreRead {\r\n store: RawTrustStore;\r\n /** Set when the file exists but could not be understood; blocks writes. */\r\n unreadable?: string;\r\n}\r\n\r\nfunction readRawStore(path: string): RawTrustStoreRead {\r\n if (!existsSync(path)) return { store: { workspaces: {} } };\r\n\r\n let parsed: unknown;\r\n try {\r\n parsed = JSON.parse(readFileSync(path, 'utf-8'));\r\n } catch (error) {\r\n return {\r\n store: { workspaces: {} },\r\n unreadable: `${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,\r\n };\r\n }\r\n\r\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\r\n return { store: { workspaces: {} }, unreadable: `${path} must contain a JSON object` };\r\n }\r\n\r\n const record = parsed as Record<string, unknown>;\r\n const version = typeof record.version === 'number' ? record.version : TRUST_STORE_VERSION;\r\n if (version > TRUST_STORE_VERSION) {\r\n return {\r\n store: { workspaces: {} },\r\n unreadable: `${path} was written by a newer version of Book (trust store v${version}); upgrade before recording new decisions`,\r\n };\r\n }\r\n\r\n const workspaces = record.workspaces;\r\n if (workspaces === undefined) return { store: { workspaces: {} } };\r\n if (workspaces === null || typeof workspaces !== 'object' || Array.isArray(workspaces)) {\r\n return { store: { workspaces: {} }, unreadable: `${path} \"workspaces\" must be an object` };\r\n }\r\n return { store: { workspaces: { ...(workspaces as Record<string, unknown>) } } };\r\n}\r\n\r\n/**\r\n * Decisions recorded for one workspace. Anything unreadable resolves to no\r\n * decisions, which withholds the gated input.\r\n */\r\nexport function loadWorkspaceTrust(\r\n workspace: string,\r\n storePath: string = defaultTrustStorePath(),\r\n): WorkspaceTrust {\r\n const { store } = readRawStore(storePath);\r\n const parsed = workspaceTrustSchema.safeParse(store.workspaces[workspaceKey(workspace)] ?? {});\r\n return parsed.success ? parsed.data : emptyWorkspaceTrust();\r\n}\r\n\r\n/**\r\n * Apply a decision to one workspace's record, leaving every other workspace's\r\n * entry byte-identical.\r\n */\r\nexport function updateWorkspaceTrust(\r\n workspace: string,\r\n mutate: (trust: WorkspaceTrust) => void,\r\n storePath: string = defaultTrustStorePath(),\r\n): { ok: boolean; error?: string } {\r\n const { store, unreadable } = readRawStore(storePath);\r\n if (unreadable) return { ok: false, error: unreadable };\r\n\r\n const key = workspaceKey(workspace);\r\n const existing = workspaceTrustSchema.safeParse(store.workspaces[key] ?? {});\r\n const candidate = existing.success ? existing.data : emptyWorkspaceTrust();\r\n mutate(candidate);\r\n\r\n const validated = workspaceTrustSchema.safeParse(candidate);\r\n if (!validated.success) return { ok: false, error: validated.error.message };\r\n\r\n const next = {\r\n version: TRUST_STORE_VERSION,\r\n workspaces: { ...store.workspaces, [key]: validated.data },\r\n };\r\n try {\r\n writeFileAtomic(storePath, `${JSON.stringify(next, null, 2)}\\n`);\r\n } catch (error) {\r\n return { ok: false, error: error instanceof Error ? error.message : String(error) };\r\n }\r\n return { ok: true };\r\n}\r\n"],"mappings":"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","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/commands/loader.ts","../src/frontmatter.ts","../src/commands/resolve.ts"],"sourcesContent":["import { readFileSync, existsSync, readdirSync, statSync } from 'fs';\r\nimport { join, extname } from 'path';\r\nimport { parseFrontmatter } from '../frontmatter.js';\r\nimport type { SlashCommand } from '../types/commands.js';\r\nimport { resolveBookHome } from '../book-home.js';\r\n\r\n/**\r\n * Load a single .md command file. Returns null if the file doesn't exist.\r\n */\r\nfunction loadCommandFile(filePath: string, source: 'user' | 'project'): SlashCommand | null {\r\n if (!existsSync(filePath) || extname(filePath) !== '.md') return null;\r\n const raw = readFileSync(filePath, 'utf-8');\r\n const { body, frontmatter } = parseFrontmatter(raw);\r\n const name = filePath.split(/[/\\\\]/).pop()!.replace(/\\.md$/, '');\r\n return {\r\n name,\r\n description: (frontmatter.description as string) || name,\r\n argumentHint: frontmatter['argument-hint'] as string | undefined,\r\n arguments: Array.isArray(frontmatter.arguments)\r\n ? (frontmatter.arguments as string[])\r\n : typeof frontmatter.arguments === 'string'\r\n ? (frontmatter.arguments as string).split(/[,\\s]+/).filter(Boolean)\r\n : undefined,\r\n allowedTools: Array.isArray(frontmatter['allowed-tools'])\r\n ? (frontmatter['allowed-tools'] as string[])\r\n : undefined,\r\n model: frontmatter.model as string | undefined,\r\n body,\r\n source,\r\n };\r\n}\r\n\r\n/**\r\n * Discover all slash commands from the conventional directories.\r\n *\r\n * Search order (project wins on name collision):\r\n * 1. ~/.book/commands/*.md (user)\r\n * 2. .book/commands/*.md (project)\r\n *\r\n * Project commands with the same name as a user command override the user one.\r\n */\r\nexport function discoverCommands(workspace: string): SlashCommand[] {\r\n const userDir = join(resolveBookHome(), 'commands');\r\n const projectDir = join(workspace, '.book', 'commands');\r\n\r\n const byName = new Map<string, SlashCommand>();\r\n\r\n // User commands first (lowest priority).\r\n for (const dir of [userDir, projectDir]) {\r\n if (!existsSync(dir)) continue;\r\n let entries: string[];\r\n try {\r\n entries = readdirSync(dir);\r\n } catch {\r\n continue;\r\n }\r\n for (const entry of entries) {\r\n if (!entry.endsWith('.md')) continue;\r\n const fullPath = join(dir, entry);\r\n let stat;\r\n try {\r\n stat = statSync(fullPath);\r\n } catch {\r\n continue;\r\n }\r\n if (!stat.isFile()) continue;\r\n const source = dir === userDir ? 'user' : 'project';\r\n const cmd = loadCommandFile(fullPath, source);\r\n if (cmd) {\r\n byName.set(cmd.name, cmd); // project overrides user via set\r\n }\r\n }\r\n }\r\n\r\n return Array.from(byName.values());\r\n}\r\n\r\n// Re-export resolveCommandBody from resolve.ts for backward compatibility.\r\nexport { resolveCommandBody } from './resolve.js';\r\n\r\n/**\r\n * Generate a compact text listing of available commands for injection into the\r\n * system prompt. Commands with disableModelInvocation are excluded.\r\n *\r\n * Pattern: mirror generateSkillListing from src/skills.ts.\r\n */\r\nexport function generateCommandListing(\r\n commands: SlashCommand[],\r\n budgetChars: number = 1024,\r\n): string {\r\n const visible = commands.filter((c) => c.source !== undefined); // all commands are visible\r\n if (visible.length === 0) return '';\r\n\r\n const lines: string[] = [];\r\n lines.push('## Available slash commands');\r\n // No command bodies follow, and nothing downstream resolves a bare `/name`\r\n // string: the host expands an invoked command before the turn reaches the\r\n // model. Promising \"execute its instructions below\" taught a mechanism that\r\n // does not exist.\r\n lines.push(\r\n 'The user can invoke these by typing /name. The host expands an invoked command into the conversation before it reaches you. If a message merely mentions a command name, treat it as a reference — do not attempt to execute the command yourself.',\r\n );\r\n\r\n let remaining = budgetChars - lines.join('\\n').length;\r\n let shown = 0;\r\n\r\n for (const cmd of visible) {\r\n const hint = cmd.argumentHint ? ` ${cmd.argumentHint}` : '';\r\n const entry = `- **/${cmd.name}${hint}**: ${cmd.description}`;\r\n if (entry.length > remaining) {\r\n // Try bare name\r\n const bare = `- **/${cmd.name}**`;\r\n if (bare.length <= remaining) {\r\n lines.push(bare);\r\n shown++;\r\n remaining -= bare.length + 1;\r\n }\r\n break;\r\n }\r\n lines.push(entry);\r\n shown++;\r\n remaining -= entry.length + 1;\r\n }\r\n\r\n if (shown < visible.length) lines.push(`- …and ${visible.length - shown} more not shown`);\r\n\r\n return lines.join('\\n');\r\n}\r\n","/**\r\n * Minimal YAML frontmatter parser shared by commands, skills, and subagents.\r\n *\r\n * Handles string values, array values (lines starting with \"-\"), and quoted\r\n * values. Returns a {body, frontmatter} pair. If no frontmatter is found\r\n * (missing opening or closing ---), the entire input is treated as the body.\r\n */\r\nexport function parseFrontmatter(raw: string): {\r\n body: string;\r\n frontmatter: Record<string, unknown>;\r\n} {\r\n // Normalize line endings before looking for the closing delimiter. Skill\r\n // packages are commonly authored on Windows and use CRLF; comparing a raw\r\n // `---\\r` line with the delimiter used to make otherwise valid metadata look\r\n // like an unparsed body.\r\n const normalized = raw.replace(/\\r\\n?/g, '\\n');\r\n const lines = normalized.split('\\n');\r\n // Must start with ---\r\n if (lines[0]?.trim() !== '---') {\r\n return { body: raw, frontmatter: {} };\r\n }\r\n const endIdx = lines.indexOf('---', 1);\r\n if (endIdx === -1) {\r\n // No closing ---; treat entire file as body.\r\n return { body: raw, frontmatter: {} };\r\n }\r\n const fmLines = lines.slice(1, endIdx);\r\n const body = lines\r\n .slice(endIdx + 1)\r\n .join('\\n')\r\n .trim();\r\n const frontmatter: Record<string, unknown> = {};\r\n let currentKey: string | null = null;\r\n let currentArray: string[] = [];\r\n\r\n for (const line of fmLines) {\r\n const trimmed = line.trim();\r\n if (!trimmed) continue;\r\n\r\n // Array item: \"- value\"\r\n const arrayMatch = trimmed.match(/^-\\s+(.+)$/);\r\n if (arrayMatch && currentKey) {\r\n currentArray.push(arrayMatch[1]);\r\n continue;\r\n }\r\n\r\n // Flush any pending array.\r\n if (currentKey) {\r\n if (currentArray.length > 0) {\r\n frontmatter[currentKey] = currentArray;\r\n }\r\n currentKey = null;\r\n currentArray = [];\r\n }\r\n\r\n // key: value\r\n const kvMatch = trimmed.match(/^([a-z][a-zA-Z0-9_-]*)\\s*:\\s*(.*)$/);\r\n if (kvMatch) {\r\n const key = kvMatch[1];\r\n const value = kvMatch[2].trim();\r\n // Could be the start of an array (value empty or starts the array on next line).\r\n if (value === '') {\r\n currentKey = key;\r\n currentArray = [];\r\n } else {\r\n // Unquote if quoted.\r\n const unquoted = value.replace(/^[\"'](.*)[\"']$/, '$1');\r\n if (\r\n (key === 'tools' || key === 'allowed-tools') &&\r\n unquoted.startsWith('[') &&\r\n unquoted.endsWith(']')\r\n ) {\r\n frontmatter[key] = unquoted\r\n .slice(1, -1)\r\n .split(',')\r\n .map((item) => item.trim().replace(/^[\"'](.*)[\"']$/, '$1'))\r\n .filter(Boolean);\r\n } else {\r\n frontmatter[key] = unquoted;\r\n }\r\n }\r\n }\r\n }\r\n // Flush any trailing array.\r\n if (currentKey && currentArray.length > 0) {\r\n frontmatter[currentKey] = currentArray;\r\n }\r\n\r\n return { body, frontmatter };\r\n}\r\n","import { exec } from 'child_process';\r\nimport { assertProjectCommandApproved, type ProjectCommandStore } from '../command-approvals.js';\r\nimport type { CommandContext, SlashCommand } from '../types/commands.js';\r\nimport {\r\n applyShellInjections,\r\n extractShellInjections,\r\n type ShellInjection,\r\n} from './shell-injection.js';\r\n\r\nexport interface ParsedSlashInput {\r\n name: string;\r\n rawArguments: string;\r\n}\r\n\r\n/** Parse slash input once; command identity is always an exact token match. */\r\nexport function parseSlashInput(value: string): ParsedSlashInput | null {\r\n if (!value.startsWith('/')) return null;\r\n const body = value.slice(1);\r\n const separator = body.search(/\\s/);\r\n return separator === -1\r\n ? { name: body, rawArguments: '' }\r\n : { name: body.slice(0, separator), rawArguments: body.slice(separator + 1).trim() };\r\n}\r\n\r\n/**\r\n * Parse raw argument string with shell-style quoting.\r\n *\r\n * Examples:\r\n * \"hello world\" → [\"hello\", \"world\"]\r\n * '\"hello world\" arg' → [\"hello world\", \"arg\"]\r\n * '' → []\r\n */\r\nexport function parseArgs(raw: string): string[] {\r\n const trimmed = raw.trim();\r\n if (!trimmed) return [];\r\n\r\n const argv: string[] = [];\r\n let current = '';\r\n let inDouble = false;\r\n let inSingle = false;\r\n\r\n for (let i = 0; i < trimmed.length; i++) {\r\n const ch = trimmed[i];\r\n\r\n if (inDouble) {\r\n if (ch === '\"') {\r\n inDouble = false;\r\n } else {\r\n current += ch;\r\n }\r\n } else if (inSingle) {\r\n if (ch === \"'\") {\r\n inSingle = false;\r\n } else {\r\n current += ch;\r\n }\r\n } else {\r\n if (ch === '\"') {\r\n inDouble = true;\r\n } else if (ch === \"'\") {\r\n inSingle = true;\r\n } else if (ch === ' ' || ch === '\\t') {\r\n if (current.length > 0) {\r\n argv.push(current);\r\n current = '';\r\n }\r\n } else {\r\n current += ch;\r\n }\r\n }\r\n }\r\n\r\n if (current.length > 0) {\r\n argv.push(current);\r\n }\r\n\r\n return argv;\r\n}\r\n\r\n/**\r\n * Resolve all variables in a command body.\r\n *\r\n * Supported variables:\r\n * $ARGUMENTS / $* — all arguments joined by spaces\r\n * $1 through $9 — positional arguments (1-indexed)\r\n * $name — named argument from `arguments` frontmatter\r\n * ${BOOK_SESSION_ID} — current session ID\r\n * ${BOOK_WORKSPACE} — workspace root path\r\n * ${BOOK_MODEL} — current model name\r\n * ${BOOK_DATE} — current date (YYYY-MM-DD)\r\n */\r\nexport function resolveVariables(\r\n body: string,\r\n argv: string[],\r\n namedArgs: string[],\r\n context?: { sessionId?: string; workspace?: string; model?: string },\r\n): string {\r\n let resolved = body;\r\n\r\n // $ARGUMENTS / $* — all arguments joined\r\n const allArgs = argv.join(' ');\r\n resolved = resolved.replace(/\\$ARGUMENTS|\\$\\*/g, allArgs);\r\n\r\n // $1..$9 — positional arguments (1-indexed)\r\n for (let i = 1; i <= 9; i++) {\r\n const val = i <= argv.length ? argv[i - 1] : '';\r\n resolved = resolved.replace(new RegExp(`\\\\$${i}`, 'g'), val);\r\n }\r\n\r\n // $name — named arguments from `arguments` frontmatter\r\n for (let i = 0; i < namedArgs.length; i++) {\r\n const name = namedArgs[i];\r\n const val = i < argv.length ? argv[i] : '';\r\n resolved = resolved.replace(new RegExp(`\\\\$${escapeRegex(name)}`, 'g'), val);\r\n }\r\n\r\n // ${ENV_VAR} style\r\n if (context?.sessionId) {\r\n resolved = resolved.replace(/\\$\\{BOOK_SESSION_ID\\}/g, context.sessionId);\r\n }\r\n if (context?.workspace) {\r\n resolved = resolved.replace(/\\$\\{BOOK_WORKSPACE\\}/g, context.workspace);\r\n }\r\n if (context?.model) {\r\n resolved = resolved.replace(/\\$\\{BOOK_MODEL\\}/g, context.model);\r\n }\r\n resolved = resolved.replace(/\\$\\{BOOK_DATE\\}/g, new Date().toISOString().split('T')[0]);\r\n\r\n return resolved;\r\n}\r\n\r\n/**\r\n * Resolve inline shell commands and fenced shell blocks in a command body.\r\n *\r\n * Spans come from a single scan of the raw body (`shell-injection.ts`), which\r\n * is also what the approval fingerprint digests, and output is substituted\r\n * back without rescanning: a command that prints an injection marker cannot\r\n * get it executed.\r\n *\r\n * Each shell command runs with a 5-second timeout. Output replaces the\r\n * placeholder inline. Errors are collected.\r\n */\r\nexport async function resolveShellInjection(\r\n body: string,\r\n workspace: string,\r\n signal?: AbortSignal,\r\n): Promise<{ resolved: string; errors: string[] }> {\r\n const errors: string[] = [];\r\n const injections = extractShellInjections(body);\r\n const outputs: string[] = [];\r\n // Sequential: a body may depend on one command having run before the next.\r\n for (const injection of injections) {\r\n outputs.push(await executeInjection(injection, workspace, errors, signal));\r\n }\r\n return { resolved: applyShellInjections(body, injections, outputs), errors };\r\n}\r\n\r\n/**\r\n * Everything a command body may need substituted into it.\r\n */\r\nexport interface CommandResolutionContext {\r\n sessionId?: string;\r\n workspace?: string;\r\n model?: string;\r\n /**\r\n * Recorded decisions about repository-declared shell substitution\r\n * (`settings.commands.projectCommands`).\r\n *\r\n * Omitting it fails closed: a project command that substitutes shell is\r\n * refused rather than run on an unrecorded decision, so a host that has not\r\n * been wired for the gate cannot quietly reopen it.\r\n */\r\n projectCommands?: ProjectCommandStore;\r\n}\r\n\r\n/**\r\n * Resolve a command body by substituting arguments, named args, env vars,\r\n * and shell injections.\r\n *\r\n * Returns the fully resolved body and any shell errors encountered. Throws\r\n * `ProjectCommandApprovalError` when a repository-declared command would run\r\n * shell the user has not approved.\r\n */\r\nexport async function resolveCommandBody(\r\n command: SlashCommand,\r\n args: string,\r\n context?: CommandResolutionContext,\r\n signal?: AbortSignal,\r\n): Promise<{ resolved: string; shellErrors: string[] }> {\r\n // Before anything runs: a checked-in body can execute shell outside the\r\n // permission system, so the decision is checked ahead of the first spawn.\r\n assertProjectCommandApproved(command, {\r\n commands: { projectCommands: context?.projectCommands },\r\n });\r\n\r\n const argv = parseArgs(args);\r\n const namedArgs = command.arguments ?? [];\r\n\r\n // Step 1: Resolve shell injections first (raw body, before var substitution).\r\n // This allows shell commands to produce text that may contain $variables,\r\n // and is why the approved fingerprint digests the raw body: no argument can\r\n // reach the shell.\r\n const { resolved: afterShell, errors } = await resolveShellInjection(\r\n command.body,\r\n context?.workspace ?? process.cwd(),\r\n signal,\r\n );\r\n\r\n // Step 2: Resolve variables ($ARGUMENTS, $1..$9, $name, ${ENV}).\r\n const resolved = resolveVariables(afterShell, argv, namedArgs, context);\r\n\r\n return { resolved, shellErrors: errors };\r\n}\r\n\r\n/**\r\n * Frontmatter enforcement carried into the agent loop for an invoked custom\r\n * command, or undefined when the command restricts nothing.\r\n *\r\n * Shared so every host (TUI, print/headless) enforces `allowed-tools` and\r\n * `model` identically.\r\n */\r\nexport function commandEnforcementContext(\r\n command: SlashCommand,\r\n resolvedBody: string,\r\n): CommandContext | undefined {\r\n return command.allowedTools || command.model\r\n ? {\r\n command,\r\n resolvedBody,\r\n modelOverride: command.model,\r\n allowedTools: command.allowedTools,\r\n }\r\n : undefined;\r\n}\r\n\r\nfunction executeInjection(\r\n injection: ShellInjection,\r\n workspace: string,\r\n errors: string[],\r\n signal?: AbortSignal,\r\n): Promise<string> {\r\n return new Promise((resolve) => {\r\n exec(\r\n injection.command,\r\n {\r\n cwd: workspace,\r\n encoding: 'utf8',\r\n timeout: 5_000,\r\n maxBuffer: 1024 * 1024,\r\n signal,\r\n windowsHide: true,\r\n },\r\n (error, stdout) => {\r\n if (!error) {\r\n resolve(stdout.trim());\r\n return;\r\n }\r\n const message = error.message;\r\n errors.push(\r\n injection.kind === 'block'\r\n ? `Shell block failed: ${message}`\r\n : `Shell command '${injection.command}' failed: ${message}`,\r\n );\r\n resolve(`[shell error: ${message}]`);\r\n },\r\n );\r\n });\r\n}\r\n\r\nfunction escapeRegex(s: string): string {\r\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\r\n}\r\n"],"mappings":"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","names":[]}
|
package/dist/chunk-EYGD6X2T.js
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
// src/sandbox.ts
|
|
2
|
-
import { accessSync, constants, existsSync, statSync } from "fs";
|
|
3
|
-
import { delimiter, join, resolve } from "path";
|
|
4
|
-
import { homedir, platform } from "os";
|
|
5
|
-
|
|
6
|
-
// src/tools/glob-regex.ts
|
|
7
|
-
function globToRegex(pattern) {
|
|
8
|
-
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*|\*/g, ".*");
|
|
9
|
-
return new RegExp("^" + escaped + "$");
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// src/sandbox.ts
|
|
13
|
-
function isExecutableFile(path) {
|
|
14
|
-
try {
|
|
15
|
-
if (!statSync(path).isFile()) return false;
|
|
16
|
-
accessSync(path, constants.X_OK);
|
|
17
|
-
return true;
|
|
18
|
-
} catch {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
function detectBwrap() {
|
|
23
|
-
const candidates = [
|
|
24
|
-
...(process.env.PATH ?? "").split(delimiter).filter(Boolean).map((dir) => join(dir, "bwrap")),
|
|
25
|
-
"/usr/local/bin/bwrap",
|
|
26
|
-
"/opt/homebrew/bin/bwrap",
|
|
27
|
-
"/usr/bin/bwrap"
|
|
28
|
-
];
|
|
29
|
-
return candidates.find(isExecutableFile) ?? null;
|
|
30
|
-
}
|
|
31
|
-
function matchesExcludedCommand(command, patterns = []) {
|
|
32
|
-
return patterns.some((pattern) => globToRegex(pattern).test(command));
|
|
33
|
-
}
|
|
34
|
-
function sandboxBackendAvailable() {
|
|
35
|
-
return platform() !== "win32" && detectBwrap() !== null;
|
|
36
|
-
}
|
|
37
|
-
function sandboxCoverage(command, settings, backendAvailable = sandboxBackendAvailable) {
|
|
38
|
-
if (!settings.enabled) return { sandboxed: false, reason: "disabled" };
|
|
39
|
-
if (matchesExcludedCommand(command, settings.excludedCommands))
|
|
40
|
-
return { sandboxed: false, reason: "excluded" };
|
|
41
|
-
if (!backendAvailable()) return { sandboxed: false, reason: "unavailable" };
|
|
42
|
-
return { sandboxed: true };
|
|
43
|
-
}
|
|
44
|
-
var SKIP_REASON_DETAIL = {
|
|
45
|
-
disabled: "sandboxing is turned off (sandbox.enabled is false)",
|
|
46
|
-
excluded: "the command matches a sandbox.excludedCommands pattern, which runs it unsandboxed",
|
|
47
|
-
unavailable: "the bubblewrap (bwrap) sandbox backend is unavailable on this platform or is not installed"
|
|
48
|
-
};
|
|
49
|
-
var SKIP_REASON_REMEDY = {
|
|
50
|
-
disabled: "Set sandbox.enabled to true",
|
|
51
|
-
excluded: "Remove the matching pattern from sandbox.excludedCommands",
|
|
52
|
-
unavailable: "Install bubblewrap (bwrap)"
|
|
53
|
-
};
|
|
54
|
-
function unsandboxedRefusalMessage(reason) {
|
|
55
|
-
return [
|
|
56
|
-
"Refused to run this command outside the sandbox:",
|
|
57
|
-
`${SKIP_REASON_DETAIL[reason]}.`,
|
|
58
|
-
"sandbox.allowUnsandboxedCommands is false, so unsandboxed commands are not permitted.",
|
|
59
|
-
`${SKIP_REASON_REMEDY[reason]}, or set sandbox.allowUnsandboxedCommands to true to allow this command to run unsandboxed.`
|
|
60
|
-
].join(" ");
|
|
61
|
-
}
|
|
62
|
-
function sandboxPolicySummary(settings, state) {
|
|
63
|
-
return {
|
|
64
|
-
unsandboxedCommands: settings.allowUnsandboxedCommands ? "allowed (sandbox.allowUnsandboxedCommands=true)" : "refused (sandbox.allowUnsandboxedCommands=false)",
|
|
65
|
-
autoAllowBash: !settings.autoAllowBashIfSandboxed ? "off \u2014 every Bash call goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=false)" : !state.sandboxActive ? "inert \u2014 no command is sandboxed here, so nothing is auto-allowed (sandbox.autoAllowBashIfSandboxed=true)" : state.adjudicationConfigured ? "inert \u2014 permissions.deny/ask rules are configured, so every Bash call still goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=true)" : "on for genuinely sandboxed commands (sandbox.autoAllowBashIfSandboxed=true)"
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
var realSandboxHost = {
|
|
69
|
-
path: { resolve, join },
|
|
70
|
-
exists: existsSync,
|
|
71
|
-
isDirectory: (path) => statSync(path).isDirectory(),
|
|
72
|
-
homedir
|
|
73
|
-
};
|
|
74
|
-
function expandPath(path, host) {
|
|
75
|
-
if (path === "~") return host.homedir();
|
|
76
|
-
if (path.startsWith("~/")) return host.path.join(host.homedir(), path.slice(2));
|
|
77
|
-
return host.path.resolve(path);
|
|
78
|
-
}
|
|
79
|
-
function bindIfPresent(args, flag, path, host) {
|
|
80
|
-
const target = expandPath(path, host);
|
|
81
|
-
if (host.exists(target)) args.push(flag, target, target);
|
|
82
|
-
}
|
|
83
|
-
function unbindablePaths(settings, host = realSandboxHost) {
|
|
84
|
-
return [
|
|
85
|
-
...settings.filesystem.allowWrite,
|
|
86
|
-
...settings.filesystem.denyWrite,
|
|
87
|
-
...settings.filesystem.denyRead
|
|
88
|
-
].filter((path) => !host.exists(expandPath(path, host)));
|
|
89
|
-
}
|
|
90
|
-
function hasDomainPolicy(settings) {
|
|
91
|
-
return settings.network.allowedDomains.length > 0 || settings.network.deniedDomains.length > 0;
|
|
92
|
-
}
|
|
93
|
-
function buildSandboxExecution(bwrapPath, command, workspaceRoot, settings, host = realSandboxHost) {
|
|
94
|
-
const args = [];
|
|
95
|
-
args.push("--unshare-pid", "--unshare-ipc", "--unshare-uts", "--die-with-parent");
|
|
96
|
-
args.push("--proc", "/proc");
|
|
97
|
-
args.push("--dev", "/dev");
|
|
98
|
-
for (const dir of ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/etc", "/opt"]) {
|
|
99
|
-
bindIfPresent(args, "--ro-bind", dir, host);
|
|
100
|
-
}
|
|
101
|
-
args.push("--tmpfs", "/tmp");
|
|
102
|
-
bindIfPresent(args, "--bind", workspaceRoot, host);
|
|
103
|
-
for (const path of settings.filesystem.allowWrite) bindIfPresent(args, "--bind", path, host);
|
|
104
|
-
for (const path of settings.filesystem.denyWrite) bindIfPresent(args, "--ro-bind", path, host);
|
|
105
|
-
for (const path of settings.filesystem.denyRead) {
|
|
106
|
-
const target = expandPath(path, host);
|
|
107
|
-
if (!host.exists(target)) continue;
|
|
108
|
-
if (host.isDirectory(target)) args.push("--tmpfs", target);
|
|
109
|
-
else args.push("--ro-bind", "/dev/null", target);
|
|
110
|
-
}
|
|
111
|
-
args.push(hasDomainPolicy(settings) ? "--unshare-net" : "--share-net");
|
|
112
|
-
args.push("--cap-drop", "ALL");
|
|
113
|
-
args.push("--", "/bin/bash", "-c", command);
|
|
114
|
-
return { file: bwrapPath, args };
|
|
115
|
-
}
|
|
116
|
-
function createSandbox(settings) {
|
|
117
|
-
if (!settings.enabled) return null;
|
|
118
|
-
const os = platform();
|
|
119
|
-
if (os === "win32") {
|
|
120
|
-
if (settings.failIfUnavailable) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
"Bash sandbox is not available on Windows. Disable sandbox.enabled or set failIfUnavailable to false."
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
console.warn("\u26A0 Bash sandbox is not available on Windows. Commands will run unsandboxed.");
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
const bwrap = detectBwrap();
|
|
129
|
-
if (!bwrap) {
|
|
130
|
-
if (settings.failIfUnavailable) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
"bubblewrap (bwrap) not found. Install it to enable bash sandboxing, or set failIfUnavailable to false."
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
console.warn(
|
|
136
|
-
"\u26A0 bubblewrap (bwrap) not found \u2014 install it for bash sandboxing. Commands will run unsandboxed."
|
|
137
|
-
);
|
|
138
|
-
return null;
|
|
139
|
-
}
|
|
140
|
-
if (hasDomainPolicy(settings)) {
|
|
141
|
-
console.warn(
|
|
142
|
-
"\u26A0 sandbox.network domain rules cannot be enforced by bubblewrap, which has no per-domain filtering. Network access is disabled entirely for sandboxed commands."
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
|
-
const unbindable = unbindablePaths(settings);
|
|
146
|
-
if (unbindable.length > 0) {
|
|
147
|
-
console.warn(
|
|
148
|
-
`\u26A0 sandbox.filesystem rules skipped \u2014 nothing exists at: ${unbindable.join(", ")}. These paths are not protected.`
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
return {
|
|
152
|
-
wrap(command, workspaceRoot) {
|
|
153
|
-
return buildSandboxExecution(bwrap, command, workspaceRoot, settings);
|
|
154
|
-
},
|
|
155
|
-
describe() {
|
|
156
|
-
return [
|
|
157
|
-
`bubblewrap (${bwrap})`,
|
|
158
|
-
hasDomainPolicy(settings) ? "network disabled" : "host network shared",
|
|
159
|
-
`${settings.filesystem.allowWrite.length} extra writable path(s)`,
|
|
160
|
-
`${settings.filesystem.denyWrite.length} read-only path(s)`,
|
|
161
|
-
`${settings.filesystem.denyRead.length} masked path(s)`,
|
|
162
|
-
unbindable.length > 0 ? `${unbindable.length} skipped (missing)` : "all paths resolved"
|
|
163
|
-
].join("; ");
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export {
|
|
169
|
-
globToRegex,
|
|
170
|
-
matchesExcludedCommand,
|
|
171
|
-
sandboxBackendAvailable,
|
|
172
|
-
sandboxCoverage,
|
|
173
|
-
unsandboxedRefusalMessage,
|
|
174
|
-
sandboxPolicySummary,
|
|
175
|
-
unbindablePaths,
|
|
176
|
-
hasDomainPolicy,
|
|
177
|
-
buildSandboxExecution,
|
|
178
|
-
createSandbox
|
|
179
|
-
};
|
|
180
|
-
//# sourceMappingURL=chunk-EYGD6X2T.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/sandbox.ts","../src/tools/glob-regex.ts"],"sourcesContent":["import { accessSync, constants, existsSync, statSync } from 'fs';\r\nimport { delimiter, join, resolve } from 'path';\r\nimport { homedir, platform } from 'os';\r\nimport type { ResolvedSettings } from './settings.js';\r\nimport type { CommandExecution } from './types/runtime.js';\r\nimport { globToRegex } from './tools/glob-regex.js';\r\n\r\nexport interface Sandbox {\r\n /**\r\n * Resolve a shell command into a sandboxed argv spawn.\r\n *\r\n * `workspaceRoot` is the only directory bound writable by default, and it is\r\n * deliberately not the command's working directory: `workdir` is a\r\n * model-supplied tool argument, and binding it would let the model widen its\r\n * own sandbox to any path — `workdir: \"/\"` would shadow every other mount and\r\n * hand back the whole host filesystem, read-write. Callers must keep the\r\n * working directory inside the workspace; extra paths go through\r\n * `sandbox.filesystem.allowWrite`.\r\n *\r\n * Returns null if the sandbox is unavailable.\r\n */\r\n wrap(command: string, workspaceRoot: string): CommandExecution | null;\r\n /** One-line description of the policy actually enforced, for diagnostics. */\r\n describe(): string;\r\n}\r\n\r\n/**\r\n * Try to detect bubblewrap (bwrap) on the system PATH.\r\n * Returns the bwrap binary path or null if not found.\r\n */\r\nfunction isExecutableFile(path: string): boolean {\r\n try {\r\n if (!statSync(path).isFile()) return false;\r\n accessSync(path, constants.X_OK);\r\n return true;\r\n } catch {\r\n return false;\r\n }\r\n}\r\n\r\nfunction detectBwrap(): string | null {\r\n const candidates = [\r\n ...(process.env.PATH ?? '')\r\n .split(delimiter)\r\n .filter(Boolean)\r\n .map((dir) => join(dir, 'bwrap')),\r\n '/usr/local/bin/bwrap',\r\n '/opt/homebrew/bin/bwrap',\r\n '/usr/bin/bwrap',\r\n ];\r\n return candidates.find(isExecutableFile) ?? null;\r\n}\r\n\r\n/**\r\n * Why a specific command will not run inside a real sandbox boundary.\r\n *\r\n * These are the only three ways a Bash command escapes the sandbox, and both\r\n * settings that depend on \"is this command actually sandboxed?\"\r\n * (`allowUnsandboxedCommands` and `autoAllowBashIfSandboxed`) are decided from\r\n * this one enumeration so they can never disagree about a given command.\r\n */\r\nexport type SandboxSkipReason = 'disabled' | 'excluded' | 'unavailable';\r\n\r\nexport interface SandboxCoverage {\r\n /** True only when this exact command will execute inside a bubblewrap namespace. */\r\n sandboxed: boolean;\r\n /** Set whenever `sandboxed` is false. */\r\n reason?: SandboxSkipReason;\r\n}\r\n\r\n/**\r\n * Does a command match one of the `sandbox.excludedCommands` escape patterns?\r\n *\r\n * The whole command string is matched, not just its first line: the permission\r\n * path and the execution path must agree about the same text, and\r\n * `getPrimaryArg` truncates a multi-line command to its first line.\r\n */\r\nexport function matchesExcludedCommand(command: string, patterns: string[] = []): boolean {\r\n return patterns.some((pattern) => globToRegex(pattern).test(command));\r\n}\r\n\r\n/**\r\n * Is the bubblewrap backend usable on this machine at all? Independent of\r\n * settings, so callers can distinguish \"turned off\" from \"cannot be turned on\".\r\n */\r\nexport function sandboxBackendAvailable(): boolean {\r\n return platform() !== 'win32' && detectBwrap() !== null;\r\n}\r\n\r\n/**\r\n * Decide whether one specific command genuinely executes inside the sandbox.\r\n *\r\n * Ordering is deliberate. `enabled` is checked first because \"sandboxing is\r\n * off\" is the actionable diagnosis even on a host with no bwrap installed, and\r\n * because it is false by default — the common path never touches the\r\n * filesystem. `excludedCommands` is checked before the backend probe for the\r\n * same reason: an excluded command is unsandboxed regardless of what is\r\n * installed, so probing PATH for it would be wasted work.\r\n *\r\n * `backendAvailable` is injectable purely so tests can exercise the\r\n * missing-bwrap branch on a host that has bwrap installed.\r\n */\r\nexport function sandboxCoverage(\r\n command: string,\r\n settings: ResolvedSettings['sandbox'],\r\n backendAvailable: () => boolean = sandboxBackendAvailable,\r\n): SandboxCoverage {\r\n if (!settings.enabled) return { sandboxed: false, reason: 'disabled' };\r\n if (matchesExcludedCommand(command, settings.excludedCommands))\r\n return { sandboxed: false, reason: 'excluded' };\r\n if (!backendAvailable()) return { sandboxed: false, reason: 'unavailable' };\r\n return { sandboxed: true };\r\n}\r\n\r\nconst SKIP_REASON_DETAIL: Record<SandboxSkipReason, string> = {\r\n disabled: 'sandboxing is turned off (sandbox.enabled is false)',\r\n excluded: 'the command matches a sandbox.excludedCommands pattern, which runs it unsandboxed',\r\n unavailable:\r\n 'the bubblewrap (bwrap) sandbox backend is unavailable on this platform or is not installed',\r\n};\r\n\r\nconst SKIP_REASON_REMEDY: Record<SandboxSkipReason, string> = {\r\n disabled: 'Set sandbox.enabled to true',\r\n excluded: 'Remove the matching pattern from sandbox.excludedCommands',\r\n unavailable: 'Install bubblewrap (bwrap)',\r\n};\r\n\r\n/**\r\n * The refusal shown when `sandbox.allowUnsandboxedCommands` is false and a\r\n * command would otherwise have run outside the sandbox. It names the setting\r\n * that caused the refusal and the specific reason the command could not be\r\n * sandboxed, because \"permission denied\" with neither is unactionable.\r\n */\r\nexport function unsandboxedRefusalMessage(reason: SandboxSkipReason): string {\r\n return [\r\n 'Refused to run this command outside the sandbox:',\r\n `${SKIP_REASON_DETAIL[reason]}.`,\r\n 'sandbox.allowUnsandboxedCommands is false, so unsandboxed commands are not permitted.',\r\n `${SKIP_REASON_REMEDY[reason]}, or set sandbox.allowUnsandboxedCommands to true to allow this command to run unsandboxed.`,\r\n ].join(' ');\r\n}\r\n\r\n/**\r\n * Everything outside `sandbox.*` that decides whether the two policy switches\r\n * can actually bite. Passed as one object rather than as trailing booleans so a\r\n * new condition cannot be added to the permission path and forgotten here —\r\n * which is how `book doctor` starts reporting a policy stronger than the\r\n * enforced one.\r\n */\r\nexport interface SandboxPolicyState {\r\n /** A sandbox was really created, so some command can genuinely be confined. */\r\n sandboxActive: boolean;\r\n /**\r\n * The user configured `permissions.deny` / `permissions.ask` rules. See\r\n * `sandboxAutoAllows` in permissions.ts: any such rule keeps the default ask,\r\n * because a shell line evades a glob far too easily for the matched rules to\r\n * be the whole protection.\r\n */\r\n adjudicationConfigured: boolean;\r\n}\r\n\r\n/**\r\n * Effective (not merely configured) state of the two policy switches, for\r\n * `book doctor`. A setting whose configured value cannot bite is reported as\r\n * inert rather than as enabled, so the reported policy matches the enforced one.\r\n */\r\nexport function sandboxPolicySummary(\r\n settings: ResolvedSettings['sandbox'],\r\n state: SandboxPolicyState,\r\n): { unsandboxedCommands: string; autoAllowBash: string } {\r\n return {\r\n unsandboxedCommands: settings.allowUnsandboxedCommands\r\n ? 'allowed (sandbox.allowUnsandboxedCommands=true)'\r\n : 'refused (sandbox.allowUnsandboxedCommands=false)',\r\n autoAllowBash: !settings.autoAllowBashIfSandboxed\r\n ? 'off — every Bash call goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=false)'\r\n : !state.sandboxActive\r\n ? 'inert — no command is sandboxed here, so nothing is auto-allowed (sandbox.autoAllowBashIfSandboxed=true)'\r\n : state.adjudicationConfigured\r\n ? 'inert — permissions.deny/ask rules are configured, so every Bash call still goes through the normal permission path (sandbox.autoAllowBashIfSandboxed=true)'\r\n : 'on for genuinely sandboxed commands (sandbox.autoAllowBashIfSandboxed=true)',\r\n };\r\n}\r\n\r\n/**\r\n * The host facts the argv builder depends on: which paths exist, whether they\r\n * are directories, and the path semantics used to resolve configured entries.\r\n *\r\n * Injectable so the generated argv can be pinned by tests on any platform.\r\n * The unit suite runs on Windows CI, where probing the real filesystem finds\r\n * none of the POSIX system mounts (`resolve('/usr')` lands on `C:\\usr`), so a\r\n * test against the real host would be asserting on binds that were never\r\n * emitted. Production always uses the real host below.\r\n */\r\nexport interface SandboxHost {\r\n /** Path semantics for configured entries; tests inject path.win32/path.posix. */\r\n path: { resolve(...segments: string[]): string; join(...segments: string[]): string };\r\n exists(path: string): boolean;\r\n isDirectory(path: string): boolean;\r\n homedir(): string;\r\n}\r\n\r\nconst realSandboxHost: SandboxHost = {\r\n path: { resolve, join },\r\n exists: existsSync,\r\n isDirectory: (path) => statSync(path).isDirectory(),\r\n homedir,\r\n};\r\n\r\n/**\r\n * Resolve a configured sandbox path. `~` is expanded the same way the rest of\r\n * the codebase expands it; a relative entry stays relative to the process cwd,\r\n * which `unbindablePaths` reports so it does not pass silently.\r\n */\r\nfunction expandPath(path: string, host: SandboxHost): string {\r\n if (path === '~') return host.homedir();\r\n if (path.startsWith('~/')) return host.path.join(host.homedir(), path.slice(2));\r\n return host.path.resolve(path);\r\n}\r\n\r\n/** bwrap fails the whole invocation if a bind source does not exist. */\r\nfunction bindIfPresent(args: string[], flag: string, path: string, host: SandboxHost): void {\r\n const target = expandPath(path, host);\r\n if (host.exists(target)) args.push(flag, target, target);\r\n}\r\n\r\n/**\r\n * Configured filesystem paths that cannot be applied because nothing exists at\r\n * them. bwrap aborts the whole invocation on a missing bind source, so these\r\n * have to be skipped — but a skipped rule is unenforced policy the user\r\n * believes is active, so it is reported rather than dropped quietly.\r\n */\r\nexport function unbindablePaths(\r\n settings: ResolvedSettings['sandbox'],\r\n host: SandboxHost = realSandboxHost,\r\n): string[] {\r\n return [\r\n ...settings.filesystem.allowWrite,\r\n ...settings.filesystem.denyWrite,\r\n ...settings.filesystem.denyRead,\r\n ].filter((path) => !host.exists(expandPath(path, host)));\r\n}\r\n\r\n/**\r\n * True when the declared network policy asks for something finer than\r\n * all-or-nothing. bwrap has no DNS or domain awareness, so a per-domain policy\r\n * cannot be honoured as written.\r\n */\r\nexport function hasDomainPolicy(settings: ResolvedSettings['sandbox']): boolean {\r\n return settings.network.allowedDomains.length > 0 || settings.network.deniedDomains.length > 0;\r\n}\r\n\r\n/**\r\n * Build the bubblewrap argument vector for one command.\r\n *\r\n * Mount order is significant: bwrap applies operations in sequence and a later\r\n * mount shadows an earlier one covering the same path. The workspace bind\r\n * therefore comes *after* the system read-only binds and the /tmp tmpfs (a\r\n * workspace under /usr/local or /tmp would otherwise be silently shadowed), and\r\n * explicit filesystem policy comes after the workspace so it can override it.\r\n *\r\n * Exported for testing: it does not require bwrap to be installed, and with an\r\n * injected `host` its output is fully determined by its arguments.\r\n */\r\nexport function buildSandboxExecution(\r\n bwrapPath: string,\r\n command: string,\r\n workspaceRoot: string,\r\n settings: ResolvedSettings['sandbox'],\r\n host: SandboxHost = realSandboxHost,\r\n): CommandExecution {\r\n const args: string[] = [];\r\n\r\n // Fresh PID/IPC/UTS namespaces, and the sandbox dies if the spawning process\r\n // does.\r\n //\r\n // Deliberately NOT --new-session: it makes bwrap call setsid(), which moves\r\n // the sandboxed tree into its own process group. Every teardown path here\r\n // (KillShell, foreground timeout, Ctrl-C) signals the group Node created with\r\n // `detached: true` and confirms death with `kill(-pgid, 0)`, so the group\r\n // would read as empty while the command kept running — a kill that reports\r\n // success and does nothing. The TIOCSTI hardening --new-session buys is moot\r\n // anyway: all three spawn sites pipe stdio and never hand over a tty.\r\n args.push('--unshare-pid', '--unshare-ipc', '--unshare-uts', '--die-with-parent');\r\n args.push('--proc', '/proc');\r\n args.push('--dev', '/dev');\r\n\r\n // Minimal system directories, read-only.\r\n for (const dir of ['/usr', '/lib', '/lib64', '/bin', '/sbin', '/etc', '/opt']) {\r\n bindIfPresent(args, '--ro-bind', dir, host);\r\n }\r\n\r\n args.push('--tmpfs', '/tmp');\r\n\r\n // The workspace is writable. Bound after /tmp so a workspace inside /tmp\r\n // (every mkdtemp-based test run, among others) survives the tmpfs.\r\n bindIfPresent(args, '--bind', workspaceRoot, host);\r\n\r\n // Declared filesystem policy overrides the defaults above.\r\n for (const path of settings.filesystem.allowWrite) bindIfPresent(args, '--bind', path, host);\r\n for (const path of settings.filesystem.denyWrite) bindIfPresent(args, '--ro-bind', path, host);\r\n // bwrap cannot unmount a subpath, so a denied path is masked instead. The\r\n // mask has to match the kind: --tmpfs needs to mkdir its target, so pointing\r\n // it at a file aborts the entire invocation with \"Not a directory\" — and a\r\n // credentials *file* is the most natural thing to deny. Files are masked with\r\n // /dev/null instead, which makes reads fail outright.\r\n for (const path of settings.filesystem.denyRead) {\r\n const target = expandPath(path, host);\r\n if (!host.exists(target)) continue;\r\n if (host.isDirectory(target)) args.push('--tmpfs', target);\r\n else args.push('--ro-bind', '/dev/null', target);\r\n }\r\n\r\n // bwrap can only share or unshare the network wholesale. A declared\r\n // per-domain policy cannot be enforced as written, so fail closed rather\r\n // than hand out the unrestricted host network the caller did not ask for.\r\n args.push(hasDomainPolicy(settings) ? '--unshare-net' : '--share-net');\r\n\r\n args.push('--cap-drop', 'ALL');\r\n\r\n // The command string is a single argv element: bash inside the sandbox\r\n // parses it, and nothing outside the sandbox ever does.\r\n args.push('--', '/bin/bash', '-c', command);\r\n\r\n return { file: bwrapPath, args };\r\n}\r\n\r\n/**\r\n * Create a sandbox wrapper if bwrap is available on this platform.\r\n * On Windows, returns null with a warning (sandbox not supported).\r\n *\r\n * @param settings - Resolved sandbox settings\r\n * @returns Sandbox instance or null\r\n */\r\nexport function createSandbox(settings: ResolvedSettings['sandbox']): Sandbox | null {\r\n if (!settings.enabled) return null;\r\n\r\n const os = platform();\r\n if (os === 'win32') {\r\n if (settings.failIfUnavailable) {\r\n throw new Error(\r\n 'Bash sandbox is not available on Windows. Disable sandbox.enabled or set failIfUnavailable to false.',\r\n );\r\n }\r\n console.warn('⚠ Bash sandbox is not available on Windows. Commands will run unsandboxed.');\r\n return null;\r\n }\r\n\r\n const bwrap = detectBwrap();\r\n if (!bwrap) {\r\n if (settings.failIfUnavailable) {\r\n throw new Error(\r\n 'bubblewrap (bwrap) not found. Install it to enable bash sandboxing, or set failIfUnavailable to false.',\r\n );\r\n }\r\n console.warn(\r\n '⚠ bubblewrap (bwrap) not found — install it for bash sandboxing. Commands will run unsandboxed.',\r\n );\r\n return null;\r\n }\r\n\r\n // Diagnostics belong to whoever builds the sandbox, and callers are expected\r\n // to build it once per session — `createSandbox` runs per Bash call would\r\n // repeat these on every command.\r\n if (hasDomainPolicy(settings)) {\r\n console.warn(\r\n '⚠ sandbox.network domain rules cannot be enforced by bubblewrap, which has no per-domain filtering. Network access is disabled entirely for sandboxed commands.',\r\n );\r\n }\r\n const unbindable = unbindablePaths(settings);\r\n if (unbindable.length > 0) {\r\n console.warn(\r\n `⚠ sandbox.filesystem rules skipped — nothing exists at: ${unbindable.join(', ')}. These paths are not protected.`,\r\n );\r\n }\r\n\r\n return {\r\n wrap(command: string, workspaceRoot: string): CommandExecution {\r\n return buildSandboxExecution(bwrap, command, workspaceRoot, settings);\r\n },\r\n describe(): string {\r\n return [\r\n `bubblewrap (${bwrap})`,\r\n hasDomainPolicy(settings) ? 'network disabled' : 'host network shared',\r\n `${settings.filesystem.allowWrite.length} extra writable path(s)`,\r\n `${settings.filesystem.denyWrite.length} read-only path(s)`,\r\n `${settings.filesystem.denyRead.length} masked path(s)`,\r\n unbindable.length > 0 ? `${unbindable.length} skipped (missing)` : 'all paths resolved',\r\n ].join('; ');\r\n },\r\n };\r\n}\r\n","/**\r\n * Convert a glob pattern to a regex. Supports * (any chars) and ** (same as *).\r\n * The pattern is anchored at both ends (^...$).\r\n */\r\nexport function globToRegex(pattern: string): RegExp {\r\n const escaped = pattern.replace(/[.+^${}()|[\\]\\\\]/g, '\\\\$&').replace(/\\*\\*|\\*/g, '.*');\r\n return new RegExp('^' + escaped + '$');\r\n}\r\n"],"mappings":"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","names":[]}
|