@agent-beam/beam 0.2.37 → 0.2.39

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 (112) 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/behavioral-anomaly.d.ts +16 -0
  23. package/dist/behavioral-anomaly.js +66 -0
  24. package/dist/behavioral-anomaly.js.map +1 -0
  25. package/dist/cli.js +28 -10
  26. package/dist/cli.js.map +1 -1
  27. package/dist/client.d.ts +1 -1
  28. package/dist/client.js +203 -100
  29. package/dist/client.js.map +1 -1
  30. package/dist/connect.d.ts +7 -0
  31. package/dist/connect.js +12 -0
  32. package/dist/connect.js.map +1 -1
  33. package/dist/core.d.ts +50 -2
  34. package/dist/core.js +73 -24
  35. package/dist/core.js.map +1 -1
  36. package/dist/credential-egress.d.ts +17 -0
  37. package/dist/credential-egress.js +325 -0
  38. package/dist/credential-egress.js.map +1 -0
  39. package/dist/custom-rules.js +2 -1
  40. package/dist/custom-rules.js.map +1 -1
  41. package/dist/data-detectors.js +60 -4
  42. package/dist/data-detectors.js.map +1 -1
  43. package/dist/destructive-risk.d.ts +1 -0
  44. package/dist/destructive-risk.js +70 -0
  45. package/dist/destructive-risk.js.map +1 -0
  46. package/dist/edit-stats.js +5 -5
  47. package/dist/edit-stats.js.map +1 -1
  48. package/dist/entity-redaction.d.ts +11 -0
  49. package/dist/entity-redaction.js +88 -0
  50. package/dist/entity-redaction.js.map +1 -0
  51. package/dist/file-lock.d.ts +1 -0
  52. package/dist/file-lock.js +54 -0
  53. package/dist/file-lock.js.map +1 -0
  54. package/dist/forward.d.ts +13 -6
  55. package/dist/forward.js +120 -42
  56. package/dist/forward.js.map +1 -1
  57. package/dist/hook-adapters.d.ts +2 -2
  58. package/dist/hook-adapters.js +59 -60
  59. package/dist/hook-adapters.js.map +1 -1
  60. package/dist/hook-catalog.d.ts +12 -0
  61. package/dist/hook-catalog.js +469 -0
  62. package/dist/hook-catalog.js.map +1 -0
  63. package/dist/hook-config.d.ts +8 -0
  64. package/dist/hook-config.js +80 -0
  65. package/dist/hook-config.js.map +1 -0
  66. package/dist/hook-spool.d.ts +3 -0
  67. package/dist/hook-spool.js +56 -0
  68. package/dist/hook-spool.js.map +1 -0
  69. package/dist/install.d.ts +5 -5
  70. package/dist/install.js +48 -199
  71. package/dist/install.js.map +1 -1
  72. package/dist/inventory.js +1 -1
  73. package/dist/inventory.js.map +1 -1
  74. package/dist/jev.js +2 -1
  75. package/dist/jev.js.map +1 -1
  76. package/dist/judge.js +2 -1
  77. package/dist/judge.js.map +1 -1
  78. package/dist/mitigation-pattern.d.ts +1 -0
  79. package/dist/mitigation-pattern.js +140 -0
  80. package/dist/mitigation-pattern.js.map +1 -0
  81. package/dist/package-risk-rules.js +2 -2
  82. package/dist/package-risk-rules.js.map +1 -1
  83. package/dist/policy-paths.d.ts +20 -0
  84. package/dist/policy-paths.js +145 -0
  85. package/dist/policy-paths.js.map +1 -0
  86. package/dist/policy.d.ts +5 -0
  87. package/dist/policy.js +78 -10
  88. package/dist/policy.js.map +1 -1
  89. package/dist/rule-catalog.d.ts +3 -1
  90. package/dist/rule-catalog.js +124 -24
  91. package/dist/rule-catalog.js.map +1 -1
  92. package/dist/rule-matches.d.ts +13 -0
  93. package/dist/rule-matches.js +42 -0
  94. package/dist/rule-matches.js.map +1 -0
  95. package/dist/serve.js +55 -5
  96. package/dist/serve.js.map +1 -1
  97. package/dist/server.js +7 -6
  98. package/dist/server.js.map +1 -1
  99. package/dist/setup.js +26 -13
  100. package/dist/setup.js.map +1 -1
  101. package/dist/skill-risk-rules.js +2 -2
  102. package/dist/skill-risk-rules.js.map +1 -1
  103. package/dist/skill-setup.js +4 -5
  104. package/dist/skill-setup.js.map +1 -1
  105. package/dist/skill-sync.d.ts +5 -1
  106. package/dist/skill-sync.js +115 -14
  107. package/dist/skill-sync.js.map +1 -1
  108. package/dist/store.js +6 -9
  109. package/dist/store.js.map +1 -1
  110. package/dist/studio.js +2 -2
  111. package/package.json +6 -3
  112. package/stress-report.json +48 -48
