@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,253 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { globalDir } from "../config/paths.js";
|
|
4
|
+
import { COMMANDS_DIR_NAME, GLOBAL_DIR_NAME, HOOKS_FILE_NAME, } from "../constants.js";
|
|
5
|
+
import { defaultBlocking, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, SlashFrontmatterSchema, } from "./types.js";
|
|
6
|
+
/** The real sources for a project root. */
|
|
7
|
+
export function defaultHookSources(cwd) {
|
|
8
|
+
return {
|
|
9
|
+
project: path.join(path.resolve(cwd), GLOBAL_DIR_NAME),
|
|
10
|
+
user: globalDir(),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Load and resolve the full hook + command catalog. */
|
|
14
|
+
export async function loadHookCatalog(sources) {
|
|
15
|
+
const hookCandidates = [];
|
|
16
|
+
const commandCandidates = [];
|
|
17
|
+
const errors = [];
|
|
18
|
+
// Precedence order: project first, then user.
|
|
19
|
+
for (const source of HOOK_SOURCE_PRECEDENCE) {
|
|
20
|
+
await scanHooksFile(source, sources[source], hookCandidates, errors);
|
|
21
|
+
await scanCommandsDir(source, sources[source], commandCandidates, errors);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
hooks: resolvePrecedence(hookCandidates),
|
|
25
|
+
commands: resolvePrecedence(commandCandidates),
|
|
26
|
+
errors,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
// ── hooks.json ────────────────────────────────────────────────────────────────
|
|
30
|
+
async function scanHooksFile(source, dir, out, errors) {
|
|
31
|
+
const file = path.join(dir, HOOKS_FILE_NAME);
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = await fs.readFile(file, "utf8");
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
// Missing file is normal (source absent); other errors are reported.
|
|
38
|
+
if (err.code !== "ENOENT") {
|
|
39
|
+
errors.push({
|
|
40
|
+
source,
|
|
41
|
+
file,
|
|
42
|
+
name: HOOKS_FILE_NAME,
|
|
43
|
+
message: `could not read ${HOOKS_FILE_NAME}: ${err.message}`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
let json;
|
|
49
|
+
try {
|
|
50
|
+
json = JSON.parse(text);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
errors.push({
|
|
54
|
+
source,
|
|
55
|
+
file,
|
|
56
|
+
name: HOOKS_FILE_NAME,
|
|
57
|
+
message: `invalid JSON: ${err.message}`,
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Top-level shape only; each hook is validated individually below so ONE bad
|
|
62
|
+
// entry is excluded + surfaced while the valid hooks in the same file survive
|
|
63
|
+
// (same discipline as the skills loader — never all-or-nothing).
|
|
64
|
+
if (json === null ||
|
|
65
|
+
typeof json !== "object" ||
|
|
66
|
+
!Array.isArray(json.hooks)) {
|
|
67
|
+
errors.push({
|
|
68
|
+
source,
|
|
69
|
+
file,
|
|
70
|
+
name: HOOKS_FILE_NAME,
|
|
71
|
+
message: 'hooks.json must be an object with a "hooks" array',
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const rawHooks = json.hooks;
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const [i, raw] of rawHooks.entries()) {
|
|
78
|
+
const label = raw &&
|
|
79
|
+
typeof raw === "object" &&
|
|
80
|
+
typeof raw.name === "string"
|
|
81
|
+
? raw.name
|
|
82
|
+
: `entry #${i}`;
|
|
83
|
+
const parsed = HookSpecSchema.safeParse(raw);
|
|
84
|
+
if (!parsed.success) {
|
|
85
|
+
errors.push({
|
|
86
|
+
source,
|
|
87
|
+
file,
|
|
88
|
+
name: label,
|
|
89
|
+
message: `invalid hook: ${formatZod(parsed.error)}`,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const spec = parsed.data;
|
|
94
|
+
if (seen.has(spec.name)) {
|
|
95
|
+
errors.push({
|
|
96
|
+
source,
|
|
97
|
+
file,
|
|
98
|
+
name: spec.name,
|
|
99
|
+
message: `duplicate hook name "${spec.name}" within the ${source} source`,
|
|
100
|
+
});
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
seen.add(spec.name);
|
|
104
|
+
out.push({
|
|
105
|
+
name: spec.name,
|
|
106
|
+
event: spec.event,
|
|
107
|
+
command: spec.command,
|
|
108
|
+
blocking: spec.blocking ?? defaultBlocking(spec.event),
|
|
109
|
+
source,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// ── commands/*.md ─────────────────────────────────────────────────────────────
|
|
114
|
+
async function scanCommandsDir(source, dir, out, errors) {
|
|
115
|
+
const commandsDir = path.join(dir, COMMANDS_DIR_NAME);
|
|
116
|
+
let entries;
|
|
117
|
+
try {
|
|
118
|
+
entries = await fs.readdir(commandsDir, { withFileTypes: true });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err.code !== "ENOENT") {
|
|
122
|
+
errors.push({
|
|
123
|
+
source,
|
|
124
|
+
file: commandsDir,
|
|
125
|
+
name: COMMANDS_DIR_NAME,
|
|
126
|
+
message: `could not read commands dir: ${err.message}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (!entry.isFile() || !entry.name.endsWith(".md"))
|
|
134
|
+
continue;
|
|
135
|
+
const name = entry.name.slice(0, -".md".length);
|
|
136
|
+
const file = path.join(commandsDir, entry.name);
|
|
137
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
138
|
+
errors.push({
|
|
139
|
+
source,
|
|
140
|
+
file,
|
|
141
|
+
name,
|
|
142
|
+
message: `command filename must be kebab-case: "${entry.name}"`,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let text;
|
|
147
|
+
try {
|
|
148
|
+
text = await fs.readFile(file, "utf8");
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
errors.push({ source, file, name, message: err.message });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
out.push(parseCommand(text, name, source, file));
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
errors.push({ source, file, name, message: err.message });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Parse + validate one `commands/<name>.md` into a {@link SlashCommandSpec}. */
|
|
163
|
+
function parseCommand(text, name, source, file) {
|
|
164
|
+
const { raw, body } = splitFrontmatter(text);
|
|
165
|
+
const parsed = SlashFrontmatterSchema.safeParse(raw);
|
|
166
|
+
if (!parsed.success) {
|
|
167
|
+
throw new Error(`invalid frontmatter: ${formatZod(parsed.error)}`);
|
|
168
|
+
}
|
|
169
|
+
const fm = parsed.data;
|
|
170
|
+
if (fm.kind === "shell") {
|
|
171
|
+
if (!fm.command) {
|
|
172
|
+
throw new Error('a "shell" command requires a `command:` in frontmatter');
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
name,
|
|
176
|
+
kind: "shell",
|
|
177
|
+
description: fm.description,
|
|
178
|
+
command: fm.command,
|
|
179
|
+
source,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// prompt (default): the markdown body IS the template.
|
|
183
|
+
const template = body.trim();
|
|
184
|
+
if (template === "") {
|
|
185
|
+
throw new Error('a "prompt" command needs a non-empty template body');
|
|
186
|
+
}
|
|
187
|
+
if (fm.command) {
|
|
188
|
+
throw new Error('a "prompt" command must not set `command:` (use kind: shell)');
|
|
189
|
+
}
|
|
190
|
+
void file;
|
|
191
|
+
return {
|
|
192
|
+
name,
|
|
193
|
+
kind: "prompt",
|
|
194
|
+
description: fm.description,
|
|
195
|
+
template,
|
|
196
|
+
source,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// ── shared helpers ────────────────────────────────────────────────────────────
|
|
200
|
+
/**
|
|
201
|
+
* First occurrence of a name wins (candidates arrive in precedence order —
|
|
202
|
+
* project before user), so a project definition overrides a user one. A
|
|
203
|
+
* duplicate *within* a source was already reported by the scanner.
|
|
204
|
+
*/
|
|
205
|
+
function resolvePrecedence(candidates) {
|
|
206
|
+
const byName = new Map();
|
|
207
|
+
for (const c of candidates)
|
|
208
|
+
if (!byName.has(c.name))
|
|
209
|
+
byName.set(c.name, c);
|
|
210
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
211
|
+
}
|
|
212
|
+
const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/;
|
|
213
|
+
/** Split a `---`-delimited frontmatter block (flat `key: value` scalars) from
|
|
214
|
+
* the body. A strict, tiny YAML subset — anything else is a loud error. */
|
|
215
|
+
function splitFrontmatter(text) {
|
|
216
|
+
if (!/^---[ \t]*\r?\n/.test(text)) {
|
|
217
|
+
throw new Error("must begin with a YAML frontmatter block delimited by '---'");
|
|
218
|
+
}
|
|
219
|
+
const match = FRONTMATTER_RE.exec(text);
|
|
220
|
+
if (!match) {
|
|
221
|
+
throw new Error("frontmatter block is not terminated by a closing '---'");
|
|
222
|
+
}
|
|
223
|
+
const raw = {};
|
|
224
|
+
for (const rawLine of match[1].split(/\r?\n/)) {
|
|
225
|
+
const line = rawLine.trim();
|
|
226
|
+
if (line === "" || line.startsWith("#"))
|
|
227
|
+
continue;
|
|
228
|
+
const colon = line.indexOf(":");
|
|
229
|
+
if (colon === -1) {
|
|
230
|
+
throw new Error(`invalid frontmatter line (expected "key: value"): ${JSON.stringify(rawLine)}`);
|
|
231
|
+
}
|
|
232
|
+
const key = line.slice(0, colon).trim();
|
|
233
|
+
if (key in raw)
|
|
234
|
+
throw new Error(`duplicate frontmatter key: ${JSON.stringify(key)}`);
|
|
235
|
+
raw[key] = stripQuotes(line.slice(colon + 1).trim());
|
|
236
|
+
}
|
|
237
|
+
return { raw, body: match[2] ?? "" };
|
|
238
|
+
}
|
|
239
|
+
function stripQuotes(value) {
|
|
240
|
+
if (value.length >= 2) {
|
|
241
|
+
const first = value[0];
|
|
242
|
+
const last = value[value.length - 1];
|
|
243
|
+
if ((first === '"' || first === "'") && first === last) {
|
|
244
|
+
return value.slice(1, -1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
function formatZod(error) {
|
|
250
|
+
return error.issues
|
|
251
|
+
.map((i) => i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message)
|
|
252
|
+
.join("; ");
|
|
253
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { HOOK_EVENTS, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, HooksFileSchema, SlashFrontmatterSchema, defaultBlocking, type HookCatalog, type HookConfigError, type HookDefinition, type HookEvent, type HookSource, type HookSpec, type HookTrust, type SlashCommandSpec, type SlashKind, } from "./types.js";
|
|
2
|
+
export { defaultHookSources, loadHookCatalog, type HookSources, } from "./config.js";
|
|
3
|
+
export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPath, type TrustStore, } from "./trust.js";
|
|
4
|
+
export { HookRunner, type HookRunnerDeps, type TrustPromptInfo, } from "./runner.js";
|
|
5
|
+
export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, type SlashResolution, } from "./slash.js";
|
|
6
|
+
export { buildHooksService, type BuildHooksServiceOptions, type HooksService, } from "./service.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { HOOK_EVENTS, HOOK_SOURCE_PRECEDENCE, HookSpecSchema, HooksFileSchema, SlashFrontmatterSchema, defaultBlocking, } from "./types.js";
|
|
2
|
+
export { defaultHookSources, loadHookCatalog, } from "./config.js";
|
|
3
|
+
export { fileTrustStore, fingerprintHooks, isTrusted, memoryTrustStore, trustPath, } from "./trust.js";
|
|
4
|
+
export { HookRunner, } from "./runner.js";
|
|
5
|
+
export { BUILTIN_SLASH_COMMANDS, expandTemplate, isBuiltinSlash, resolveSlash, } from "./slash.js";
|
|
6
|
+
export { buildHooksService, } from "./service.js";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ToolContext } from "../tools/types.js";
|
|
2
|
+
import { type TrustStore } from "./trust.js";
|
|
3
|
+
import type { HookDefinition, HookEvent } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The hook runner (C.19) — the security core. When a lifecycle event fires, it
|
|
6
|
+
* runs the matching user-authored hooks, but a hook is **never** an approval
|
|
7
|
+
* bypass:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Project trust gate** (supply-chain safety). Before ANY project hook runs,
|
|
10
|
+
* the repo must be trusted for its current hook fingerprint. Untrusted →
|
|
11
|
+
* prompt (interactive) or `CRUXY_E_HOOK_UNTRUSTED` (non-interactive / declined
|
|
12
|
+
* / `trustPrompt` off). A cloned repo's hooks never execute silently. User
|
|
13
|
+
* hooks skip this prompt (you authored them) but NOT the gate below.
|
|
14
|
+
* 2. **Per-command gate + sandbox** — every hook command goes through the SAME
|
|
15
|
+
* {@link runGatedShell} as `run_command`: the U.3 approval gate then the C.16
|
|
16
|
+
* sandbox (or host). There is no privileged route.
|
|
17
|
+
* 3. **Blocking vs advisory** — a blocking hook that fails aborts the action
|
|
18
|
+
* fail-closed (`CRUXY_E_HOOK_FAILED`); an advisory hook reports and continues.
|
|
19
|
+
*/
|
|
20
|
+
/** What the interactive trust prompt is shown. */
|
|
21
|
+
export interface TrustPromptInfo {
|
|
22
|
+
root: string;
|
|
23
|
+
hooks: HookDefinition[];
|
|
24
|
+
}
|
|
25
|
+
export interface HookRunnerDeps {
|
|
26
|
+
/** Resolved hooks (with sources) for this project. */
|
|
27
|
+
hooks: HookDefinition[];
|
|
28
|
+
/** Persisted per-repo trust (see `trust.ts`). */
|
|
29
|
+
trust: TrustStore;
|
|
30
|
+
/** `config.hooks.enabled` — when false, nothing ever fires. */
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
/** `config.hooks.trustPrompt` — when false, untrusted project hooks fail loud
|
|
33
|
+
* rather than prompting (never auto-trust). */
|
|
34
|
+
trustPrompt: boolean;
|
|
35
|
+
/** Whether cruxy can actually prompt (stdin is a TTY). */
|
|
36
|
+
interactive: boolean;
|
|
37
|
+
/** Project root — the trust key and fingerprint scope. */
|
|
38
|
+
cwd: string;
|
|
39
|
+
/** Interactive trust prompt (returns true to trust). Required only when a
|
|
40
|
+
* project defines hooks and `trustPrompt` + `interactive` are both on. */
|
|
41
|
+
promptTrust?: (info: TrustPromptInfo) => Promise<boolean>;
|
|
42
|
+
/** "running hook: <name>" surface (visible on every fire). */
|
|
43
|
+
announce?: (message: string) => void;
|
|
44
|
+
/** Advisory-failure surface (blocking failures throw instead). */
|
|
45
|
+
reportFailure?: (message: string) => void;
|
|
46
|
+
/** Injectable clock for the recorded trust timestamp (tests). */
|
|
47
|
+
now?: () => string;
|
|
48
|
+
}
|
|
49
|
+
export declare class HookRunner {
|
|
50
|
+
private readonly deps;
|
|
51
|
+
constructor(deps: HookRunnerDeps);
|
|
52
|
+
/** The project hooks — the trust-gated subset. */
|
|
53
|
+
private get projectHooks();
|
|
54
|
+
/**
|
|
55
|
+
* Fire every hook registered for `event`, in catalog order. Resolves normally
|
|
56
|
+
* when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
|
|
57
|
+
* when a blocking hook fails, or `CRUXY_E_HOOK_UNTRUSTED` when a project's
|
|
58
|
+
* hooks are not trusted. A no-op when hooks are disabled or none match.
|
|
59
|
+
*/
|
|
60
|
+
fire(event: HookEvent, ctx: ToolContext): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Ensure this repo's project hooks are trusted for their current fingerprint.
|
|
63
|
+
* Records trust on an interactive accept; throws `CRUXY_E_HOOK_UNTRUSTED` on
|
|
64
|
+
* decline, when `trustPrompt` is off, or when non-interactive — NEVER
|
|
65
|
+
* auto-trusts. The fingerprint covers ALL project hooks, so a change to any of
|
|
66
|
+
* them invalidates a prior decision (stale → re-prompt).
|
|
67
|
+
*/
|
|
68
|
+
private ensureProjectTrust;
|
|
69
|
+
/**
|
|
70
|
+
* Run one hook command through the shared gate + sandbox path and reduce it to
|
|
71
|
+
* a pass/fail verdict. A throw from {@link runGatedShell} (non-interactive
|
|
72
|
+
* approval, sandbox start failure) is a failure whose policy the caller
|
|
73
|
+
* applies — so an advisory hook can never abort the run on an infra error.
|
|
74
|
+
*/
|
|
75
|
+
private runOne;
|
|
76
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { hookFailed, hookUntrusted, messageOf } from "../errors/index.js";
|
|
2
|
+
import { runGatedShell } from "../tools/shell/exec.js";
|
|
3
|
+
import { fingerprintHooks, isTrusted } from "./trust.js";
|
|
4
|
+
export class HookRunner {
|
|
5
|
+
deps;
|
|
6
|
+
constructor(deps) {
|
|
7
|
+
this.deps = deps;
|
|
8
|
+
}
|
|
9
|
+
/** The project hooks — the trust-gated subset. */
|
|
10
|
+
get projectHooks() {
|
|
11
|
+
return this.deps.hooks.filter((h) => h.source === "project");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fire every hook registered for `event`, in catalog order. Resolves normally
|
|
15
|
+
* when all hooks pass (or advisory ones fail); THROWS `CRUXY_E_HOOK_FAILED`
|
|
16
|
+
* when a blocking hook fails, or `CRUXY_E_HOOK_UNTRUSTED` when a project's
|
|
17
|
+
* hooks are not trusted. A no-op when hooks are disabled or none match.
|
|
18
|
+
*/
|
|
19
|
+
async fire(event, ctx) {
|
|
20
|
+
if (!this.deps.enabled)
|
|
21
|
+
return;
|
|
22
|
+
const applicable = this.deps.hooks.filter((h) => h.event === event);
|
|
23
|
+
if (applicable.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
// Trust gate FIRST — before any project hook can reach execution.
|
|
26
|
+
if (applicable.some((h) => h.source === "project")) {
|
|
27
|
+
await this.ensureProjectTrust();
|
|
28
|
+
}
|
|
29
|
+
for (const hook of applicable) {
|
|
30
|
+
this.deps.announce?.(`running hook: ${hook.name}`);
|
|
31
|
+
const verdict = await this.runOne(hook, ctx);
|
|
32
|
+
if (verdict.ok)
|
|
33
|
+
continue;
|
|
34
|
+
if (hook.blocking) {
|
|
35
|
+
// Fail-closed: stop, do not run the remaining hooks, abort the action.
|
|
36
|
+
throw hookFailed(hook.name, verdict.reason ?? "hook failed");
|
|
37
|
+
}
|
|
38
|
+
// Advisory: the action already happened (or proceeds) — report, continue.
|
|
39
|
+
this.deps.reportFailure?.(`hook "${hook.name}" failed (advisory): ${verdict.reason ?? "unknown"}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Ensure this repo's project hooks are trusted for their current fingerprint.
|
|
44
|
+
* Records trust on an interactive accept; throws `CRUXY_E_HOOK_UNTRUSTED` on
|
|
45
|
+
* decline, when `trustPrompt` is off, or when non-interactive — NEVER
|
|
46
|
+
* auto-trusts. The fingerprint covers ALL project hooks, so a change to any of
|
|
47
|
+
* them invalidates a prior decision (stale → re-prompt).
|
|
48
|
+
*/
|
|
49
|
+
async ensureProjectTrust() {
|
|
50
|
+
const projectHooks = this.projectHooks;
|
|
51
|
+
const fingerprint = fingerprintHooks(projectHooks);
|
|
52
|
+
if (isTrusted(this.deps.trust, this.deps.cwd, fingerprint))
|
|
53
|
+
return;
|
|
54
|
+
// Never auto-trust: no prompt possible → fail loud.
|
|
55
|
+
if (!this.deps.interactive ||
|
|
56
|
+
!this.deps.trustPrompt ||
|
|
57
|
+
!this.deps.promptTrust) {
|
|
58
|
+
throw hookUntrusted(this.deps.cwd, projectHooks.length);
|
|
59
|
+
}
|
|
60
|
+
const trusted = await this.deps.promptTrust({
|
|
61
|
+
root: this.deps.cwd,
|
|
62
|
+
hooks: projectHooks,
|
|
63
|
+
});
|
|
64
|
+
if (!trusted) {
|
|
65
|
+
// Decline is not recorded — the next run asks again. Nothing runs now.
|
|
66
|
+
throw hookUntrusted(this.deps.cwd, projectHooks.length);
|
|
67
|
+
}
|
|
68
|
+
this.deps.trust.record({
|
|
69
|
+
root: this.deps.cwd,
|
|
70
|
+
fingerprint,
|
|
71
|
+
at: this.deps.now?.() ?? nowIso(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Run one hook command through the shared gate + sandbox path and reduce it to
|
|
76
|
+
* a pass/fail verdict. A throw from {@link runGatedShell} (non-interactive
|
|
77
|
+
* approval, sandbox start failure) is a failure whose policy the caller
|
|
78
|
+
* applies — so an advisory hook can never abort the run on an infra error.
|
|
79
|
+
*/
|
|
80
|
+
async runOne(hook, ctx) {
|
|
81
|
+
let outcome;
|
|
82
|
+
try {
|
|
83
|
+
outcome = await runGatedShell(hook.command, ctx);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return { ok: false, reason: messageOf(err) ?? "hook could not run" };
|
|
87
|
+
}
|
|
88
|
+
return evaluate(outcome);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Reduce a gated-shell outcome to pass/fail. Success is EXACTLY exit code 0. */
|
|
92
|
+
function evaluate(outcome) {
|
|
93
|
+
if (!outcome.approved) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
reason: outcome.rejection ?? "declined at the approval prompt",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const e = outcome.exec;
|
|
100
|
+
if (e.timedOut)
|
|
101
|
+
return { ok: false, reason: "timed out" };
|
|
102
|
+
if (e.spawnError !== undefined)
|
|
103
|
+
return { ok: false, reason: e.spawnError };
|
|
104
|
+
if (e.exitCode !== 0) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
reason: `exited with code ${e.exitCode ?? e.signal ?? "unknown"}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
function nowIso() {
|
|
113
|
+
return new Date().toISOString();
|
|
114
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CruxyConfig } from "../config/index.js";
|
|
2
|
+
import type { logger as Logger } from "../utils/logger.js";
|
|
3
|
+
import { type HookSources } from "./config.js";
|
|
4
|
+
import { HookRunner, type TrustPromptInfo } from "./runner.js";
|
|
5
|
+
import { type TrustStore } from "./trust.js";
|
|
6
|
+
import type { HookCatalog, SlashCommandSpec } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Construction/wiring for the hooks subsystem (C.19). Loads the layered catalog,
|
|
9
|
+
* builds the {@link HookRunner} with the real trust store + an interactive trust
|
|
10
|
+
* prompt, and exposes the resolved custom slash commands. Everything the runner
|
|
11
|
+
* needs is injectable so the security behavior is unit-testable without touching
|
|
12
|
+
* disk or a TTY.
|
|
13
|
+
*/
|
|
14
|
+
export interface HooksService {
|
|
15
|
+
/** The lifecycle firing seam (threaded into the loop / session). */
|
|
16
|
+
runner: HookRunner;
|
|
17
|
+
/** Resolved custom slash commands (project > user). */
|
|
18
|
+
commands: SlashCommandSpec[];
|
|
19
|
+
/** The full catalog (for `cruxy hooks list` + surfacing load errors). */
|
|
20
|
+
catalog: HookCatalog;
|
|
21
|
+
}
|
|
22
|
+
export interface BuildHooksServiceOptions {
|
|
23
|
+
cwd: string;
|
|
24
|
+
config: CruxyConfig;
|
|
25
|
+
/** Whether cruxy can prompt (stdin is a TTY). */
|
|
26
|
+
interactive: boolean;
|
|
27
|
+
logger: typeof Logger;
|
|
28
|
+
sources?: HookSources;
|
|
29
|
+
trust?: TrustStore;
|
|
30
|
+
promptTrust?: (info: TrustPromptInfo) => Promise<boolean>;
|
|
31
|
+
now?: () => string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Load the catalog and assemble the {@link HooksService}. Malformed definitions
|
|
35
|
+
* are surfaced (never eval'd, never silently dropped) through the logger; the
|
|
36
|
+
* valid ones proceed.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildHooksService(opts: BuildHooksServiceOptions): Promise<HooksService>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readSingleKey } from "../components/input.js";
|
|
2
|
+
import { shouldUseColor } from "../errors/index.js";
|
|
3
|
+
import { themeForColor } from "../theme/index.js";
|
|
4
|
+
import { defaultHookSources, loadHookCatalog, } from "./config.js";
|
|
5
|
+
import { HookRunner } from "./runner.js";
|
|
6
|
+
import { fileTrustStore } from "./trust.js";
|
|
7
|
+
/**
|
|
8
|
+
* Load the catalog and assemble the {@link HooksService}. Malformed definitions
|
|
9
|
+
* are surfaced (never eval'd, never silently dropped) through the logger; the
|
|
10
|
+
* valid ones proceed.
|
|
11
|
+
*/
|
|
12
|
+
export async function buildHooksService(opts) {
|
|
13
|
+
const sources = opts.sources ?? defaultHookSources(opts.cwd);
|
|
14
|
+
const catalog = await loadHookCatalog(sources);
|
|
15
|
+
for (const err of catalog.errors) {
|
|
16
|
+
opts.logger.warn(`ignoring malformed ${err.source} hook/command "${err.name}": ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
const trust = opts.trust ?? fileTrustStore();
|
|
19
|
+
const runner = new HookRunner({
|
|
20
|
+
hooks: catalog.hooks,
|
|
21
|
+
trust,
|
|
22
|
+
enabled: opts.config.hooks.enabled,
|
|
23
|
+
trustPrompt: opts.config.hooks.trustPrompt,
|
|
24
|
+
interactive: opts.interactive,
|
|
25
|
+
cwd: opts.cwd,
|
|
26
|
+
promptTrust: opts.promptTrust ?? defaultTrustPrompt,
|
|
27
|
+
announce: (message) => opts.logger.info(message),
|
|
28
|
+
reportFailure: (message) => opts.logger.warn(message),
|
|
29
|
+
now: opts.now,
|
|
30
|
+
});
|
|
31
|
+
return { runner, commands: catalog.commands, catalog };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The real interactive trust prompt: list the project's hooks (name → event →
|
|
35
|
+
* command) and read a single y/N key. Default-deny — anything but `y` (including
|
|
36
|
+
* EOF / Ctrl-C) declines, so an untrusted repo never runs on a stray keypress.
|
|
37
|
+
*/
|
|
38
|
+
async function defaultTrustPrompt(info) {
|
|
39
|
+
const t = themeForColor(shouldUseColor());
|
|
40
|
+
const out = process.stderr;
|
|
41
|
+
out.write(`\n${t.warning(t.strong("!"))} this project defines ${t.strong(String(info.hooks.length))} hook${info.hooks.length === 1 ? "" : "s"} (authored by the repo):\n`);
|
|
42
|
+
for (const h of info.hooks) {
|
|
43
|
+
out.write(` ${t.muted(`${h.event}`)} ${t.strong(h.name)} ${t.muted("→")} ${h.command}\n`);
|
|
44
|
+
}
|
|
45
|
+
out.write(`${t.muted("review these carefully.")} trust and run this project's hooks? ${t.muted("[y/N]")} `);
|
|
46
|
+
const key = (await readSingleKey()).toLowerCase();
|
|
47
|
+
out.write("\n");
|
|
48
|
+
return key === "y";
|
|
49
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SlashCommandSpec } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Custom slash-command resolution (C.19). Builtins are reserved and always win —
|
|
4
|
+
* a custom command can never shadow `/help`, `/exit`, etc. A resolved custom
|
|
5
|
+
* command is either a `prompt` (expanded to safe text fed to the agent) or a
|
|
6
|
+
* `shell` binding (executed through the SAME gate + sandbox as everything else,
|
|
7
|
+
* by the caller). Nothing here executes anything; it only resolves + expands.
|
|
8
|
+
*/
|
|
9
|
+
/** The builtin slash commands (C.13) — reserved, not overridable. */
|
|
10
|
+
export declare const BUILTIN_SLASH_COMMANDS: readonly ["help", "clear", "compact", "reload", "plan", "exit", "quit"];
|
|
11
|
+
/** Is `name` (no leading slash) a reserved builtin? */
|
|
12
|
+
export declare function isBuiltinSlash(name: string): boolean;
|
|
13
|
+
/** The outcome of resolving a `/…` line against the custom catalog. */
|
|
14
|
+
export type SlashResolution =
|
|
15
|
+
/** A builtin — the REPL's own dispatch handles it (custom never shadows it). */
|
|
16
|
+
{
|
|
17
|
+
kind: "builtin";
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
/** A prompt-template command, already expanded to the text to send the agent. */
|
|
21
|
+
| {
|
|
22
|
+
kind: "prompt";
|
|
23
|
+
spec: SlashCommandSpec;
|
|
24
|
+
prompt: string;
|
|
25
|
+
}
|
|
26
|
+
/** A shell-bound command — the caller runs `spec.command` through the gate. */
|
|
27
|
+
| {
|
|
28
|
+
kind: "shell";
|
|
29
|
+
spec: SlashCommandSpec;
|
|
30
|
+
args: string;
|
|
31
|
+
}
|
|
32
|
+
/** Not a slash line, or an unknown command. */
|
|
33
|
+
| {
|
|
34
|
+
kind: "none";
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Resolve one input line. Non-slash input and unknown names → `none`. Builtins
|
|
38
|
+
* short-circuit to `builtin` BEFORE the custom catalog is consulted, so a custom
|
|
39
|
+
* command named after a builtin is inert (surfaced separately at load time).
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveSlash(line: string, commands: readonly SlashCommandSpec[]): SlashResolution;
|
|
42
|
+
/**
|
|
43
|
+
* Expand a prompt template — substitute every `{{args}}` with the caller's args.
|
|
44
|
+
* Pure text in, pure text out: the result is fed to the agent as a user prompt,
|
|
45
|
+
* so a prompt command can never execute anything (that is the whole safety of
|
|
46
|
+
* the default `prompt` kind).
|
|
47
|
+
*/
|
|
48
|
+
export declare function expandTemplate(template: string, args: string): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom slash-command resolution (C.19). Builtins are reserved and always win —
|
|
3
|
+
* a custom command can never shadow `/help`, `/exit`, etc. A resolved custom
|
|
4
|
+
* command is either a `prompt` (expanded to safe text fed to the agent) or a
|
|
5
|
+
* `shell` binding (executed through the SAME gate + sandbox as everything else,
|
|
6
|
+
* by the caller). Nothing here executes anything; it only resolves + expands.
|
|
7
|
+
*/
|
|
8
|
+
/** The builtin slash commands (C.13) — reserved, not overridable. */
|
|
9
|
+
export const BUILTIN_SLASH_COMMANDS = [
|
|
10
|
+
"help",
|
|
11
|
+
"clear",
|
|
12
|
+
"compact",
|
|
13
|
+
"reload",
|
|
14
|
+
"plan",
|
|
15
|
+
"exit",
|
|
16
|
+
"quit",
|
|
17
|
+
];
|
|
18
|
+
const BUILTINS = new Set(BUILTIN_SLASH_COMMANDS);
|
|
19
|
+
/** Is `name` (no leading slash) a reserved builtin? */
|
|
20
|
+
export function isBuiltinSlash(name) {
|
|
21
|
+
return BUILTINS.has(name);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve one input line. Non-slash input and unknown names → `none`. Builtins
|
|
25
|
+
* short-circuit to `builtin` BEFORE the custom catalog is consulted, so a custom
|
|
26
|
+
* command named after a builtin is inert (surfaced separately at load time).
|
|
27
|
+
*/
|
|
28
|
+
export function resolveSlash(line, commands) {
|
|
29
|
+
const trimmed = line.trim();
|
|
30
|
+
if (!trimmed.startsWith("/"))
|
|
31
|
+
return { kind: "none" };
|
|
32
|
+
const space = trimmed.indexOf(" ");
|
|
33
|
+
const name = (space === -1 ? trimmed.slice(1) : trimmed.slice(1, space)).trim();
|
|
34
|
+
const args = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
35
|
+
if (name === "")
|
|
36
|
+
return { kind: "none" };
|
|
37
|
+
if (isBuiltinSlash(name))
|
|
38
|
+
return { kind: "builtin", name };
|
|
39
|
+
const spec = commands.find((c) => c.name === name);
|
|
40
|
+
if (!spec)
|
|
41
|
+
return { kind: "none" };
|
|
42
|
+
if (spec.kind === "shell")
|
|
43
|
+
return { kind: "shell", spec, args };
|
|
44
|
+
return {
|
|
45
|
+
kind: "prompt",
|
|
46
|
+
spec,
|
|
47
|
+
prompt: expandTemplate(spec.template ?? "", args),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Expand a prompt template — substitute every `{{args}}` with the caller's args.
|
|
52
|
+
* Pure text in, pure text out: the result is fed to the agent as a user prompt,
|
|
53
|
+
* so a prompt command can never execute anything (that is the whole safety of
|
|
54
|
+
* the default `prompt` kind).
|
|
55
|
+
*/
|
|
56
|
+
export function expandTemplate(template, args) {
|
|
57
|
+
return template.replaceAll("{{args}}", args);
|
|
58
|
+
}
|