@cruxy/cli 0.14.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 (66) hide show
  1. package/dist/agent/loop.d.ts +26 -0
  2. package/dist/agent/loop.js +59 -3
  3. package/dist/agent/session.d.ts +17 -1
  4. package/dist/agent/session.js +23 -2
  5. package/dist/brand/index.d.ts +1 -0
  6. package/dist/brand/index.js +1 -0
  7. package/dist/brand/voice.d.ts +94 -0
  8. package/dist/brand/voice.js +127 -0
  9. package/dist/cli/commands/checkpoint.js +1 -1
  10. package/dist/cli/commands/hooks.d.ts +8 -0
  11. package/dist/cli/commands/hooks.js +83 -0
  12. package/dist/cli/commands/init.js +1 -1
  13. package/dist/cli/commands/pr.js +10 -2
  14. package/dist/cli/commands/rollback.js +1 -1
  15. package/dist/cli/commands/run.js +13 -3
  16. package/dist/cli/commands/skills.js +2 -2
  17. package/dist/cli/program.js +5 -2
  18. package/dist/cli/repl.d.ts +2 -1
  19. package/dist/cli/repl.js +54 -3
  20. package/dist/cli/session-factory.d.ts +2 -2
  21. package/dist/cli/session-factory.js +13 -2
  22. package/dist/config/schema.d.ts +139 -46
  23. package/dist/config/schema.js +42 -0
  24. package/dist/constants.d.ts +9 -0
  25. package/dist/constants.js +9 -0
  26. package/dist/errors/constructors.d.ts +25 -0
  27. package/dist/errors/constructors.js +98 -6
  28. package/dist/errors/types.d.ts +14 -0
  29. package/dist/errors/types.js +23 -0
  30. package/dist/hooks/config.d.ts +21 -0
  31. package/dist/hooks/config.js +253 -0
  32. package/dist/hooks/index.d.ts +6 -0
  33. package/dist/hooks/index.js +6 -0
  34. package/dist/hooks/runner.d.ts +76 -0
  35. package/dist/hooks/runner.js +114 -0
  36. package/dist/hooks/service.d.ts +38 -0
  37. package/dist/hooks/service.js +49 -0
  38. package/dist/hooks/slash.d.ts +48 -0
  39. package/dist/hooks/slash.js +58 -0
  40. package/dist/hooks/trust.d.ts +46 -0
  41. package/dist/hooks/trust.js +106 -0
  42. package/dist/hooks/types.d.ts +147 -0
  43. package/dist/hooks/types.js +61 -0
  44. package/dist/onboarding/steps.js +1 -1
  45. package/dist/plan/service.d.ts +6 -0
  46. package/dist/plan/service.js +4 -0
  47. package/dist/render/state.js +4 -1
  48. package/dist/render/types.d.ts +7 -1
  49. package/dist/routing/index.d.ts +2 -0
  50. package/dist/routing/index.js +5 -0
  51. package/dist/routing/resolve.d.ts +17 -0
  52. package/dist/routing/resolve.js +18 -0
  53. package/dist/routing/router.d.ts +47 -0
  54. package/dist/routing/router.js +84 -0
  55. package/dist/routing/types.d.ts +42 -0
  56. package/dist/routing/types.js +27 -0
  57. package/dist/subagent/orchestrator.d.ts +6 -0
  58. package/dist/subagent/orchestrator.js +2 -0
  59. package/dist/subagent/types.d.ts +6 -0
  60. package/dist/tools/shell/exec.d.ts +53 -0
  61. package/dist/tools/shell/exec.js +128 -0
  62. package/dist/tools/shell/run-command.d.ts +4 -0
  63. package/dist/tools/shell/run-command.js +26 -116
  64. package/dist/vcs/generate.d.ts +3 -1
  65. package/dist/vcs/generate.js +4 -1
  66. package/package.json +2 -2
@@ -17,7 +17,7 @@ const c = (io) => themeForColor(io.color);
17
17
  */
