@cruxy/cli 0.13.0 → 0.16.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/dist/agent/loop.d.ts +14 -0
- package/dist/agent/loop.js +47 -1
- package/dist/agent/session.d.ts +11 -1
- package/dist/agent/session.js +14 -1
- package/dist/approval/prompt.js +17 -3
- package/dist/brand/index.d.ts +1 -0
- package/dist/brand/index.js +1 -0
- package/dist/brand/voice.d.ts +74 -0
- package/dist/brand/voice.js +73 -0
- package/dist/cli/commands/checkpoint.js +1 -1
- package/dist/cli/commands/hooks.d.ts +8 -0
- package/dist/cli/commands/hooks.js +83 -0
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/pr.js +1 -1
- package/dist/cli/commands/rollback.js +1 -1
- package/dist/cli/commands/run.js +13 -3
- package/dist/cli/commands/skills.js +2 -2
- package/dist/cli/program.js +5 -2
- package/dist/cli/repl.d.ts +2 -1
- package/dist/cli/repl.js +54 -3
- package/dist/cli/session-factory.d.ts +2 -2
- package/dist/cli/session-factory.js +4 -2
- package/dist/components/fuzzy.js +7 -1
- package/dist/config/schema.d.ts +81 -30
- package/dist/config/schema.js +22 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +9 -0
- package/dist/errors/constructors.d.ts +16 -0
- package/dist/errors/constructors.js +57 -0
- package/dist/errors/types.d.ts +11 -0
- package/dist/errors/types.js +19 -0
- package/dist/hooks/config.d.ts +21 -0
- package/dist/hooks/config.js +253 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +6 -0
- package/dist/hooks/runner.d.ts +76 -0
- package/dist/hooks/runner.js +114 -0
- package/dist/hooks/service.d.ts +38 -0
- package/dist/hooks/service.js +49 -0
- package/dist/hooks/slash.d.ts +48 -0
- package/dist/hooks/slash.js +58 -0
- package/dist/hooks/trust.d.ts +46 -0
- package/dist/hooks/trust.js +106 -0
- package/dist/hooks/types.d.ts +147 -0
- package/dist/hooks/types.js +61 -0
- package/dist/onboarding/steps.js +5 -2
- package/dist/render/capabilities.d.ts +10 -2
- package/dist/render/capabilities.js +26 -6
- package/dist/render/index.d.ts +9 -5
- package/dist/render/index.js +12 -5
- package/dist/render/plain-renderer.d.ts +3 -3
- package/dist/render/plain-renderer.js +10 -2
- package/dist/render/screen-reader-renderer.d.ts +45 -0
- package/dist/render/screen-reader-renderer.js +75 -0
- package/dist/render/types.d.ts +15 -1
- package/dist/theme/resolve.d.ts +18 -7
- package/dist/theme/resolve.js +32 -10
- package/dist/theme/tokens.d.ts +16 -1
- package/dist/theme/tokens.js +27 -0
- package/dist/tools/shell/exec.d.ts +53 -0
- package/dist/tools/shell/exec.js +128 -0
- package/dist/tools/shell/run-command.d.ts +4 -0
- package/dist/tools/shell/run-command.js +26 -116
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { PlainRenderer } from "./plain-renderer.js";
|
|
2
|
+
import { describePhase, phaseIdentity } from "./state.js";
|
|
3
|
+
/**
|
|
4
|
+
* The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
|
|
5
|
+
* append-only, zero-cursor-control output — plus two things a screen reader
|
|
6
|
+
* needs that the plain path drops.
|
|
7
|
+
*
|
|
8
|
+
* 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
|
|
9
|
+
* table (via `caps.screenReader`), so every inherited code path that writes
|
|
10
|
+
* `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
|
|
11
|
+
* Nothing here special-cases it — the glyph table does the work.
|
|
12
|
+
*
|
|
13
|
+
* 2. **Announced state.** A screen reader cannot see an in-place live region, so
|
|
14
|
+
* each state change is emitted as its own committed line (never a redraw):
|
|
15
|
+
* a phase becomes `working: read_file src/x.ts`, a tool run brackets as
|
|
16
|
+
* `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
|
|
17
|
+
* Announcements are deduped by phase *identity* so token-count updates within
|
|
18
|
+
* the same activity don't repeat a line.
|
|
19
|
+
*
|
|
20
|
+
* Everything else — text streaming, `endSegment`, `note`, `preview` — is
|
|
21
|
+
* inherited unchanged. This is the whole of screen-reader mode: no parallel
|
|
22
|
+
* path, no behavior change to the plain or TTY renderers.
|
|
23
|
+
*/
|
|
24
|
+
export class ScreenReaderRenderer extends PlainRenderer {
|
|
25
|
+
/** Identity of the last phase announced, so we speak each activity once. */
|
|
26
|
+
lastPhaseId = "";
|
|
27
|
+
/** Text of the last progress line announced, to avoid repeats. */
|
|
28
|
+
lastProgress = "";
|
|
29
|
+
constructor(caps, out, err) {
|
|
30
|
+
super(caps, out, err);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Announce a phase transition as a committed line. `null`, `awaiting-approval`
|
|
34
|
+
* (the prompt block is its own announcement), and `calling-tool` (announced by
|
|
35
|
+
* {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
|
|
36
|
+
*/
|
|
37
|
+
setPhase(phase) {
|
|
38
|
+
const id = phaseIdentity(phase);
|
|
39
|
+
if (id === this.lastPhaseId)
|
|
40
|
+
return;
|
|
41
|
+
this.lastPhaseId = id;
|
|
42
|
+
if (phase === null ||
|
|
43
|
+
phase.kind === "awaiting-approval" ||
|
|
44
|
+
phase.kind === "calling-tool") {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
this.note(describePhase(phase, this.theme.glyph));
|
|
48
|
+
}
|
|
49
|
+
/** Announce plan-step progress as `step i of n: title`, deduped. */
|
|
50
|
+
progress(state) {
|
|
51
|
+
if (state === null) {
|
|
52
|
+
this.lastProgress = "";
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const line = `step ${state.step} of ${state.of}: ${state.title}`;
|
|
56
|
+
if (line === this.lastProgress)
|
|
57
|
+
return;
|
|
58
|
+
this.lastProgress = line;
|
|
59
|
+
this.note(line);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Bracket a tool call with two committed lines — `working: label` on start,
|
|
63
|
+
* `done/failed label` on end — so a screen-reader user hears both that a call
|
|
64
|
+
* began (crucial for long ones) and how it resolved. The end note + honest
|
|
65
|
+
* duration is the inherited plain behavior; only the start line is added.
|
|
66
|
+
*/
|
|
67
|
+
toolLifecycle(event) {
|
|
68
|
+
if (event.event === "start") {
|
|
69
|
+
super.toolLifecycle(event); // records the start time (silent in plain)
|
|
70
|
+
this.note(`${this.theme.glyph.running}: ${event.label}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
super.toolLifecycle(event); // commits `done/failed label (duration)`
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/render/types.d.ts
CHANGED
|
@@ -20,8 +20,22 @@ export interface RenderCapabilities {
|
|
|
20
20
|
color: boolean;
|
|
21
21
|
/** Cursor-control sequences are safe (`tty` and not `TERM=dumb`). */
|
|
22
22
|
cursor: boolean;
|
|
23
|
-
/** Animation is welcome (`cursor` and
|
|
23
|
+
/** Animation is welcome (`cursor` and motion is not reduced). */
|
|
24
24
|
spinner: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Motion is reduced (U.11): no spinner animation/timer, no in-place
|
|
27
|
+
* re-animation — state survives as static text, only movement stops. True
|
|
28
|
+
* under `CRUXY_NO_SPINNER` (alias) / `NO_MOTION` / `CRUXY_REDUCED_MOTION`, and
|
|
29
|
+
* implied by `screenReader`. The one axis the spinner gate keys off.
|
|
30
|
+
*/
|
|
31
|
+
reducedMotion: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Screen-reader mode (U.11): plain, linear, announce-friendly output — no
|
|
34
|
+
* live-region redraws (each state change is a committed line), no spinners,
|
|
35
|
+
* status glyphs rendered as words. Opt-in via `CRUXY_SCREEN_READER` /
|
|
36
|
+
* `ACCESSIBLE`; routes rendering to the linear path regardless of `cursor`.
|
|
37
|
+
*/
|
|
38
|
+
screenReader: boolean;
|
|
25
39
|
/** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
|
|
26
40
|
* independent of `color`. Drives the theme's glyph table, not its stylers. */
|
|
27
41
|
unicode: boolean;
|
package/dist/theme/resolve.d.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { type Theme, type ThemeCapabilities } from "./tokens.js";
|
|
2
2
|
/**
|
|
3
|
-
* Theme resolution (U.1) — the ONE place picocolors is used. The
|
|
3
|
+
* Theme resolution (U.1/U.11) — the ONE place picocolors is used. The axes are
|
|
4
4
|
* fully independent by construction:
|
|
5
5
|
*
|
|
6
6
|
* - `color` drives the stylers only. `pc.createColors(false)` is the identity,
|
|
7
7
|
* so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
|
|
8
8
|
* tests.
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* - the glyph table is chosen by `screenReader` (words) → else `unicode`
|
|
10
|
+
* (✓/✗) → else ASCII (`[ok]`/`[x]`). Never touches color: a NO_COLOR unicode
|
|
11
|
+
* terminal still prints ✓/✗ (glyphs aren't ANSI), a colored CRUXY_ASCII
|
|
12
|
+
* terminal prints a colored `[ok]`, and a screen reader gets colorless words.
|
|
12
13
|
*/
|
|
13
14
|
export declare function resolveTheme(caps: ThemeCapabilities): Theme;
|
|
14
15
|
/**
|
|
@@ -23,10 +24,20 @@ export declare function resolveTheme(caps: ThemeCapabilities): Theme;
|
|
|
23
24
|
* signature change.
|
|
24
25
|
*/
|
|
25
26
|
export declare function detectUnicode(env?: NodeJS.ProcessEnv): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the user asked for screen-reader mode (U.11) — opt-in via
|
|
29
|
+
* `CRUXY_SCREEN_READER` or the ecosystem `ACCESSIBLE` flag (set-and-non-empty).
|
|
30
|
+
* Deliberately NOT inferred from a pipe: auto-wording every non-TTY stream would
|
|
31
|
+
* change existing CI/plain output. The single source of the rule, called by
|
|
32
|
+
* `render/capabilities.ts` (to fill `RenderCapabilities.screenReader`) and by
|
|
33
|
+
* the boolean-seam {@link themeForColor} surfaces (approval, plan, onboarding).
|
|
34
|
+
*/
|
|
35
|
+
export declare function detectScreenReader(env?: NodeJS.ProcessEnv): boolean;
|
|
26
36
|
/**
|
|
27
37
|
* Build a theme for a surface that only knows a `color` boolean (the U.3
|
|
28
|
-
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode
|
|
29
|
-
* from the environment so these surfaces still
|
|
30
|
-
*
|
|
38
|
+
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode and
|
|
39
|
+
* screen-reader mode are detected from the environment so these surfaces still
|
|
40
|
+
* degrade on dumb terminals and speak words to a screen reader, with no change
|
|
41
|
+
* to their public boolean signatures.
|
|
31
42
|
*/
|
|
32
43
|
export declare function themeForColor(color: boolean, env?: NodeJS.ProcessEnv): Theme;
|
package/dist/theme/resolve.js
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
-
import { ASCII_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
|
|
2
|
+
import { ASCII_GLYPHS, SCREEN_READER_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
|
|
3
3
|
/**
|
|
4
|
-
* Theme resolution (U.1) — the ONE place picocolors is used. The
|
|
4
|
+
* Theme resolution (U.1/U.11) — the ONE place picocolors is used. The axes are
|
|
5
5
|
* fully independent by construction:
|
|
6
6
|
*
|
|
7
7
|
* - `color` drives the stylers only. `pc.createColors(false)` is the identity,
|
|
8
8
|
* so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
|
|
9
9
|
* tests.
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - the glyph table is chosen by `screenReader` (words) → else `unicode`
|
|
11
|
+
* (✓/✗) → else ASCII (`[ok]`/`[x]`). Never touches color: a NO_COLOR unicode
|
|
12
|
+
* terminal still prints ✓/✗ (glyphs aren't ANSI), a colored CRUXY_ASCII
|
|
13
|
+
* terminal prints a colored `[ok]`, and a screen reader gets colorless words.
|
|
13
14
|
*/
|
|
14
15
|
export function resolveTheme(caps) {
|
|
15
16
|
const c = pc.createColors(caps.color);
|
|
16
|
-
const glyph = caps.
|
|
17
|
+
const glyph = caps.screenReader
|
|
18
|
+
? SCREEN_READER_GLYPHS
|
|
19
|
+
: caps.unicode
|
|
20
|
+
? UNICODE_GLYPHS
|
|
21
|
+
: ASCII_GLYPHS;
|
|
17
22
|
const strong = c.bold;
|
|
18
23
|
const indent = (text, level = 1) => {
|
|
19
24
|
const pad = " ".repeat(Math.max(0, level));
|
|
@@ -62,12 +67,29 @@ export function detectUnicode(env = process.env) {
|
|
|
62
67
|
return false;
|
|
63
68
|
return true;
|
|
64
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether the user asked for screen-reader mode (U.11) — opt-in via
|
|
72
|
+
* `CRUXY_SCREEN_READER` or the ecosystem `ACCESSIBLE` flag (set-and-non-empty).
|
|
73
|
+
* Deliberately NOT inferred from a pipe: auto-wording every non-TTY stream would
|
|
74
|
+
* change existing CI/plain output. The single source of the rule, called by
|
|
75
|
+
* `render/capabilities.ts` (to fill `RenderCapabilities.screenReader`) and by
|
|
76
|
+
* the boolean-seam {@link themeForColor} surfaces (approval, plan, onboarding).
|
|
77
|
+
*/
|
|
78
|
+
export function detectScreenReader(env = process.env) {
|
|
79
|
+
const set = (v) => v !== undefined && v !== "";
|
|
80
|
+
return set(env.CRUXY_SCREEN_READER) || set(env.ACCESSIBLE);
|
|
81
|
+
}
|
|
65
82
|
/**
|
|
66
83
|
* Build a theme for a surface that only knows a `color` boolean (the U.3
|
|
67
|
-
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode
|
|
68
|
-
* from the environment so these surfaces still
|
|
69
|
-
*
|
|
84
|
+
* PromptIO, error formatting, plan/onboarding/CLI copy). Unicode and
|
|
85
|
+
* screen-reader mode are detected from the environment so these surfaces still
|
|
86
|
+
* degrade on dumb terminals and speak words to a screen reader, with no change
|
|
87
|
+
* to their public boolean signatures.
|
|
70
88
|
*/
|
|
71
89
|
export function themeForColor(color, env = process.env) {
|
|
72
|
-
return resolveTheme({
|
|
90
|
+
return resolveTheme({
|
|
91
|
+
color,
|
|
92
|
+
unicode: detectUnicode(env),
|
|
93
|
+
screenReader: detectScreenReader(env),
|
|
94
|
+
});
|
|
73
95
|
}
|
package/dist/theme/tokens.d.ts
CHANGED
|
@@ -90,10 +90,16 @@ export interface Theme {
|
|
|
90
90
|
readonly color: boolean;
|
|
91
91
|
readonly unicode: boolean;
|
|
92
92
|
}
|
|
93
|
-
/** The
|
|
93
|
+
/** The axes a theme is resolved from — a structural subset of RenderCapabilities. */
|
|
94
94
|
export interface ThemeCapabilities {
|
|
95
95
|
color: boolean;
|
|
96
96
|
unicode: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* Screen-reader mode (U.11): status glyphs render as words. Optional so every
|
|
99
|
+
* existing `{color, unicode}` caller is unchanged (defaults to off). When set
|
|
100
|
+
* it takes precedence over `unicode` for the glyph table.
|
|
101
|
+
*/
|
|
102
|
+
screenReader?: boolean;
|
|
97
103
|
}
|
|
98
104
|
/** The unicode glyph table (real terminals). */
|
|
99
105
|
export declare const UNICODE_GLYPHS: ThemeGlyphs;
|
|
@@ -102,3 +108,12 @@ export declare const UNICODE_GLYPHS: ThemeGlyphs;
|
|
|
102
108
|
* mojibake — the intentional U.1 degradation for unicode-unsafe terminals.
|
|
103
109
|
*/
|
|
104
110
|
export declare const ASCII_GLYPHS: ThemeGlyphs;
|
|
111
|
+
/**
|
|
112
|
+
* The screen-reader glyph table (U.11): the status glyphs a screen reader would
|
|
113
|
+
* otherwise announce as bare punctuation are spelled as words — `done`,
|
|
114
|
+
* `failed`, `working`, `pending` — so `✓ read_file` becomes `done read_file`.
|
|
115
|
+
* Structural glyphs stay ASCII-legible (a pointer/caret has no useful word).
|
|
116
|
+
* This is the entire "worded status" of screen-reader mode: swap the table,
|
|
117
|
+
* reuse every existing renderer/theme code path unchanged.
|
|
118
|
+
*/
|
|
119
|
+
export declare const SCREEN_READER_GLYPHS: ThemeGlyphs;
|
package/dist/theme/tokens.js
CHANGED
|
@@ -50,3 +50,30 @@ export const ASCII_GLYPHS = {
|
|
|
50
50
|
spinnerFrames: ["-", "\\", "|", "/"],
|
|
51
51
|
spinnerStatic: "~",
|
|
52
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* The screen-reader glyph table (U.11): the status glyphs a screen reader would
|
|
55
|
+
* otherwise announce as bare punctuation are spelled as words — `done`,
|
|
56
|
+
* `failed`, `working`, `pending` — so `✓ read_file` becomes `done read_file`.
|
|
57
|
+
* Structural glyphs stay ASCII-legible (a pointer/caret has no useful word).
|
|
58
|
+
* This is the entire "worded status" of screen-reader mode: swap the table,
|
|
59
|
+
* reuse every existing renderer/theme code path unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export const SCREEN_READER_GLYPHS = {
|
|
62
|
+
success: "done",
|
|
63
|
+
failure: "failed",
|
|
64
|
+
pending: "pending",
|
|
65
|
+
running: "working",
|
|
66
|
+
pointer: ">",
|
|
67
|
+
caret: ">",
|
|
68
|
+
arrow: "->",
|
|
69
|
+
caretUp: "up",
|
|
70
|
+
caretDown: "down",
|
|
71
|
+
cursorBar: "",
|
|
72
|
+
bullet: "-",
|
|
73
|
+
sep: "-",
|
|
74
|
+
ellipsis: "...",
|
|
75
|
+
play: ">",
|
|
76
|
+
// Unused in the linear screen-reader path (no live region), kept legible.
|
|
77
|
+
spinnerFrames: ["working"],
|
|
78
|
+
spinnerStatic: "working",
|
|
79
|
+
};
|
|
@@ -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;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { z } from "zod";
|
|
2
|
+
import { runGatedShell } from "./exec.js";
|
|
3
3
|
/**
|
|
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 const runCommandTool = {
|
|
9
13
|
name: "run_command",
|
|
@@ -14,129 +18,35 @@ export const runCommandTool = {
|
|
|
14
18
|
.describe("The shell command to run (executed via the system shell)."),
|
|
15
19
|
}),
|
|
16
20
|
async execute(input, ctx) {
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
command: input.command,
|
|
22
|
-
});
|
|
23
|
-
if (!decision.allow) {
|
|
21
|
+
// A thrown CRUXY_E_APPROVAL_REQUIRED (non-interactive) / sandbox coded error
|
|
22
|
+
// propagates from runGatedShell — do not catch (fail loud).
|
|
23
|
+
const outcome = await runGatedShell(input.command, ctx);
|
|
24
|
+
if (!outcome.approved) {
|
|
24
25
|
return {
|
|
25
26
|
ok: false,
|
|
26
|
-
error:
|
|
27
|
+
error: outcome.rejection ?? "command denied by the user",
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
-
// never on the host; absent → host, unchanged. There is no fallback path — a
|
|
31
|
-
// sandbox that can't run throws a coded error (see runSandboxed) rather than
|
|
32
|
-
// silently reaching runBounded.
|
|
33
|
-
return ctx.sandbox
|
|
34
|
-
? runSandboxed(input.command, ctx)
|
|
35
|
-
: runBounded(input.command, ctx);
|
|
30
|
+
return mapExecResult(outcome.exec, ctx);
|
|
36
31
|
},
|
|
37
32
|
};
|
|
38
33
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* `
|
|
34
|
+
* Map a {@link ShellExecResult} onto the tool's `ToolResult` — the same "exit
|
|
35
|
+
* code N" framing, truncation note, and timeout / spawn-error messages as
|
|
36
|
+
* before the shared-path refactor. `exitCode ?? signal ?? "unknown"` reproduces
|
|
37
|
+
* both the sandbox (`exitCode ?? "unknown"`, signal always null) and host
|
|
38
|
+
* (`code ?? signal ?? "unknown"`) framings from the original tool.
|
|
44
39
|
*/
|
|
45
|
-
function
|
|
40
|
+
function mapExecResult(r, ctx) {
|
|
46
41
|
const { timeoutMs, maxOutputBytes } = ctx.config.shell;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (result.timedOut) {
|
|
56
|
-
return { ok: false, error: `timed out after ${timeoutMs}ms` };
|
|
57
|
-
}
|
|
58
|
-
const exit = result.exitCode ?? "unknown";
|
|
59
|
-
let output = `exit code ${exit}\n${result.output}`;
|
|
60
|
-
if (result.outputTruncated) {
|
|
61
|
-
output += `\n… [output truncated at ${maxOutputBytes} bytes]`;
|
|
62
|
-
}
|
|
63
|
-
return { ok: true, output };
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
/** Spawn the command, capture bounded output, and enforce the timeout. */
|
|
67
|
-
function runBounded(command, ctx) {
|
|
68
|
-
const { timeoutMs, maxOutputBytes } = ctx.config.shell;
|
|
69
|
-
return new Promise((resolve) => {
|
|
70
|
-
// `detached` makes the child its own process-group leader so the whole tree
|
|
71
|
-
// (the shell plus anything it spawns) can be killed on timeout.
|
|
72
|
-
const child = spawn(command, {
|
|
73
|
-
shell: true,
|
|
74
|
-
cwd: ctx.cwd,
|
|
75
|
-
detached: true,
|
|
76
|
-
});
|
|
77
|
-
const chunks = [];
|
|
78
|
-
let captured = 0;
|
|
79
|
-
let truncated = false;
|
|
80
|
-
const capture = (buf) => {
|
|
81
|
-
if (truncated)
|
|
82
|
-
return;
|
|
83
|
-
const room = maxOutputBytes - captured;
|
|
84
|
-
if (buf.length <= room) {
|
|
85
|
-
chunks.push(buf);
|
|
86
|
-
captured += buf.length;
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
if (room > 0) {
|
|
90
|
-
chunks.push(buf.subarray(0, room));
|
|
91
|
-
captured += room;
|
|
92
|
-
}
|
|
93
|
-
truncated = true;
|
|
94
|
-
}
|
|
95
|
-
};
|
|
96
|
-
child.stdout?.on("data", capture);
|
|
97
|
-
child.stderr?.on("data", capture);
|
|
98
|
-
// A single guard so the timeout-kill and the natural close can't both fire.
|
|
99
|
-
let settled = false;
|
|
100
|
-
const timer = setTimeout(() => {
|
|
101
|
-
if (settled)
|
|
102
|
-
return;
|
|
103
|
-
settled = true;
|
|
104
|
-
killTree(child.pid);
|
|
105
|
-
resolve({ ok: false, error: `timed out after ${timeoutMs}ms` });
|
|
106
|
-
}, timeoutMs);
|
|
107
|
-
child.on("error", (err) => {
|
|
108
|
-
if (settled)
|
|
109
|
-
return;
|
|
110
|
-
settled = true;
|
|
111
|
-
clearTimeout(timer);
|
|
112
|
-
resolve({ ok: false, error: err.message });
|
|
113
|
-
});
|
|
114
|
-
child.on("close", (code, signal) => {
|
|
115
|
-
if (settled)
|
|
116
|
-
return;
|
|
117
|
-
settled = true;
|
|
118
|
-
clearTimeout(timer);
|
|
119
|
-
const exit = code ?? signal ?? "unknown";
|
|
120
|
-
let output = `exit code ${exit}\n${Buffer.concat(chunks).toString("utf8")}`;
|
|
121
|
-
if (truncated) {
|
|
122
|
-
output += `\n… [output truncated at ${maxOutputBytes} bytes]`;
|
|
123
|
-
}
|
|
124
|
-
resolve({ ok: true, output });
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Kill the command's entire process group. POSIX-specific (negative pid targets
|
|
130
|
-
* the group); fine on our darwin/linux targets. Swallows errors — the process
|
|
131
|
-
* may already be gone.
|
|
132
|
-
*/
|
|
133
|
-
function killTree(pid) {
|
|
134
|
-
if (pid === undefined)
|
|
135
|
-
return;
|
|
136
|
-
try {
|
|
137
|
-
process.kill(-pid, "SIGKILL");
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
// Already exited, or no group — nothing to kill.
|
|
42
|
+
if (r.timedOut)
|
|
43
|
+
return { ok: false, error: `timed out after ${timeoutMs}ms` };
|
|
44
|
+
if (r.spawnError !== undefined)
|
|
45
|
+
return { ok: false, error: r.spawnError };
|
|
46
|
+
const exit = r.exitCode ?? r.signal ?? "unknown";
|
|
47
|
+
let output = `exit code ${exit}\n${r.output}`;
|
|
48
|
+
if (r.truncated) {
|
|
49
|
+
output += `\n… [output truncated at ${maxOutputBytes} bytes]`;
|
|
141
50
|
}
|
|
51
|
+
return { ok: true, output };
|
|
142
52
|
}
|