@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.
Files changed (121) hide show
  1. package/README.md +7 -13
  2. package/dist/access-risk.d.ts +10 -0
  3. package/dist/access-risk.js +43 -0
  4. package/dist/access-risk.js.map +1 -0
  5. package/dist/action-context.d.ts +30 -0
  6. package/dist/action-context.js +203 -0
  7. package/dist/action-context.js.map +1 -0
  8. package/dist/activity.d.ts +22 -0
  9. package/dist/activity.js +132 -0
  10. package/dist/activity.js.map +1 -0
  11. package/dist/agent-hook-install.d.ts +8 -0
  12. package/dist/agent-hook-install.js +278 -0
  13. package/dist/agent-hook-install.js.map +1 -0
  14. package/dist/agent-installers.js +16 -3
  15. package/dist/agent-installers.js.map +1 -1
  16. package/dist/agent-plugins.d.ts +3 -0
  17. package/dist/agent-plugins.js +66 -0
  18. package/dist/agent-plugins.js.map +1 -0
  19. package/dist/agents.d.ts +1 -0
  20. package/dist/agents.js +30 -184
  21. package/dist/agents.js.map +1 -1
  22. package/dist/approvals.d.ts +13 -1
  23. package/dist/approvals.js +14 -3
  24. package/dist/approvals.js.map +1 -1
  25. package/dist/approved-actions.d.ts +11 -0
  26. package/dist/approved-actions.js +53 -0
  27. package/dist/approved-actions.js.map +1 -0
  28. package/dist/behavioral-anomaly.d.ts +16 -0
  29. package/dist/behavioral-anomaly.js +66 -0
  30. package/dist/behavioral-anomaly.js.map +1 -0
  31. package/dist/cli.js +77 -9
  32. package/dist/cli.js.map +1 -1
  33. package/dist/client.d.ts +18 -1
  34. package/dist/client.js +359 -80
  35. package/dist/client.js.map +1 -1
  36. package/dist/core.d.ts +55 -2
  37. package/dist/core.js +85 -26
  38. package/dist/core.js.map +1 -1
  39. package/dist/credential-egress.d.ts +17 -0
  40. package/dist/credential-egress.js +325 -0
  41. package/dist/credential-egress.js.map +1 -0
  42. package/dist/custom-rules.js +3 -2
  43. package/dist/custom-rules.js.map +1 -1
  44. package/dist/data-detectors.js +60 -4
  45. package/dist/data-detectors.js.map +1 -1
  46. package/dist/destructive-risk.d.ts +1 -0
  47. package/dist/destructive-risk.js +70 -0
  48. package/dist/destructive-risk.js.map +1 -0
  49. package/dist/edit-stats.js +5 -5
  50. package/dist/edit-stats.js.map +1 -1
  51. package/dist/entity-redaction.d.ts +11 -0
  52. package/dist/entity-redaction.js +88 -0
  53. package/dist/entity-redaction.js.map +1 -0
  54. package/dist/file-lock.d.ts +1 -0
  55. package/dist/file-lock.js +54 -0
  56. package/dist/file-lock.js.map +1 -0
  57. package/dist/forward.d.ts +13 -6
  58. package/dist/forward.js +120 -42
  59. package/dist/forward.js.map +1 -1
  60. package/dist/hook-adapters.d.ts +2 -2
  61. package/dist/hook-adapters.js +59 -60
  62. package/dist/hook-adapters.js.map +1 -1
  63. package/dist/hook-catalog.d.ts +12 -0
  64. package/dist/hook-catalog.js +469 -0
  65. package/dist/hook-catalog.js.map +1 -0
  66. package/dist/hook-config.d.ts +8 -0
  67. package/dist/hook-config.js +80 -0
  68. package/dist/hook-config.js.map +1 -0
  69. package/dist/hook-spool.d.ts +3 -0
  70. package/dist/hook-spool.js +56 -0
  71. package/dist/hook-spool.js.map +1 -0
  72. package/dist/install.d.ts +5 -5
  73. package/dist/install.js +48 -199
  74. package/dist/install.js.map +1 -1
  75. package/dist/inventory.js +1 -1
  76. package/dist/inventory.js.map +1 -1
  77. package/dist/jev.js +2 -1
  78. package/dist/jev.js.map +1 -1
  79. package/dist/judge.js +2 -1
  80. package/dist/judge.js.map +1 -1
  81. package/dist/mitigation-pattern.d.ts +1 -0
  82. package/dist/mitigation-pattern.js +140 -0
  83. package/dist/mitigation-pattern.js.map +1 -0
  84. package/dist/package-risk-rules.js +2 -2
  85. package/dist/package-risk-rules.js.map +1 -1
  86. package/dist/package-scan.js +1 -1
  87. package/dist/package-scan.js.map +1 -1
  88. package/dist/packages.js +1 -1
  89. package/dist/packages.js.map +1 -1
  90. package/dist/policy-paths.d.ts +20 -0
  91. package/dist/policy-paths.js +145 -0
  92. package/dist/policy-paths.js.map +1 -0
  93. package/dist/policy.d.ts +10 -0
  94. package/dist/policy.js +99 -11
  95. package/dist/policy.js.map +1 -1
  96. package/dist/rule-catalog.d.ts +4 -2
  97. package/dist/rule-catalog.js +124 -24
  98. package/dist/rule-catalog.js.map +1 -1
  99. package/dist/rule-matches.d.ts +13 -0
  100. package/dist/rule-matches.js +42 -0
  101. package/dist/rule-matches.js.map +1 -0
  102. package/dist/serve.js +55 -5
  103. package/dist/serve.js.map +1 -1
  104. package/dist/server.js +34 -7
  105. package/dist/server.js.map +1 -1
  106. package/dist/setup.js +5 -3
  107. package/dist/setup.js.map +1 -1
  108. package/dist/skill-risk-rules.js +2 -2
  109. package/dist/skill-risk-rules.js.map +1 -1
  110. package/dist/skill-scan.js +1 -1
  111. package/dist/skill-scan.js.map +1 -1
  112. package/dist/skill-setup.js +4 -5
  113. package/dist/skill-setup.js.map +1 -1
  114. package/dist/skill-sync.d.ts +5 -1
  115. package/dist/skill-sync.js +115 -14
  116. package/dist/skill-sync.js.map +1 -1
  117. package/dist/store.js +6 -9
  118. package/dist/store.js.map +1 -1
  119. package/dist/studio.js +4 -4
  120. package/package.json +5 -2
  121. 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(100_000);
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
- if (data.hook_event_name === "UserPromptSubmit" && typeof data.prompt === "string") {
416
- data.event_type = data.event_type ?? "prompt.submit";
417
- data.tool_name = data.tool_name ?? "UserPromptSubmit";
418
- data.command = data.command ?? data.prompt;
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
- // Only acts when a manager has set the policy to advisory/enforce; observe mode
461
- // (the default) is a no-op and Beam stays observation-only.
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 = String(data.command ?? toolInput.command ?? "");
465
- let decision = evaluate(await readPolicy().catch(() => null), {
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
- // Detection and policy enforcement above already ran against the real prompt text and
553
- // session id -- neither is forwarded to the local collector or the workspace dashboard from
554
- // this point on. event_type/tool_name still identify a prompt-submit event; the content and
555
- // the session identifier do not leave this machine. Scoped to prompt.submit only -- deleting
556
- // session_id on every event type broke session grouping (and the tool_use_id dedup in
557
- // core.ts, which is keyed per-hostname/phase but not per-session) for all other hook events.
558
- // The only things derived from a prompt that are allowed out: a session title (first prompt) and
559
- // a short per-turn topic, both redacted and capped at 80 characters -- see session-title.ts.
560
- // Raw prompt text still never leaves the machine.
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
- if (data.event_type === "prompt.submit") {
587
- delete data.prompt;
588
- delete data.command;
589
- delete data.session_id;
590
- delete data.sessionId;
591
- delete data.session;
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
- // 2. Local capture (best-effort — a failure here must not skip enforcement or throw). The
621
- // running collector (serve.ts) also batches this event into its own workspace forward --
622
- // see ForwardQueue in forward.ts -- so most events reach the dashboard in groups rather
623
- // than one request per action. Allowed prompt.submit events are skipped here: policy
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 = redactSensitive(value, decision.customDetectors);
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
- return { data: redactValue(copy), changed };
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 both PreToolUse and UserPromptSubmit).
986
+ // code.claude.com/docs/en/hooks for PreToolUse, UserPromptSubmit, and PostToolUse).
759
987
  function claudeStyleHookEventName(event) {
760
- return event === "UserPromptSubmit" ? "UserPromptSubmit" : "PreToolUse";
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 (agent === "claude-code" || agent === "codex") {
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
- // Hermes shell hooks block with {"decision": "block", "reason"} (or exit 2, set below).
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`);