18
18
  export async function acquireKeyStep(io, deps, provider) {
19
19
  const col = c(io);
20
- io.write(`\nYou'll need a Cruxy API key. Create one at ${col.accent(CREATE_KEY_URL)}\n`);
20
+ io.write(`\nYou'll need a cruxy API key. Create one at ${col.accent(CREATE_KEY_URL)}\n`);
21
21
  for (let attempt = 1; attempt <= MAX_KEY_ATTEMPTS; attempt++) {
22
22
  io.write(col.strong("Paste your API key: "));
23
23
  const key = (await io.readSecret()).trim();
@@ -4,6 +4,7 @@ import type { PromptIO } from "../approval/index.js";
4
4
  import { ToolRegistry, type ToolContext } from "../tools/index.js";
5
5
  import { type AgentResult } from "../agent/loop.js";
6
6
  import type { StreamRenderer } from "../render/index.js";
7
+ import type { Router } from "../routing/index.js";
7
8
  import { PlanExecutionPolicy } from "./policy.js";
8
9
  /**
9
10
  * Orchestrates a plan-mode turn (C.31): propose → approve/revise (capped) →
@@ -36,5 +37,10 @@ export interface PlanSessionArgs {
36
37
  renderer?: StreamRenderer;
37
38
  /** Revision cap (defaults to {@link MAX_PLAN_REVISIONS}). */
38
39
  maxRevisions?: number;
40
+ /**
41
+ * Multi-model routing (C.30). When set, the propose phase routes on `plan`
42
+ * and step execution on `main-turn`; omitted → the provider default.
43
+ */
44
+ router?: Router;
39
45
  }
40
46
  export declare function runPlanSession(args: PlanSessionArgs): Promise<AgentResult>;
@@ -75,6 +75,8 @@ export async function runPlanSession(args) {
75
75
  projectInstructions: args.projectInstructions,
76
76
  renderer: args.renderer,
77
77
  planMode: true,
78
+ router: args.router,
79
+ taskClass: "plan",
78
80
  }));
79
81
  if (!holder.plan) {
80
82
  throw planInvalid("the model ended its turn without calling submit_plan");
@@ -106,6 +108,8 @@ export async function runPlanSession(args) {
106
108
  git: args.git,
107
109
  projectInstructions: args.projectInstructions,
108
110
  renderer: args.renderer,
111
+ router: args.router,
112
+ taskClass: "main-turn",
109
113
  }));
110
114
  };
