@letrquan/book 0.2.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 +2504 -0
- package/LICENSE +123 -0
- package/README.md +1383 -0
- package/dist/app-MOXXWEOY.js +18945 -0
- package/dist/app-MOXXWEOY.js.map +1 -0
- package/dist/chunk-24AXE6SP.js +12 -0
- package/dist/chunk-24AXE6SP.js.map +1 -0
- package/dist/chunk-3U5IBM24.js +213 -0
- package/dist/chunk-3U5IBM24.js.map +1 -0
- package/dist/chunk-574C6MQR.js +743 -0
- package/dist/chunk-574C6MQR.js.map +1 -0
- package/dist/chunk-5GDZ22YP.js +449 -0
- package/dist/chunk-5GDZ22YP.js.map +1 -0
- package/dist/chunk-5KLVH3PY.js +167 -0
- package/dist/chunk-5KLVH3PY.js.map +1 -0
- package/dist/chunk-5RDRAO4B.js +92 -0
- package/dist/chunk-5RDRAO4B.js.map +1 -0
- package/dist/chunk-7N4J557C.js +10 -0
- package/dist/chunk-7N4J557C.js.map +1 -0
- package/dist/chunk-A7JLBK2Y.js +3506 -0
- package/dist/chunk-A7JLBK2Y.js.map +1 -0
- package/dist/chunk-ANSJPYGI.js +84 -0
- package/dist/chunk-ANSJPYGI.js.map +1 -0
- package/dist/chunk-BTZZUCLI.js +264 -0
- package/dist/chunk-BTZZUCLI.js.map +1 -0
- package/dist/chunk-C7D4NJ2D.js +217 -0
- package/dist/chunk-C7D4NJ2D.js.map +1 -0
- package/dist/chunk-CAGOB2N4.js +208 -0
- package/dist/chunk-CAGOB2N4.js.map +1 -0
- package/dist/chunk-DGMQEI6Y.js +98 -0
- package/dist/chunk-DGMQEI6Y.js.map +1 -0
- package/dist/chunk-E75IPST5.js +289 -0
- package/dist/chunk-E75IPST5.js.map +1 -0
- package/dist/chunk-EYGD6X2T.js +180 -0
- package/dist/chunk-EYGD6X2T.js.map +1 -0
- package/dist/chunk-GTVB4HEM.js +765 -0
- package/dist/chunk-GTVB4HEM.js.map +1 -0
- package/dist/chunk-J7MIZ5CA.js +1907 -0
- package/dist/chunk-J7MIZ5CA.js.map +1 -0
- package/dist/chunk-KWHQ2XXL.js +626 -0
- package/dist/chunk-KWHQ2XXL.js.map +1 -0
- package/dist/chunk-L6LK2QIQ.js +657 -0
- package/dist/chunk-L6LK2QIQ.js.map +1 -0
- package/dist/chunk-MAQXCUR4.js +1007 -0
- package/dist/chunk-MAQXCUR4.js.map +1 -0
- package/dist/chunk-S4XL7HOM.js +402 -0
- package/dist/chunk-S4XL7HOM.js.map +1 -0
- package/dist/chunk-URLQAXTR.js +60 -0
- package/dist/chunk-URLQAXTR.js.map +1 -0
- package/dist/chunk-V4BOYR52.js +3504 -0
- package/dist/chunk-V4BOYR52.js.map +1 -0
- package/dist/chunk-VZQXX3WW.js +210 -0
- package/dist/chunk-VZQXX3WW.js.map +1 -0
- package/dist/chunk-WKBO5R4O.js +34 -0
- package/dist/chunk-WKBO5R4O.js.map +1 -0
- package/dist/chunk-XIFFTATU.js +15753 -0
- package/dist/chunk-XIFFTATU.js.map +1 -0
- package/dist/command-approvals-6Y57VCWZ.js +28 -0
- package/dist/command-approvals-6Y57VCWZ.js.map +1 -0
- package/dist/context-ZC5IFEAX.js +25 -0
- package/dist/context-ZC5IFEAX.js.map +1 -0
- package/dist/hook-approvals-CTUFKEJT.js +22 -0
- package/dist/hook-approvals-CTUFKEJT.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2582 -0
- package/dist/index.js.map +1 -0
- package/dist/interactive-assets-CCAAWVKH.js +31 -0
- package/dist/interactive-assets-CCAAWVKH.js.map +1 -0
- package/dist/job-runner.d.ts +2 -0
- package/dist/job-runner.js +235 -0
- package/dist/job-runner.js.map +1 -0
- package/dist/loader-4IDS2RIE.js +15 -0
- package/dist/loader-4IDS2RIE.js.map +1 -0
- package/dist/mcp-approvals-WNPH65BK.js +18 -0
- package/dist/mcp-approvals-WNPH65BK.js.map +1 -0
- package/dist/mcp-config-YIJIES2C.js +16 -0
- package/dist/mcp-config-YIJIES2C.js.map +1 -0
- package/dist/model-window-store-YFVNXCIP.js +27 -0
- package/dist/model-window-store-YFVNXCIP.js.map +1 -0
- package/dist/openai-compatible-RHZUBHV6.js +10 -0
- package/dist/openai-compatible-RHZUBHV6.js.map +1 -0
- package/dist/permission-approvals-76PVREFD.js +14 -0
- package/dist/permission-approvals-76PVREFD.js.map +1 -0
- package/dist/permissions-CHCKTFQQ.js +28 -0
- package/dist/permissions-CHCKTFQQ.js.map +1 -0
- package/dist/sandbox-CNVAQDA7.js +23 -0
- package/dist/sandbox-CNVAQDA7.js.map +1 -0
- package/dist/scrollback-LUKLIBVI.js +136 -0
- package/dist/scrollback-LUKLIBVI.js.map +1 -0
- package/dist/sdk.d.ts +4574 -0
- package/dist/sdk.js +665 -0
- package/dist/sdk.js.map +1 -0
- package/dist/settings-loader-M2QP5TX2.js +21 -0
- package/dist/settings-loader-M2QP5TX2.js.map +1 -0
- package/dist/settings-removed-JWWPT4WA.js +19 -0
- package/dist/settings-removed-JWWPT4WA.js.map +1 -0
- package/dist/shell-selection-V6QAYXPX.js +17 -0
- package/dist/shell-selection-V6QAYXPX.js.map +1 -0
- package/package.json +143 -0
- package/patches/ink+6.8.0.patch +13 -0
- package/scripts/apply-ink-patch.mjs +29 -0
- package/scripts/ink-patch.mjs +59 -0
- package/scripts/verify-ink-patch.mjs +11 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
updateWorkspaceTrust
|
|
3
|
+
} from "./chunk-DGMQEI6Y.js";
|
|
4
|
+
import {
|
|
5
|
+
HOOK_EVENTS
|
|
6
|
+
} from "./chunk-574C6MQR.js";
|
|
7
|
+
|
|
8
|
+
// src/hook-approvals.ts
|
|
9
|
+
import { createHash } from "crypto";
|
|
10
|
+
function hookFingerprint(event, entry) {
|
|
11
|
+
const sortedEnv = Object.fromEntries(
|
|
12
|
+
Object.entries(entry.env ?? {}).sort(([a], [b]) => a.localeCompare(b))
|
|
13
|
+
);
|
|
14
|
+
const canonical = JSON.stringify({
|
|
15
|
+
event,
|
|
16
|
+
matcher: entry.matcher ?? null,
|
|
17
|
+
command: entry.command,
|
|
18
|
+
env: sortedEnv
|
|
19
|
+
});
|
|
20
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
21
|
+
}
|
|
22
|
+
function collectDeclaredHooks(settings) {
|
|
23
|
+
const declared = [];
|
|
24
|
+
for (const event of HOOK_EVENTS) {
|
|
25
|
+
for (const entry of settings?.hooks?.[event] ?? []) {
|
|
26
|
+
declared.push({ event, entry });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return declared;
|
|
30
|
+
}
|
|
31
|
+
function evaluateProjectHook(store, fingerprint) {
|
|
32
|
+
return store?.[fingerprint] ?? "unknown";
|
|
33
|
+
}
|
|
34
|
+
function partitionProjectHooks(hooks, store) {
|
|
35
|
+
const partition = { approved: [], pending: [], rejected: [] };
|
|
36
|
+
for (const hook of hooks) {
|
|
37
|
+
const state = evaluateProjectHook(store, hookFingerprint(hook.event, hook.entry));
|
|
38
|
+
if (state === "approved") partition.approved.push(hook);
|
|
39
|
+
else if (state === "rejected") partition.rejected.push(hook);
|
|
40
|
+
else partition.pending.push(hook);
|
|
41
|
+
}
|
|
42
|
+
return partition;
|
|
43
|
+
}
|
|
44
|
+
function renderUntrustedText(value, maxLength = 200) {
|
|
45
|
+
const escaped = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (character) => {
|
|
46
|
+
if (character === "\n") return "\\n";
|
|
47
|
+
if (character === "\r") return "\\r";
|
|
48
|
+
if (character === " ") return "\\t";
|
|
49
|
+
return `\\x${character.codePointAt(0).toString(16).padStart(2, "0")}`;
|
|
50
|
+
});
|
|
51
|
+
return escaped.length > maxLength ? `${escaped.slice(0, maxLength)}\u2026` : escaped;
|
|
52
|
+
}
|
|
53
|
+
function describeDeclaredHook(hook) {
|
|
54
|
+
const details = [`fingerprint: ${hookFingerprint(hook.event, hook.entry)}`];
|
|
55
|
+
if (hook.entry.matcher !== void 0) {
|
|
56
|
+
details.push(`matcher: ${renderUntrustedText(hook.entry.matcher)}`);
|
|
57
|
+
}
|
|
58
|
+
for (const [key, value] of Object.entries(hook.entry.env ?? {}).sort(
|
|
59
|
+
([a], [b]) => a.localeCompare(b)
|
|
60
|
+
)) {
|
|
61
|
+
details.push(`env: ${renderUntrustedText(key, 64)}=${renderUntrustedText(value)}`);
|
|
62
|
+
}
|
|
63
|
+
return { headline: `${hook.event}: ${renderUntrustedText(hook.entry.command)}`, details };
|
|
64
|
+
}
|
|
65
|
+
function persistProjectHookChoice(workspace, fingerprint, choice, options = {}) {
|
|
66
|
+
return updateWorkspaceTrust(
|
|
67
|
+
workspace,
|
|
68
|
+
(trust) => {
|
|
69
|
+
trust.hookEntries[fingerprint] = choice;
|
|
70
|
+
},
|
|
71
|
+
options.trustStorePath
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export {
|
|
76
|
+
hookFingerprint,
|
|
77
|
+
collectDeclaredHooks,
|
|
78
|
+
evaluateProjectHook,
|
|
79
|
+
partitionProjectHooks,
|
|
80
|
+
renderUntrustedText,
|
|
81
|
+
describeDeclaredHook,
|
|
82
|
+
persistProjectHookChoice
|
|
83
|
+
};
|
|
84
|
+
//# sourceMappingURL=chunk-ANSJPYGI.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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":[]}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import {
|
|
2
|
+
partitionProjectAllowRules
|
|
3
|
+
} from "./chunk-WKBO5R4O.js";
|
|
4
|
+
import {
|
|
5
|
+
collectDeclaredHooks,
|
|
6
|
+
partitionProjectHooks
|
|
7
|
+
} from "./chunk-ANSJPYGI.js";
|
|
8
|
+
import {
|
|
9
|
+
defaultTrustStorePath,
|
|
10
|
+
loadWorkspaceTrust
|
|
11
|
+
} from "./chunk-DGMQEI6Y.js";
|
|
12
|
+
import {
|
|
13
|
+
normalizeRemovedSettings
|
|
14
|
+
} from "./chunk-5RDRAO4B.js";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_SETTINGS,
|
|
17
|
+
HOOK_EVENTS,
|
|
18
|
+
SettingsRepository,
|
|
19
|
+
bookSettingsSchema,
|
|
20
|
+
writeFileAtomic
|
|
21
|
+
} from "./chunk-574C6MQR.js";
|
|
22
|
+
import {
|
|
23
|
+
resolveBookHome
|
|
24
|
+
} from "./chunk-24AXE6SP.js";
|
|
25
|
+
|
|
26
|
+
// src/settings-loader.ts
|
|
27
|
+
import { readFileSync, existsSync } from "fs";
|
|
28
|
+
import { join, normalize } from "path";
|
|
29
|
+
var LEGACY_PERMISSIONS_MIGRATION_VERSION = 1;
|
|
30
|
+
var CONCATENATED_ARRAY_PATHS = /* @__PURE__ */ new Set([
|
|
31
|
+
"permissions.allow",
|
|
32
|
+
"permissions.ask",
|
|
33
|
+
"permissions.deny",
|
|
34
|
+
...[
|
|
35
|
+
"SessionStart",
|
|
36
|
+
"SessionEnd",
|
|
37
|
+
"UserPromptSubmit",
|
|
38
|
+
"PreToolUse",
|
|
39
|
+
"PostToolUse",
|
|
40
|
+
"Stop",
|
|
41
|
+
"PreCompact",
|
|
42
|
+
"PostCompact",
|
|
43
|
+
"SubagentStart",
|
|
44
|
+
"SubagentStop"
|
|
45
|
+
].map((event) => `hooks.${event}`)
|
|
46
|
+
]);
|
|
47
|
+
function mergeObject(base, override, prefix = "") {
|
|
48
|
+
const result = structuredClone(base);
|
|
49
|
+
for (const [key, value] of Object.entries(override)) {
|
|
50
|
+
if (value === void 0) continue;
|
|
51
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
52
|
+
const existing = result[key];
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
if (path === "additionalDirectories") {
|
|
55
|
+
const combined = [...Array.isArray(existing) ? existing : [], ...value].map(
|
|
56
|
+
(entry) => normalize(String(entry))
|
|
57
|
+
);
|
|
58
|
+
result[key] = [...new Set(combined)];
|
|
59
|
+
} else if (CONCATENATED_ARRAY_PATHS.has(path)) {
|
|
60
|
+
result[key] = [...Array.isArray(existing) ? existing : [], ...value];
|
|
61
|
+
} else {
|
|
62
|
+
result[key] = structuredClone(value);
|
|
63
|
+
}
|
|
64
|
+
} else if (typeof value === "object" && value !== null && typeof existing === "object" && existing !== null && !Array.isArray(existing)) {
|
|
65
|
+
result[key] = mergeObject(
|
|
66
|
+
existing,
|
|
67
|
+
value,
|
|
68
|
+
path
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
result[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
var WORKSPACE_FORBIDDEN_PATHS = [
|
|
77
|
+
["mcp", "projectServers"],
|
|
78
|
+
["permissions", "projectAllowRules"],
|
|
79
|
+
["hooks", "projectEntries"],
|
|
80
|
+
["commands", "projectCommands"]
|
|
81
|
+
];
|
|
82
|
+
function stripPaths(settings, paths) {
|
|
83
|
+
for (const [parent, key] of paths) {
|
|
84
|
+
const container = settings[parent];
|
|
85
|
+
if (container && typeof container === "object" && !Array.isArray(container)) {
|
|
86
|
+
delete container[key];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function sanitizeLayer(settings, trust) {
|
|
91
|
+
if (trust === "trusted") return settings;
|
|
92
|
+
const sanitized = structuredClone(settings);
|
|
93
|
+
if (sanitized.defaultMode === "bypassPermissions") delete sanitized.defaultMode;
|
|
94
|
+
delete sanitized.shell;
|
|
95
|
+
stripPaths(sanitized, WORKSPACE_FORBIDDEN_PATHS);
|
|
96
|
+
return sanitized;
|
|
97
|
+
}
|
|
98
|
+
function mergeLayer(resolved, layer, trust) {
|
|
99
|
+
const candidate = sanitizeLayer(layer, trust);
|
|
100
|
+
if (resolved.disableBypassPermissionsMode === true && candidate.disableBypassPermissionsMode === false) {
|
|
101
|
+
candidate.disableBypassPermissionsMode = true;
|
|
102
|
+
}
|
|
103
|
+
return mergeSettings(resolved, candidate);
|
|
104
|
+
}
|
|
105
|
+
function mergeSettings(base, override) {
|
|
106
|
+
return mergeObject(
|
|
107
|
+
base,
|
|
108
|
+
override
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
function loadSettingsFile(path) {
|
|
112
|
+
if (!existsSync(path)) return null;
|
|
113
|
+
const raw = readFileSync(path, "utf-8");
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(raw);
|
|
117
|
+
} catch (e) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Invalid JSON in settings file: ${path}
|
|
120
|
+
${e instanceof Error ? e.message : String(e)}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
parsed = normalizeRemovedSettings(parsed);
|
|
124
|
+
const result = bookSettingsSchema.safeParse(parsed);
|
|
125
|
+
if (!result.success) throw new Error(`Invalid settings in ${path}:
|
|
126
|
+
${result.error.message}`);
|
|
127
|
+
return parsed;
|
|
128
|
+
}
|
|
129
|
+
function settingsLayerPaths(workspace, overridePath, paths = {}) {
|
|
130
|
+
const userPath = paths.userSettingsPath ?? (paths.home ? join(paths.home, ".book", "settings.json") : join(resolveBookHome(), "settings.json"));
|
|
131
|
+
const layers = [
|
|
132
|
+
userPath,
|
|
133
|
+
paths.projectSettingsPath ?? join(workspace, ".book", "settings.json"),
|
|
134
|
+
paths.localSettingsPath ?? join(workspace, ".book", "settings.local.json")
|
|
135
|
+
];
|
|
136
|
+
if (overridePath) layers.push(overridePath);
|
|
137
|
+
return layers;
|
|
138
|
+
}
|
|
139
|
+
function resolveSettings(workspace, overridePath, paths = {}) {
|
|
140
|
+
let resolved = structuredClone(DEFAULT_SETTINGS);
|
|
141
|
+
const userPath = paths.userSettingsPath ?? (paths.home ? join(paths.home, ".book", "settings.json") : join(resolveBookHome(), "settings.json"));
|
|
142
|
+
const user = loadSettingsFile(userPath);
|
|
143
|
+
if (user) resolved = mergeLayer(resolved, user, "trusted");
|
|
144
|
+
const projectPath = paths.projectSettingsPath ?? join(workspace, ".book", "settings.json");
|
|
145
|
+
const project = loadSettingsFile(projectPath);
|
|
146
|
+
const declaredProjectAllow = project?.permissions?.allow ?? [];
|
|
147
|
+
const declaredProjectHooks = collectDeclaredHooks(project);
|
|
148
|
+
const userHookCounts = Object.fromEntries(
|
|
149
|
+
HOOK_EVENTS.map((event) => [event, resolved.hooks[event].length])
|
|
150
|
+
);
|
|
151
|
+
if (project) {
|
|
152
|
+
let withheld = project;
|
|
153
|
+
if (declaredProjectAllow.length > 0) {
|
|
154
|
+
withheld = { ...withheld, permissions: { ...withheld.permissions, allow: [] } };
|
|
155
|
+
}
|
|
156
|
+
if (declaredProjectHooks.length > 0) {
|
|
157
|
+
withheld = { ...withheld, hooks: structuredClone(DEFAULT_SETTINGS.hooks) };
|
|
158
|
+
}
|
|
159
|
+
resolved = mergeLayer(resolved, withheld, "repository");
|
|
160
|
+
}
|
|
161
|
+
const localPath = paths.localSettingsPath ?? join(workspace, ".book", "settings.local.json");
|
|
162
|
+
const local = loadSettingsFile(localPath);
|
|
163
|
+
if (local) resolved = mergeLayer(resolved, local, "local");
|
|
164
|
+
if (overridePath) {
|
|
165
|
+
const override = loadSettingsFile(overridePath);
|
|
166
|
+
if (override) resolved = mergeLayer(resolved, override, "trusted");
|
|
167
|
+
}
|
|
168
|
+
const trust = loadWorkspaceTrust(
|
|
169
|
+
workspace,
|
|
170
|
+
paths.trustStorePath ?? defaultTrustStorePath(paths.home)
|
|
171
|
+
);
|
|
172
|
+
resolved.permissions.projectAllowRules = {
|
|
173
|
+
...resolved.permissions.projectAllowRules,
|
|
174
|
+
...trust.permissionAllowRules
|
|
175
|
+
};
|
|
176
|
+
resolved.mcp.projectServers = { ...resolved.mcp.projectServers, ...trust.mcpServers };
|
|
177
|
+
resolved.hooks.projectEntries = { ...resolved.hooks.projectEntries, ...trust.hookEntries };
|
|
178
|
+
resolved.commands.projectCommands = {
|
|
179
|
+
...resolved.commands.projectCommands,
|
|
180
|
+
...trust.projectCommands
|
|
181
|
+
};
|
|
182
|
+
if (declaredProjectAllow.length > 0) {
|
|
183
|
+
const { approved } = partitionProjectAllowRules(
|
|
184
|
+
declaredProjectAllow,
|
|
185
|
+
resolved.permissions?.projectAllowRules
|
|
186
|
+
);
|
|
187
|
+
if (approved.length > 0) {
|
|
188
|
+
resolved.permissions.allow = [...resolved.permissions.allow ?? [], ...approved];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (declaredProjectHooks.length > 0) {
|
|
192
|
+
const { approved } = partitionProjectHooks(
|
|
193
|
+
declaredProjectHooks,
|
|
194
|
+
resolved.hooks?.projectEntries
|
|
195
|
+
);
|
|
196
|
+
const approvedByEvent = /* @__PURE__ */ new Map();
|
|
197
|
+
for (const { event, entry } of approved) {
|
|
198
|
+
const entries = approvedByEvent.get(event) ?? [];
|
|
199
|
+
entries.push(entry);
|
|
200
|
+
approvedByEvent.set(event, entries);
|
|
201
|
+
}
|
|
202
|
+
for (const [event, entries] of approvedByEvent) {
|
|
203
|
+
resolved.hooks[event].splice(userHookCounts[event], 0, ...entries);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const settings = bookSettingsSchema.parse(resolved);
|
|
207
|
+
return settings;
|
|
208
|
+
}
|
|
209
|
+
function migrateLegacyPermissions(workspace, home, validatedSettings) {
|
|
210
|
+
if (!validatedSettings) {
|
|
211
|
+
resolveSettings(workspace, void 0, home ? { home } : void 0);
|
|
212
|
+
}
|
|
213
|
+
const legacyPath = join(home ? join(home, ".book") : resolveBookHome(), "permissions.json");
|
|
214
|
+
if (!existsSync(legacyPath)) return false;
|
|
215
|
+
const markerPath = join(workspace, ".book", "migrations.json");
|
|
216
|
+
try {
|
|
217
|
+
const marker = JSON.parse(readFileSync(markerPath, "utf-8"));
|
|
218
|
+
if ((marker.legacyPermissions ?? 0) >= LEGACY_PERMISSIONS_MIGRATION_VERSION) return false;
|
|
219
|
+
} catch {
|
|
220
|
+
}
|
|
221
|
+
let legacyRules = [];
|
|
222
|
+
try {
|
|
223
|
+
const raw = readFileSync(legacyPath, "utf-8");
|
|
224
|
+
const parsed = JSON.parse(raw);
|
|
225
|
+
legacyRules = parsed.rules ?? [];
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (legacyRules.length === 0) return false;
|
|
230
|
+
const localDir = join(workspace, ".book");
|
|
231
|
+
const localPath = join(localDir, "settings.local.json");
|
|
232
|
+
const result = new SettingsRepository(localPath).update((existing) => {
|
|
233
|
+
const permissions = existing.permissions ?? {};
|
|
234
|
+
for (const key of ["allow", "ask", "deny"]) {
|
|
235
|
+
if (!Array.isArray(permissions[key])) permissions[key] = [];
|
|
236
|
+
}
|
|
237
|
+
for (const rule of legacyRules) {
|
|
238
|
+
const specifier = rule.pattern ? `${rule.toolName}(${rule.pattern})` : rule.toolName;
|
|
239
|
+
const effect = rule.effect;
|
|
240
|
+
if (!permissions[effect].includes(specifier)) permissions[effect].push(specifier);
|
|
241
|
+
}
|
|
242
|
+
existing.permissions = permissions;
|
|
243
|
+
});
|
|
244
|
+
if (!result.ok) return false;
|
|
245
|
+
writeFileAtomic(
|
|
246
|
+
markerPath,
|
|
247
|
+
`${JSON.stringify({ legacyPermissions: LEGACY_PERMISSIONS_MIGRATION_VERSION }, null, 2)}
|
|
248
|
+
`
|
|
249
|
+
);
|
|
250
|
+
if (!result.changed) return false;
|
|
251
|
+
console.warn(
|
|
252
|
+
`\u26A0 Migrated ${legacyRules.length} permission rule(s) from ~/.book/permissions.json to ${localPath}. Delete the legacy file after verifying the migration.`
|
|
253
|
+
);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export {
|
|
258
|
+
mergeSettings,
|
|
259
|
+
loadSettingsFile,
|
|
260
|
+
settingsLayerPaths,
|
|
261
|
+
resolveSettings,
|
|
262
|
+
migrateLegacyPermissions
|
|
263
|
+
};
|
|
264
|
+
//# sourceMappingURL=chunk-BTZZUCLI.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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":[]}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// src/cli/ink-scroll-renderer.ts
|
|
2
|
+
import { createRequire } from "module";
|
|
3
|
+
import { dirname, join } from "path";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
var pendingHint = null;
|
|
6
|
+
var rendererInstalled = false;
|
|
7
|
+
function isInkScrollRendererEnabled(value) {
|
|
8
|
+
return value === "on";
|
|
9
|
+
}
|
|
10
|
+
function setTranscriptScrollHint(hint) {
|
|
11
|
+
if (!Number.isFinite(hint.delta) || hint.delta === 0) return;
|
|
12
|
+
if (pendingHint && pendingHint.top === hint.top && pendingHint.bottom === hint.bottom && Math.sign(pendingHint.delta) === Math.sign(hint.delta)) {
|
|
13
|
+
pendingHint = { ...pendingHint, delta: pendingHint.delta + hint.delta };
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
pendingHint = { ...hint };
|
|
17
|
+
}
|
|
18
|
+
function splitFrame(frame) {
|
|
19
|
+
return frame.endsWith("\n") ? frame.slice(0, -1).split("\n") : frame.split("\n");
|
|
20
|
+
}
|
|
21
|
+
function getFrameEndRow(frame, visibleLineCount) {
|
|
22
|
+
return visibleLineCount + (frame.endsWith("\n") ? 1 : 0);
|
|
23
|
+
}
|
|
24
|
+
function cursorTo(row) {
|
|
25
|
+
return `\x1B[${Math.max(1, Math.floor(row))};1H`;
|
|
26
|
+
}
|
|
27
|
+
function setScrollRegion(top, bottom) {
|
|
28
|
+
return `\x1B[${Math.max(1, Math.floor(top))};${Math.max(1, Math.floor(bottom))}r`;
|
|
29
|
+
}
|
|
30
|
+
function isPureVerticalShift(previousLines, nextLines, hint) {
|
|
31
|
+
if (previousLines.length !== nextLines.length) return false;
|
|
32
|
+
const top = Math.floor(hint.top) - 1;
|
|
33
|
+
const bottom = Math.floor(hint.bottom);
|
|
34
|
+
const delta = Math.trunc(hint.delta);
|
|
35
|
+
if (top < 0 || bottom > nextLines.length || bottom - top < 2) return false;
|
|
36
|
+
if (Math.abs(delta) >= bottom - top) return false;
|
|
37
|
+
for (let row = 0; row < top; row++) {
|
|
38
|
+
if (previousLines[row] !== nextLines[row]) return false;
|
|
39
|
+
}
|
|
40
|
+
for (let row = bottom; row < nextLines.length; row++) {
|
|
41
|
+
if (previousLines[row] !== nextLines[row]) return false;
|
|
42
|
+
}
|
|
43
|
+
if (delta > 0) {
|
|
44
|
+
for (let row = top; row < bottom - delta; row++) {
|
|
45
|
+
if (nextLines[row] !== previousLines[row + delta]) return false;
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
const distance = Math.abs(delta);
|
|
49
|
+
for (let row = top + distance; row < bottom; row++) {
|
|
50
|
+
if (nextLines[row] !== previousLines[row - distance]) return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
function writeExposedRows(stream, lines, top, bottom, delta) {
|
|
56
|
+
const distance = Math.abs(delta);
|
|
57
|
+
const first = delta > 0 ? bottom - distance : top;
|
|
58
|
+
for (let offset = 0; offset < distance; offset++) {
|
|
59
|
+
const row = first + offset;
|
|
60
|
+
stream.write(cursorTo(row + 1) + (lines[row] ?? "") + "\x1B[K");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function createScrollAwareRenderer(stream, options, createBase) {
|
|
64
|
+
const base = createBase(stream, options);
|
|
65
|
+
let previousFrame = "";
|
|
66
|
+
const render = ((output) => {
|
|
67
|
+
const hint = pendingHint;
|
|
68
|
+
pendingHint = null;
|
|
69
|
+
const previousLines = splitFrame(previousFrame);
|
|
70
|
+
const nextLines = splitFrame(output);
|
|
71
|
+
const terminalRows = stream.rows ?? 0;
|
|
72
|
+
const frameEndRow = getFrameEndRow(output, nextLines.length);
|
|
73
|
+
const canScroll = Boolean(stream.isTTY) && terminalRows > 0 && frameEndRow <= terminalRows && previousFrame.length > 0 && hint !== null && hint.bottom <= nextLines.length && isPureVerticalShift(previousLines, nextLines, hint);
|
|
74
|
+
if (canScroll && hint) {
|
|
75
|
+
const top = Math.floor(hint.top);
|
|
76
|
+
const bottom = Math.floor(hint.bottom);
|
|
77
|
+
const delta = Math.trunc(hint.delta);
|
|
78
|
+
stream.write("\x1B[?25l" + setScrollRegion(top, bottom));
|
|
79
|
+
stream.write(`\x1B[${Math.abs(delta)}${delta > 0 ? "S" : "T"}`);
|
|
80
|
+
writeExposedRows(stream, nextLines, top - 1, bottom, delta);
|
|
81
|
+
stream.write(setScrollRegion(1, terminalRows) + cursorTo(frameEndRow));
|
|
82
|
+
previousFrame = output;
|
|
83
|
+
base.sync(output);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
previousFrame = output;
|
|
87
|
+
return base(output);
|
|
88
|
+
});
|
|
89
|
+
render.clear = () => {
|
|
90
|
+
pendingHint = null;
|
|
91
|
+
previousFrame = "";
|
|
92
|
+
base.clear();
|
|
93
|
+
};
|
|
94
|
+
render.done = () => {
|
|
95
|
+
pendingHint = null;
|
|
96
|
+
previousFrame = "";
|
|
97
|
+
base.done();
|
|
98
|
+
};
|
|
99
|
+
render.sync = (output) => {
|
|
100
|
+
pendingHint = null;
|
|
101
|
+
previousFrame = output;
|
|
102
|
+
base.sync(output);
|
|
103
|
+
};
|
|
104
|
+
render.setCursorPosition = (position) => base.setCursorPosition(position);
|
|
105
|
+
render.isCursorDirty = () => base.isCursorDirty();
|
|
106
|
+
render.willRender = (output) => {
|
|
107
|
+
const shouldRender = base.willRender(output);
|
|
108
|
+
if (!shouldRender) pendingHint = null;
|
|
109
|
+
return shouldRender;
|
|
110
|
+
};
|
|
111
|
+
return render;
|
|
112
|
+
}
|
|
113
|
+
async function installInkScrollRenderer(enabled) {
|
|
114
|
+
if (rendererInstalled) return;
|
|
115
|
+
if (!(enabled ?? isInkScrollRendererEnabled(process.env.BOOK_SCROLL_RENDERER))) return;
|
|
116
|
+
rendererInstalled = true;
|
|
117
|
+
try {
|
|
118
|
+
const require2 = createRequire(import.meta.url);
|
|
119
|
+
const inkEntry = require2.resolve("ink");
|
|
120
|
+
const logUpdateUrl = pathToFileURL(join(dirname(inkEntry), "log-update.js")).href;
|
|
121
|
+
const module = await import(logUpdateUrl);
|
|
122
|
+
const logUpdate = module.default;
|
|
123
|
+
const createBase = logUpdate.create;
|
|
124
|
+
logUpdate.create = (stream, options) => createScrollAwareRenderer(stream, options, createBase);
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// src/cli/frame-buffer.ts
|
|
130
|
+
import { createRequire as createRequire2 } from "module";
|
|
131
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
132
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
133
|
+
var latestFrame = "";
|
|
134
|
+
var lastCursorPosition = null;
|
|
135
|
+
var captureInstalled = false;
|
|
136
|
+
var frameUpdateListeners = /* @__PURE__ */ new Set();
|
|
137
|
+
function notifyFrameUpdate() {
|
|
138
|
+
for (const listener of frameUpdateListeners) {
|
|
139
|
+
try {
|
|
140
|
+
listener();
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function splitFrame2(frame) {
|
|
146
|
+
return frame.endsWith("\n") ? frame.slice(0, -1).split("\n") : frame.split("\n");
|
|
147
|
+
}
|
|
148
|
+
function toCursorPosition(position) {
|
|
149
|
+
if (!position || typeof position !== "object") return null;
|
|
150
|
+
const candidate = position;
|
|
151
|
+
if (typeof candidate.x !== "number" || typeof candidate.y !== "number") return null;
|
|
152
|
+
return { x: candidate.x, y: candidate.y };
|
|
153
|
+
}
|
|
154
|
+
function getFrameLines() {
|
|
155
|
+
return latestFrame.length === 0 ? [] : splitFrame2(latestFrame);
|
|
156
|
+
}
|
|
157
|
+
function getLastCursorPosition() {
|
|
158
|
+
return lastCursorPosition;
|
|
159
|
+
}
|
|
160
|
+
function subscribeToFrameUpdates(listener) {
|
|
161
|
+
frameUpdateListeners.add(listener);
|
|
162
|
+
return () => frameUpdateListeners.delete(listener);
|
|
163
|
+
}
|
|
164
|
+
function createFrameCapturingRenderer(stream, options, createBase) {
|
|
165
|
+
const base = createBase(stream, options);
|
|
166
|
+
const render = ((output) => {
|
|
167
|
+
latestFrame = output;
|
|
168
|
+
const rendered = base(output);
|
|
169
|
+
if (rendered !== false) notifyFrameUpdate();
|
|
170
|
+
return rendered;
|
|
171
|
+
});
|
|
172
|
+
render.clear = () => {
|
|
173
|
+
latestFrame = "";
|
|
174
|
+
base.clear();
|
|
175
|
+
};
|
|
176
|
+
render.done = () => {
|
|
177
|
+
latestFrame = "";
|
|
178
|
+
lastCursorPosition = null;
|
|
179
|
+
base.done();
|
|
180
|
+
};
|
|
181
|
+
render.sync = (output) => {
|
|
182
|
+
latestFrame = output;
|
|
183
|
+
base.sync(output);
|
|
184
|
+
notifyFrameUpdate();
|
|
185
|
+
};
|
|
186
|
+
render.setCursorPosition = (position) => {
|
|
187
|
+
lastCursorPosition = toCursorPosition(position);
|
|
188
|
+
base.setCursorPosition(position);
|
|
189
|
+
};
|
|
190
|
+
render.isCursorDirty = () => base.isCursorDirty();
|
|
191
|
+
render.willRender = (output) => base.willRender(output);
|
|
192
|
+
return render;
|
|
193
|
+
}
|
|
194
|
+
async function installFrameCapture() {
|
|
195
|
+
if (captureInstalled) return;
|
|
196
|
+
captureInstalled = true;
|
|
197
|
+
try {
|
|
198
|
+
const require2 = createRequire2(import.meta.url);
|
|
199
|
+
const inkEntry = require2.resolve("ink");
|
|
200
|
+
const logUpdateUrl = pathToFileURL2(join2(dirname2(inkEntry), "log-update.js")).href;
|
|
201
|
+
const module = await import(logUpdateUrl);
|
|
202
|
+
const logUpdate = module.default;
|
|
203
|
+
const createBase = logUpdate.create;
|
|
204
|
+
logUpdate.create = (stream, options) => createFrameCapturingRenderer(stream, options, createBase);
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export {
|
|
210
|
+
setTranscriptScrollHint,
|
|
211
|
+
installInkScrollRenderer,
|
|
212
|
+
getFrameLines,
|
|
213
|
+
getLastCursorPosition,
|
|
214
|
+
subscribeToFrameUpdates,
|
|
215
|
+
installFrameCapture
|
|
216
|
+
};
|
|
217
|
+
//# sourceMappingURL=chunk-C7D4NJ2D.js.map
|