@agent-beam/beam 0.2.38 → 0.2.40
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/README.md +7 -13
- package/dist/access-risk.d.ts +10 -0
- package/dist/access-risk.js +43 -0
- package/dist/access-risk.js.map +1 -0
- package/dist/action-context.d.ts +30 -0
- package/dist/action-context.js +203 -0
- package/dist/action-context.js.map +1 -0
- package/dist/activity.d.ts +22 -0
- package/dist/activity.js +132 -0
- package/dist/activity.js.map +1 -0
- package/dist/agent-hook-install.d.ts +8 -0
- package/dist/agent-hook-install.js +278 -0
- package/dist/agent-hook-install.js.map +1 -0
- package/dist/agent-installers.js +16 -3
- package/dist/agent-installers.js.map +1 -1
- package/dist/agent-plugins.d.ts +3 -0
- package/dist/agent-plugins.js +66 -0
- package/dist/agent-plugins.js.map +1 -0
- package/dist/agents.d.ts +1 -0
- package/dist/agents.js +30 -184
- package/dist/agents.js.map +1 -1
- package/dist/approvals.d.ts +13 -1
- package/dist/approvals.js +14 -3
- package/dist/approvals.js.map +1 -1
- package/dist/approved-actions.d.ts +11 -0
- package/dist/approved-actions.js +53 -0
- package/dist/approved-actions.js.map +1 -0
- package/dist/behavioral-anomaly.d.ts +16 -0
- package/dist/behavioral-anomaly.js +66 -0
- package/dist/behavioral-anomaly.js.map +1 -0
- package/dist/cli.js +77 -9
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +18 -1
- package/dist/client.js +359 -80
- package/dist/client.js.map +1 -1
- package/dist/core.d.ts +55 -2
- package/dist/core.js +85 -26
- package/dist/core.js.map +1 -1
- package/dist/credential-egress.d.ts +17 -0
- package/dist/credential-egress.js +325 -0
- package/dist/credential-egress.js.map +1 -0
- package/dist/custom-rules.js +3 -2
- package/dist/custom-rules.js.map +1 -1
- package/dist/data-detectors.js +60 -4
- package/dist/data-detectors.js.map +1 -1
- package/dist/destructive-risk.d.ts +1 -0
- package/dist/destructive-risk.js +70 -0
- package/dist/destructive-risk.js.map +1 -0
- package/dist/edit-stats.js +5 -5
- package/dist/edit-stats.js.map +1 -1
- package/dist/entity-redaction.d.ts +11 -0
- package/dist/entity-redaction.js +88 -0
- package/dist/entity-redaction.js.map +1 -0
- package/dist/file-lock.d.ts +1 -0
- package/dist/file-lock.js +54 -0
- package/dist/file-lock.js.map +1 -0
- package/dist/forward.d.ts +13 -6
- package/dist/forward.js +120 -42
- package/dist/forward.js.map +1 -1
- package/dist/hook-adapters.d.ts +2 -2
- package/dist/hook-adapters.js +59 -60
- package/dist/hook-adapters.js.map +1 -1
- package/dist/hook-catalog.d.ts +12 -0
- package/dist/hook-catalog.js +469 -0
- package/dist/hook-catalog.js.map +1 -0
- package/dist/hook-config.d.ts +8 -0
- package/dist/hook-config.js +80 -0
- package/dist/hook-config.js.map +1 -0
- package/dist/hook-spool.d.ts +3 -0
- package/dist/hook-spool.js +56 -0
- package/dist/hook-spool.js.map +1 -0
- package/dist/install.d.ts +5 -5
- package/dist/install.js +48 -199
- package/dist/install.js.map +1 -1
- package/dist/inventory.js +1 -1
- package/dist/inventory.js.map +1 -1
- package/dist/jev.js +2 -1
- package/dist/jev.js.map +1 -1
- package/dist/judge.js +2 -1
- package/dist/judge.js.map +1 -1
- package/dist/mitigation-pattern.d.ts +1 -0
- package/dist/mitigation-pattern.js +140 -0
- package/dist/mitigation-pattern.js.map +1 -0
- package/dist/package-risk-rules.js +2 -2
- package/dist/package-risk-rules.js.map +1 -1
- package/dist/package-scan.js +1 -1
- package/dist/package-scan.js.map +1 -1
- package/dist/packages.js +1 -1
- package/dist/packages.js.map +1 -1
- package/dist/policy-paths.d.ts +20 -0
- package/dist/policy-paths.js +145 -0
- package/dist/policy-paths.js.map +1 -0
- package/dist/policy.d.ts +10 -0
- package/dist/policy.js +99 -11
- package/dist/policy.js.map +1 -1
- package/dist/rule-catalog.d.ts +4 -2
- package/dist/rule-catalog.js +124 -24
- package/dist/rule-catalog.js.map +1 -1
- package/dist/rule-matches.d.ts +13 -0
- package/dist/rule-matches.js +42 -0
- package/dist/rule-matches.js.map +1 -0
- package/dist/serve.js +55 -5
- package/dist/serve.js.map +1 -1
- package/dist/server.js +34 -7
- package/dist/server.js.map +1 -1
- package/dist/setup.js +5 -3
- package/dist/setup.js.map +1 -1
- package/dist/skill-risk-rules.js +2 -2
- package/dist/skill-risk-rules.js.map +1 -1
- package/dist/skill-scan.js +1 -1
- package/dist/skill-scan.js.map +1 -1
- package/dist/skill-setup.js +4 -5
- package/dist/skill-setup.js.map +1 -1
- package/dist/skill-sync.d.ts +5 -1
- package/dist/skill-sync.js +115 -14
- package/dist/skill-sync.js.map +1 -1
- package/dist/store.js +6 -9
- package/dist/store.js.map +1 -1
- package/dist/studio.js +4 -4
- package/package.json +5 -2
- package/stress-report.json +45 -45
package/dist/client.js
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
import { spoolHookEvent } from "./hook-spool.js";
|
|
2
|
+
import { hookKind } from "./hook-catalog.js";
|
|
3
|
+
import { activityFor, commandOf } from "./activity.js";
|
|
4
|
+
import { classifyActionContext } from "./action-context.js";
|
|
5
|
+
import { environmentRisk } from "./access-risk.js";
|
|
1
6
|
import { mkdir, open, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
7
|
import { homedir } from "node:os";
|
|
3
8
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import { getBeamHome, getCollectorUrl, getDataDirectory } from "./config.js";
|
|
5
|
-
import { normalize, scanText } from "./core.js";
|
|
9
|
+
import { getApiUrl, getBeamHome, getCollectorUrl, getDataDirectory } from "./config.js";
|
|
10
|
+
import { normalize, scanText, setIgnoredRules } from "./core.js";
|
|
6
11
|
import { adaptHookPayload } from "./hook-adapters.js";
|
|
7
12
|
import { forwardEvents } from "./forward.js";
|
|
8
13
|
import { evaluate, readPolicy } from "./policy.js";
|
|
9
14
|
import { extractAgent } from "./extract.js";
|
|
15
|
+
import { readIdentity } from "./enroll.js";
|
|
10
16
|
import { loadCustomRules } from "./custom-rules.js";
|
|
11
17
|
import { redactSensitive } from "./data-detectors.js";
|
|
18
|
+
import { redactWithSessionEntities } from "./entity-redaction.js";
|
|
19
|
+
import { recordAndCheckAnomaly } from "./behavioral-anomaly.js";
|
|
12
20
|
import { applyJevHook } from "./jev.js";
|
|
13
21
|
import { applyJudgeHook } from "./judge.js";
|
|
14
22
|
import { isPathTrusted, readTrustedPaths } from "./trusted-paths.js";
|
|
@@ -16,6 +24,8 @@ import { addBlockedTarget, isTargetBlocked, readBlockedTargets } from "./blocked
|
|
|
16
24
|
import { promptTty } from "./tty-prompt.js";
|
|
17
25
|
import { gateSkill } from "./skill-gate.js";
|
|
18
26
|
import { notePrompt, sessionContextFor, sessionTitlesEnabled } from "./session-title.js";
|
|
27
|
+
import { clampGrantMinutes, createApprovalRequest } from "./approvals.js";
|
|
28
|
+
import { consumeApprovedAction, recordApprovedAction } from "./approved-actions.js";
|
|
19
29
|
export async function readToken() {
|
|
20
30
|
if (process.env.BEAM_TOKEN)
|
|
21
31
|
return process.env.BEAM_TOKEN;
|
|
@@ -39,6 +49,16 @@ async function send(route, body) {
|
|
|
39
49
|
throw new Error(`Collector rejected request: ${await response.text()}`);
|
|
40
50
|
return response.json();
|
|
41
51
|
}
|
|
52
|
+
async function get(route) {
|
|
53
|
+
const url = getCollectorUrl();
|
|
54
|
+
const response = await fetch(`${url.origin}${route}`, {
|
|
55
|
+
headers: { Authorization: `Bearer ${await readToken()}` },
|
|
56
|
+
signal: AbortSignal.timeout(5000)
|
|
57
|
+
});
|
|
58
|
+
if (!response.ok)
|
|
59
|
+
throw new Error(`Collector rejected request: ${await response.text()}`);
|
|
60
|
+
return response.json();
|
|
61
|
+
}
|
|
42
62
|
async function boundedFile(filePath) {
|
|
43
63
|
const stats = await stat(filePath);
|
|
44
64
|
if (stats.size > 2_000_000)
|
|
@@ -51,6 +71,112 @@ export async function importEvents(filePath) {
|
|
|
51
71
|
export async function reloadRemoteRules() {
|
|
52
72
|
return send("/rules/reload", "{}");
|
|
53
73
|
}
|
|
74
|
+
function fromRemoteApproval(remote) {
|
|
75
|
+
return {
|
|
76
|
+
request_id: remote.id, approver: remote.approver, level: remote.level, mode: "SINGLE",
|
|
77
|
+
agent: remote.agent, tool: remote.tool, command: remote.command ?? "",
|
|
78
|
+
path: remote.path ?? undefined, url: remote.url ?? undefined,
|
|
79
|
+
policy_version: 1, risk_score: remote.riskScore, risk_level: remote.riskLevel, risk_factors: remote.riskFactors,
|
|
80
|
+
created_at: remote.createdAt, expires_at: remote.expiresAt,
|
|
81
|
+
status: remote.status, decision: remote.decision ?? undefined,
|
|
82
|
+
fingerprint: "", // approvalStillValid()'s dedup concept never applies to a remote-filed request
|
|
83
|
+
reason: remote.reason ?? undefined, decision_reason: remote.decisionReason ?? undefined,
|
|
84
|
+
grant_minutes: remote.grantMinutes ?? undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// An enrolled device's request needs a workspace admin to see and resolve it, which only ever
|
|
88
|
+
// happens on the shared collector -- same getApiUrl() + device-secret precedent as
|
|
89
|
+
// forwardMcpInventory/forwardSkillInventory in forward.ts, never the local loopback server (which
|
|
90
|
+
// has no concept of "another person" to ask). The collector's own /v1/approvals is
|
|
91
|
+
// device-authenticated and device-scoped on purpose: a device can file and poll its own request,
|
|
92
|
+
// but there is deliberately no device-authenticated way to resolve one -- that would let a device
|
|
93
|
+
// approve its own blocked action. Resolution is an admin action in the dashboard (or, for a
|
|
94
|
+
// single, unenrolled machine, the local `beam policy approve`/`reject` below).
|
|
95
|
+
async function askRemoteApproval(identity, options, timeoutMs) {
|
|
96
|
+
const base = getApiUrl();
|
|
97
|
+
const auth = { Authorization: `Bearer ${identity.deviceSecret}` };
|
|
98
|
+
const postRes = await fetch(`${base}/v1/approvals`, {
|
|
99
|
+
method: "POST", signal: AbortSignal.timeout(5000),
|
|
100
|
+
headers: { ...auth, "Content-Type": "application/json" },
|
|
101
|
+
body: JSON.stringify({
|
|
102
|
+
agent: options.agent, tool: options.tool, command: options.command, path: options.path, url: options.url,
|
|
103
|
+
reason: options.reason, level: options.level, approver: options.approver ? [options.approver] : undefined,
|
|
104
|
+
timeoutSeconds: Math.max(1, Math.min(3600, Math.round(timeoutMs / 1000))),
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
107
|
+
if (!postRes.ok)
|
|
108
|
+
throw new Error(`Collector rejected request: ${await postRes.text()}`);
|
|
109
|
+
const filed = fromRemoteApproval(await postRes.json());
|
|
110
|
+
const pollMs = options.pollMs ?? 2000;
|
|
111
|
+
for (;;) {
|
|
112
|
+
const listRes = await fetch(`${base}/v1/approvals`, { headers: auth, signal: AbortSignal.timeout(5000) });
|
|
113
|
+
if (!listRes.ok)
|
|
114
|
+
throw new Error(`Collector rejected request: ${await listRes.text()}`);
|
|
115
|
+
const all = (await listRes.json()).map(fromRemoteApproval);
|
|
116
|
+
const current = all.find(r => r.request_id === filed.request_id);
|
|
117
|
+
if (!current)
|
|
118
|
+
throw new Error("The collector lost track of this approval request.");
|
|
119
|
+
if (current.status !== "PENDING")
|
|
120
|
+
return current;
|
|
121
|
+
if (Date.parse(current.expires_at) <= Date.now())
|
|
122
|
+
return { ...current, status: "EXPIRED", decision: "EXPIRE" };
|
|
123
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function askLocalApproval(options, timeoutMs) {
|
|
127
|
+
const request = createApprovalRequest({
|
|
128
|
+
approver: [options.approver ?? "admin"],
|
|
129
|
+
level: options.level ?? "ADMIN",
|
|
130
|
+
mode: "SINGLE",
|
|
131
|
+
agent: options.agent,
|
|
132
|
+
tool: options.tool,
|
|
133
|
+
command: options.command ?? "",
|
|
134
|
+
path: options.path,
|
|
135
|
+
url: options.url,
|
|
136
|
+
policy_version: 1,
|
|
137
|
+
risk_score: 50,
|
|
138
|
+
risk_level: "MEDIUM",
|
|
139
|
+
risk_factors: [],
|
|
140
|
+
reason: options.reason,
|
|
141
|
+
}, timeoutMs);
|
|
142
|
+
await send("/approvals", JSON.stringify(request));
|
|
143
|
+
const pollMs = options.pollMs ?? 2000;
|
|
144
|
+
for (;;) {
|
|
145
|
+
const all = await get("/approvals");
|
|
146
|
+
const current = all.find(r => r.request_id === request.request_id);
|
|
147
|
+
if (!current)
|
|
148
|
+
throw new Error("The collector lost track of this approval request.");
|
|
149
|
+
if (current.status !== "PENDING")
|
|
150
|
+
return current;
|
|
151
|
+
if (Date.parse(current.expires_at) <= Date.now())
|
|
152
|
+
return { ...current, status: "EXPIRED", decision: "EXPIRE" };
|
|
153
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
export async function askApproval(options) {
|
|
157
|
+
const timeoutMs = options.timeoutMs ?? 10 * 60_000;
|
|
158
|
+
const identity = await readIdentity();
|
|
159
|
+
const result = identity ? await askRemoteApproval(identity, options, timeoutMs) : await askLocalApproval(options, timeoutMs);
|
|
160
|
+
// Closes the loop: the agent's retry of this exact action goes straight through next time,
|
|
161
|
+
// without filing a second request -- see approved-actions.ts. The approver's own chosen
|
|
162
|
+
// duration (10-60 minutes, grant_minutes) drives how long the retry window stays open; an
|
|
163
|
+
// older collector/dashboard that never set it falls back to DEFAULT_GRANT_MINUTES.
|
|
164
|
+
if (result.status === "APPROVED") {
|
|
165
|
+
const ttlMs = clampGrantMinutes(result.grant_minutes) * 60_000;
|
|
166
|
+
await recordApprovedAction({ agent: options.agent, tool: options.tool, command: options.command, path: options.path, url: options.url }, ttlMs).catch(() => { });
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
// Local-machine only, deliberately: see askRemoteApproval()'s comment above for why an enrolled
|
|
171
|
+
// device has no remote equivalent of these two yet (that's an admin-authenticated dashboard
|
|
172
|
+
// surface, not this device-authenticated collector).
|
|
173
|
+
export async function listPendingApprovals() {
|
|
174
|
+
const all = await get("/approvals");
|
|
175
|
+
return all.filter(r => r.status === "PENDING");
|
|
176
|
+
}
|
|
177
|
+
export async function resolveApproval(requestId, decision, approver, decisionReason, grantMinutes) {
|
|
178
|
+
return send("/approvals/resolve", JSON.stringify({ request_id: requestId, decision, approver, decision_reason: decisionReason, grant_minutes: grantMinutes }));
|
|
179
|
+
}
|
|
54
180
|
export async function scanFile(filePath, options = {}, beamHome = getBeamHome()) {
|
|
55
181
|
const content = await boundedFile(filePath);
|
|
56
182
|
const kind = options.mcp ? "mcp" : "skill";
|
|
@@ -70,6 +196,7 @@ const INGEST_BATCH_SIZE = 2000; // matches the collector's own per-request recor
|
|
|
70
196
|
// custom rules instead of erroring, so keep them distinct rather than reusing one parameter.
|
|
71
197
|
export async function extractPreview(agentId, limit = 200, home = homedir(), beamHome = getBeamHome()) {
|
|
72
198
|
await loadCustomRules(beamHome);
|
|
199
|
+
setIgnoredRules((await readPolicy().catch(() => null))?.rules.ignoredRules ?? [], agentId);
|
|
73
200
|
const records = await extractAgent(agentId, home);
|
|
74
201
|
const events = [];
|
|
75
202
|
for (const record of records) {
|
|
@@ -405,17 +532,38 @@ async function readStdin(limitBytes) {
|
|
|
405
532
|
return input;
|
|
406
533
|
}
|
|
407
534
|
// Never throws: best-effort capture must not suppress configured policy enforcement.
|
|
408
|
-
export async function captureHook(sourceAgent = "claude-code", home = homedir()) {
|
|
535
|
+
export async function captureHook(sourceAgent = "claude-code", home = homedir(), suppliedEvent) {
|
|
536
|
+
let canBlock = suppliedEvent === undefined || hookKind(suppliedEvent) === "pre";
|
|
409
537
|
try {
|
|
410
|
-
const input = await readStdin(
|
|
538
|
+
const input = await readStdin(2_000_000);
|
|
411
539
|
const raw = JSON.parse(input);
|
|
412
|
-
let data = adaptHookPayload(sourceAgent, raw);
|
|
540
|
+
let data = adaptHookPayload(sourceAgent, raw, suppliedEvent);
|
|
541
|
+
sourceAgent = String(data.source_agent ?? sourceAgent);
|
|
413
542
|
data.source_agent = sourceAgent;
|
|
414
543
|
data.source_type = "hook";
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
544
|
+
const eventKind = hookKind(String(data.hook_event_name ?? ""));
|
|
545
|
+
const promptGate = eventKind === "prompt" && ["claude-code", "codex", "cursor", "gemini"].includes(sourceAgent);
|
|
546
|
+
canBlock = eventKind === "pre" || promptGate;
|
|
547
|
+
if (eventKind === "prompt" && typeof data.prompt === "string") {
|
|
548
|
+
data.event_type = "prompt.submit";
|
|
549
|
+
data.tool_name = data.tool_name ?? data.hook_event_name;
|
|
550
|
+
data.command = data.prompt;
|
|
551
|
+
if (sessionTitlesEnabled())
|
|
552
|
+
await notePrompt(String(data.session_id ?? ""), data.prompt, String(data.cwd ?? "")).catch(() => { });
|
|
553
|
+
}
|
|
554
|
+
// Completed actions, permission notifications, and lifecycle callbacks are observation only.
|
|
555
|
+
// They must never run pre-action policy, skill approval, or action-judge gates.
|
|
556
|
+
if (!canBlock) {
|
|
557
|
+
if (eventKind === "post" && sourceAgent === "claude-code") {
|
|
558
|
+
const bundle = await readPolicy().catch(() => null);
|
|
559
|
+
await emitRedactedOutput(sourceAgent, data.tool_response, String(data.session_id ?? ""), bundle?.rules.customDetectors).catch(() => { });
|
|
560
|
+
}
|
|
561
|
+
await captureActivity(data);
|
|
562
|
+
if (sourceAgent === "cline")
|
|
563
|
+
process.stdout.write(JSON.stringify({ cancel: false }));
|
|
564
|
+
if (sourceAgent === "antigravity" && eventKind === "assistant")
|
|
565
|
+
process.stdout.write(JSON.stringify({ decision: "stop" }));
|
|
566
|
+
return;
|
|
419
567
|
}
|
|
420
568
|
// Model and token-usage backfill are independent: a newer agent build (Codex 0.155.1+ sends
|
|
421
569
|
// `model` as a common field on every hook event -- see learn.chatgpt.com/docs/hooks) can
|
|
@@ -457,13 +605,29 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
|
|
|
457
605
|
catch { /* best-effort enrichment only -- never block the hook on this */ }
|
|
458
606
|
}
|
|
459
607
|
// 1. Enforcement first — a local file read, so it works even if the collector is down.
|
|
460
|
-
//
|
|
461
|
-
//
|
|
608
|
+
// General rules follow advisory/enforce mode. Explicit mandatory mitigation
|
|
609
|
+
// blocks can deny matching calls while unrelated calls remain in observe mode.
|
|
462
610
|
const toolInput = data.tool_input && typeof data.tool_input === "object"
|
|
463
611
|
? data.tool_input : {};
|
|
464
|
-
const command =
|
|
465
|
-
|
|
612
|
+
const command = commandOf(data);
|
|
613
|
+
// Beam's own escape hatch: an agent blocked by policy -- even a fully disabled agent or a
|
|
614
|
+
// workspace-wide "blockedTools: [Bash]" -- must still be able to ask a human for an
|
|
615
|
+
// exception, or the block is a dead end with no way out. This unconditionally skips every
|
|
616
|
+
// check below (policy evaluate(), self-protection, risk escalation, anomaly tracking) for
|
|
617
|
+
// exactly that one command, before any of them run.
|
|
618
|
+
if (isApprovalEscapeHatch(String(data.tool_name ?? ""), command)) {
|
|
619
|
+
data.policy_decision = { action: "allow", rule: "beam.approval-escape-hatch" };
|
|
620
|
+
await captureActivity(data);
|
|
621
|
+
if (["cline", "antigravity"].includes(sourceAgent))
|
|
622
|
+
emitAllow(sourceAgent, undefined, String(data.hook_event_name ?? "PreToolUse"));
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
const policyBundle = await readPolicy().catch(() => null);
|
|
626
|
+
setIgnoredRules(policyBundle?.rules.ignoredRules ?? [], sourceAgent);
|
|
627
|
+
let decision = evaluate(policyBundle, {
|
|
466
628
|
agent: sourceAgent,
|
|
629
|
+
eventType: promptGate ? "prompt.submit" : undefined,
|
|
630
|
+
input: toolInput,
|
|
467
631
|
tool: String(data.tool_name ?? ""),
|
|
468
632
|
command,
|
|
469
633
|
args: Array.isArray(toolInput.args) ? toolInput.args.map(String) : command.split(/\s+/).slice(1),
|
|
@@ -471,7 +635,7 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
|
|
|
471
635
|
url: String(toolInput.url ?? data.url ?? ""),
|
|
472
636
|
role: typeof data.role === "string" ? data.role : undefined,
|
|
473
637
|
user: typeof data.user === "string" ? data.user : undefined,
|
|
474
|
-
cwd: typeof data.cwd === "string" ? data.cwd : undefined,
|
|
638
|
+
cwd: typeof toolInput.workdir === "string" ? toolInput.workdir : typeof data.cwd === "string" ? data.cwd : undefined,
|
|
475
639
|
repository: typeof data.repository === "string" ? data.repository : undefined,
|
|
476
640
|
branch: typeof data.branch === "string" ? data.branch : undefined,
|
|
477
641
|
environment: typeof data.environment === "string" ? data.environment : undefined,
|
|
@@ -496,6 +660,25 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
|
|
|
496
660
|
reason: "Beam protection cannot be disabled, removed, or modified by an agent.",
|
|
497
661
|
};
|
|
498
662
|
}
|
|
663
|
+
// Behavioral anomaly: a burst of high/critical-severity actions from one session, independent
|
|
664
|
+
// of whether any single one was individually blocked -- the shape an agent shows when it's
|
|
665
|
+
// been talked into a chain of risky actions (injection, a compromised skill, a confused
|
|
666
|
+
// multi-step plan), even if each step alone only ever rated "ask". Recorded regardless of this
|
|
667
|
+
// action's own decision (a blocked/asked action still happened and still counts), but only
|
|
668
|
+
// ever escalates a decision that's currently allow/warn -- never downgrades an existing ask/deny.
|
|
669
|
+
try {
|
|
670
|
+
const anomaly = await recordAndCheckAnomaly(String(data.session_id ?? data.sessionId ?? ""), decision.riskLevel);
|
|
671
|
+
if (anomaly.triggered) {
|
|
672
|
+
const reason = `${anomaly.count} high/critical-severity actions from this session in the last ${anomaly.windowMinutes} minutes.`;
|
|
673
|
+
if (decision.mode === "observe" && decision.action === "allow") {
|
|
674
|
+
decision = { ...decision, riskFactors: [...new Set([...decision.riskFactors, "behavioral anomaly: action burst"])], reason };
|
|
675
|
+
}
|
|
676
|
+
else if (decision.mode !== "observe" && (decision.action === "allow" || decision.action === "warn")) {
|
|
677
|
+
decision = { ...decision, action: "ask", rule: "beam.behavioral-anomaly", reason };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
catch { /* best-effort: must never block the agent on anomaly-tracking failure */ }
|
|
499
682
|
const policyMode = decision.mode;
|
|
500
683
|
if (decision.action === "allow" && workspace) {
|
|
501
684
|
const escaping = outsideWorkspacePaths(workspace, targetPath, command);
|
|
@@ -549,46 +732,38 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
|
|
|
549
732
|
// step only ever runs for Beam's own soft, non-mandatory fallback.
|
|
550
733
|
if (decision.action === "warn")
|
|
551
734
|
decision = await resolveWarnInteractively(decision, targetPath, command);
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (sessionTitlesEnabled()) {
|
|
562
|
-
try {
|
|
563
|
-
const sessionId = String(data.session_id ?? data.sessionId ?? "");
|
|
564
|
-
// Cursor (beforeSubmitPrompt) and Gemini (BeforeAgent) name their prompt hook differently
|
|
565
|
-
// from Claude Code / Codex, and are not rewritten to prompt.submit above (each agent's
|
|
566
|
-
// block contract is keyed on its own event name), so recognise them here.
|
|
567
|
-
const otherPromptHook = ["beforeSubmitPrompt", "BeforeAgent"].includes(String(data.hook_event_name)) && typeof data.prompt === "string";
|
|
568
|
-
if (data.event_type === "prompt.submit" || otherPromptHook) {
|
|
569
|
-
await notePrompt(sessionId, String(data.prompt ?? ""), String(data.cwd ?? ""));
|
|
570
|
-
if (otherPromptHook)
|
|
571
|
-
delete data.prompt; // the raw prompt must not travel further than this check
|
|
572
|
-
}
|
|
573
|
-
else {
|
|
574
|
-
const context = await sessionContextFor(sourceAgent, sessionId, { home, transcriptPath: typeof data.transcript_path === "string" ? data.transcript_path : undefined });
|
|
575
|
-
if (context) {
|
|
576
|
-
data.session_title = context.title;
|
|
577
|
-
data.session_title_source = context.titleSource;
|
|
578
|
-
data.turn = context.turn;
|
|
579
|
-
data.turn_topic = context.topic;
|
|
580
|
-
data.turn_shift = context.shift;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
735
|
+
// Retain redacted prompts and session identities now that prompt activity is part of capture.
|
|
736
|
+
if (sessionTitlesEnabled() && data.session_id && eventKind !== "prompt") {
|
|
737
|
+
const context = await sessionContextFor(sourceAgent, String(data.session_id), { home }).catch(() => null);
|
|
738
|
+
if (context) {
|
|
739
|
+
data.session_title = context.title;
|
|
740
|
+
data.session_title_source = context.titleSource;
|
|
741
|
+
data.turn = context.turn;
|
|
742
|
+
data.turn_topic = context.topic;
|
|
743
|
+
data.turn_shift = context.shift;
|
|
583
744
|
}
|
|
584
|
-
catch { /* best-effort: a title must never affect enforcement */ }
|
|
585
745
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
746
|
+
// An admin already approved this exact action (via `beam policy ask`, resolved in the
|
|
747
|
+
// dashboard or locally with `beam policy approve`) -- let the retry through instead of
|
|
748
|
+
// blocking it again. Single-use (see approved-actions.ts) and never for self-protection, same
|
|
749
|
+
// as the hint below: that guardrail isn't something this channel can grant an exception to.
|
|
750
|
+
if ((decision.action === "deny" || decision.action === "ask") && decision.rule !== "beam.self-protection") {
|
|
751
|
+
const granted = await consumeApprovedAction({ agent: sourceAgent, tool: String(data.tool_name ?? ""), command, path: targetPath, url: String(toolInput.url ?? data.url ?? "") }).catch(() => false);
|
|
752
|
+
if (granted)
|
|
753
|
+
decision = { ...decision, action: "allow", rule: "beam.approval-granted", reason: "Approved by a workspace admin." };
|
|
754
|
+
}
|
|
755
|
+
// Tell the agent its way out: a flat "no" with no next step just invites it to retry, rephrase,
|
|
756
|
+
// or work around the block. Skipped for Beam's own self-protection -- that guardrail is never
|
|
757
|
+
// meant to be appealed through this channel.
|
|
758
|
+
if (decision.action === "deny" && decision.rule !== "beam.self-protection") {
|
|
759
|
+
const target = (toolInput.file_path ?? toolInput.path ?? data.file_path ?? toolInput.url ?? data.url ?? command ?? "").toString().replace(/"/g, '\\"');
|
|
760
|
+
// --agent/--tool must name the real agent and tool that's blocked here, not the CLI's own
|
|
761
|
+
// "cli"/"Bash" defaults -- an approval is recorded under exactly those two fields
|
|
762
|
+
// (approved-actions.ts), and the retry this is meant to unblock will report the real ones,
|
|
763
|
+
// not the defaults. A mismatch here is why an approval silently fails to grant the retry.
|
|
764
|
+
const toolName = String(data.tool_name ?? "Bash").replace(/"/g, '\\"');
|
|
765
|
+
if (target)
|
|
766
|
+
decision = { ...decision, reason: `${decision.reason ?? "Blocked by workspace policy."} If this is necessary, ask a workspace admin for an exception: beam policy ask "${target}" "<explain why you need this>" --agent "${sourceAgent}" --tool "${toolName}"` };
|
|
592
767
|
}
|
|
593
768
|
data.policy_decision = { action: decision.action, rule: decision.rule, reason: decision.reason, risk_score: decision.riskScore, risk_level: decision.riskLevel, risk_factors: decision.riskFactors };
|
|
594
769
|
const hookEvent = String(data.hook_event_name ?? "PreToolUse");
|
|
@@ -602,7 +777,7 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
|
|
|
602
777
|
emitAsk(sourceAgent, decision.reason ?? "This action requires approval.", hookEvent);
|
|
603
778
|
}
|
|
604
779
|
else if (decision.action === "redact") {
|
|
605
|
-
const transformed = redactPayload(data, decision);
|
|
780
|
+
const transformed = await redactPayload(data, decision, String(data.session_id ?? data.sessionId ?? ""));
|
|
606
781
|
if (!transformed.changed) {
|
|
607
782
|
emitAllow(sourceAgent, decision.reason, hookEvent);
|
|
608
783
|
}
|
|
@@ -617,21 +792,10 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
|
|
|
617
792
|
// prompt.
|
|
618
793
|
process.stderr.write(`\n⚠ Beam policy (advisory): ${decision.reason}\n`);
|
|
619
794
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
// enforcement above already ran against them, but every keystroke-triggered prompt
|
|
625
|
-
// otherwise floods the Activity/Overview views with noise nobody reviews. A blocked
|
|
626
|
-
// prompt is still surfaced -- see step 3 below.
|
|
627
|
-
if (data.event_type !== "prompt.submit" || decision.action === "deny") {
|
|
628
|
-
try {
|
|
629
|
-
await send("/ingest", JSON.stringify(data));
|
|
630
|
-
}
|
|
631
|
-
catch (e) {
|
|
632
|
-
console.error(e instanceof Error ? e.message : String(e));
|
|
633
|
-
}
|
|
634
|
-
}
|
|
795
|
+
if (["cline", "antigravity"].includes(sourceAgent) && ["allow", "audit", "warn"].includes(decision.action))
|
|
796
|
+
emitAllow(sourceAgent, decision.reason, hookEvent);
|
|
797
|
+
// Capture every lifecycle event, including allowed prompts and tool completions.
|
|
798
|
+
await captureActivity(data);
|
|
635
799
|
// 3. A policy-blocked action carries a finding the collector's own normalize() can't
|
|
636
800
|
// reconstruct (it only knows about *this* process's policy decision) -- forward it to the
|
|
637
801
|
// workspace immediately rather than folding it into the batch, so a manager sees a block
|
|
@@ -651,13 +815,46 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
|
|
|
651
815
|
// confirms exit 2 blocks both BeforeTool and BeforeAgent, same as decision: "deny") and is
|
|
652
816
|
// also treated as failure by OpenCode's native plugin wrapper.
|
|
653
817
|
const policy = await readPolicy().catch(() => null);
|
|
654
|
-
if (policy && (policy.rules.mode === "enforce" || policy.errors?.length)) {
|
|
818
|
+
if (canBlock && policy && (policy.rules.mode === "enforce" || policy.errors?.length)) {
|
|
655
819
|
// emitDeny already sets exit code 2 for the agents where that's the documented fail-closed
|
|
656
820
|
// signal (see EXIT_2_FAILS_CLOSED above).
|
|
657
821
|
emitDeny(sourceAgent, "Beam security policy could not complete safely; execution is denied.");
|
|
658
822
|
}
|
|
659
823
|
}
|
|
660
824
|
}
|
|
825
|
+
async function captureActivity(data) {
|
|
826
|
+
try {
|
|
827
|
+
const policy = await readPolicy().catch(() => null);
|
|
828
|
+
const redacted = await redactPayload(data, { customDetectors: policy?.rules.customDetectors }, String(data.session_id ?? ""));
|
|
829
|
+
const a = activityFor(redacted.data, value => redactSensitive(value, policy?.rules.customDetectors).text);
|
|
830
|
+
if (!a)
|
|
831
|
+
return;
|
|
832
|
+
const projected = {};
|
|
833
|
+
for (const field of ["source_agent", "source_type", "hook_event_name", "session_id", "parent_session_id", "tool_name", "tool_use_id", "event_type", "command", "file_path", "url", "cwd", "model", "timestamp", "session_title", "session_title_source", "turn", "turn_topic", "turn_shift", "input_tokens", "output_tokens", "policy_decision", "status", "error", "is_error", "reason", "decision", "permission_decision", "permissionDecision", "approval", "approved", "agent_id", "agent_type", "subagent_id", "child_session_id", "exit_code", "exitCode", "duration_ms"]) {
|
|
834
|
+
const value = redacted.data[field];
|
|
835
|
+
if (value !== undefined)
|
|
836
|
+
projected[field] = typeof value === "string" ? value.slice(0, 4000) : value;
|
|
837
|
+
}
|
|
838
|
+
const details = a.details;
|
|
839
|
+
if (details)
|
|
840
|
+
for (const [key, value] of Object.entries(details))
|
|
841
|
+
projected[key] = value;
|
|
842
|
+
projected.tool_input = a.input;
|
|
843
|
+
projected.tool_response = a.output;
|
|
844
|
+
projected.prompt = a.prompt;
|
|
845
|
+
projected.assistant_text = a.assistant;
|
|
846
|
+
projected.capture_truncated = a.truncated;
|
|
847
|
+
try {
|
|
848
|
+
await send("/ingest", JSON.stringify(projected));
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
await spoolHookEvent(projected);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
catch (e) {
|
|
855
|
+
process.stderr.write(`Beam activity capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
661
858
|
function safeNormalize(data) {
|
|
662
859
|
try {
|
|
663
860
|
return normalize(data);
|
|
@@ -695,12 +892,18 @@ async function resolveWarnInteractively(decision, targetPath, command) {
|
|
|
695
892
|
}
|
|
696
893
|
function isEnvironmentAccess(tool, command, path, input) {
|
|
697
894
|
const text = `${tool} ${command} ${path} ${Object.values(input).filter(value => typeof value === "string").join(" ")}`;
|
|
895
|
+
const context = classifyActionContext(tool, { ...input, file_path: path }, "", command, v => v, false);
|
|
896
|
+
const scoped = environmentRisk(context);
|
|
897
|
+
if (context.actions.length && context.actions.every(a => a.operation === "source-search"))
|
|
898
|
+
return false;
|
|
698
899
|
// File reads: .env, .env.local, .env.prod, etc. Also catch common env/config
|
|
699
900
|
// locations that contain exported credentials.
|
|
700
901
|
if (/(^|[\\/\s"'])\.env(?:\.[\w.-]+)?(?:$|[\\/\s"'])/i.test(text))
|
|
701
902
|
return true;
|
|
702
903
|
if (/(^|[\\/\s"'])\.aws[\\/]credentials(?:$|[\\/\s"'])|(^|[\\/\s"'])\.config[\\/]gcloud(?:$|[\\/\s"'])/i.test(text))
|
|
703
904
|
return true;
|
|
905
|
+
if (scoped && ("suppress" in scoped || "severity" in scoped && scoped.severity === "info"))
|
|
906
|
+
return false;
|
|
704
907
|
// Shell/process environment reads: env, printenv, set, export -p, $FOO,
|
|
705
908
|
// process.env, os.environ and equivalent tool arguments.
|
|
706
909
|
return /\b(?:printenv|env|export\s+-p|process\.env|os\.environ|System\.getenv)\b/i.test(text)
|
|
@@ -724,6 +927,30 @@ function outsideWorkspacePaths(cwd, target, command) {
|
|
|
724
927
|
return escaped === ".." || escaped.startsWith(`..${sep}`) || isAbsolute(escaped);
|
|
725
928
|
});
|
|
726
929
|
}
|
|
930
|
+
// Exempts exactly `beam policy ask`/`pending` from every policy check -- filing or reading a
|
|
931
|
+
// request is harmless and must always be reachable, even for a fully disabled agent. Deliberately
|
|
932
|
+
// NOT `approve`/`reject`: those decide a request rather than merely file or read one, and exempting
|
|
933
|
+
// them would let an agent on an unenrolled, single-machine setup file its own request and
|
|
934
|
+
// immediately self-approve it, defeating the whole point of asking a human.
|
|
935
|
+
function isApprovalEscapeHatch(tool, command) {
|
|
936
|
+
if (!/^(?:bash|shell|terminal|exec_command|shell_command|run_shell_command|command|execute_bash|run_command|run_terminal_cmd)$/i.test(tool))
|
|
937
|
+
return false;
|
|
938
|
+
const trimmed = command.trim();
|
|
939
|
+
// Command substitution ($(...) and backticks) stays live even inside double quotes in a POSIX
|
|
940
|
+
// shell, so a malicious payload hidden in the reason/target argument -- `beam policy ask
|
|
941
|
+
// "$(rm -rf /)" "x"` -- can't be waved through just because it's quoted; checked against the
|
|
942
|
+
// raw string, before anything is stripped.
|
|
943
|
+
if (/\$\(|`/.test(trimmed))
|
|
944
|
+
return false;
|
|
945
|
+
// Control operators (; & | < > newline) lose their meaning once quoted, so a legitimate reason
|
|
946
|
+
// like "deploy; already reviewed" must not trip this -- checked with quoted spans stripped out
|
|
947
|
+
// first. Anything left outside quotes (real chaining, e.g. `beam policy ask x y && rm -rf /`)
|
|
948
|
+
// still gets caught.
|
|
949
|
+
const unquoted = trimmed.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, "");
|
|
950
|
+
if (/[;&|<>\n]/.test(unquoted))
|
|
951
|
+
return false;
|
|
952
|
+
return /^(?:\S*[\\/])?beam(?:\.exe)?\s+policy\s+(?:ask\s+\S[\s\S]*|pending\b(?:\s+--?[\w-]+(?:\s+\S+)?)*\s*)$/i.test(trimmed);
|
|
953
|
+
}
|
|
727
954
|
function isBeamSelfProtectionTarget(tool, command, target) {
|
|
728
955
|
const beamHome = resolve(getBeamHome());
|
|
729
956
|
const beamData = resolve(getDataDirectory());
|
|
@@ -736,28 +963,55 @@ function isBeamSelfProtectionTarget(tool, command, target) {
|
|
|
736
963
|
function blockedFinding(reason = "Blocked by workspace policy.") {
|
|
737
964
|
return { id: "policy.blocked", title: "Blocked by workspace policy", severity: "high", explanation: reason, evidence: "" };
|
|
738
965
|
}
|
|
739
|
-
function redactPayload(data, decision) {
|
|
966
|
+
async function redactPayload(data, decision, sessionId) {
|
|
740
967
|
const copy = structuredClone(data);
|
|
741
968
|
let changed = false;
|
|
742
|
-
const redactValue = (value) => {
|
|
969
|
+
const redactValue = async (value) => {
|
|
743
970
|
if (typeof value === "string") {
|
|
744
|
-
const result =
|
|
971
|
+
const result = await redactWithSessionEntities(value, sessionId, decision.customDetectors);
|
|
745
972
|
changed ||= result.text !== value;
|
|
746
973
|
return result.text;
|
|
747
974
|
}
|
|
748
975
|
if (Array.isArray(value))
|
|
749
|
-
return value.map(redactValue);
|
|
976
|
+
return Promise.all(value.map(redactValue));
|
|
750
977
|
if (value && typeof value === "object")
|
|
751
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactValue(v)]));
|
|
978
|
+
return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([k, v]) => [k, await redactValue(v)])));
|
|
752
979
|
return value;
|
|
753
980
|
};
|
|
754
|
-
|
|
981
|
+
const redacted = await redactValue(copy);
|
|
982
|
+
return { data: redacted, changed };
|
|
755
983
|
}
|
|
756
984
|
// Claude Code and Codex use the same hookSpecificOutput.permissionDecision contract for every
|
|
757
985
|
// hook event -- only the echoed hookEventName differs per event (verified against
|
|
758
|
-
// code.claude.com/docs/en/hooks for
|
|
986
|
+
// code.claude.com/docs/en/hooks for PreToolUse, UserPromptSubmit, and PostToolUse).
|
|
759
987
|
function claudeStyleHookEventName(event) {
|
|
760
|
-
return event === "UserPromptSubmit"
|
|
988
|
+
return event === "UserPromptSubmit" || event === "PostToolUse" ? event : "PreToolUse";
|
|
989
|
+
}
|
|
990
|
+
// A tool has already run by the time PostToolUse fires -- there is nothing left to block, only
|
|
991
|
+
// what Claude sees afterward to rewrite (hookSpecificOutput.updatedToolOutput). This is the
|
|
992
|
+
// counterpart to redactPayload()/emitRedacted() below, which redact a secret that was already in
|
|
993
|
+
// the *command text* before it runs; this instead catches a secret that only appears in a
|
|
994
|
+
// command's *output* (`cat .env` holding a real, un-migrated value; an app echoing its own
|
|
995
|
+
// resolved config) -- the input had nothing to redact, so PreToolUse redaction never saw it.
|
|
996
|
+
// Only wired for claude-code (see agents.ts notes): Codex's PostToolUse payload shape and
|
|
997
|
+
// updatedToolOutput support are unverified, and sending a rewrite a client doesn't honor would
|
|
998
|
+
// silently do nothing while looking like protection.
|
|
999
|
+
async function emitRedactedOutput(agent, toolResponse, sessionId, customDetectors) {
|
|
1000
|
+
if (agent !== "claude-code" || toolResponse === null || toolResponse === undefined)
|
|
1001
|
+
return;
|
|
1002
|
+
const redactValue = async (value) => {
|
|
1003
|
+
if (typeof value === "string")
|
|
1004
|
+
return (await redactWithSessionEntities(value, sessionId, customDetectors)).text;
|
|
1005
|
+
if (Array.isArray(value))
|
|
1006
|
+
return Promise.all(value.map(redactValue));
|
|
1007
|
+
if (value && typeof value === "object")
|
|
1008
|
+
return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([k, v]) => [k, await redactValue(v)])));
|
|
1009
|
+
return value;
|
|
1010
|
+
};
|
|
1011
|
+
const redacted = await redactValue(toolResponse);
|
|
1012
|
+
if (JSON.stringify(redacted) === JSON.stringify(toolResponse))
|
|
1013
|
+
return; // nothing to rewrite
|
|
1014
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: redacted } }));
|
|
761
1015
|
}
|
|
762
1016
|
function emitAsk(agent, reason, event = "PreToolUse") {
|
|
763
1017
|
if (agent === "claude-code" || agent === "codex") {
|
|
@@ -787,6 +1041,14 @@ function emitRedacted(agent, data, reason, event = "PreToolUse") {
|
|
|
787
1041
|
emitDeny(agent, "Beam cannot safely return transformed input through this hook contract.", event);
|
|
788
1042
|
}
|
|
789
1043
|
function emitAllow(agent, reason, event = "PreToolUse") {
|
|
1044
|
+
if (agent === "cline") {
|
|
1045
|
+
process.stdout.write(JSON.stringify({ cancel: false }));
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (agent === "antigravity") {
|
|
1049
|
+
process.stdout.write(JSON.stringify({ decision: "ask" }));
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
790
1052
|
if (agent === "claude-code" || agent === "codex")
|
|
791
1053
|
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: claudeStyleHookEventName(event), permissionDecision: "allow", permissionDecisionReason: reason } }));
|
|
792
1054
|
else if (agent === "cursor" && event === "beforeSubmitPrompt")
|
|
@@ -804,12 +1066,12 @@ function emitAllow(agent, reason, event = "PreToolUse") {
|
|
|
804
1066
|
// content", separately from the exit-0 hookSpecificOutput.permissionDecision path). Setting this
|
|
805
1067
|
// alongside the JSON is belt-and-suspenders, not a replacement -- the JSON still carries the
|
|
806
1068
|
// clean reason text; exit 2 just guarantees the block itself isn't silently skipped.
|
|
807
|
-
const EXIT_2_FAILS_CLOSED = new Set(["claude-code", "codex", "cursor", "copilot-cli", "gemini", "opencode", "openclaw", "hermes"]);
|
|
1069
|
+
const EXIT_2_FAILS_CLOSED = new Set(["claude-code", "codex", "cursor", "copilot-cli", "gemini", "opencode", "openclaw", "hermes", "windsurf", "kimi", "qwen", "auggie", "kiro", "goose", "openhands", "crush", "junie"]);
|
|
808
1070
|
// Claude Code / Codex hook contract: a JSON decision on stdout denies the tool call or prompt.
|
|
809
1071
|
// Other agents get a stderr note only (their block contracts differ and aren't verified), except
|
|
810
1072
|
// where noted below.
|
|
811
1073
|
function emitDeny(agent, reason, event = "PreToolUse") {
|
|
812
|
-
if (
|
|
1074
|
+
if (["claude-code", "codex", "muse", "vscode", "factory"].includes(agent)) {
|
|
813
1075
|
process.stdout.write(JSON.stringify({
|
|
814
1076
|
hookSpecificOutput: {
|
|
815
1077
|
hookEventName: claudeStyleHookEventName(event),
|
|
@@ -839,9 +1101,26 @@ function emitDeny(agent, reason, event = "PreToolUse") {
|
|
|
839
1101
|
process.stdout.write(JSON.stringify({ decision: "deny", reason: `Beam: ${reason}` }));
|
|
840
1102
|
}
|
|
841
1103
|
else if (agent === "hermes") {
|
|
842
|
-
|
|
1104
|
+
process.stdout.write(JSON.stringify({ action: "block", message: `Beam: ${reason}`, decision: "block", reason: `Beam: ${reason}` }));
|
|
1105
|
+
}
|
|
1106
|
+
else if (["pi", "kilo"].includes(agent)) {
|
|
1107
|
+
process.stdout.write(JSON.stringify({ block: true, reason: `Beam: ${reason}` }));
|
|
1108
|
+
}
|
|
1109
|
+
else if (agent === "cline") {
|
|
1110
|
+
process.stdout.write(JSON.stringify({ cancel: true, errorMessage: `Beam: ${reason}` }));
|
|
1111
|
+
}
|
|
1112
|
+
else if (agent === "amp") {
|
|
1113
|
+
process.stdout.write(JSON.stringify({ action: "reject-and-continue", message: `Beam: ${reason}` }));
|
|
1114
|
+
}
|
|
1115
|
+
else if (["antigravity", "grok"].includes(agent)) {
|
|
1116
|
+
process.stdout.write(JSON.stringify({ decision: "deny", reason: `Beam: ${reason}` }));
|
|
1117
|
+
}
|
|
1118
|
+
else if (agent === "devin") {
|
|
843
1119
|
process.stdout.write(JSON.stringify({ decision: "block", reason: `Beam: ${reason}` }));
|
|
844
1120
|
}
|
|
1121
|
+
else if (["windsurf", "kimi", "qwen", "auggie", "kiro", "goose", "openhands", "crush", "junie"].includes(agent)) {
|
|
1122
|
+
// These hosts use exit code 2, set below.
|
|
1123
|
+
}
|
|
845
1124
|
else {
|
|
846
1125
|
// OpenCode has no installed hook/plugin path in this repository. A message cannot block it.
|
|
847
1126
|
process.stderr.write(`\n✖ Beam cannot block this agent (${agent}): no verified enforcement adapter is installed.\n`);
|