package/dist/client.js CHANGED
@@ -1,3 +1,8 @@
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";
@@ -9,6 +14,8 @@ import { evaluate, readPolicy } from "./policy.js";
9
14
  import { extractAgent } from "./extract.js";
10
15
  import { loadCustomRules } from "./custom-rules.js";
11
16
  import { redactSensitive } from "./data-detectors.js";
17
+ import { redactWithSessionEntities } from "./entity-redaction.js";
18
+ import { recordAndCheckAnomaly } from "./behavioral-anomaly.js";
12
19
  import { applyJevHook } from "./jev.js";
13
20
  import { applyJudgeHook } from "./judge.js";
14
21
  import { isPathTrusted, readTrustedPaths } from "./trusted-paths.js";
@@ -405,17 +412,38 @@ async function readStdin(limitBytes) {
405
412
  return input;
406
413
  }
407
414
  // Never throws: best-effort capture must not suppress configured policy enforcement.
408
- export async function captureHook(sourceAgent = "claude-code", home = homedir()) {
415
+ export async function captureHook(sourceAgent = "claude-code", home = homedir(), suppliedEvent) {
416
+ let canBlock = suppliedEvent === undefined || hookKind(suppliedEvent) === "pre";
409
417
  try {
410
- const input = await readStdin(100_000);
418
+ const input = await readStdin(2_000_000);
411
419
  const raw = JSON.parse(input);
412
- let data = adaptHookPayload(sourceAgent, raw);
420
+ let data = adaptHookPayload(sourceAgent, raw, suppliedEvent);
421
+ sourceAgent = String(data.source_agent ?? sourceAgent);
413
422
  data.source_agent = sourceAgent;
414
423
  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;
424
+ const eventKind = hookKind(String(data.hook_event_name ?? ""));
425
+ const promptGate = eventKind === "prompt" && ["claude-code", "codex", "cursor", "gemini"].includes(sourceAgent);
426
+ canBlock = eventKind === "pre" || promptGate;
427
+ if (eventKind === "prompt" && typeof data.prompt === "string") {
428
+ data.event_type = "prompt.submit";
429
+ data.tool_name = data.tool_name ?? data.hook_event_name;
430
+ data.command = data.prompt;
431
+ if (sessionTitlesEnabled())
432
+ await notePrompt(String(data.session_id ?? ""), data.prompt, String(data.cwd ?? "")).catch(() => { });
433
+ }
434
+ // Completed actions, permission notifications, and lifecycle callbacks are observation only.
435
+ // They must never run pre-action policy, skill approval, or action-judge gates.
436
+ if (!canBlock) {
437
+ if (eventKind === "post" && sourceAgent === "claude-code") {
438
+ const bundle = await readPolicy().catch(() => null);
439
+ await emitRedactedOutput(sourceAgent, data.tool_response, String(data.session_id ?? ""), bundle?.rules.customDetectors).catch(() => { });
440
+ }
441
+ await captureActivity(data);
442
+ if (sourceAgent === "cline")
443
+ process.stdout.write(JSON.stringify({ cancel: false }));
444
+ if (sourceAgent === "antigravity" && eventKind === "assistant")
445
+ process.stdout.write(JSON.stringify({ decision: "stop" }));
446
+ return;
419
447
  }
420
448
  // Model and token-usage backfill are independent: a newer agent build (Codex 0.155.1+ sends
421
449
  // `model` as a common field on every hook event -- see learn.chatgpt.com/docs/hooks) can
@@ -457,13 +485,15 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
457
485
  catch { /* best-effort enrichment only -- never block the hook on this */ }
458
486
  }
459
487
  // 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.
488
+ // General rules follow advisory/enforce mode. Explicit mandatory mitigation
489
+ // blocks can deny matching calls while unrelated calls remain in observe mode.
462
490
  const toolInput = data.tool_input && typeof data.tool_input === "object"
463
491
  ? data.tool_input : {};
464
- const command = String(data.command ?? toolInput.command ?? "");
492
+ const command = commandOf(data);
465
493
  let decision = evaluate(await readPolicy().catch(() => null), {
466
494
  agent: sourceAgent,
495
+ eventType: promptGate ? "prompt.submit" : undefined,
496
+ input: toolInput,
467
497
  tool: String(data.tool_name ?? ""),
468
498
  command,
469
499
  args: Array.isArray(toolInput.args) ? toolInput.args.map(String) : command.split(/\s+/).slice(1),
@@ -471,21 +501,20 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
471
501
  url: String(toolInput.url ?? data.url ?? ""),
472
502
  role: typeof data.role === "string" ? data.role : undefined,
473
503
  user: typeof data.user === "string" ? data.user : undefined,
474
- cwd: typeof data.cwd === "string" ? data.cwd : undefined,
504
+ cwd: typeof toolInput.workdir === "string" ? toolInput.workdir : typeof data.cwd === "string" ? data.cwd : undefined,
475
505
  repository: typeof data.repository === "string" ? data.repository : undefined,
476
506
  branch: typeof data.branch === "string" ? data.branch : undefined,
477
507
  environment: typeof data.environment === "string" ? data.environment : undefined,
478
508
  time: typeof data.timestamp === "string" ? data.timestamp : undefined,
479
509
  });
480
- if ((decision.action === "allow" || decision.action === "warn") && isEnvironmentAccess(String(data.tool_name ?? ""), command, String(toolInput.file_path ?? toolInput.path ?? data.file_path ?? ""), toolInput)) {
481
- // Sensitive data (.env, credentials, secrets) is never left at a soft, easy-to-miss
482
- // stderr warning -- it's always promoted to a real approval gate, regardless of what the
483
- // matched policy rule or risk mapping would otherwise have produced.
484
- decision = {
485
- ...decision,
486
- action: "ask",
487
- reason: "Reading environment variables or .env files requires approval.",
488
- };
510
+ const readsEnvironment = isEnvironmentAccess(String(data.tool_name ?? ""), command, String(toolInput.file_path ?? toolInput.path ?? data.file_path ?? ""), toolInput);
511
+ if (readsEnvironment && decision.mode === "observe" && decision.action === "allow") {
512
+ // Allow-all still records this sensitive read as a risk, without turning it into an approval gate.
513
+ decision = { ...decision, riskScore: Math.max(decision.riskScore, 35), riskLevel: decision.riskScore >= 70 ? decision.riskLevel : "MEDIUM", riskFactors: [...new Set([...decision.riskFactors, "sensitive environment data"])], reason: "Sensitive environment data access was allowed and flagged for review." };
514
+ }
515
+ else if (decision.mode !== "observe" && (decision.action === "allow" || decision.action === "warn") && readsEnvironment) {
516
+ // Sensitive data is always promoted to an approval gate in protective policy modes.
517
+ decision = { ...decision, action: "ask", reason: "Reading environment variables or .env files requires approval." };
489
518
  }
490
519
  const workspace = String(data.cwd ?? "");
491
520
  const targetPath = String(toolInput.file_path ?? toolInput.path ?? data.file_path ?? "");
@@ -497,20 +526,44 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
497
526
  reason: "Beam protection cannot be disabled, removed, or modified by an agent.",
498
527
  };
499
528
  }
529
+ // Behavioral anomaly: a burst of high/critical-severity actions from one session, independent
530
+ // of whether any single one was individually blocked -- the shape an agent shows when it's
531
+ // been talked into a chain of risky actions (injection, a compromised skill, a confused
532
+ // multi-step plan), even if each step alone only ever rated "ask". Recorded regardless of this
533
+ // action's own decision (a blocked/asked action still happened and still counts), but only
534
+ // ever escalates a decision that's currently allow/warn -- never downgrades an existing ask/deny.
535
+ try {
536
+ const anomaly = await recordAndCheckAnomaly(String(data.session_id ?? data.sessionId ?? ""), decision.riskLevel);
537
+ if (anomaly.triggered) {
538
+ const reason = `${anomaly.count} high/critical-severity actions from this session in the last ${anomaly.windowMinutes} minutes.`;
539
+ if (decision.mode === "observe" && decision.action === "allow") {
540
+ decision = { ...decision, riskFactors: [...new Set([...decision.riskFactors, "behavioral anomaly: action burst"])], reason };
541
+ }
542
+ else if (decision.mode !== "observe" && (decision.action === "allow" || decision.action === "warn")) {
543
+ decision = { ...decision, action: "ask", rule: "beam.behavioral-anomaly", reason };
544
+ }
545
+ }
546
+ }
547
+ catch { /* best-effort: must never block the agent on anomaly-tracking failure */ }
500
548
  const policyMode = decision.mode;
501
- if (decision.action === "allow" && policyMode !== "observe" && workspace) {
549
+ if (decision.action === "allow" && workspace) {
502
550
  const escaping = outsideWorkspacePaths(workspace, targetPath, command);
503
- if (escaping.length) {
504
- // A human can pre-approve specific outside-workspace paths with `beam trust <path>`
505
- // (~/.beam/data/trusted-paths.json) so this ask doesn't fire again for them. The agent
506
- // can't write that file itself -- isBeamSelfProtectionTarget above blocks it.
507
- const trusted = await readTrustedPaths().catch(() => []);
508
- const untrusted = escaping.filter(path => !isPathTrusted(path, trusted));
509
- if (untrusted.length) {
510
- // Hooks run as child processes while the host agent owns the terminal. Reading
511
- // /dev/tty here steals input from the host UI and can stall the session until timeout.
512
- // Return ASK promptly so the host can show its own permission prompt instead.
513
- decision = { ...decision, action: "ask", reason: "This action accesses a path outside the current workspace." };
551
+ if (policyMode === "observe" && escaping.length) {
552
+ decision = { ...decision, riskScore: Math.max(decision.riskScore, 35), riskLevel: decision.riskScore >= 70 ? decision.riskLevel : "MEDIUM", riskFactors: [...new Set([...decision.riskFactors, "access outside the current workspace"])], reason: "Access outside the current workspace was allowed and flagged for review." };
553
+ }
554
+ if (policyMode !== "observe") {
555
+ if (escaping.length) {
556
+ // A human can pre-approve specific outside-workspace paths with `beam trust <path>`
557
+ // (~/.beam/data/trusted-paths.json) so this ask doesn't fire again for them. The agent
558
+ // can't write that file itself -- isBeamSelfProtectionTarget above blocks it.
559
+ const trusted = await readTrustedPaths().catch(() => []);
560
+ const untrusted = escaping.filter(path => !isPathTrusted(path, trusted));
561
+ if (untrusted.length) {
562
+ // Hooks run as child processes while the host agent owns the terminal. Reading
563
+ // /dev/tty here steals input from the host UI and can stall the session until timeout.
564
+ // Return ASK promptly so the host can show its own permission prompt instead.
565
+ decision = { ...decision, action: "ask", reason: "This action accesses a path outside the current workspace." };
566
+ }
514
567
  }
515
568
  }
516
569
  }
@@ -522,7 +575,7 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
522
575
  const skill = await gateSkill(String(data.tool_name ?? ""), toolInput, workspace, policyMode === "enforce");
523
576
  if (skill) {
524
577
  decision = { ...decision, riskScore: Math.max(decision.riskScore, skill.scan.score), riskLevel: skill.scan.level, riskFactors: [...decision.riskFactors, ...skill.scan.findings.slice(0, 5).map(f => f.title)] };
525
- if (skill.action !== "allow")
578
+ if (skill.action !== "allow" && policyMode !== "observe")
526
579
  decision = { ...decision, action: skill.action, rule: "beam.skill-scan", reason: skill.reason };
527
580
  else if (skill.reason)
528
581
  process.stderr.write(`\n⚠ Beam skill scan: ${skill.reason}\n`);
@@ -545,46 +598,16 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
545
598
  // step only ever runs for Beam's own soft, non-mandatory fallback.
546
599
  if (decision.action === "warn")
547
600
  decision = await resolveWarnInteractively(decision, targetPath, command);
548
- // Detection and policy enforcement above already ran against the real prompt text and
549
- // session id -- neither is forwarded to the local collector or the workspace dashboard from
550
- // this point on. event_type/tool_name still identify a prompt-submit event; the content and
551
- // the session identifier do not leave this machine. Scoped to prompt.submit only -- deleting
552
- // session_id on every event type broke session grouping (and the tool_use_id dedup in
553
- // core.ts, which is keyed per-hostname/phase but not per-session) for all other hook events.
554
- // The only things derived from a prompt that are allowed out: a session title (first prompt) and
555
- // a short per-turn topic, both redacted and capped at 80 characters -- see session-title.ts.
556
- // Raw prompt text still never leaves the machine.
557
- if (sessionTitlesEnabled()) {
558
- try {
559
- const sessionId = String(data.session_id ?? data.sessionId ?? "");
560
- // Cursor (beforeSubmitPrompt) and Gemini (BeforeAgent) name their prompt hook differently
561
- // from Claude Code / Codex, and are not rewritten to prompt.submit above (each agent's
562
- // block contract is keyed on its own event name), so recognise them here.
563
- const otherPromptHook = ["beforeSubmitPrompt", "BeforeAgent"].includes(String(data.hook_event_name)) && typeof data.prompt === "string";
564
- if (data.event_type === "prompt.submit" || otherPromptHook) {
565
- await notePrompt(sessionId, String(data.prompt ?? ""), String(data.cwd ?? ""));
566
- if (otherPromptHook)
567
- delete data.prompt; // the raw prompt must not travel further than this check
568
- }
569
- else {
570
- const context = await sessionContextFor(sourceAgent, sessionId, { home, transcriptPath: typeof data.transcript_path === "string" ? data.transcript_path : undefined });
571
- if (context) {
572
- data.session_title = context.title;
573
- data.session_title_source = context.titleSource;
574
- data.turn = context.turn;
575
- data.turn_topic = context.topic;
576
- data.turn_shift = context.shift;
577
- }
578
- }
601
+ // Retain redacted prompts and session identities now that prompt activity is part of capture.
602
+ if (sessionTitlesEnabled() && data.session_id && eventKind !== "prompt") {
603
+ const context = await sessionContextFor(sourceAgent, String(data.session_id), { home }).catch(() => null);
604
+ if (context) {
605
+ data.session_title = context.title;
606
+ data.session_title_source = context.titleSource;
607
+ data.turn = context.turn;
608
+ data.turn_topic = context.topic;
609
+ data.turn_shift = context.shift;
579
610
  }
580
- catch { /* best-effort: a title must never affect enforcement */ }
581
- }
582
- if (data.event_type === "prompt.submit") {
583
- delete data.prompt;
584
- delete data.command;
585
- delete data.session_id;
586
- delete data.sessionId;
587
- delete data.session;
588
611
  }
589
612
  data.policy_decision = { action: decision.action, rule: decision.rule, reason: decision.reason, risk_score: decision.riskScore, risk_level: decision.riskLevel, risk_factors: decision.riskFactors };
590
613
  const hookEvent = String(data.hook_event_name ?? "PreToolUse");
@@ -598,7 +621,7 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
598
621
  emitAsk(sourceAgent, decision.reason ?? "This action requires approval.", hookEvent);
599
622
  }
600
623
  else if (decision.action === "redact") {
601
- const transformed = redactPayload(data, decision);
624
+ const transformed = await redactPayload(data, decision, String(data.session_id ?? data.sessionId ?? ""));
602
625
  if (!transformed.changed) {
603
626
  emitAllow(sourceAgent, decision.reason, hookEvent);
604
627
  }
@@ -613,21 +636,10 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
613
636
  // prompt.
614
637
  process.stderr.write(`\n⚠ Beam policy (advisory): ${decision.reason}\n`);
615
638
  }
616
- // 2. Local capture (best-effort — a failure here must not skip enforcement or throw). The
617
- // running collector (serve.ts) also batches this event into its own workspace forward --
618
- // see ForwardQueue in forward.ts -- so most events reach the dashboard in groups rather
619
- // than one request per action. Allowed prompt.submit events are skipped here: policy
620
- // enforcement above already ran against them, but every keystroke-triggered prompt
621
- // otherwise floods the Activity/Overview views with noise nobody reviews. A blocked
622
- // prompt is still surfaced -- see step 3 below.
623
- if (data.event_type !== "prompt.submit" || decision.action === "deny") {
624
- try {
625
- await send("/ingest", JSON.stringify(data));
626
- }
627
- catch (e) {
628
- console.error(e instanceof Error ? e.message : String(e));
629
- }
630
- }
639
+ if (["cline", "antigravity"].includes(sourceAgent) && ["allow", "audit", "warn"].includes(decision.action))
640
+ emitAllow(sourceAgent, decision.reason, hookEvent);
641
+ // Capture every lifecycle event, including allowed prompts and tool completions.
642
+ await captureActivity(data);
631
643
  // 3. A policy-blocked action carries a finding the collector's own normalize() can't
632
644
  // reconstruct (it only knows about *this* process's policy decision) -- forward it to the
633
645
  // workspace immediately rather than folding it into the batch, so a manager sees a block
@@ -647,13 +659,46 @@ export async function captureHook(sourceAgent = "claude-code", home = homedir())
647
659
  // confirms exit 2 blocks both BeforeTool and BeforeAgent, same as decision: "deny") and is
648
660
  // also treated as failure by OpenCode's native plugin wrapper.
649
661
  const policy = await readPolicy().catch(() => null);
650
- if (policy && (policy.rules.mode === "enforce" || policy.errors?.length)) {
662
+ if (canBlock && policy && (policy.rules.mode === "enforce" || policy.errors?.length)) {
651
663
  // emitDeny already sets exit code 2 for the agents where that's the documented fail-closed
652
664
  // signal (see EXIT_2_FAILS_CLOSED above).
653
665
  emitDeny(sourceAgent, "Beam security policy could not complete safely; execution is denied.");
654
666
  }
655
667
  }
656
668
  }
669
+ async function captureActivity(data) {
670
+ try {
671
+ const policy = await readPolicy().catch(() => null);
672
+ const redacted = await redactPayload(data, { customDetectors: policy?.rules.customDetectors }, String(data.session_id ?? ""));
673
+ const a = activityFor(redacted.data, value => redactSensitive(value, policy?.rules.customDetectors).text);
674
+ if (!a)
675
+ return;
676
+ const projected = {};
677
+ 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"]) {
678
+ const value = redacted.data[field];
679
+ if (value !== undefined)
680
+ projected[field] = typeof value === "string" ? value.slice(0, 4000) : value;
681
+ }
682
+ const details = a.details;
683
+ if (details)
684
+ for (const [key, value] of Object.entries(details))
685
+ projected[key] = value;
686
+ projected.tool_input = a.input;
687
+ projected.tool_response = a.output;
688
+ projected.prompt = a.prompt;
689
+ projected.assistant_text = a.assistant;
690
+ projected.capture_truncated = a.truncated;
691
+ try {
692
+ await send("/ingest", JSON.stringify(projected));
693
+ }
694
+ catch {
695
+ await spoolHookEvent(projected);
696
+ }
697
+ }
698
+ catch (e) {
699
+ process.stderr.write(`Beam activity capture failed: ${e instanceof Error ? e.message : String(e)}\n`);
700
+ }
701
+ }
657
702
  function safeNormalize(data) {
658
703
  try {
659
704
  return normalize(data);
@@ -691,12 +736,18 @@ async function resolveWarnInteractively(decision, targetPath, command) {
691
736
  }
692
737
  function isEnvironmentAccess(tool, command, path, input) {
693
738
  const text = `${tool} ${command} ${path} ${Object.values(input).filter(value => typeof value === "string").join(" ")}`;
739
+ const context = classifyActionContext(tool, { ...input, file_path: path }, "", command, v => v, false);
740
+ const scoped = environmentRisk(context);
741
+ if (context.actions.length && context.actions.every(a => a.operation === "source-search"))
742
+ return false;
694
743
  // File reads: .env, .env.local, .env.prod, etc. Also catch common env/config
695
744
  // locations that contain exported credentials.
696
745
  if (/(^|[\\/\s"'])\.env(?:\.[\w.-]+)?(?:$|[\\/\s"'])/i.test(text))
697
746
  return true;
698
747
  if (/(^|[\\/\s"'])\.aws[\\/]credentials(?:$|[\\/\s"'])|(^|[\\/\s"'])\.config[\\/]gcloud(?:$|[\\/\s"'])/i.test(text))
699
748
  return true;
749
+ if (scoped && ("suppress" in scoped || "severity" in scoped && scoped.severity === "info"))
750
+ return false;
700
751
  // Shell/process environment reads: env, printenv, set, export -p, $FOO,
701
752
  // process.env, os.environ and equivalent tool arguments.
702
753
  return /\b(?:printenv|env|export\s+-p|process\.env|os\.environ|System\.getenv)\b/i.test(text)
@@ -732,28 +783,55 @@ function isBeamSelfProtectionTarget(tool, command, target) {
732
783
  function blockedFinding(reason = "Blocked by workspace policy.") {
733
784
  return { id: "policy.blocked", title: "Blocked by workspace policy", severity: "high", explanation: reason, evidence: "" };
734
785
  }
735
- function redactPayload(data, decision) {
786
+ async function redactPayload(data, decision, sessionId) {
736
787
  const copy = structuredClone(data);
737
788
  let changed = false;
738
- const redactValue = (value) => {
789
+ const redactValue = async (value) => {
739
790
  if (typeof value === "string") {
740
- const result = redactSensitive(value, decision.customDetectors);
791
+ const result = await redactWithSessionEntities(value, sessionId, decision.customDetectors);
741
792
  changed ||= result.text !== value;
742
793
  return result.text;
743
794
  }
744
795
  if (Array.isArray(value))
745
- return value.map(redactValue);
796
+ return Promise.all(value.map(redactValue));
746
797
  if (value && typeof value === "object")
747
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactValue(v)]));
798
+ return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([k, v]) => [k, await redactValue(v)])));
748
799
  return value;
749
800
  };
750
- return { data: redactValue(copy), changed };
801
+ const redacted = await redactValue(copy);
802
+ return { data: redacted, changed };
751
803
  }
752
804
  // Claude Code and Codex use the same hookSpecificOutput.permissionDecision contract for every
753
805
  // hook event -- only the echoed hookEventName differs per event (verified against
754
- // code.claude.com/docs/en/hooks for both PreToolUse and UserPromptSubmit).
806
+ // code.claude.com/docs/en/hooks for PreToolUse, UserPromptSubmit, and PostToolUse).
755
807
  function claudeStyleHookEventName(event) {
756
- return event === "UserPromptSubmit" ? "UserPromptSubmit" : "PreToolUse";
808
+ return event === "UserPromptSubmit" || event === "PostToolUse" ? event : "PreToolUse";
809
+ }
810
+ // A tool has already run by the time PostToolUse fires -- there is nothing left to block, only
811
+ // what Claude sees afterward to rewrite (hookSpecificOutput.updatedToolOutput). This is the
812
+ // counterpart to redactPayload()/emitRedacted() below, which redact a secret that was already in
813
+ // the *command text* before it runs; this instead catches a secret that only appears in a
814
+ // command's *output* (`cat .env` holding a real, un-migrated value; an app echoing its own
815
+ // resolved config) -- the input had nothing to redact, so PreToolUse redaction never saw it.
816
+ // Only wired for claude-code (see agents.ts notes): Codex's PostToolUse payload shape and
817
+ // updatedToolOutput support are unverified, and sending a rewrite a client doesn't honor would
818
+ // silently do nothing while looking like protection.
819
+ async function emitRedactedOutput(agent, toolResponse, sessionId, customDetectors) {
820
+ if (agent !== "claude-code" || toolResponse === null || toolResponse === undefined)
821
+ return;
822
+ const redactValue = async (value) => {
823
+ if (typeof value === "string")
824
+ return (await redactWithSessionEntities(value, sessionId, customDetectors)).text;
825
+ if (Array.isArray(value))
826
+ return Promise.all(value.map(redactValue));
827
+ if (value && typeof value === "object")
828
+ return Object.fromEntries(await Promise.all(Object.entries(value).map(async ([k, v]) => [k, await redactValue(v)])));
829
+ return value;
830
+ };
831
+ const redacted = await redactValue(toolResponse);
832
+ if (JSON.stringify(redacted) === JSON.stringify(toolResponse))
833
+ return; // nothing to rewrite
834
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", updatedToolOutput: redacted } }));
757
835
  }
758
836
  function emitAsk(agent, reason, event = "PreToolUse") {
759
837
  if (agent === "claude-code" || agent === "codex") {
@@ -783,6 +861,14 @@ function emitRedacted(agent, data, reason, event = "PreToolUse") {
783
861
  emitDeny(agent, "Beam cannot safely return transformed input through this hook contract.", event);
784
862
  }
785
863
  function emitAllow(agent, reason, event = "PreToolUse") {
864
+ if (agent === "cline") {
865
+ process.stdout.write(JSON.stringify({ cancel: false }));
866
+ return;
867
+ }
868
+ if (agent === "antigravity") {
869
+ process.stdout.write(JSON.stringify({ decision: "ask" }));
870
+ return;
871
+ }
786
872
  if (agent === "claude-code" || agent === "codex")
787
873
  process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: claudeStyleHookEventName(event), permissionDecision: "allow", permissionDecisionReason: reason } }));
788
874
  else if (agent === "cursor" && event === "beforeSubmitPrompt")
@@ -800,12 +886,12 @@ function emitAllow(agent, reason, event = "PreToolUse") {
800
886
  // content", separately from the exit-0 hookSpecificOutput.permissionDecision path). Setting this
801
887
  // alongside the JSON is belt-and-suspenders, not a replacement -- the JSON still carries the
802
888
  // clean reason text; exit 2 just guarantees the block itself isn't silently skipped.
803
- const EXIT_2_FAILS_CLOSED = new Set(["claude-code", "codex", "cursor", "copilot-cli", "gemini", "opencode", "openclaw", "hermes"]);
889
+ 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"]);
804
890
  // Claude Code / Codex hook contract: a JSON decision on stdout denies the tool call or prompt.
805
891
  // Other agents get a stderr note only (their block contracts differ and aren't verified), except
806
892
  // where noted below.
807
893
  function emitDeny(agent, reason, event = "PreToolUse") {
808
- if (agent === "claude-code" || agent === "codex") {
894
+ if (["claude-code", "codex", "muse", "vscode", "factory"].includes(agent)) {
809
895
  process.stdout.write(JSON.stringify({
810
896
  hookSpecificOutput: {
811
897
  hookEventName: claudeStyleHookEventName(event),
@@ -835,9 +921,26 @@ function emitDeny(agent, reason, event = "PreToolUse") {
835
921
  process.stdout.write(JSON.stringify({ decision: "deny", reason: `Beam: ${reason}` }));
836
922
  }
837
923
  else if (agent === "hermes") {
838
- // Hermes shell hooks block with {"decision": "block", "reason"} (or exit 2, set below).
924
+ process.stdout.write(JSON.stringify({ action: "block", message: `Beam: ${reason}`, decision: "block", reason: `Beam: ${reason}` }));
925
+ }
926
+ else if (["pi", "kilo"].includes(agent)) {
927
+ process.stdout.write(JSON.stringify({ block: true, reason: `Beam: ${reason}` }));
928
+ }
929
+ else if (agent === "cline") {
930
+ process.stdout.write(JSON.stringify({ cancel: true, errorMessage: `Beam: ${reason}` }));
931
+ }
932
+ else if (agent === "amp") {
933
+ process.stdout.write(JSON.stringify({ action: "reject-and-continue", message: `Beam: ${reason}` }));
934
+ }
935
+ else if (["antigravity", "grok"].includes(agent)) {
936
+ process.stdout.write(JSON.stringify({ decision: "deny", reason: `Beam: ${reason}` }));
937
+ }
938
+ else if (agent === "devin") {
839
939
  process.stdout.write(JSON.stringify({ decision: "block", reason: `Beam: ${reason}` }));
840
940
  }
941
+ else if (["windsurf", "kimi", "qwen", "auggie", "kiro", "goose", "openhands", "crush", "junie"].includes(agent)) {
942
+ // These hosts use exit code 2, set below.
943
+ }
841
944
  else {
842
945
  // OpenCode has no installed hook/plugin path in this repository. A message cannot block it.
843
946
  process.stderr.write(`\n✖ Beam cannot block this agent (${agent}): no verified enforcement adapter is installed.\n`);