@cruxy/cli 0.14.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/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/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 +1 -1
- 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,46 @@
|
|
|
1
|
+
import type { HookDefinition, HookTrust } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The project-hook trust model (C.19). Trust is recorded in the GLOBAL dir
|
|
4
|
+
* (`~/.cruxy/trust.json`) — in the user's home, NEVER inside a repo — so cloning
|
|
5
|
+
* a repo carries zero trust and an attacker cannot ship a pre-trusted marker.
|
|
6
|
+
*
|
|
7
|
+
* Trust is bound to a {@link fingerprintHooks fingerprint} of the exact project
|
|
8
|
+
* hook commands seen at trust time. It is re-checked on every run: if the
|
|
9
|
+
* project's hooks change (a command / event / blocking edit), the fingerprint no
|
|
10
|
+
* longer matches and trust is stale → the user is re-prompted. This is what
|
|
11
|
+
* defeats trust-then-swap.
|
|
12
|
+
*/
|
|
13
|
+
/** ~/.cruxy/trust.json */
|
|
14
|
+
export declare function trustPath(): string;
|
|
15
|
+
/**
|
|
16
|
+
* A stable content fingerprint of a repo's PROJECT hook definitions. Canonical
|
|
17
|
+
* by construction so a benign reformat of `hooks.json` (reindent, reordered
|
|
18
|
+
* keys, extra whitespace inside a command) does NOT change it, while any real
|
|
19
|
+
* change to a command, event, or blocking flag DOES:
|
|
20
|
+
* - only the meaning-bearing fields are hashed (name, event, command, blocking);
|
|
21
|
+
* - the command string is whitespace-normalized (trim + collapse runs);
|
|
22
|
+
* - specs are sorted by name and serialized with a fixed key order.
|
|
23
|
+
*/
|
|
24
|
+
export declare function fingerprintHooks(hooks: readonly HookDefinition[]): string;
|
|
25
|
+
/** The persisted trust seam — a file-backed store in production, injectable for
|
|
26
|
+
* tests. `get`/`record` are synchronous (the record is tiny). */
|
|
27
|
+
export interface TrustStore {
|
|
28
|
+
/** The recorded decision for a repo root, or undefined if never trusted. */
|
|
29
|
+
get(root: string): HookTrust | undefined;
|
|
30
|
+
/** Persist a trust decision (overwrites any prior one for the same root). */
|
|
31
|
+
record(trust: HookTrust): void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Is this repo's current project-hook set trusted? True only when a decision
|
|
35
|
+
* exists AND its fingerprint matches the current one — a changed hook set is
|
|
36
|
+
* treated as untrusted (stale), forcing a fresh decision.
|
|
37
|
+
*/
|
|
38
|
+
export declare function isTrusted(store: TrustStore, root: string, currentFingerprint: string): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The real store, persisting to `~/.cruxy/trust.json` as `{ [root]: HookTrust }`.
|
|
41
|
+
* Reads are lazy + cached; a corrupt file is treated as "no trust recorded"
|
|
42
|
+
* (fail-closed — a broken trust file must never grant trust).
|
|
43
|
+
*/
|
|
44
|
+
export declare function fileTrustStore(file?: string): TrustStore;
|
|
45
|
+
/** An in-memory store for tests (and any ephemeral run). */
|
|
46
|
+
export declare function memoryTrustStore(seed?: HookTrust[]): TrustStore;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { globalDir } from "../config/paths.js";
|
|
5
|
+
import { TRUST_FILE_NAME } from "../constants.js";
|
|
6
|
+
/**
|
|
7
|
+
* The project-hook trust model (C.19). Trust is recorded in the GLOBAL dir
|
|
8
|
+
* (`~/.cruxy/trust.json`) — in the user's home, NEVER inside a repo — so cloning
|
|
9
|
+
* a repo carries zero trust and an attacker cannot ship a pre-trusted marker.
|
|
10
|
+
*
|
|
11
|
+
* Trust is bound to a {@link fingerprintHooks fingerprint} of the exact project
|
|
12
|
+
* hook commands seen at trust time. It is re-checked on every run: if the
|
|
13
|
+
* project's hooks change (a command / event / blocking edit), the fingerprint no
|
|
14
|
+
* longer matches and trust is stale → the user is re-prompted. This is what
|
|
15
|
+
* defeats trust-then-swap.
|
|
16
|
+
*/
|
|
17
|
+
/** ~/.cruxy/trust.json */
|
|
18
|
+
export function trustPath() {
|
|
19
|
+
return path.join(globalDir(), TRUST_FILE_NAME);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A stable content fingerprint of a repo's PROJECT hook definitions. Canonical
|
|
23
|
+
* by construction so a benign reformat of `hooks.json` (reindent, reordered
|
|
24
|
+
* keys, extra whitespace inside a command) does NOT change it, while any real
|
|
25
|
+
* change to a command, event, or blocking flag DOES:
|
|
26
|
+
* - only the meaning-bearing fields are hashed (name, event, command, blocking);
|
|
27
|
+
* - the command string is whitespace-normalized (trim + collapse runs);
|
|
28
|
+
* - specs are sorted by name and serialized with a fixed key order.
|
|
29
|
+
*/
|
|
30
|
+
export function fingerprintHooks(hooks) {
|
|
31
|
+
const canonical = hooks
|
|
32
|
+
.map((h) => ({
|
|
33
|
+
name: h.name,
|
|
34
|
+
event: h.event,
|
|
35
|
+
command: h.command.trim().replace(/\s+/g, " "),
|
|
36
|
+
blocking: h.blocking,
|
|
37
|
+
}))
|
|
38
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
39
|
+
// Fixed key order — JSON of an object literal preserves insertion order.
|
|
40
|
+
.map((h) => [h.name, h.event, h.command, h.blocking]);
|
|
41
|
+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Is this repo's current project-hook set trusted? True only when a decision
|
|
45
|
+
* exists AND its fingerprint matches the current one — a changed hook set is
|
|
46
|
+
* treated as untrusted (stale), forcing a fresh decision.
|
|
47
|
+
*/
|
|
48
|
+
export function isTrusted(store, root, currentFingerprint) {
|
|
49
|
+
const record = store.get(path.resolve(root));
|
|
50
|
+
return record !== undefined && record.fingerprint === currentFingerprint;
|
|
51
|
+
}
|
|
52
|
+
// ── file-backed store ─────────────────────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* The real store, persisting to `~/.cruxy/trust.json` as `{ [root]: HookTrust }`.
|
|
55
|
+
* Reads are lazy + cached; a corrupt file is treated as "no trust recorded"
|
|
56
|
+
* (fail-closed — a broken trust file must never grant trust).
|
|
57
|
+
*/
|
|
58
|
+
export function fileTrustStore(file = trustPath()) {
|
|
59
|
+
let cache = null;
|
|
60
|
+
const load = () => {
|
|
61
|
+
if (cache)
|
|
62
|
+
return cache;
|
|
63
|
+
try {
|
|
64
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
65
|
+
cache =
|
|
66
|
+
raw && typeof raw === "object"
|
|
67
|
+
? raw
|
|
68
|
+
: {};
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Missing or corrupt → no trust (fail-closed).
|
|
72
|
+
cache = {};
|
|
73
|
+
}
|
|
74
|
+
return cache;
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
get(root) {
|
|
78
|
+
return load()[path.resolve(root)];
|
|
79
|
+
},
|
|
80
|
+
record(trust) {
|
|
81
|
+
const store = load();
|
|
82
|
+
store[path.resolve(trust.root)] = {
|
|
83
|
+
...trust,
|
|
84
|
+
root: path.resolve(trust.root),
|
|
85
|
+
};
|
|
86
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
87
|
+
// 0600: trust records name local paths; keep them owner-only.
|
|
88
|
+
writeFileSync(file, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
89
|
+
if (existsSync(file))
|
|
90
|
+
cache = store;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** An in-memory store for tests (and any ephemeral run). */
|
|
95
|
+
export function memoryTrustStore(seed = []) {
|
|
96
|
+
const store = new Map();
|
|
97
|
+
for (const t of seed)
|
|
98
|
+
store.set(path.resolve(t.root), t);
|
|
99
|
+
return {
|
|
100
|
+
get: (root) => store.get(path.resolve(root)),
|
|
101
|
+
record: (trust) => void store.set(path.resolve(trust.root), {
|
|
102
|
+
...trust,
|
|
103
|
+
root: path.resolve(trust.root),
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Types for hooks + custom slash commands (C.19). Everything here is **data**:
|
|
4
|
+
* hook and command definitions are validated (fail-loud, malformed excluded) and
|
|
5
|
+
* NEVER eval'd — a hook's `command` string only ever reaches execution through
|
|
6
|
+
* the shared U.3-gated + C.16-sandboxed shell path (see `runner.ts`), exactly
|
|
7
|
+
* like an agent-run command.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The core hook lifecycle events. Deliberately a small, closed set for this
|
|
11
|
+
* build; the enum is the extension point for future events (out of scope).
|
|
12
|
+
* - `before-run` / `after-run` — around a whole user turn (the agent loop).
|
|
13
|
+
* - `before-tool` / `after-tool` — around each tool call.
|
|
14
|
+
* - `on-file-change` — after a file-mutating tool (write/edit/patch) succeeds.
|
|
15
|
+
*/
|
|
16
|
+
export declare const HOOK_EVENTS: readonly ["before-run", "after-run", "before-tool", "after-tool", "on-file-change"];
|
|
17
|
+
export type HookEvent = (typeof HOOK_EVENTS)[number];
|
|
18
|
+
/** `before-*` events default to blocking (fail-closed pre-checks); everything
|
|
19
|
+
* that fires *after* an action defaults to advisory. Overridable per hook. */
|
|
20
|
+
export declare function defaultBlocking(event: HookEvent): boolean;
|
|
21
|
+
/** Where a hook / command was discovered. `project` is the supply-chain-risky
|
|
22
|
+
* source (another author); `user` is your own `~/.cruxy`. */
|
|
23
|
+
export type HookSource = "project" | "user";
|
|
24
|
+
/** Precedence: a project definition overrides a user one of the same name. */
|
|
25
|
+
export declare const HOOK_SOURCE_PRECEDENCE: readonly HookSource[];
|
|
26
|
+
/** Strict schema for one hook definition in a `hooks.json`. Unknown keys are a
|
|
27
|
+
* loud error (`.strict()`); `blocking` is optional (resolved from the event). */
|
|
28
|
+
export declare const HookSpecSchema: z.ZodObject<{
|
|
29
|
+
name: z.ZodString;
|
|
30
|
+
event: z.ZodEnum<["before-run", "after-run", "before-tool", "after-tool", "on-file-change"]>;
|
|
31
|
+
command: z.ZodString;
|
|
32
|
+
blocking: z.ZodOptional<z.ZodBoolean>;
|
|
33
|
+
}, "strict", z.ZodTypeAny, {
|
|
34
|
+
name: string;
|
|
35
|
+
command: string;
|
|
36
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
37
|
+
blocking?: boolean | undefined;
|
|
38
|
+
}, {
|
|
39
|
+
name: string;
|
|
40
|
+
command: string;
|
|
41
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
42
|
+
blocking?: boolean | undefined;
|
|
43
|
+
}>;
|
|
44
|
+
export type HookSpec = z.infer<typeof HookSpecSchema>;
|
|
45
|
+
/** The whole `hooks.json` file shape. */
|
|
46
|
+
export declare const HooksFileSchema: z.ZodObject<{
|
|
47
|
+
hooks: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
48
|
+
name: z.ZodString;
|
|
49
|
+
event: z.ZodEnum<["before-run", "after-run", "before-tool", "after-tool", "on-file-change"]>;
|
|
50
|
+
command: z.ZodString;
|
|
51
|
+
blocking: z.ZodOptional<z.ZodBoolean>;
|
|
52
|
+
}, "strict", z.ZodTypeAny, {
|
|
53
|
+
name: string;
|
|
54
|
+
command: string;
|
|
55
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
56
|
+
blocking?: boolean | undefined;
|
|
57
|
+
}, {
|
|
58
|
+
name: string;
|
|
59
|
+
command: string;
|
|
60
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
61
|
+
blocking?: boolean | undefined;
|
|
62
|
+
}>, "many">>;
|
|
63
|
+
}, "strict", z.ZodTypeAny, {
|
|
64
|
+
hooks: {
|
|
65
|
+
name: string;
|
|
66
|
+
command: string;
|
|
67
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
68
|
+
blocking?: boolean | undefined;
|
|
69
|
+
}[];
|
|
70
|
+
}, {
|
|
71
|
+
hooks?: {
|
|
72
|
+
name: string;
|
|
73
|
+
command: string;
|
|
74
|
+
event: "before-run" | "after-run" | "before-tool" | "after-tool" | "on-file-change";
|
|
75
|
+
blocking?: boolean | undefined;
|
|
76
|
+
}[] | undefined;
|
|
77
|
+
}>;
|
|
78
|
+
/** A validated hook, tagged with its source and with `blocking` resolved. */
|
|
79
|
+
export interface HookDefinition {
|
|
80
|
+
name: string;
|
|
81
|
+
event: HookEvent;
|
|
82
|
+
command: string;
|
|
83
|
+
/** Resolved (event default applied) — the value the runner acts on. */
|
|
84
|
+
blocking: boolean;
|
|
85
|
+
source: HookSource;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A custom slash command. `prompt` (the safe default) expands a text template
|
|
89
|
+
* fed to the agent; `shell` binds a command that runs through the SAME gate +
|
|
90
|
+
* sandbox as everything else. Never arbitrary code by default.
|
|
91
|
+
*/
|
|
92
|
+
export type SlashKind = "prompt" | "shell";
|
|
93
|
+
/** Strict frontmatter for a `commands/<name>.md`. The command NAME comes from
|
|
94
|
+
* the filename; the markdown BODY is the prompt template (kind `prompt`). */
|
|
95
|
+
export declare const SlashFrontmatterSchema: z.ZodObject<{
|
|
96
|
+
kind: z.ZodDefault<z.ZodEnum<["prompt", "shell"]>>;
|
|
97
|
+
description: z.ZodString;
|
|
98
|
+
/** Required (and only meaningful) when `kind: "shell"`. */
|
|
99
|
+
command: z.ZodOptional<z.ZodString>;
|
|
100
|
+
}, "strict", z.ZodTypeAny, {
|
|
101
|
+
kind: "shell" | "prompt";
|
|
102
|
+
description: string;
|
|
103
|
+
command?: string | undefined;
|
|
104
|
+
}, {
|
|
105
|
+
description: string;
|
|
106
|
+
command?: string | undefined;
|
|
107
|
+
kind?: "shell" | "prompt" | undefined;
|
|
108
|
+
}>;
|
|
109
|
+
export type SlashFrontmatter = z.infer<typeof SlashFrontmatterSchema>;
|
|
110
|
+
/** A validated custom slash command, tagged with source. */
|
|
111
|
+
export interface SlashCommandSpec {
|
|
112
|
+
name: string;
|
|
113
|
+
kind: SlashKind;
|
|
114
|
+
description: string;
|
|
115
|
+
/** Prompt template (kind `prompt`); `{{args}}` is substituted at expansion. */
|
|
116
|
+
template?: string;
|
|
117
|
+
/** Shell command (kind `shell`) — gated + sandboxed when run. */
|
|
118
|
+
command?: string;
|
|
119
|
+
source: HookSource;
|
|
120
|
+
}
|
|
121
|
+
/** A malformed hook / command, excluded from the catalog and surfaced loudly
|
|
122
|
+
* (never eval'd, never silently dropped). */
|
|
123
|
+
export interface HookConfigError {
|
|
124
|
+
source: HookSource;
|
|
125
|
+
/** Absolute path of the offending file. */
|
|
126
|
+
file: string;
|
|
127
|
+
/** The would-be name (filename / spec name), for display. */
|
|
128
|
+
name: string;
|
|
129
|
+
message: string;
|
|
130
|
+
}
|
|
131
|
+
/** The resolved hook catalog: valid definitions (deduped by precedence) + the
|
|
132
|
+
* malformed ones that were excluded. */
|
|
133
|
+
export interface HookCatalog {
|
|
134
|
+
hooks: HookDefinition[];
|
|
135
|
+
commands: SlashCommandSpec[];
|
|
136
|
+
errors: HookConfigError[];
|
|
137
|
+
}
|
|
138
|
+
/** One repo's recorded trust decision (persisted in `~/.cruxy/trust.json`). The
|
|
139
|
+
* `fingerprint` binds trust to the exact hook commands seen at trust time. */
|
|
140
|
+
export interface HookTrust {
|
|
141
|
+
/** Absolute project root. */
|
|
142
|
+
root: string;
|
|
143
|
+
/** sha256 of the canonicalized project hook specs (see `trust.ts`). */
|
|
144
|
+
fingerprint: string;
|
|
145
|
+
/** ISO timestamp the decision was recorded. */
|
|
146
|
+
at: string;
|
|
147
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Types for hooks + custom slash commands (C.19). Everything here is **data**:
|
|
4
|
+
* hook and command definitions are validated (fail-loud, malformed excluded) and
|
|
5
|
+
* NEVER eval'd — a hook's `command` string only ever reaches execution through
|
|
6
|
+
* the shared U.3-gated + C.16-sandboxed shell path (see `runner.ts`), exactly
|
|
7
|
+
* like an agent-run command.
|
|
8
|
+
*/
|
|
9
|
+
// ── lifecycle events ──────────────────────────────────────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* The core hook lifecycle events. Deliberately a small, closed set for this
|
|
12
|
+
* build; the enum is the extension point for future events (out of scope).
|
|
13
|
+
* - `before-run` / `after-run` — around a whole user turn (the agent loop).
|
|
14
|
+
* - `before-tool` / `after-tool` — around each tool call.
|
|
15
|
+
* - `on-file-change` — after a file-mutating tool (write/edit/patch) succeeds.
|
|
16
|
+
*/
|
|
17
|
+
export const HOOK_EVENTS = [
|
|
18
|
+
"before-run",
|
|
19
|
+
"after-run",
|
|
20
|
+
"before-tool",
|
|
21
|
+
"after-tool",
|
|
22
|
+
"on-file-change",
|
|
23
|
+
];
|
|
24
|
+
/** `before-*` events default to blocking (fail-closed pre-checks); everything
|
|
25
|
+
* that fires *after* an action defaults to advisory. Overridable per hook. */
|
|
26
|
+
export function defaultBlocking(event) {
|
|
27
|
+
return event === "before-run" || event === "before-tool";
|
|
28
|
+
}
|
|
29
|
+
/** Precedence: a project definition overrides a user one of the same name. */
|
|
30
|
+
export const HOOK_SOURCE_PRECEDENCE = [
|
|
31
|
+
"project",
|
|
32
|
+
"user",
|
|
33
|
+
];
|
|
34
|
+
// ── hook specs ────────────────────────────────────────────────────────────────
|
|
35
|
+
/** Strict schema for one hook definition in a `hooks.json`. Unknown keys are a
|
|
36
|
+
* loud error (`.strict()`); `blocking` is optional (resolved from the event). */
|
|
37
|
+
export const HookSpecSchema = z
|
|
38
|
+
.object({
|
|
39
|
+
name: z
|
|
40
|
+
.string()
|
|
41
|
+
.min(1)
|
|
42
|
+
.regex(/^[a-z0-9][a-z0-9-]*$/, "hook name must be kebab-case (lowercase letters, digits, hyphens)"),
|
|
43
|
+
event: z.enum(HOOK_EVENTS),
|
|
44
|
+
command: z.string().min(1, "hook command must be a non-empty string"),
|
|
45
|
+
blocking: z.boolean().optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
/** The whole `hooks.json` file shape. */
|
|
49
|
+
export const HooksFileSchema = z
|
|
50
|
+
.object({ hooks: z.array(HookSpecSchema).default([]) })
|
|
51
|
+
.strict();
|
|
52
|
+
/** Strict frontmatter for a `commands/<name>.md`. The command NAME comes from
|
|
53
|
+
* the filename; the markdown BODY is the prompt template (kind `prompt`). */
|
|
54
|
+
export const SlashFrontmatterSchema = z
|
|
55
|
+
.object({
|
|
56
|
+
kind: z.enum(["prompt", "shell"]).default("prompt"),
|
|
57
|
+
description: z.string().min(1),
|
|
58
|
+
/** Required (and only meaningful) when `kind: "shell"`. */
|
|
59
|
+
command: z.string().min(1).optional(),
|
|
60
|
+
})
|
|
61
|
+
.strict();
|
package/dist/onboarding/steps.js
CHANGED
|
@@ -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
|
|
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();
|
|
@@ -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;
|