@kal-elsam/kairo-runtime 0.16.0 → 0.17.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/scripts/ux-smoke-test.sh +3 -3
  5. package/src/cli.js +96 -11
  6. package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
  7. package/src/global/architect/architect-cli.js +76 -0
  8. package/src/global/architect/architect-codex.js +146 -0
  9. package/src/global/architect/architect-manager.js +125 -0
  10. package/src/global/architect/architect-store.js +377 -0
  11. package/src/global/architect/architect-types.js +47 -0
  12. package/src/global/cli-help.js +10 -1
  13. package/src/global/cockpit/app.js +475 -0
  14. package/src/global/cockpit/card.js +111 -0
  15. package/src/global/cockpit/cli.js +33 -0
  16. package/src/global/cockpit/gauge.js +31 -0
  17. package/src/global/cockpit/project-overlay.js +683 -0
  18. package/src/global/cockpit/rows.js +148 -0
  19. package/src/global/cockpit/theme.js +118 -0
  20. package/src/global/cockpit/view.js +1263 -0
  21. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  22. package/src/global/conversation/cli.js +53 -0
  23. package/src/global/conversation/codex-sandbox.js +230 -0
  24. package/src/global/conversation/cursor-sandbox.js +215 -0
  25. package/src/global/conversation/project-analysis.js +204 -0
  26. package/src/global/conversation/project-profile.js +178 -0
  27. package/src/global/conversation/project-router.js +149 -0
  28. package/src/global/conversation/project-strategy-store.js +64 -0
  29. package/src/global/conversation/project-strategy.js +514 -0
  30. package/src/global/conversation/sanitized-snapshot.js +169 -0
  31. package/src/global/conversation/secret-scanner.js +71 -0
  32. package/src/global/conversation/service.js +1063 -0
  33. package/src/global/conversation/session-store.js +75 -0
  34. package/src/global/conversation/transcript-store.js +79 -0
  35. package/src/global/conversation/ui.js +195 -0
  36. package/src/global/intelligence/capability-scoring.js +480 -0
  37. package/src/global/intelligence/execution-router.js +444 -0
  38. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  39. package/src/global/intelligence/kairobench-runner.js +85 -0
  40. package/src/global/intelligence/kairobench-source.js +34 -0
  41. package/src/global/intelligence/kairobench-tasks.js +47 -0
  42. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  43. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  44. package/src/global/intelligence/model-capability-registry.js +125 -0
  45. package/src/global/intelligence/model-intelligence.js +1646 -0
  46. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  47. package/src/global/intelligence/quick-ask.js +149 -0
  48. package/src/global/intelligence/role-profiles.js +251 -0
  49. package/src/global/intelligence/skill-catalog.js +67 -0
  50. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  51. package/src/global/mcp/kairo-mcp.js +51 -18
  52. package/src/global/mcp/work-snapshot-rule.js +4 -2
  53. package/src/global/mcp/workspace-binding.js +88 -0
  54. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  55. package/src/global/mcp-install.js +8 -1
  56. package/src/global/observability/artificial-analysis-models.js +118 -0
  57. package/src/global/observability/claude-models.js +31 -0
  58. package/src/global/observability/claude-usage.js +112 -0
  59. package/src/global/observability/codex-models.js +96 -0
  60. package/src/global/observability/codex-usage.js +160 -0
  61. package/src/global/observability/cursor-auth.js +88 -0
  62. package/src/global/observability/cursor-models.js +101 -0
  63. package/src/global/observability/huggingface-leaderboard.js +97 -0
  64. package/src/global/observability/opencode-models.js +101 -0
  65. package/src/global/observability/opencode-usage.js +162 -0
  66. package/src/global/paths.js +49 -2
  67. package/src/global/profile.js +23 -1
  68. package/src/global/runtime/execution-adapters/claude.js +63 -30
  69. package/src/global/runtime/execution-adapters/codex.js +9 -2
  70. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  71. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  72. package/src/global/runtime/execution-worktree-manager.js +924 -0
  73. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  74. package/src/global/runtime/execution-worktree-store.js +83 -0
  75. package/src/global/runtime/execution-worktree-types.js +45 -0
  76. package/src/global/runtime/run-events.js +38 -0
  77. package/src/global/runtime/run-manager.js +22 -6
  78. package/src/global/runtime/run-supervisor.js +41 -12
  79. package/src/global/runtime/usage-manager.js +96 -0
  80. package/src/global/runtime/usage-store.js +69 -0
  81. package/src/global/runtime/usage-types.js +62 -0
