@bubblebrain-ai/bubble 0.0.19 → 0.0.21
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 +10 -0
- package/dist/agent.js +310 -18
- package/dist/approval/controller.d.ts +6 -0
- package/dist/approval/controller.js +104 -11
- package/dist/checkpoints.d.ts +57 -0
- package/dist/checkpoints.js +0 -0
- package/dist/debug-trace.js +4 -0
- package/dist/feishu/agent-host/run-driver.js +29 -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 +86 -13
- 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.d.ts +0 -1
- package/dist/network/chatgpt-transport.js +40 -121
- 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/session.d.ts +31 -0
- package/dist/session.js +69 -0
- package/dist/slash-commands/commands.js +164 -0
- package/dist/slash-commands/types.d.ts +6 -0
- package/dist/tools/bash.js +4 -0
- package/dist/tools/edit-apply.js +63 -3
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +6 -5
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/write.d.ts +2 -1
- package/dist/tools/write.js +2 -1
- 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/image-paste.d.ts +18 -0
- package/dist/tui/image-paste.js +60 -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 +568 -223
- package/dist/tui/trace-groups.d.ts +16 -0
- package/dist/tui/trace-groups.js +82 -5
- package/dist/tui/transcript-scroll.d.ts +25 -0
- package/dist/tui/transcript-scroll.js +20 -0
- package/dist/tui/wordmark.d.ts +1 -0
- package/dist/tui/wordmark.js +56 -54
- package/dist/tui-ink/app.d.ts +4 -1
- package/dist/tui-ink/app.js +303 -248
- package/dist/tui-ink/display-history.d.ts +16 -1
- package/dist/tui-ink/display-history.js +50 -21
- package/dist/tui-ink/footer.d.ts +6 -12
- package/dist/tui-ink/footer.js +10 -29
- package/dist/tui-ink/image-paste.d.ts +59 -0
- package/dist/tui-ink/image-paste.js +277 -0
- package/dist/tui-ink/input-box.d.ts +26 -1
- package/dist/tui-ink/input-box.js +171 -41
- package/dist/tui-ink/message-list.d.ts +1 -1
- package/dist/tui-ink/message-list.js +46 -29
- package/dist/tui-ink/run.d.ts +7 -2
- package/dist/tui-ink/run.js +73 -23
- package/dist/tui-ink/terminal-mouse.d.ts +1 -0
- package/dist/tui-ink/terminal-mouse.js +4 -0
- package/dist/tui-ink/trace-groups.d.ts +16 -0
- package/dist/tui-ink/trace-groups.js +90 -6
- package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
- package/dist/tui-ink/transcript-viewport-math.js +17 -0
- package/dist/tui-ink/transcript-viewport.d.ts +24 -0
- package/dist/tui-ink/transcript-viewport.js +83 -0
- package/dist/tui-ink/welcome.d.ts +9 -7
- package/dist/tui-ink/welcome.js +7 -33
- 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,5 +1,6 @@
|
|
|
1
1
|
import type { PermissionCheckResult, PermissionQuery, PermissionRuleSet } from "../permissions/types.js";
|
|
2
2
|
import type { PermissionMode } from "../types.js";
|
|
3
|
+
import type { ExternalHookController } from "../hooks/controller.js";
|
|
3
4
|
import type { BashAllowlist } from "./session-cache.js";
|
|
4
5
|
import type { ApprovalController, ApprovalDecision, ApprovalRequest } from "./types.js";
|
|
5
6
|
export interface ApprovalControllerOptions {
|
|
@@ -23,6 +24,9 @@ export interface ApprovalControllerOptions {
|
|
|
23
24
|
* /permissions take effect immediately. Omit to disable rule-based gating.
|
|
24
25
|
*/
|
|
25
26
|
getRuleSet?: () => PermissionRuleSet;
|
|
27
|
+
/** External lifecycle hooks may observe or reject pending permission requests. */
|
|
28
|
+
externalHooks?: ExternalHookController;
|
|
29
|
+
sessionId?: string;
|
|
26
30
|
}
|
|
27
31
|
/**
|
|
28
32
|
* Default ApprovalController. Decision tree:
|
|
@@ -46,4 +50,6 @@ export declare class PermissionAwareApprovalController implements ApprovalContro
|
|
|
46
50
|
request(req: ApprovalRequest): Promise<ApprovalDecision>;
|
|
47
51
|
private requestToQuery;
|
|
48
52
|
private checkRequestRules;
|
|
53
|
+
private runPermissionRequestHook;
|
|
54
|
+
private runPermissionResultHook;
|
|
49
55
|
}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint store - pre-mutation file snapshots, keyed by conversation turn.
|
|
3
|
+
*
|
|
4
|
+
* Before the edit/write tools persist a change, they record the file's prior
|
|
5
|
+
* content here so /rewind can restore the workspace to the state it had just
|
|
6
|
+
* before a given user message. Changes made by bash commands are NOT tracked;
|
|
7
|
+
* checkpoints complement git, they do not replace it.
|
|
8
|
+
*
|
|
9
|
+
* On-disk layout (sibling of the session JSONL):
|
|
10
|
+
* <session>.checkpoints/
|
|
11
|
+
* blobs/<sha256> full file content, content-addressed (deduplicated)
|
|
12
|
+
* manifest.jsonl one {turn, path, blob, timestamp} line per first
|
|
13
|
+
* capture of a file within a turn
|
|
14
|
+
*
|
|
15
|
+
* blob === null means the file did not exist before that turn, so rewinding
|
|
16
|
+
* deletes it.
|
|
17
|
+
*/
|
|
18
|
+
export interface CheckpointManifestEntry {
|
|
19
|
+
turn: string;
|
|
20
|
+
path: string;
|
|
21
|
+
blob: string | null;
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
export interface CheckpointRestoreResult {
|
|
25
|
+
/** Files whose pre-turn content was written back. */
|
|
26
|
+
restored: string[];
|
|
27
|
+
/** Files deleted because they were created during the rewound turns. */
|
|
28
|
+
deleted: string[];
|
|
29
|
+
/** Files that could not be restored (I/O errors). */
|
|
30
|
+
failed: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare class CheckpointStore {
|
|
33
|
+
private readonly dir;
|
|
34
|
+
private readonly currentTurn;
|
|
35
|
+
private seen?;
|
|
36
|
+
constructor(dir: string, currentTurn: () => string);
|
|
37
|
+
/**
|
|
38
|
+
* Record a file's content before it is mutated. `priorContent` is the
|
|
39
|
+
* current on-disk content, or null when the file does not exist yet.
|
|
40
|
+
* Capturing must never break the mutation itself, so all errors are
|
|
41
|
+
* swallowed.
|
|
42
|
+
*/
|
|
43
|
+
captureBefore(filePath: string, priorContent: string | null): Promise<void>;
|
|
44
|
+
listEntries(): CheckpointManifestEntry[];
|
|
45
|
+
/** Unique files first captured at or after the given turn. */
|
|
46
|
+
filesTouchedSince(turn: string): string[];
|
|
47
|
+
/** Unique files captured during exactly the given turn. */
|
|
48
|
+
filesTouchedAt(turn: string): string[];
|
|
49
|
+
/**
|
|
50
|
+
* Restore every tracked file to its content from just before `turn`.
|
|
51
|
+
* For each file the earliest capture at-or-after the cutoff wins (it holds
|
|
52
|
+
* the oldest pre-mutation content). Consumed manifest entries are pruned so
|
|
53
|
+
* a later rewind over reused turn ids cannot resurrect stale state.
|
|
54
|
+
*/
|
|
55
|
+
restoreTo(turn: string): Promise<CheckpointRestoreResult>;
|
|
56
|
+
private loadSeen;
|
|
57
|
+
}
|
|
Binary file
|
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);
|
|
@@ -75,6 +78,7 @@ export class RunDriver {
|
|
|
75
78
|
approvalController,
|
|
76
79
|
lspService,
|
|
77
80
|
fileStateTracker,
|
|
81
|
+
checkpoints: () => session.manager.getCheckpoints(),
|
|
78
82
|
// questionController intentionally omitted — Feishu v1 doesn't surface
|
|
79
83
|
// the question tool to the agent.
|
|
80
84
|
});
|
|
@@ -137,6 +141,7 @@ export class RunDriver {
|
|
|
137
141
|
fileStateTracker,
|
|
138
142
|
agentCategories: this.opts.deps.userConfig.getAgentCategories(),
|
|
139
143
|
providerFactory: (route) => this.opts.deps.createProviderForRoute(route, promptCacheKey),
|
|
144
|
+
externalHooks: hookController,
|
|
140
145
|
});
|
|
141
146
|
sessionTitleUpdater = createSessionTitleUpdater({
|
|
142
147
|
sessionManager: session.manager,
|
|
@@ -150,6 +155,18 @@ export class RunDriver {
|
|
|
150
155
|
thinkingLevel: agent.thinking,
|
|
151
156
|
reasoningEffort: agent.thinking,
|
|
152
157
|
});
|
|
158
|
+
await hookController.runEvent({
|
|
159
|
+
eventName: "SessionStart",
|
|
160
|
+
cwd: session.cwd,
|
|
161
|
+
sessionId: session.manager.getSessionFile(),
|
|
162
|
+
agentRole: "driver",
|
|
163
|
+
target: "feishu",
|
|
164
|
+
payload: {
|
|
165
|
+
chatId: req.chatId,
|
|
166
|
+
providerId,
|
|
167
|
+
model,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
153
170
|
// Restore prior history into the running Agent instance.
|
|
154
171
|
if (!session.fresh) {
|
|
155
172
|
const history = session.manager.getMessages();
|
|
@@ -248,6 +265,18 @@ export class RunDriver {
|
|
|
248
265
|
}
|
|
249
266
|
finally {
|
|
250
267
|
clearInterval(watchdog);
|
|
268
|
+
await hookController.runEvent({
|
|
269
|
+
eventName: "SessionEnd",
|
|
270
|
+
cwd: session.cwd,
|
|
271
|
+
sessionId: session.manager.getSessionFile(),
|
|
272
|
+
agentRole: "driver",
|
|
273
|
+
target: "feishu",
|
|
274
|
+
payload: {
|
|
275
|
+
chatId: req.chatId,
|
|
276
|
+
providerId: agent.providerId,
|
|
277
|
+
model: agent.apiModel,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
251
280
|
// Cancel any pending approval prompts attached to this run.
|
|
252
281
|
this.opts.approvalUI.cancelForChat(req.chatId, "Run ended");
|
|
253
282
|
}
|
|
@@ -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
|
+
}
|