111
115
  await executePlan(plan, {
@@ -39,9 +39,12 @@ export function describePhase(phase, glyph = UNICODE_GLYPHS) {
39
39
  case "thinking": {
40
40
  const t = phase.tokens;
41
41
  // Honest numbers only: no usage yet → no figure at all.
42
- return t && t.input + t.output > 0
42
+ const base = t && t.input + t.output > 0
43
43
  ? `thinking${e} ${glyph.sep} tokens ${glyph.caretUp}${formatTokens(t.input)} ${glyph.caretDown}${formatTokens(t.output)}`
44
44
  : `thinking${e}`;
45
+ // The real routing tier (C.30), appended honestly when present — same
46
+ // ` · ` joiner as the token counts.
47
+ return phase.tier ? `${base} ${glyph.sep} ${phase.tier}` : base;
45
48
  }
46
49
  case "calling-tool":
47
50
  return `${phase.label}${e}`;
@@ -53,10 +53,16 @@ export interface TokenUsage {
53
53
  * One phase is live at a time — it is a register, not a queue.
54
54
  */
55
55
  export type RenderPhase =
56
- /** Waiting on the model. `tokens` = usage accumulated so far, omitted at 0. */
56
+ /**
57
+ * Waiting on the model. `tokens` = usage accumulated so far, omitted at 0.
58
+ * `tier` = the routing tier this turn runs on (C.30), shown only when routing
59
+ * is active — an honest signal of the real tier, never a fabricated one, and
60
+ * always a tier name (never an upstream model id, U.8).
61
+ */
57
62
  {
58
63
  kind: "thinking";
59
64
  tokens?: TokenUsage;
65
+ tier?: string;
60
66
  }
61
67
  /** A tool call is executing; `label` is the human form ("read_file src/x.ts"). */
62
68
  | {
@@ -0,0 +1,2 @@
1
+ export * from "./types.js";
2
+ export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export { ConfigRouter, DEFAULT_TIER, routerForConfig, resolveTaskModel, } from "./router.js";
3
+ // `resolve.ts` (tier → wire model-id) is deliberately NOT re-exported: the
4
+ // mapping is internal to routing, so it can never be reached from a user-facing
5
+ // render path (the internal-mapping-isolation guarantee, U.8).
@@ -0,0 +1,17 @@
1
+ import type { Tier } from "./types.js";
2
+ /**
3
+ * Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
4
+ * it is INTERNAL to the routing package (deliberately not re-exported from the
5
+ * barrel) so it can never be called from a user-facing render path.
6
+ *
7
+ * For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
8
+ * to a concrete upstream model SERVER-SIDE. So this returns the tier name
9
+ * unchanged: the output is always a tier, and no upstream model name can
10
+ * originate here. That property is what keeps the U.8 gag structural rather than
11
+ * a filter — there is no upstream id in the process to leak (see the tier-gag
12
+ * test, which asserts this output is always a MODEL_TIERS member).
13
+ *
14
+ * The seam exists for a future provider whose tiers map to distinct wire ids;
15
+ * that mapping would live here and here only.
16
+ */
17
+ export declare function resolveModelId(tier: Tier): string;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Tier → gateway wire model-id. This is the SINGLE place the mapping lives, and
3
+ * it is INTERNAL to the routing package (deliberately not re-exported from the
4
+ * barrel) so it can never be called from a user-facing render path.
5
+ *
6
+ * For the Cruxy gateway a tier IS the wire model id — the gateway maps the tier
7
+ * to a concrete upstream model SERVER-SIDE. So this returns the tier name
8
+ * unchanged: the output is always a tier, and no upstream model name can
9
+ * originate here. That property is what keeps the U.8 gag structural rather than
10
+ * a filter — there is no upstream id in the process to leak (see the tier-gag
11
+ * test, which asserts this output is always a MODEL_TIERS member).
12
+ *
13
+ * The seam exists for a future provider whose tiers map to distinct wire ids;
14
+ * that mapping would live here and here only.
15
+ */
16
+ export function resolveModelId(tier) {
17
+ return tier;
18
+ }
@@ -0,0 +1,47 @@
1
+ import type { CruxyConfig } from "../config/index.js";
2
+ import type { Router, RoutingConfig, TaskClass, Tier } from "./types.js";
3
+ /**
4
+ * The tier a config resolves to when nothing else pins one down — mirrors the
5
+ * gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
6
+ * cruxy session lands on exactly the tier it does today.
7
+ */
8
+ export declare const DEFAULT_TIER: Tier;
9
+ /**
10
+ * The config-driven {@link Router}: maps a declared task class to a tier from
11
+ * `{ default, map }`, and fails loud when the resolved tier is not offered. It
12
+ * NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
13
+ */
14
+ export declare class ConfigRouter implements Router {
15
+ private readonly cfg;
16
+ private readonly offered;
17
+ /**
18
+ * @param cfg the resolved routing table (default tier + per-task map)
19
+ * @param offered the tiers this gateway/plan actually provides; a resolved
20
+ * tier outside this set fails loud. Defaults to all tiers — the
21
+ * seam a future entitlement check narrows (never a silent
22
+ * downgrade).
23
+ */
24
+ constructor(cfg: RoutingConfig, offered?: Iterable<Tier>);
25
+ select(taskClass: TaskClass): Tier;
26
+ }
27
+ /**
28
+ * Build a router from resolved config, or `null` when routing should stay
29
+ * inert. Routing is:
30
+ *
31
+ * - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
32
+ * providers get `null` (no override, their `model.model` is used unchanged);
33
+ * - opt-in — with no `routing.default` and an empty `routing.map`, this returns
34
+ * `null` so behavior (and the wire body, and the state line) is byte-identical
35
+ * to today. Multi-tier routing activates only once the user configures it.
36
+ */
37
+ export declare function routerForConfig(config: CruxyConfig): Router | null;
38
+ /**
39
+ * Resolve a declared task class to `{ tier, model }`: the tier for honest
40
+ * surfacing (the U.4 state line), the wire model id for the request. The model
41
+ * id comes from the internal {@link resolveModelId} — callers never touch that
42
+ * mapping directly, so it stays the single source of truth.
43
+ */
44
+ export declare function resolveTaskModel(router: Router, taskClass: TaskClass): {
45
+ tier: Tier;
46
+ model: string;
47
+ };
@@ -0,0 +1,84 @@
1
+ import { MODEL_TIERS } from "../brand/voice.js";
2
+ import { routingTierUnavailable } from "../errors/index.js";
3
+ import { resolveModelId } from "./resolve.js";
4
+ /**
5
+ * The tier a config resolves to when nothing else pins one down — mirrors the
6
+ * gateway's `auto` fallback (`AUTO_FALLBACK_TIER` in the SDK), so an unrouted
7
+ * cruxy session lands on exactly the tier it does today.
8
+ */
9
+ export const DEFAULT_TIER = "vaani";
10
+ /**
11
+ * The config-driven {@link Router}: maps a declared task class to a tier from
12
+ * `{ default, map }`, and fails loud when the resolved tier is not offered. It
13
+ * NEVER inspects prompt content — selection is purely `map[taskClass] ?? default`.
14
+ */
15
+ export class ConfigRouter {
16
+ cfg;
17
+ offered;
18
+ /**
19
+ * @param cfg the resolved routing table (default tier + per-task map)
20
+ * @param offered the tiers this gateway/plan actually provides; a resolved
21
+ * tier outside this set fails loud. Defaults to all tiers — the
22
+ * seam a future entitlement check narrows (never a silent
23
+ * downgrade).
24
+ */
25
+ constructor(cfg, offered = MODEL_TIERS) {
26
+ this.cfg = cfg;
27
+ this.offered = new Set(offered);
28
+ }
29
+ select(taskClass) {
30
+ // Explicit override, else the default — an unmapped/unknown class is not an
31
+ // error, it just takes the default (never a crash, never the cheapest).
32
+ const tier = this.cfg.map[taskClass] ?? this.cfg.default;
33
+ // Fail loud: a configured tier the gateway does not offer is a usage error
34
+ // to fix, NOT a silent substitution to some other tier (a user who asked for
35
+ // mira reasoning must never be quietly handed kavi).
36
+ if (!this.offered.has(tier)) {
37
+ throw routingTierUnavailable(tier, taskClass, [...this.offered]);
38
+ }
39
+ return tier;
40
+ }
41
+ }
42
+ /**
43
+ * The base tier implied by the session's `model.model`: a real tier passes
44
+ * through; `auto` (and any non-tier value) falls back to {@link DEFAULT_TIER}.
45
+ * Used so that when a user has pinned a single tier, an opt-in routing table
46
+ * that omits `routing.default` still defaults to THEIR tier, not a fixed one.
47
+ */
48
+ function baseTierFromModel(model) {
49
+ return MODEL_TIERS.includes(model)
50
+ ? model
51
+ : DEFAULT_TIER;
52
+ }
53
+ /**
54
+ * Build a router from resolved config, or `null` when routing should stay
55
+ * inert. Routing is:
56
+ *
57
+ * - a cruxy-gateway concept — tiers do not apply to BYO providers, so non-cruxy
58
+ * providers get `null` (no override, their `model.model` is used unchanged);
59
+ * - opt-in — with no `routing.default` and an empty `routing.map`, this returns
60
+ * `null` so behavior (and the wire body, and the state line) is byte-identical
61
+ * to today. Multi-tier routing activates only once the user configures it.
62
+ */
63
+ export function routerForConfig(config) {
64
+ if (config.model.provider !== "cruxy")
65
+ return null;
66
+ const { default: def, map } = config.routing;
67
+ const configured = def !== undefined || Object.keys(map).length > 0;
68
+ if (!configured)
69
+ return null;
70
+ return new ConfigRouter({
71
+ default: def ?? baseTierFromModel(config.model.model),
72
+ map,
73
+ });
74
+ }
75
+ /**
76
+ * Resolve a declared task class to `{ tier, model }`: the tier for honest
77
+ * surfacing (the U.4 state line), the wire model id for the request. The model
78
+ * id comes from the internal {@link resolveModelId} — callers never touch that
79
+ * mapping directly, so it stays the single source of truth.
80
+ */
81
+ export function resolveTaskModel(router, taskClass) {
82
+ const tier = router.select(taskClass);
83
+ return { tier, model: resolveModelId(tier) };
84
+ }
@@ -0,0 +1,42 @@
1
+ import { MODEL_TIERS } from "../brand/voice.js";
2
+ /**
3
+ * Multi-model routing (C.30): route each unit of work to the right tier instead
4
+ * of running one model for everything. The caller DECLARES a {@link TaskClass}
5
+ * at the call site; a {@link Router} maps that class to a {@link Tier} via config
6
+ * — it never sniffs the prompt to guess difficulty. The tier→gateway model-id
7
+ * mapping is internal (see `resolve.ts`); only tier names ever appear in config,
8
+ * logs, errors, or the state line (the U.8 tier gag).
9
+ */
10
+ /**
11
+ * The unit-of-work classes a caller can declare. Each is an explicit intent —
12
+ * NOT a difficulty the router infers. Unknown/unset resolves to the router's
13
+ * default tier, never a crash.
14
+ */
15
+ export declare const TASK_CLASSES: readonly ["main-turn", "subagent", "plan", "commit-msg", "classify", "summarize"];
16
+ export type TaskClass = (typeof TASK_CLASSES)[number];
17
+ /** A routing tier — the ONLY model vocabulary the user ever sees (U.8). */
18
+ export type Tier = (typeof MODEL_TIERS)[number];
19
+ /**
20
+ * Selects a tier for a declared task class. Deliberately one method: the caller
21
+ * passes intent, the router returns a tier from its configured mapping. No
22
+ * prompt-content inspection, ever — difficulty detection is explicitly out of
23
+ * scope (a caller declares; the router does not guess).
24
+ */
25
+ export interface Router {
26
+ /**
27
+ * Map a task class to its tier. Falls back to the configured default when the
28
+ * class has no explicit mapping; throws `CRUXY_E_ROUTING_TIER_UNAVAILABLE`
29
+ * (fail loud, never a silent substitution) when the resolved tier is not
30
+ * offered.
31
+ */
32
+ select(taskClass: TaskClass): Tier;
33
+ }
34
+ /**
35
+ * The declarative routing table: a `default` tier plus per-task overrides. Lives
36
+ * in config (`routing.default`, `routing.map`). An empty map means every class
37
+ * resolves to `default` — a single tier, no traffic splitting.
38
+ */
39
+ export interface RoutingConfig {
40
+ default: Tier;
41
+ map: Partial<Record<TaskClass, Tier>>;
42
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Multi-model routing (C.30): route each unit of work to the right tier instead
3
+ * of running one model for everything. The caller DECLARES a {@link TaskClass}
4
+ * at the call site; a {@link Router} maps that class to a {@link Tier} via config
5
+ * — it never sniffs the prompt to guess difficulty. The tier→gateway model-id
6
+ * mapping is internal (see `resolve.ts`); only tier names ever appear in config,
7
+ * logs, errors, or the state line (the U.8 tier gag).
8
+ */
9
+ /**
10
+ * The unit-of-work classes a caller can declare. Each is an explicit intent —
11
+ * NOT a difficulty the router infers. Unknown/unset resolves to the router's
12
+ * default tier, never a crash.
13
+ */
14
+ export const TASK_CLASSES = [
15
+ /** An interactive main-agent turn. */
16
+ "main-turn",
17
+ /** A spawned subagent task (C.14). */
18
+ "subagent",
19
+ /** Plan proposal / revision (C.31). */
20
+ "plan",
21
+ /** Commit / pull-request text generation. */
22
+ "commit-msg",
23
+ /** A cheap one-shot classification. */
24
+ "classify",
25
+ /** Context compaction / summarization. */
26
+ "summarize",
27
+ ];
@@ -2,6 +2,7 @@ import type { Provider } from "@cruxy/sdk";
2
2
  import type { ApprovalDecision } from "../approval/types.js";
3
3
  import type { CruxyConfig } from "../config/index.js";
4
4
  import type { StreamRenderer } from "../render/index.js";
5
+ import type { Router } from "../routing/index.js";
5
6
  import type { ApproveAction, ToolContext, ToolRegistry } from "../tools/index.js";
6
7
  import type { SandboxService } from "../sandbox/index.js";
7
8
  import type { SubagentResult, SubagentSpec } from "./types.js";
@@ -15,6 +16,11 @@ import type { SubagentResult, SubagentSpec } from "./types.js";
15
16
  export interface SubagentOrchestratorDeps {
16
17
  provider: Provider;
17
18
  config: CruxyConfig;
19
+ /**
20
+ * Multi-model routing (C.30). When set, child runs route on their spec's task
21
+ * class (default `subagent`); omitted → the provider default (unchanged).
22
+ */
23
+ router?: Router;
18
24
  /** The parent's registry — the ceiling every child scope derives from. */
19
25
  parentRegistry: ToolRegistry;
20
26
  cwd: string;
@@ -91,6 +91,8 @@ export class SubagentOrchestrator {
91
91
  projectInstructions: deps.projectInstructions,
92
92
  subagent: true,
93
93
  budget,
94
+ router: deps.router,
95
+ taskClass: spec.taskClass ?? "subagent",
94
96
  });
95
97
  }
96
98
  catch (err) {
@@ -1,4 +1,5 @@
1
1
  import type { Usage } from "@cruxy/sdk";
2
+ import type { TaskClass } from "../routing/index.js";
2
3
  /**
3
4
  * Types for subagent orchestration (C.14): the main agent delegates a bounded
4
5
  * subtask to a child agent that runs the SAME loop with its own fresh history,
@@ -35,6 +36,11 @@ export interface SubagentSpec {
35
36
  * spawn can narrow its budget, never raise it past the configured ceilings.
36
37
  */
37
38
  budget?: Partial<BudgetLimits>;
39
+ /**
40
+ * Routing task class for this spawn (C.30); defaults to `subagent`. A
41
+ * declaration at the spawn call site — not something the router guesses.
42
+ */
43
+ taskClass?: TaskClass;
38
44
  }
39
45
  /**
40
46
  * What the parent gets back — compact structured data, never the transcript.
@@ -0,0 +1,53 @@
1
+ import type { ToolContext } from "../types.js";
2
+ /**
3
+ * The ONE gated + sandboxed shell path (C.16 + C.19). Both `run_command` and the
4
+ * C.19 hook runner funnel through {@link runGatedShell}: a command reaches
5
+ * execution only after passing the SAME `ctx.requestApproval` gate and only
6
+ * through the SAME `ctx.sandbox` (or host) substrate. There is deliberately no
7
+ * second exec route, so a hook can never get a privileged path — proven by the
8
+ * fact that both callers invoke this exact function.
9
+ *
10
+ * The structured {@link ShellExecResult} carries the raw exit code (which
11
+ * `run_command`'s text `ToolResult` hides) so the hook runner can decide
12
+ * blocking pass/fail on exit 0 vs non-zero, while `run_command` maps the same
13
+ * result back to its byte-identical `ToolResult`.
14
+ */
15
+ /** The raw outcome of executing a shell command (gate already passed). */
16
+ export interface ShellExecResult {
17
+ /** The wall-clock timeout tripped and the process tree was killed. */
18
+ timedOut: boolean;
19
+ /** Numeric exit code, or null when killed / signalled / unknown. */
20
+ exitCode: number | null;
21
+ /** Terminating signal (host path only); null under the sandbox. */
22
+ signal: string | null;
23
+ /** Combined stdout+stderr, capped to `shell.maxOutputBytes`. */
24
+ output: string;
25
+ /** Output was truncated at the cap. */
26
+ truncated: boolean;
27
+ /** The host process failed to *start* (spawn error). Sandbox start failures
28
+ * throw a coded error instead (propagated, never returned here). */
29
+ spawnError?: string;
30
+ }
31
+ /** The result of the gate + (if allowed) execution. */
32
+ export interface GatedShellOutcome {
33
+ /** The U.3 gate allowed the command. When false, nothing executed. */
34
+ approved: boolean;
35
+ /** The rejection feedback when `approved` is false. */
36
+ rejection?: string;
37
+ /** The execution result — present iff `approved`. */
38
+ exec?: ShellExecResult;
39
+ }
40
+ /**
41
+ * Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
42
+ * it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
43
+ * rejection returns `{ approved: false }` and runs nothing. A non-interactive
44
+ * gate throws `CRUXY_E_APPROVAL_REQUIRED` (propagated, never swallowed); a
45
+ * sandbox that can't run throws its coded error (fail loud, no host fallback).
46
+ */
47
+ export declare function runGatedShell(command: string, ctx: ToolContext): Promise<GatedShellOutcome>;
48
+ /**
49
+ * Execute an already-approved command. Substrate is chosen SOLELY by
50
+ * `ctx.sandbox`: present → the container (C.16), never the host; absent → the
51
+ * bounded host spawn. No fallback path — a sandbox that can't run throws.
52
+ */
53
+ export declare function execShell(command: string, ctx: ToolContext): Promise<ShellExecResult>;
@@ -0,0 +1,128 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Gate `command` through `ctx.requestApproval`, then — only if allowed — execute
4
+ * it via the sandbox (when `ctx.sandbox` is set) or the bounded host spawn. A
5
+ * rejection returns `{ approved: false }` and runs nothing. A non-interactive
6
+ * gate throws `CRUXY_E_APPROVAL_REQUIRED` (propagated, never swallowed); a
7
+ * sandbox that can't run throws its coded error (fail loud, no host fallback).
8
+ */
9
+ export async function runGatedShell(command, ctx) {
10
+ const decision = await ctx.requestApproval({ kind: "shell", command });
11
+ if (!decision.allow) {
12
+ return { approved: false, rejection: decision.feedback };
13
+ }
14
+ return { approved: true, exec: await execShell(command, ctx) };
15
+ }
16
+ /**
17
+ * Execute an already-approved command. Substrate is chosen SOLELY by
18
+ * `ctx.sandbox`: present → the container (C.16), never the host; absent → the
19
+ * bounded host spawn. No fallback path — a sandbox that can't run throws.
20
+ */
21
+ export function execShell(command, ctx) {
22
+ return ctx.sandbox ? runSandboxed(command, ctx) : runBounded(command, ctx);
23
+ }
24
+ /** Run in the sandbox and normalize its neutral ExecResult (start failures
25
+ * throw coded errors from `sandbox.exec` and propagate — never caught here). */
26
+ async function runSandboxed(command, ctx) {
27
+ const { timeoutMs, maxOutputBytes } = ctx.config.shell;
28
+ const result = await ctx.sandbox.exec(command, {
29
+ cwd: ctx.cwd,
30
+ timeoutMs,
31
+ maxOutputBytes,
32
+ capture: "head",
33
+ });
34
+ return {
35
+ timedOut: result.timedOut,
36
+ exitCode: result.exitCode,
37
+ signal: null,
38
+ output: result.output,
39
+ truncated: result.outputTruncated,
40
+ };
41
+ }
42
+ /** Spawn the command on the host, capture bounded output, enforce the timeout. */
43
+ function runBounded(command, ctx) {
44
+ const { timeoutMs, maxOutputBytes } = ctx.config.shell;
45
+ return new Promise((resolve) => {
46
+ // `detached` makes the child its own process-group leader so the whole tree
47
+ // (the shell plus anything it spawns) can be killed on timeout.
48
+ const child = spawn(command, { shell: true, cwd: ctx.cwd, detached: true });
49
+ const chunks = [];
50
+ let captured = 0;
51
+ let truncated = false;
52
+ const capture = (buf) => {
53
+ if (truncated)
54
+ return;
55
+ const room = maxOutputBytes - captured;
56
+ if (buf.length <= room) {
57
+ chunks.push(buf);
58
+ captured += buf.length;
59
+ }
60
+ else {
61
+ if (room > 0) {
62
+ chunks.push(buf.subarray(0, room));
63
+ captured += room;
64
+ }
65
+ truncated = true;
66
+ }
67
+ };
68
+ child.stdout?.on("data", capture);
69
+ child.stderr?.on("data", capture);
70
+ // A single guard so the timeout-kill and the natural close can't both fire.
71
+ let settled = false;
72
+ const timer = setTimeout(() => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ killTree(child.pid);
77
+ resolve({
78
+ timedOut: true,
79
+ exitCode: null,
80
+ signal: null,
81
+ output: "",
82
+ truncated,
83
+ });
84
+ }, timeoutMs);
85
+ child.on("error", (err) => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ clearTimeout(timer);
90
+ resolve({
91
+ timedOut: false,
92
+ exitCode: null,
93
+ signal: null,
94
+ output: "",
95
+ truncated: false,
96
+ spawnError: err.message,
97
+ });
98
+ });
99
+ child.on("close", (code, signal) => {
100
+ if (settled)
101
+ return;
102
+ settled = true;
103
+ clearTimeout(timer);
104
+ resolve({
105
+ timedOut: false,
106
+ exitCode: code,
107
+ signal: signal ?? null,
108
+ output: Buffer.concat(chunks).toString("utf8"),
109
+ truncated,
110
+ });
111
+ });
112
+ });
113
+ }
114
+ /**
115
+ * Kill the command's entire process group. POSIX-specific (negative pid targets
116
+ * the group); fine on our darwin/linux targets. Swallows errors — the process
117
+ * may already be gone.
118
+ */
119
+ function killTree(pid) {
120
+ if (pid === undefined)
121
+ return;
122
+ try {
123
+ process.kill(-pid, "SIGKILL");
124
+ }
125
+ catch {
126
+ // Already exited, or no group — nothing to kill.
127
+ }
128
+ }
@@ -4,6 +4,10 @@ import type { Tool } from "../types.js";
4
4
  * Run an arbitrary shell command in the project root. The highest-risk tool we
5
5
  * ship: it is gated on `ctx.approve` (a denial runs nothing) and bounded by a
6
6
  * timeout that kills the whole process tree plus a cap on captured output.
7
+ *
8
+ * Gate + execution live in the shared {@link runGatedShell} (also used by the
9
+ * C.19 hook runner — the single, un-bypassable shell path); this tool only maps
10
+ * the structured result back onto its `ToolResult` framing.
7
11
  */
8
12
  export declare const runCommandTool: Tool<z.ZodObject<{
9
13
  command: z.ZodString;