@@ -0,0 +1,251 @@
1
+ // A neutral contract for Bootstrap Analysis, so conversation/service.js's
2
+ // runBootstrapAnalysis doesn't special-case providers inline — each real
3
+ // provider gets its own adapter, and adding a new one (Cursor, a future
4
+ // verified Claude boundary, etc.) means adding a factory here, not
5
+ // branching inside runBootstrapAnalysis.
6
+ //
7
+ // Every adapter exposes:
8
+ // adapterId, modelId
9
+ // checkEligibility(): Promise<{ eligible, reason?, isolation, canaryTested }>
10
+ // isolation and canaryTested are separate axes — WHO enforces the
11
+ // boundary is not the same question as WHETHER it's been proven:
12
+ // isolation: "verified" | "restricted" | "unverified" — WHO enforces
13
+ // it. "verified" = an OS/kernel-enforced boundary (Codex's
14
+ // sandbox-exec, see codex-sandbox.js) that holds even if the CLI's
15
+ // own logic has a bug. "restricted" = an application-enforced
16
+ // boundary — the provider's own CLI/tool-permission logic (Claude's
17
+ // --restricted). "unverified" = no real boundary available.
18
+ // canaryTested: boolean — WHETHER that boundary has actually been
19
+ // empirically proven (a real canary read outside it was attempted
20
+ // and denied), as opposed to merely documented/assumed from
21
+ // --help text or a vendor's own claim. Both Codex's and Claude's
22
+ // adapters are canaryTested: true today; a provider could in
23
+ // principle be "restricted" but NOT canaryTested if its isolation
24
+ // claim were never independently checked — never conflate the two.
25
+ // analyze({ question, snapshotRoot, timeoutMs }): Promise<{status, answer, error}>
26
+ // same response shape intelligence/quick-ask.js's askProvider already
27
+ // returns, so callers don't need to branch on adapter type downstream.
28
+ //
29
+ // This module only wires the providers that already have a real
30
+ // implementation (Codex, Claude). Requesting an adapterId with no real
31
+ // implementation yet returns an honest "not implemented" ineligible
32
+ // adapter — never a silent fallback to a provider the caller didn't ask
33
+ // for.
34
+
35
+ import { askProvider as defaultAskProvider } from "../intelligence/quick-ask.js";
36
+ import {
37
+ getCodexIsolationStatus as defaultGetCodexIsolationStatus,
38
+ runCodexSandboxedBootstrap as defaultRunCodexSandboxedBootstrap
39
+ } from "./codex-sandbox.js";
40
+ import { verifyClaudeSubscriptionAuth as defaultVerifyClaudeSubscriptionAuth } from "../runtime/execution-adapters/claude.js";
41
+ import { readClaudeModels as defaultReadClaudeModels } from "../observability/claude-models.js";
42
+ import { readCursorModels as defaultReadCursorModels } from "../observability/cursor-models.js";
43
+ import { probeCursorAuth as defaultProbeCursorAuth } from "../observability/cursor-auth.js";
44
+ import {
45
+ getCursorIsolationStatus as defaultGetCursorIsolationStatus,
46
+ runCursorSandboxedBootstrap as defaultRunCursorSandboxedBootstrap
47
+ } from "./cursor-sandbox.js";
48
+
49
+ export function createCodexBootstrapAnalyzerAdapter({ modelId, deps = {} } = {}) {
50
+ const runSandboxed = deps.runCodexSandboxedBootstrap ?? defaultRunCodexSandboxedBootstrap;
51
+ const getIsolation = deps.getCodexIsolationStatus ?? defaultGetCodexIsolationStatus;
52
+ return {
53
+ adapterId: "codex",
54
+ modelId,
55
+ async checkEligibility() {
56
+ const isolation = await getIsolation(deps.isolationDeps ?? {});
57
+ return {
58
+ eligible: isolation.available,
59
+ reason: isolation.available ? undefined : isolation.reason,
60
+ isolation: isolation.available ? "verified" : "unverified",
61
+ // The sandbox-exec mechanism itself was empirically canary-tested
62
+ // (a real absolute-path read outside the confined root was denied,
63
+ // see codex-sandbox.js's own header) — `available` reflects that
64
+ // the same proven mechanism is usable here (platform + binary
65
+ // present), not a fresh proof on every call.
66
+ canaryTested: isolation.available
67
+ };
68
+ },
69
+ async analyze({ question, snapshotRoot, timeoutMs }) {
70
+ return runSandboxed({ question, model: modelId, snapshotRoot, timeoutMs, deps: deps.isolationDeps ?? {} });
71
+ }
72
+ };
73
+ }
74
+
75
+ export function createClaudeBootstrapAnalyzerAdapter({ modelId, deps = {} } = {}) {
76
+ const ask = deps.askProvider ?? defaultAskProvider;
77
+ const verifyAuth = deps.verifyClaudeSubscriptionAuth ?? defaultVerifyClaudeSubscriptionAuth;
78
+ const listModels = deps.readClaudeModels ?? defaultReadClaudeModels;
79
+ return {
80
+ adapterId: "claude",
81
+ modelId,
82
+ // Real, integral eligibility — not a hardcoded claim:
83
+ // 1. CLI + authentication: reuses execution-adapters/claude.js's own
84
+ // `claude auth status` check (the same real one gating a Claude
85
+ // execution run) — an unauthenticated or missing CLI fails here,
86
+ // never silently reported eligible.
87
+ // 2. Model availability: checked against Claude's documented model
88
+ // catalog (observability/claude-models.js). Claude's CLI has no
89
+ // live model-discovery command (verified via its own --help, see
90
+ // that module's own header) — this catches an unknown/typo'd
91
+ // modelId, though it can't prove live per-account entitlement the
92
+ // way Codex/OpenCode's live catalogs can.
93
+ // 3. isolation: "restricted" (application-enforced, by the claude
94
+ // CLI's own in-process tool-permission logic — not an OS kernel
95
+ // sandbox like Codex's sandbox-exec, so a bug in that logic could
96
+ // theoretically be bypassed, unlike a kernel boundary).
97
+ // canaryTested: true — --restricted's actual confinement was
98
+ // empirically canary-tested (not assumed): a real absolute-path
99
+ // read outside cwd came back in the JSON output's own
100
+ // `permission_denials` array (Claude's Read tool itself refused
101
+ // it), while an in-bounds read succeeded with an empty
102
+ // `permission_denials`. That proof is WHETHER it was tested, not
103
+ // WHO enforces it — it does not make this "verified"; only a
104
+ // kernel-enforced boundary earns that label.
105
+ async checkEligibility() {
106
+ try {
107
+ await verifyAuth({});
108
+ } catch (error) {
109
+ return { eligible: false, reason: error?.message ?? String(error), isolation: "unverified", canaryTested: false };
110
+ }
111
+ if (modelId) {
112
+ const catalog = listModels();
113
+ const known = catalog.models.some((m) => m.id === modelId);
114
+ if (!known) {
115
+ return { eligible: false, reason: `"${modelId}" is not in Claude's documented model catalog.`, isolation: "unverified", canaryTested: false };
116
+ }
117
+ }
118
+ return { eligible: true, isolation: "restricted", canaryTested: true };
119
+ },
120
+ async analyze({ question, snapshotRoot, timeoutMs }) {
121
+ return ask({ provider: "claude", question, model: modelId, cwd: snapshotRoot, timeoutMs });
122
+ }
123
+ };
124
+ }
125
+
126
+ const CURSOR_AUTO_IDS = new Set(["auto", "cursor:auto", "cursor-auto"]);
127
+ const CURSOR_AUTO_CANONICAL = "cursor:auto";
128
+ const CURSOR_ANALYZE_TIMEOUT_MS = 180_000;
129
+
130
+ export function createCursorBootstrapAnalyzerAdapter({ modelId, deps = {} } = {}) {
131
+ const listModels = deps.readCursorModels ?? defaultReadCursorModels;
132
+ const probeAuth = deps.probeCursorAuth ?? defaultProbeCursorAuth;
133
+ const getIsolation = deps.getCursorIsolationStatus ?? defaultGetCursorIsolationStatus;
134
+ const runSandboxed = deps.runCursorSandboxedBootstrap ?? defaultRunCursorSandboxedBootstrap;
135
+ const isAuto = modelId != null && CURSOR_AUTO_IDS.has(String(modelId).toLowerCase());
136
+ // Cursor Auto is a distinct, real candidate identity, never an implicit
137
+ // default for a missing selection — its own outcomes are ALWAYS
138
+ // attributed to this canonical "cursor:auto" id, never to a guessed
139
+ // inner model (Cursor never discloses which model actually answered in
140
+ // Auto mode). A caller must explicitly choose it.
141
+ const normalizedModelId = isAuto ? CURSOR_AUTO_CANONICAL : modelId;
142
+ return {
143
+ adapterId: "cursor",
144
+ modelId: normalizedModelId,
145
+ // Real eligibility — currently, honestly, negative. Real gates, none
146
+ // skipped or assumed:
147
+ // 1. A modelId must actually be provided — either a real explicit
148
+ // model or the canonical "cursor:auto" opaque-router candidate;
149
+ // an absent selection is never silently defaulted to either.
150
+ // 2. For an EXPLICIT model: checked against the real per-account
151
+ // catalog (observability/cursor-models.js's readCursorModels — a
152
+ // real `cursor-agent models` call, exit-status-checked). Cursor
153
+ // Auto is exempt from this specific check (it's a routing mode,
154
+ // not a listed catalog model) — but exempting the catalog check
155
+ // must never also exempt authentication: `cursor-agent status`/
156
+ // `whoami` do NOT reliably reflect whether a real invocation will
157
+ // work (verified empirically — status can report "Logged in"
158
+ // while a real -p call still fails with "Authentication
159
+ // required"), so Auto is separately gated on
160
+ // observability/cursor-auth.js's probeCursorAuth, a real
161
+ // invocation-based probe, not the unreliable status/whoami claim.
162
+ // 3. Isolation proof, for BOTH explicit models and Cursor Auto alike:
163
+ // Cursor's OWN --sandbox enabled does NOT confine reads (verified
164
+ // empirically — a real out-of-bounds absolute-path read under
165
+ // --sandbox enabled alone succeeded and disclosed real content).
166
+ // The real boundary is cursor-sandbox.js's external macOS
167
+ // sandbox-exec wrapper (mirroring codex-sandbox.js), independently
168
+ // canary-tested and proven to hold: an in-bounds read succeeds, an
169
+ // out-of-bounds one is denied ("Permission denied"). isolation:
170
+ // "verified" + canaryTested: true only when that mechanism is
171
+ // actually available (macOS + sandbox-exec present) — never on
172
+ // any other platform, and never via Cursor's own --sandbox flag.
173
+ async checkEligibility() {
174
+ if (!modelId) {
175
+ return {
176
+ eligible: false,
177
+ reason: "No Cursor model selection was provided — pass an explicit model or \"cursor:auto\".",
178
+ isolation: "unverified", canaryTested: false
179
+ };
180
+ }
181
+ if (!isAuto) {
182
+ const catalog = await listModels();
183
+ if (catalog.status !== "measured") {
184
+ return {
185
+ eligible: false, reason: `Could not read Cursor's real model catalog: ${catalog.error ?? catalog.status}`,
186
+ isolation: "unverified", canaryTested: false
187
+ };
188
+ }
189
+ if (catalog.models.length === 0) {
190
+ return { eligible: false, reason: "No models are enabled for this Cursor account.", isolation: "unverified", canaryTested: false };
191
+ }
192
+ if (!catalog.models.some((m) => m.id === modelId)) {
193
+ return {
194
+ eligible: false, reason: `"${modelId}" is not in this account's real Cursor model catalog.`,
195
+ isolation: "unverified", canaryTested: false
196
+ };
197
+ }
198
+ } else {
199
+ const auth = await probeAuth();
200
+ if (auth.status !== "measured") {
201
+ return {
202
+ eligible: false, reason: `Could not determine whether Cursor Auto can actually be invoked: ${auth.reason ?? auth.status}`,
203
+ isolation: "unverified", canaryTested: false
204
+ };
205
+ }
206
+ if (!auth.authenticated) {
207
+ return { eligible: false, reason: auth.reason, isolation: "unverified", canaryTested: false };
208
+ }
209
+ }
210
+ const isolation = await getIsolation(deps.isolationDeps ?? {});
211
+ if (!isolation.available) {
212
+ return { eligible: false, reason: isolation.reason, isolation: "unverified", canaryTested: false };
213
+ }
214
+ return { eligible: true, isolation: "verified", canaryTested: true };
215
+ },
216
+ // Routed through cursor-sandbox.js's external sandbox-exec wrapper —
217
+ // never a bare `cursor-agent` spawn, since Cursor's own --sandbox
218
+ // enabled does not confine reads (see that module's header for the
219
+ // full empirical finding). Cursor Auto omits a model id; the wrapper
220
+ // itself omits --model entirely for that case.
221
+ async analyze({ question, snapshotRoot, timeoutMs = CURSOR_ANALYZE_TIMEOUT_MS }) {
222
+ return runSandboxed({
223
+ question, model: isAuto ? null : modelId, snapshotRoot, timeoutMs, deps: deps.isolationDeps ?? {}
224
+ });
225
+ }
226
+ };
227
+ }
228
+
229
+ const ADAPTER_FACTORIES = Object.freeze({
230
+ codex: createCodexBootstrapAnalyzerAdapter,
231
+ claude: createClaudeBootstrapAnalyzerAdapter,
232
+ cursor: createCursorBootstrapAnalyzerAdapter
233
+ });
234
+
235
+ /**
236
+ * @param {string} adapterId
237
+ * @param {{modelId: string, deps?: object}} [options]
238
+ * @returns {{adapterId: string, modelId: string, checkEligibility: Function, analyze: Function}}
239
+ */
240
+ export function createBootstrapAnalyzerAdapter(adapterId, { modelId, deps = {} } = {}) {
241
+ const factory = ADAPTER_FACTORIES[adapterId];
242
+ if (!factory) {
243
+ const reason = `No Bootstrap Analyzer adapter implemented for "${adapterId}" yet.`;
244
+ return {
245
+ adapterId, modelId,
246
+ checkEligibility() { return { eligible: false, reason, isolation: "unverified", canaryTested: false }; },
247
+ async analyze() { throw new Error(reason); }
248
+ };
249
+ }
250
+ return factory({ modelId, deps });
251
+ }
@@ -0,0 +1,53 @@
1
+ import { createConversationService } from "./service.js";
2
+ import { printJson } from "../json-output.js";
3
+
4
+ /**
5
+ * The scripted CLI's own preview/confirm contract, mirroring the cockpit
6
+ * and browser local UI exactly: no `--confirm` is always a read-only
7
+ * preview (service.planExecution — never reserves quota or starts a run);
8
+ * `--confirm` re-fetches that same preview fresh, right before executing,
9
+ * and executes exactly what it shows. PROJECT TEAM is the sole authority
10
+ * for execution — `--role` is required (never inferred from task text),
11
+ * and there is no `--model`/`--agent` override left to bypass it; only a
12
+ * confirmed `confirmationTarget` from the fresh preview ever reaches
13
+ * executePlan.
14
+ * @param {ReturnType<typeof createConversationService>} service
15
+ * @param {object} options
16
+ */
17
+ async function runExecuteAction(service, options) {
18
+ if (!options.role) {
19
+ throw new Error(`Missing --role. PROJECT TEAM is the sole authority for execution — pick the real role this task is for (see /project or 'conversation snapshot' for the active team's roles).`);
20
+ }
21
+ const preview = await service.planExecution({ cwd: options.cwd, taskId: options.taskId, role: options.role });
22
+ if (!options.confirm) return preview;
23
+
24
+ if (!preview.confirmationTarget) {
25
+ throw new Error(`Cannot execute "${options.taskId}": ${preview.why}`);
26
+ }
27
+ return service.executePlan({ cwd: options.cwd, taskId: options.taskId, confirmationTarget: preview.confirmationTarget });
28
+ }
29
+
30
+ export async function runConversationCli(options, deps = {}) {
31
+ const service = deps.service ?? createConversationService(deps);
32
+ const action = options.conversationAction ?? "snapshot";
33
+ let result;
34
+ if (action === "snapshot") result = await service.snapshot({ cwd: options.cwd });
35
+ else if (action === "architect") {
36
+ result = await service.submitArchitecture({ cwd: options.cwd, task: options.task, model: options.model });
37
+ } else if (action === "show") {
38
+ result = await service.showPlan({ cwd: options.cwd, taskId: options.taskId });
39
+ } else if (action === "approve" || action === "reject") {
40
+ result = await service.decidePlan({
41
+ cwd: options.cwd,
42
+ taskId: options.taskId,
43
+ decision: action === "approve" ? "approved" : "rejected"
44
+ });
45
+ } else if (action === "execute") {
46
+ result = await runExecuteAction(service, options);
47
+ } else if (action === "cancel") {
48
+ result = await service.cancelExecution({ cwd: options.cwd, taskId: options.taskId });
49
+ } else throw new Error(`Unknown conversation action "${action}".`);
50
+ if (options.json) printJson(result);
51
+ else console.log(JSON.stringify(result, null, 2));
52
+ return result;
53
+ }
@@ -0,0 +1,230 @@
1
+ // Real OS-level filesystem confinement for the Codex CLI, used ONLY for
2
+ // Bootstrap Analysis (conversation/service.js's runBootstrapAnalysis) —
3
+ // general ASK (intelligence/quick-ask.js's askProvider) keeps using
4
+ // Codex's own `--sandbox read-only`, which is NOT read-confining (see
5
+ // sanitized-snapshot.js's header: it blocks writes only, a real absolute
6
+ // path outside cwd is still readable). This module closes that specific
7
+ // gap with an OS-enforced boundary instead of relying on redaction alone.
8
+ //
9
+ // Mechanism: wrap `codex exec` in an external `sandbox-exec` (macOS SBPL)
10
+ // profile, and pass Codex `--dangerously-bypass-approvals-and-sandbox` so
11
+ // Codex's OWN internal sandboxing is off — Codex's `--sandbox read-only`
12
+ // internally re-invokes sandbox-exec per tool call, and nesting an outer
13
+ // sandbox-exec around that breaks every tool call outright (verified
14
+ // empirically: every Codex tool invocation failed with a sandbox_apply
15
+ // error). With Codex's own sandbox disabled, the external profile becomes
16
+ // the sole enforcement layer.
17
+ //
18
+ // Empirically proven, not assumed (see engram memory
19
+ // "Codex sandbox-exec confinement proven for Bootstrap Analyst
20
+ // isolation"): under this exact wrapper, a real `codex exec` run reads a
21
+ // file inside the confined root correctly, and is denied
22
+ // ("Operation not permitted") reading a file outside it via an absolute
23
+ // path.
24
+ //
25
+ // HONEST LIMIT: SBPL applies uniformly to a sandboxed process and every
26
+ // child it execs — there is no SBPL primitive that grants Codex's own
27
+ // process read/write access to CODEX_HOME while denying that same access
28
+ // to tools Codex spawns. Both are required: Codex fails hard ("failed to
29
+ // initialize in-process app-server client: Operation not permitted",
30
+ // verified empirically) without WRITE access to CODEX_HOME too, not just
31
+ // read. So CODEX_HOME is fully readable and writable by the whole
32
+ // confined tree, not just Codex's top-level process. This does not
33
+ // weaken the actual isolation goal (nothing in that tree can escape the
34
+ // snapshot boundary either way) — it only means a per-process auth/tool
35
+ // split, as asked for in review, cannot be built on sandbox-exec alone.
36
+ //
37
+ // macOS only. Any other platform returns { available: false }; callers
38
+ // must fail closed (isolation_unavailable) and never silently fall back
39
+ // to Codex's own non-confining --sandbox read-only for Bootstrap Analysis.
40
+
41
+ import { spawn as defaultSpawn } from "node:child_process";
42
+ import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
43
+ import { constants as fsConstants } from "node:fs";
44
+ import { tmpdir, homedir } from "node:os";
45
+ import { join } from "node:path";
46
+
47
+ const SANDBOX_EXEC_PATH = "/usr/bin/sandbox-exec";
48
+
49
+ // Bootstrap Analysis is a real project investigation, not a quick
50
+ // question — mirrors service.js's own BOOTSTRAP_ANALYST_TIMEOUT_MS.
51
+ const DEFAULT_TIMEOUT_MS = 180_000;
52
+
53
+ const SAFE_ENV_KEYS = Object.freeze([
54
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
55
+ "TMPDIR", "TERM", "CODEX_HOME", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
56
+ "http_proxy", "https_proxy", "no_proxy", "NODE_EXTRA_CA_CERTS"
57
+ ]);
58
+
59
+ function buildSandboxedCodexEnv(sourceEnv = process.env) {
60
+ const env = Object.create(null);
61
+ for (const key of SAFE_ENV_KEYS) {
62
+ if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
63
+ }
64
+ return env;
65
+ }
66
+
67
+ function unknown(error) {
68
+ return { status: "error", answer: null, error: String(error) };
69
+ }
70
+
71
+ export async function isCodexSandboxSupported(deps = {}) {
72
+ if ((deps.platform ?? process.platform) !== "darwin") return false;
73
+ try {
74
+ await (deps.access ?? access)(SANDBOX_EXEC_PATH, fsConstants.X_OK);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Real, checkable isolation status for Codex — never a hardcoded claim.
83
+ * `boundaryVerified` reflects that the sandbox-exec mechanism itself has
84
+ * been empirically proven (canary-denial test) on this platform, not that
85
+ * this specific call was independently re-verified.
86
+ */
87
+ export async function getCodexIsolationStatus(deps = {}) {
88
+ const available = await isCodexSandboxSupported(deps);
89
+ return {
90
+ available,
91
+ platform: deps.platform ?? process.platform,
92
+ boundaryVerified: available,
93
+ reason: available
94
+ ? null
95
+ : "OS-level read confinement for Codex (sandbox-exec) is only implemented for macOS; Codex is not eligible for isolated Bootstrap Analysis on this platform."
96
+ };
97
+ }
98
+
99
+ async function resolvedForms(path, deps) {
100
+ const forms = new Set([path]);
101
+ try {
102
+ forms.add(await (deps.realpath ?? realpath)(path));
103
+ } catch {
104
+ // path may not exist yet — the literal form alone still covers it
105
+ }
106
+ return [...forms];
107
+ }
108
+
109
+ function subpathRules(paths) {
110
+ return paths.map((p) => ` (subpath "${p}")`).join("\n");
111
+ }
112
+
113
+ /**
114
+ * Builds a real SBPL profile confining reads/writes to `snapshotRoot` (the
115
+ * sanitized-snapshot.js temp copy the analyst investigates) plus
116
+ * `codexHome` (Codex's own auth config — without it the CLI can't
117
+ * authenticate at all) and the minimal system paths Codex needs to run.
118
+ * Resolves both the given path and its real path (handles macOS's
119
+ * /tmp -> /private/tmp and /var -> /private/var symlinks automatically,
120
+ * rather than hardcoding either form).
121
+ */
122
+ export async function buildCodexSandboxProfile({ snapshotRoot, codexHome = join(homedir(), ".codex") }, deps = {}) {
123
+ const snapshotForms = await resolvedForms(snapshotRoot, deps);
124
+ const codexHomeForms = await resolvedForms(codexHome, deps);
125
+ const readableExtra = [
126
+ "/usr", "/System", "/bin", "/sbin", "/private/var/db/dyld", "/Library", "/opt", "/private/etc"
127
+ ];
128
+ return `(version 1)
129
+ (deny default)
130
+ (allow process-fork)
131
+ (allow process-exec)
132
+ (allow file-read-metadata (subpath "/"))
133
+ (allow file-read-data (literal "/"))
134
+ (allow file-read*
135
+ ${subpathRules([...snapshotForms, ...codexHomeForms, ...readableExtra])}
136
+ (literal "/dev/null")
137
+ (literal "/dev/urandom")
138
+ (literal "/dev/tty"))
139
+ (allow file-write*
140
+ ${subpathRules([...snapshotForms, ...codexHomeForms, "/private/var/folders", "/private/tmp"])})
141
+ (allow file-read-metadata (subpath "/private/var/folders"))
142
+ (allow sysctl-read)
143
+ (allow mach-lookup)
144
+ (allow signal (target self))
145
+ (allow network*)
146
+ (allow system-socket)
147
+ `;
148
+ }
149
+
150
+ /**
151
+ * Runs a real, OS-sandboxed Codex Bootstrap Analysis question. The ONLY
152
+ * intended caller is conversation/service.js's runBootstrapAnalysis.
153
+ * Fails closed with `status: "error", error: "isolation_unavailable"`
154
+ * (never a silent fallback to Codex's own non-confining --sandbox
155
+ * read-only) when this platform has no verified boundary.
156
+ * @param {object} args
157
+ * @param {string} args.question
158
+ * @param {string|null} [args.model]
159
+ * @param {string} args.snapshotRoot - sanitized-snapshot.js's temp copy
160
+ * @param {string} [args.codexHome]
161
+ */
162
+ export async function runCodexSandboxedBootstrap({
163
+ question, model = null, snapshotRoot, codexHome = join(homedir(), ".codex"),
164
+ spawn = defaultSpawn, timeoutMs = DEFAULT_TIMEOUT_MS, sourceEnv = process.env, deps = {}
165
+ }) {
166
+ const isolation = await getCodexIsolationStatus(deps);
167
+ if (!isolation.available) {
168
+ return { status: "error", answer: null, error: "isolation_unavailable", isolation };
169
+ }
170
+
171
+ let workDir;
172
+ try {
173
+ workDir = await (deps.mkdtemp ?? mkdtemp)(join(tmpdir(), "kairo-codex-sandbox-"));
174
+ } catch (error) {
175
+ return { ...unknown(error?.message ?? error), isolation };
176
+ }
177
+ const profilePath = join(workDir, "bootstrap.sb");
178
+ const outFile = join(workDir, "answer.txt");
179
+
180
+ try {
181
+ const profile = await buildCodexSandboxProfile({ snapshotRoot, codexHome }, deps);
182
+ await (deps.writeFile ?? writeFile)(profilePath, profile, "utf8");
183
+
184
+ // --skip-git-repo-check: snapshotRoot deliberately excludes .git.
185
+ // --ephemeral: no session files persisted to disk for this run.
186
+ // --ignore-user-config: doesn't load $CODEX_HOME/config.toml (auth
187
+ // itself still resolves via CODEX_HOME, per `codex exec --help`).
188
+ const args = [
189
+ "-f", profilePath, "codex", "exec",
190
+ "--dangerously-bypass-approvals-and-sandbox",
191
+ "--skip-git-repo-check", "--ephemeral", "--ignore-user-config",
192
+ "-o", outFile
193
+ ];
194
+ if (model) args.push("--model", model);
195
+ args.push(question);
196
+
197
+ const env = buildSandboxedCodexEnv(sourceEnv);
198
+ const result = await new Promise((resolve) => {
199
+ let child;
200
+ try {
201
+ child = spawn("sandbox-exec", args, { cwd: snapshotRoot, env, stdio: ["ignore", "pipe", "pipe"] });
202
+ } catch (error) {
203
+ resolve(unknown(error?.message ?? error));
204
+ return;
205
+ }
206
+ let finished = false;
207
+ const timer = setTimeout(() => finish(unknown("sandboxed codex exec timed out")), timeoutMs);
208
+ function finish(res) {
209
+ if (finished) return;
210
+ finished = true;
211
+ clearTimeout(timer);
212
+ try { child.kill?.(); } catch { /* best effort */ }
213
+ resolve(res);
214
+ }
215
+ child.once?.("error", (error) => finish(unknown(error?.message ?? error)));
216
+ child.once?.("close", async () => {
217
+ try {
218
+ const text = (await (deps.readFile ?? readFile)(outFile, "utf8")).trim();
219
+ if (!text) return finish(unknown("sandboxed codex exec produced no final message"));
220
+ finish({ status: "answered", answer: text, error: null });
221
+ } catch (error) {
222
+ finish(unknown(error?.message ?? error));
223
+ }
224
+ });
225
+ });
226
+ return { ...result, isolation };
227
+ } finally {
228
+ await (deps.rm ?? rm)(workDir, { recursive: true, force: true }).catch(() => {});
229
+ }
230
+ }