@bubblebrain-ai/bubble 0.0.18 → 0.0.20
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/internal-reminder-sanitizer.d.ts +1 -0
- package/dist/agent/internal-reminder-sanitizer.js +46 -0
- package/dist/agent.d.ts +9 -0
- package/dist/agent.js +305 -17
- package/dist/approval/controller.d.ts +6 -0
- package/dist/approval/controller.js +104 -11
- package/dist/debug-trace.js +4 -0
- package/dist/feishu/agent-host/run-driver.js +28 -0
- package/dist/hooks/config.d.ts +9 -0
- package/dist/hooks/config.js +278 -0
- package/dist/hooks/controller.d.ts +24 -0
- package/dist/hooks/controller.js +254 -0
- package/dist/hooks/index.d.ts +6 -0
- package/dist/hooks/index.js +4 -0
- package/dist/hooks/log.d.ts +14 -0
- package/dist/hooks/log.js +54 -0
- package/dist/hooks/runner.d.ts +5 -0
- package/dist/hooks/runner.js +225 -0
- package/dist/hooks/trust.d.ts +37 -0
- package/dist/hooks/trust.js +143 -0
- package/dist/hooks/types.d.ts +173 -0
- package/dist/hooks/types.js +46 -0
- package/dist/main.js +32 -0
- package/dist/memory/prompts.js +3 -1
- package/dist/model-catalog.js +2 -0
- package/dist/model-pricing.js +8 -0
- package/dist/network/chatgpt-transport.js +34 -9
- package/dist/network/provider-transport.d.ts +32 -0
- package/dist/network/provider-transport.js +265 -0
- package/dist/network/retry.d.ts +29 -0
- package/dist/network/retry.js +88 -0
- package/dist/network/system-proxy.d.ts +18 -0
- package/dist/network/system-proxy.js +175 -0
- package/dist/provider-anthropic.d.ts +1 -0
- package/dist/provider-anthropic.js +127 -52
- package/dist/provider-openai-codex.js +19 -29
- package/dist/session-log.js +3 -3
- package/dist/slash-commands/commands.js +84 -0
- package/dist/slash-commands/types.d.ts +2 -0
- package/dist/tools/edit-apply.js +63 -3
- package/dist/tools/edit.js +4 -4
- package/dist/tui/display-history.d.ts +4 -3
- package/dist/tui/display-history.js +34 -57
- package/dist/tui/display-sanitizer.d.ts +3 -0
- package/dist/tui/display-sanitizer.js +38 -0
- package/dist/tui/paste-placeholder.d.ts +1 -0
- package/dist/tui/paste-placeholder.js +7 -0
- package/dist/tui/run.d.ts +2 -0
- package/dist/tui/run.js +260 -155
- package/dist/tui/trace-groups.js +40 -4
- package/dist/tui/wordmark.d.ts +1 -0
- package/dist/tui/wordmark.js +56 -54
- package/dist/tui-ink/app.js +2 -1
- package/dist/tui-ink/trace-groups.js +40 -4
- package/dist/tui-opentui/app.js +2 -1
- package/dist/tui-opentui/trace-groups.js +40 -4
- package/dist/types.d.ts +27 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkPermission } from "../permissions/rule.js";
|
|
2
|
+
import { truncateHookText } from "../hooks/index.js";
|
|
2
3
|
/**
|
|
3
4
|
* Default ApprovalController. Decision tree:
|
|
4
5
|
*
|
|
@@ -27,40 +28,48 @@ export class PermissionAwareApprovalController {
|
|
|
27
28
|
}
|
|
28
29
|
async request(req) {
|
|
29
30
|
const ruleResult = this.checkRequestRules(req);
|
|
31
|
+
const finalize = async (decision) => {
|
|
32
|
+
await this.runPermissionResultHook(req, decision);
|
|
33
|
+
return decision;
|
|
34
|
+
};
|
|
30
35
|
if (ruleResult.decision === "deny") {
|
|
31
|
-
return {
|
|
36
|
+
return finalize({
|
|
32
37
|
action: "reject",
|
|
33
38
|
feedback: `Blocked by deny rule: ${ruleResult.rule?.source ?? "<unknown>"}`,
|
|
34
|
-
};
|
|
39
|
+
});
|
|
35
40
|
}
|
|
36
41
|
const mode = this.options.getMode();
|
|
42
|
+
const hookDecision = await this.runPermissionRequestHook(req, mode, ruleResult.decision);
|
|
43
|
+
if (hookDecision.action === "reject") {
|
|
44
|
+
return finalize(hookDecision);
|
|
45
|
+
}
|
|
37
46
|
if (mode === "bypassPermissions") {
|
|
38
|
-
return { action: "approve" };
|
|
47
|
+
return finalize({ action: "approve" });
|
|
39
48
|
}
|
|
40
49
|
if (mode === "default" && (req.type === "edit" || req.type === "write" || req.type === "patch")) {
|
|
41
|
-
return { action: "approve" };
|
|
50
|
+
return finalize({ action: "approve" });
|
|
42
51
|
}
|
|
43
52
|
if (mode === "plan") {
|
|
44
|
-
return {
|
|
53
|
+
return finalize({
|
|
45
54
|
action: "reject",
|
|
46
55
|
feedback: "Plan mode is active. Do not call destructive tools directly — propose your changes via exit_plan_mode and wait for user approval.",
|
|
47
|
-
};
|
|
56
|
+
});
|
|
48
57
|
}
|
|
49
58
|
if (ruleResult.decision === "allow") {
|
|
50
|
-
return { action: "approve" };
|
|
59
|
+
return finalize({ action: "approve" });
|
|
51
60
|
}
|
|
52
61
|
// Session-scoped allowlist: previously-approved bash prefixes skip the prompt.
|
|
53
62
|
if (req.type === "bash" && this.options.bashAllowlist?.matches(req.command)) {
|
|
54
|
-
return { action: "approve" };
|
|
63
|
+
return finalize({ action: "approve" });
|
|
55
64
|
}
|
|
56
65
|
const handler = this.options.handlerRef.current;
|
|
57
66
|
if (!handler) {
|
|
58
|
-
return {
|
|
67
|
+
return finalize({
|
|
59
68
|
action: "reject",
|
|
60
69
|
feedback: "No interactive UI is available to approve this tool call.",
|
|
61
|
-
};
|
|
70
|
+
});
|
|
62
71
|
}
|
|
63
|
-
return handler(req);
|
|
72
|
+
return finalize(await handler(req));
|
|
64
73
|
}
|
|
65
74
|
requestToQuery(req) {
|
|
66
75
|
switch (req.type) {
|
|
@@ -92,4 +101,88 @@ export class PermissionAwareApprovalController {
|
|
|
92
101
|
}
|
|
93
102
|
return { decision: "ask" };
|
|
94
103
|
}
|
|
104
|
+
async runPermissionRequestHook(req, mode, ruleDecision) {
|
|
105
|
+
const hooks = this.options.externalHooks;
|
|
106
|
+
if (!hooks)
|
|
107
|
+
return { action: "approve" };
|
|
108
|
+
try {
|
|
109
|
+
const result = await hooks.runEvent({
|
|
110
|
+
eventName: "PermissionRequest",
|
|
111
|
+
cwd: this.options.cwd,
|
|
112
|
+
sessionId: this.options.sessionId,
|
|
113
|
+
agentRole: "driver",
|
|
114
|
+
target: approvalTarget(req),
|
|
115
|
+
payload: {
|
|
116
|
+
request: summarizeApprovalRequest(req),
|
|
117
|
+
mode,
|
|
118
|
+
ruleDecision,
|
|
119
|
+
},
|
|
120
|
+
fullPayload: { permissionRequest: req },
|
|
121
|
+
});
|
|
122
|
+
if (result.decision === "deny") {
|
|
123
|
+
return {
|
|
124
|
+
action: "reject",
|
|
125
|
+
feedback: result.reason ?? `Blocked by hook ${result.sourceHookId ?? "<unknown>"}.`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Hook failures are handled by the hook controller policy and must not
|
|
131
|
+
// crash approval handling.
|
|
132
|
+
}
|
|
133
|
+
return { action: "approve" };
|
|
134
|
+
}
|
|
135
|
+
async runPermissionResultHook(req, decision) {
|
|
136
|
+
const hooks = this.options.externalHooks;
|
|
137
|
+
if (!hooks)
|
|
138
|
+
return;
|
|
139
|
+
try {
|
|
140
|
+
await hooks.runEvent({
|
|
141
|
+
eventName: "PermissionResult",
|
|
142
|
+
cwd: this.options.cwd,
|
|
143
|
+
sessionId: this.options.sessionId,
|
|
144
|
+
agentRole: "driver",
|
|
145
|
+
target: approvalTarget(req),
|
|
146
|
+
payload: {
|
|
147
|
+
request: summarizeApprovalRequest(req),
|
|
148
|
+
decision: decision.action,
|
|
149
|
+
feedback: decision.feedback ? truncateHookText(decision.feedback, 500) : undefined,
|
|
150
|
+
},
|
|
151
|
+
fullPayload: {
|
|
152
|
+
permissionRequest: req,
|
|
153
|
+
permissionDecision: decision,
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Observe-only.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function approvalTarget(req) {
|
|
163
|
+
switch (req.type) {
|
|
164
|
+
case "bash":
|
|
165
|
+
return "Bash";
|
|
166
|
+
case "write":
|
|
167
|
+
return "Write";
|
|
168
|
+
case "edit":
|
|
169
|
+
case "patch":
|
|
170
|
+
return "Edit";
|
|
171
|
+
case "lsp":
|
|
172
|
+
return "Lsp";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function summarizeApprovalRequest(req) {
|
|
176
|
+
switch (req.type) {
|
|
177
|
+
case "bash":
|
|
178
|
+
return { type: req.type, commandPreview: truncateHookText(req.command, 500), cwd: req.cwd };
|
|
179
|
+
case "write":
|
|
180
|
+
return { type: req.type, path: req.path, fileExists: req.fileExists, contentLength: req.content.length };
|
|
181
|
+
case "edit":
|
|
182
|
+
return { type: req.type, path: req.path, fileExists: req.fileExists, diffLength: req.diff.length };
|
|
183
|
+
case "patch":
|
|
184
|
+
return { type: req.type, path: req.path, paths: req.paths, files: req.files, diffLength: req.diff.length };
|
|
185
|
+
case "lsp":
|
|
186
|
+
return { type: req.type, path: req.path, operation: req.operation };
|
|
187
|
+
}
|
|
95
188
|
}
|
package/dist/debug-trace.js
CHANGED
|
@@ -181,6 +181,10 @@ export function summarizeAgentEventForTrace(event) {
|
|
|
181
181
|
case "text_delta":
|
|
182
182
|
case "reasoning_delta":
|
|
183
183
|
return { type: event.type, content: summarizeTraceText(event.content) };
|
|
184
|
+
case "hook_start":
|
|
185
|
+
case "hook_end":
|
|
186
|
+
case "hook_error":
|
|
187
|
+
return { ...event };
|
|
184
188
|
case "tool_call_start":
|
|
185
189
|
return { type: event.type, id: event.id, name: event.name };
|
|
186
190
|
case "tool_call_delta":
|
|
@@ -17,6 +17,7 @@ import { BudgetLedger } from "../../agent/budget-ledger.js";
|
|
|
17
17
|
import { PermissionAwareApprovalController } from "../../approval/controller.js";
|
|
18
18
|
import { BashAllowlist } from "../../approval/session-cache.js";
|
|
19
19
|
import { getLspService } from "../../lsp/index.js";
|
|
20
|
+
import { ExternalHookController } from "../../hooks/index.js";
|
|
20
21
|
import { buildSystemPrompt } from "../../system-prompt.js";
|
|
21
22
|
import { FileStateTracker } from "../../tools/file-state.js";
|
|
22
23
|
import { buildToolPromptOptions, createAllTools } from "../../tools/index.js";
|
|
@@ -41,6 +42,7 @@ export class RunDriver {
|
|
|
41
42
|
async runOnce(req) {
|
|
42
43
|
// 1. Resolve session
|
|
43
44
|
const session = this.opts.binder.openOrBootstrap(req.scopeKey, req.scope.cwd, req.scope.defaultPermissionMode);
|
|
45
|
+
const hookController = new ExternalHookController({ cwd: session.cwd });
|
|
44
46
|
// 2. Build approval controller wired to FeishuApprovalUI
|
|
45
47
|
const bashAllowlist = new BashAllowlist();
|
|
46
48
|
const approvalHandlerRef = {
|
|
@@ -53,6 +55,7 @@ export class RunDriver {
|
|
|
53
55
|
bashAllowlist,
|
|
54
56
|
cwd: session.cwd,
|
|
55
57
|
getRuleSet: () => this.opts.deps.settingsManager.getMerged().ruleSet,
|
|
58
|
+
externalHooks: hookController,
|
|
56
59
|
});
|
|
57
60
|
// 3. Build tools + Agent
|
|
58
61
|
const lspService = getLspService(session.cwd, this.opts.deps.settingsManager.getMerged().lsp);
|
|
@@ -137,6 +140,7 @@ export class RunDriver {
|
|
|
137
140
|
fileStateTracker,
|
|
138
141
|
agentCategories: this.opts.deps.userConfig.getAgentCategories(),
|
|
139
142
|
providerFactory: (route) => this.opts.deps.createProviderForRoute(route, promptCacheKey),
|
|
143
|
+
externalHooks: hookController,
|
|
140
144
|
});
|
|
141
145
|
sessionTitleUpdater = createSessionTitleUpdater({
|
|
142
146
|
sessionManager: session.manager,
|
|
@@ -150,6 +154,18 @@ export class RunDriver {
|
|
|
150
154
|
thinkingLevel: agent.thinking,
|
|
151
155
|
reasoningEffort: agent.thinking,
|
|
152
156
|
});
|
|
157
|
+
await hookController.runEvent({
|
|
158
|
+
eventName: "SessionStart",
|
|
159
|
+
cwd: session.cwd,
|
|
160
|
+
sessionId: session.manager.getSessionFile(),
|
|
161
|
+
agentRole: "driver",
|
|
162
|
+
target: "feishu",
|
|
163
|
+
payload: {
|
|
164
|
+
chatId: req.chatId,
|
|
165
|
+
providerId,
|
|
166
|
+
model,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
153
169
|
// Restore prior history into the running Agent instance.
|
|
154
170
|
if (!session.fresh) {
|
|
155
171
|
const history = session.manager.getMessages();
|
|
@@ -248,6 +264,18 @@ export class RunDriver {
|
|
|
248
264
|
}
|
|
249
265
|
finally {
|
|
250
266
|
clearInterval(watchdog);
|
|
267
|
+
await hookController.runEvent({
|
|
268
|
+
eventName: "SessionEnd",
|
|
269
|
+
cwd: session.cwd,
|
|
270
|
+
sessionId: session.manager.getSessionFile(),
|
|
271
|
+
agentRole: "driver",
|
|
272
|
+
target: "feishu",
|
|
273
|
+
payload: {
|
|
274
|
+
chatId: req.chatId,
|
|
275
|
+
providerId: agent.providerId,
|
|
276
|
+
model: agent.apiModel,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
251
279
|
// Cancel any pending approval prompts attached to this run.
|
|
252
280
|
this.opts.approvalUI.cancelForChat(req.chatId, "Run ended");
|
|
253
281
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ProjectHookFingerprint, type TrustStoreOptions } from "./trust.js";
|
|
2
|
+
import { type HookEventName, type LoadedHookConfig } from "./types.js";
|
|
3
|
+
export interface LoadHookConfigOptions extends TrustStoreOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function loadHookConfig(options: LoadHookConfigOptions): LoadedHookConfig;
|
|
7
|
+
export declare function getProjectHookFingerprint(options: LoadHookConfigOptions): ProjectHookFingerprint | undefined;
|
|
8
|
+
export declare function formatHooksStatus(config: LoadedHookConfig): string;
|
|
9
|
+
export declare function explainHookEvent(eventName: HookEventName, config: LoadedHookConfig): string;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { getBubbleHome } from "../bubble-home.js";
|
|
4
|
+
import { buildProjectHookFingerprint, isProjectHookFingerprintTrusted, } from "./trust.js";
|
|
5
|
+
import { isHookEventName, } from "./types.js";
|
|
6
|
+
const SCOPES = ["user", "project", "local"];
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
8
|
+
const MAX_TIMEOUT_MS = 600_000;
|
|
9
|
+
const DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
10
|
+
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
11
|
+
export function loadHookConfig(options) {
|
|
12
|
+
const bubbleHome = options.bubbleHome ?? getBubbleHome();
|
|
13
|
+
const paths = {
|
|
14
|
+
user: join(bubbleHome, "settings.json"),
|
|
15
|
+
project: join(options.cwd, ".bubble", "settings.json"),
|
|
16
|
+
local: join(options.cwd, ".bubble", "settings.local.json"),
|
|
17
|
+
};
|
|
18
|
+
const diagnostics = [];
|
|
19
|
+
const rules = [];
|
|
20
|
+
for (const scope of SCOPES) {
|
|
21
|
+
const path = paths[scope];
|
|
22
|
+
const loaded = readHooksFile(scope, path);
|
|
23
|
+
diagnostics.push(...loaded.diagnostics);
|
|
24
|
+
if (!loaded.settings)
|
|
25
|
+
continue;
|
|
26
|
+
const parsed = parseHookSettings(scope, path, loaded.settings);
|
|
27
|
+
diagnostics.push(...parsed.diagnostics);
|
|
28
|
+
rules.push(...parsed.rules);
|
|
29
|
+
}
|
|
30
|
+
const projectRules = rules.filter((rule) => rule.source.scope === "project");
|
|
31
|
+
const fingerprint = projectRules.length > 0
|
|
32
|
+
? buildProjectHookFingerprint(options.cwd, paths.project, projectRules)
|
|
33
|
+
: undefined;
|
|
34
|
+
const trust = isProjectHookFingerprintTrusted(fingerprint, { bubbleHome });
|
|
35
|
+
for (const rule of rules) {
|
|
36
|
+
if (rule.source.scope !== "project")
|
|
37
|
+
continue;
|
|
38
|
+
rule.trustRequired = true;
|
|
39
|
+
rule.trusted = trust.trusted;
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
rules: rules.sort(compareRules),
|
|
43
|
+
diagnostics,
|
|
44
|
+
paths,
|
|
45
|
+
projectTrust: {
|
|
46
|
+
required: projectRules.length > 0,
|
|
47
|
+
trusted: trust.trusted,
|
|
48
|
+
projectKey: fingerprint?.projectKey,
|
|
49
|
+
fingerprint: fingerprint?.fingerprint,
|
|
50
|
+
trustedFingerprint: trust.trustedFingerprint,
|
|
51
|
+
reason: projectRules.length === 0
|
|
52
|
+
? "No project hooks configured."
|
|
53
|
+
: trust.trusted
|
|
54
|
+
? "Project hooks are trusted for the current fingerprint."
|
|
55
|
+
: "Project hooks are configured but not trusted for the current fingerprint.",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function getProjectHookFingerprint(options) {
|
|
60
|
+
const loaded = loadHookConfig(options);
|
|
61
|
+
if (!loaded.projectTrust.required)
|
|
62
|
+
return undefined;
|
|
63
|
+
const projectRules = loaded.rules.filter((rule) => rule.source.scope === "project");
|
|
64
|
+
return buildProjectHookFingerprint(options.cwd, loaded.paths.project, projectRules);
|
|
65
|
+
}
|
|
66
|
+
export function formatHooksStatus(config) {
|
|
67
|
+
const lines = ["Hooks status:"];
|
|
68
|
+
lines.push(` user: ${config.paths.user}`);
|
|
69
|
+
lines.push(` project: ${config.paths.project}`);
|
|
70
|
+
lines.push(` local: ${config.paths.local}`);
|
|
71
|
+
if (config.projectTrust.required) {
|
|
72
|
+
lines.push(` project trust: ${config.projectTrust.trusted ? "trusted" : "not trusted"}`
|
|
73
|
+
+ (config.projectTrust.fingerprint ? ` (${config.projectTrust.fingerprint.slice(0, 12)})` : ""));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
lines.push(" project trust: not required");
|
|
77
|
+
}
|
|
78
|
+
lines.push("", `Rules (${config.rules.length}):`);
|
|
79
|
+
if (config.rules.length === 0) {
|
|
80
|
+
lines.push(" (none)");
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
for (const rule of config.rules) {
|
|
84
|
+
const state = rule.enabled && rule.trusted ? "enabled" : rule.enabled ? "untrusted" : "disabled";
|
|
85
|
+
const matcher = rule.matcher ? ` matcher=${rule.matcher}` : "";
|
|
86
|
+
lines.push(` ${state} ${rule.id} [${rule.source.scope}] events=${rule.events.join(",")} command=${formatCommand(rule.command.command, rule.command.args)}${matcher}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (config.diagnostics.length > 0) {
|
|
90
|
+
lines.push("", "Diagnostics:");
|
|
91
|
+
for (const diagnostic of config.diagnostics) {
|
|
92
|
+
lines.push(` [${diagnostic.scope}] ${diagnostic.path}: ${diagnostic.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
export function explainHookEvent(eventName, config) {
|
|
98
|
+
const rules = config.rules.filter((rule) => rule.events.includes(eventName));
|
|
99
|
+
const lines = [`Hooks for ${eventName}:`];
|
|
100
|
+
if (rules.length === 0) {
|
|
101
|
+
lines.push(" (none)");
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
for (const rule of rules) {
|
|
105
|
+
const reasons = [];
|
|
106
|
+
if (!rule.enabled)
|
|
107
|
+
reasons.push("disabled");
|
|
108
|
+
if (!rule.trusted)
|
|
109
|
+
reasons.push("untrusted");
|
|
110
|
+
if (rule.source.scope === "project" && !rule.trusted)
|
|
111
|
+
reasons.push("run /hooks trust project");
|
|
112
|
+
const suffix = reasons.length ? ` - ${reasons.join(", ")}` : "";
|
|
113
|
+
lines.push(` ${rule.id} [${rule.source.scope}] ${formatCommand(rule.command.command, rule.command.args)}${suffix}`);
|
|
114
|
+
}
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|
|
117
|
+
function readHooksFile(scope, path) {
|
|
118
|
+
if (!existsSync(path))
|
|
119
|
+
return { diagnostics: [] };
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
122
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
123
|
+
return { diagnostics: [{ scope, path, message: "Settings file must contain a JSON object." }] };
|
|
124
|
+
}
|
|
125
|
+
if (parsed.hooks === undefined)
|
|
126
|
+
return { diagnostics: [] };
|
|
127
|
+
if (Array.isArray(parsed.hooks))
|
|
128
|
+
return { settings: { rules: parsed.hooks }, diagnostics: [] };
|
|
129
|
+
if (!parsed.hooks || typeof parsed.hooks !== "object" || Array.isArray(parsed.hooks)) {
|
|
130
|
+
return { diagnostics: [{ scope, path, message: "Ignored hooks setting - expected object or array." }] };
|
|
131
|
+
}
|
|
132
|
+
return { settings: parsed.hooks, diagnostics: [] };
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
diagnostics: [{
|
|
137
|
+
scope,
|
|
138
|
+
path,
|
|
139
|
+
message: `Failed to parse hooks settings: ${error.message}`,
|
|
140
|
+
}],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function parseHookSettings(scope, path, settings) {
|
|
145
|
+
const diagnostics = [];
|
|
146
|
+
const rules = [];
|
|
147
|
+
if (settings.enabled === false)
|
|
148
|
+
return { rules, diagnostics };
|
|
149
|
+
if (!Array.isArray(settings.rules)) {
|
|
150
|
+
diagnostics.push({ scope, path, message: "Ignored hooks.rules - expected array." });
|
|
151
|
+
return { rules, diagnostics };
|
|
152
|
+
}
|
|
153
|
+
settings.rules.forEach((raw, index) => {
|
|
154
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
155
|
+
diagnostics.push({ scope, path, message: `Ignored hook rule at index ${index} - expected object.` });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const parsed = parseHookRule(raw, { scope, path, index });
|
|
159
|
+
if (parsed.diagnostic)
|
|
160
|
+
diagnostics.push(parsed.diagnostic);
|
|
161
|
+
if (parsed.rule)
|
|
162
|
+
rules.push(parsed.rule);
|
|
163
|
+
});
|
|
164
|
+
return { rules, diagnostics };
|
|
165
|
+
}
|
|
166
|
+
function parseHookRule(raw, source) {
|
|
167
|
+
const events = parseEvents(raw.event, raw.events);
|
|
168
|
+
if (events.length === 0) {
|
|
169
|
+
return { diagnostic: diagnostic(source, "Ignored hook rule - event must be a known hook event.") };
|
|
170
|
+
}
|
|
171
|
+
const command = parseCommand(raw, source);
|
|
172
|
+
if (typeof command === "string") {
|
|
173
|
+
return { diagnostic: diagnostic(source, command) };
|
|
174
|
+
}
|
|
175
|
+
const matcher = typeof raw.matcher === "string" && raw.matcher.trim() ? raw.matcher.trim() : undefined;
|
|
176
|
+
if (matcher) {
|
|
177
|
+
try {
|
|
178
|
+
new RegExp(matcher);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
return { diagnostic: diagnostic(source, `Ignored hook rule - invalid matcher regex: ${error.message}`) };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const idRaw = typeof raw.id === "string" ? raw.id : typeof raw.name === "string" ? raw.name : "";
|
|
185
|
+
const id = idRaw.trim() || `${source.scope}:${source.index}:${events.join("+")}:${command.command}`;
|
|
186
|
+
const onError = parseFailurePolicy(raw.onError ?? raw.failurePolicy);
|
|
187
|
+
const include = parseStringArray(raw.include);
|
|
188
|
+
return {
|
|
189
|
+
rule: {
|
|
190
|
+
id,
|
|
191
|
+
events,
|
|
192
|
+
matcher,
|
|
193
|
+
command,
|
|
194
|
+
timeoutMs: clampNumber(raw.timeoutMs, DEFAULT_TIMEOUT_MS, 50, MAX_TIMEOUT_MS),
|
|
195
|
+
maxOutputBytes: clampNumber(raw.maxOutputBytes, DEFAULT_MAX_OUTPUT_BYTES, 1024, MAX_OUTPUT_BYTES),
|
|
196
|
+
enabled: raw.enabled !== false,
|
|
197
|
+
onError,
|
|
198
|
+
include,
|
|
199
|
+
exposeToModel: raw.exposeToModel === true,
|
|
200
|
+
inheritToSubagents: raw.inheritToSubagents === true,
|
|
201
|
+
priority: clampNumber(raw.priority, 0, -10_000, 10_000),
|
|
202
|
+
source,
|
|
203
|
+
trusted: source.scope !== "project",
|
|
204
|
+
trustRequired: source.scope === "project",
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function parseEvents(event, events) {
|
|
209
|
+
const values = Array.isArray(events) ? events : event !== undefined ? [event] : [];
|
|
210
|
+
const parsed = [];
|
|
211
|
+
for (const value of values) {
|
|
212
|
+
if (isHookEventName(value) && !parsed.includes(value))
|
|
213
|
+
parsed.push(value);
|
|
214
|
+
}
|
|
215
|
+
return parsed;
|
|
216
|
+
}
|
|
217
|
+
function parseCommand(raw, source) {
|
|
218
|
+
if (typeof raw.command !== "string" || !raw.command.trim()) {
|
|
219
|
+
return "Ignored hook rule - command must be a non-empty string.";
|
|
220
|
+
}
|
|
221
|
+
if (/[\0\r\n]/.test(raw.command)) {
|
|
222
|
+
return "Ignored hook rule - command must not contain control characters.";
|
|
223
|
+
}
|
|
224
|
+
const command = resolveCommand(raw.command.trim(), source);
|
|
225
|
+
if (source.scope === "project" && !looksLikePath(raw.command.trim())) {
|
|
226
|
+
return "Ignored project hook rule - project hook command must be an absolute or relative executable path.";
|
|
227
|
+
}
|
|
228
|
+
const args = parseStringArray(raw.args);
|
|
229
|
+
const cwd = typeof raw.cwd === "string" && raw.cwd.trim()
|
|
230
|
+
? resolveAgainstSource(raw.cwd.trim(), source)
|
|
231
|
+
: undefined;
|
|
232
|
+
const env = parseEnv(raw.env);
|
|
233
|
+
return { command, ...(args.length ? { args } : {}), ...(cwd ? { cwd } : {}), ...(env ? { env } : {}) };
|
|
234
|
+
}
|
|
235
|
+
function resolveCommand(command, source) {
|
|
236
|
+
return looksLikePath(command) ? resolveAgainstSource(command, source) : command;
|
|
237
|
+
}
|
|
238
|
+
function resolveAgainstSource(value, source) {
|
|
239
|
+
return isAbsolute(value) ? value : resolve(dirname(source.path), value);
|
|
240
|
+
}
|
|
241
|
+
function parseEnv(value) {
|
|
242
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
243
|
+
return undefined;
|
|
244
|
+
const entries = Object.entries(value)
|
|
245
|
+
.filter(([key, item]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof item === "string");
|
|
246
|
+
return entries.length ? Object.fromEntries(entries) : undefined;
|
|
247
|
+
}
|
|
248
|
+
function parseFailurePolicy(value) {
|
|
249
|
+
return value === "block" ? "block" : "allow";
|
|
250
|
+
}
|
|
251
|
+
function parseStringArray(value) {
|
|
252
|
+
if (!Array.isArray(value))
|
|
253
|
+
return [];
|
|
254
|
+
return value.filter((item) => typeof item === "string");
|
|
255
|
+
}
|
|
256
|
+
function clampNumber(value, fallback, min, max) {
|
|
257
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
258
|
+
return fallback;
|
|
259
|
+
return Math.min(max, Math.max(min, Math.floor(value)));
|
|
260
|
+
}
|
|
261
|
+
function diagnostic(source, message) {
|
|
262
|
+
return { scope: source.scope, path: source.path, message };
|
|
263
|
+
}
|
|
264
|
+
function looksLikePath(value) {
|
|
265
|
+
return value.startsWith(".") || value.startsWith("/") || value.includes("/");
|
|
266
|
+
}
|
|
267
|
+
function compareRules(a, b) {
|
|
268
|
+
if (a.priority !== b.priority)
|
|
269
|
+
return b.priority - a.priority;
|
|
270
|
+
const scopeOrder = { user: 0, project: 1, local: 2 };
|
|
271
|
+
if (scopeOrder[a.source.scope] !== scopeOrder[b.source.scope]) {
|
|
272
|
+
return scopeOrder[a.source.scope] - scopeOrder[b.source.scope];
|
|
273
|
+
}
|
|
274
|
+
return a.source.index - b.source.index;
|
|
275
|
+
}
|
|
276
|
+
function formatCommand(command, args) {
|
|
277
|
+
return [command, ...(args ?? [])].join(" ");
|
|
278
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type LoadHookConfigOptions } from "./config.js";
|
|
2
|
+
import { type HookCombinedResult, type HookEventName, type HookProgressEvent, type HookRunRequest, type LoadedHookConfig } from "./types.js";
|
|
3
|
+
export interface ExternalHookControllerOptions extends LoadHookConfigOptions {
|
|
4
|
+
sessionId?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface HookRunOptions {
|
|
7
|
+
abortSignal?: AbortSignal;
|
|
8
|
+
onProgress?: (event: HookProgressEvent) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare class ExternalHookController {
|
|
11
|
+
private readonly options;
|
|
12
|
+
private config;
|
|
13
|
+
private readonly disabledByDepth;
|
|
14
|
+
constructor(options: ExternalHookControllerOptions);
|
|
15
|
+
reload(): LoadedHookConfig;
|
|
16
|
+
getConfig(): LoadedHookConfig;
|
|
17
|
+
status(): string;
|
|
18
|
+
explain(eventName: HookEventName): string;
|
|
19
|
+
logs(limit?: number): string;
|
|
20
|
+
trustProject(): string;
|
|
21
|
+
untrustProject(): string;
|
|
22
|
+
test(eventName: HookEventName, target?: string): Promise<string>;
|
|
23
|
+
runEvent(request: HookRunRequest, runOptions?: HookRunOptions): Promise<HookCombinedResult>;
|
|
24
|
+
}
|