@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.
- package/CHANGELOG.md +50 -0
- package/package.json +2 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/ux-smoke-test.sh +3 -3
- package/src/cli.js +96 -11
- package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
- package/src/global/architect/architect-cli.js +76 -0
- package/src/global/architect/architect-codex.js +146 -0
- package/src/global/architect/architect-manager.js +125 -0
- package/src/global/architect/architect-store.js +377 -0
- package/src/global/architect/architect-types.js +47 -0
- package/src/global/cli-help.js +10 -1
- package/src/global/cockpit/app.js +475 -0
- package/src/global/cockpit/card.js +111 -0
- package/src/global/cockpit/cli.js +33 -0
- package/src/global/cockpit/gauge.js +31 -0
- package/src/global/cockpit/project-overlay.js +683 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1263 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
- package/src/global/conversation/cli.js +53 -0
- package/src/global/conversation/codex-sandbox.js +230 -0
- package/src/global/conversation/cursor-sandbox.js +215 -0
- package/src/global/conversation/project-analysis.js +204 -0
- package/src/global/conversation/project-profile.js +178 -0
- package/src/global/conversation/project-router.js +149 -0
- package/src/global/conversation/project-strategy-store.js +64 -0
- package/src/global/conversation/project-strategy.js +514 -0
- package/src/global/conversation/sanitized-snapshot.js +169 -0
- package/src/global/conversation/secret-scanner.js +71 -0
- package/src/global/conversation/service.js +1063 -0
- package/src/global/conversation/session-store.js +75 -0
- package/src/global/conversation/transcript-store.js +79 -0
- package/src/global/conversation/ui.js +195 -0
- package/src/global/intelligence/capability-scoring.js +480 -0
- package/src/global/intelligence/execution-router.js +444 -0
- package/src/global/intelligence/kairo-telemetry-source.js +59 -0
- package/src/global/intelligence/kairobench-runner.js +85 -0
- package/src/global/intelligence/kairobench-source.js +34 -0
- package/src/global/intelligence/kairobench-tasks.js +47 -0
- package/src/global/intelligence/model-candidate-catalog.js +456 -0
- package/src/global/intelligence/model-capability-registry-sources.js +145 -0
- package/src/global/intelligence/model-capability-registry.js +125 -0
- package/src/global/intelligence/model-intelligence.js +1646 -0
- package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
- package/src/global/intelligence/quick-ask.js +149 -0
- package/src/global/intelligence/role-profiles.js +251 -0
- package/src/global/intelligence/skill-catalog.js +67 -0
- package/src/global/intelligence/subscription-pressure-source.js +41 -0
- package/src/global/mcp/kairo-mcp.js +51 -18
- package/src/global/mcp/work-snapshot-rule.js +4 -2
- package/src/global/mcp/workspace-binding.js +88 -0
- package/src/global/mcp/workspace-mcp-entry.js +74 -0
- package/src/global/mcp-install.js +8 -1
- package/src/global/observability/artificial-analysis-models.js +118 -0
- package/src/global/observability/claude-models.js +31 -0
- package/src/global/observability/claude-usage.js +112 -0
- package/src/global/observability/codex-models.js +96 -0
- package/src/global/observability/codex-usage.js +160 -0
- package/src/global/observability/cursor-auth.js +88 -0
- package/src/global/observability/cursor-models.js +101 -0
- package/src/global/observability/huggingface-leaderboard.js +97 -0
- package/src/global/observability/opencode-models.js +101 -0
- package/src/global/observability/opencode-usage.js +162 -0
- package/src/global/paths.js +49 -2
- package/src/global/profile.js +23 -1
- package/src/global/runtime/execution-adapters/claude.js +63 -30
- package/src/global/runtime/execution-adapters/codex.js +9 -2
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
- package/src/global/runtime/execution-adapters/opencode.js +83 -18
- package/src/global/runtime/execution-worktree-manager.js +924 -0
- package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
- package/src/global/runtime/execution-worktree-store.js +83 -0
- package/src/global/runtime/execution-worktree-types.js +45 -0
- package/src/global/runtime/run-events.js +38 -0
- package/src/global/runtime/run-manager.js +22 -6
- package/src/global/runtime/run-supervisor.js +41 -12
- package/src/global/runtime/usage-manager.js +96 -0
- package/src/global/runtime/usage-store.js +69 -0
- 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
|
+
}
|