@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.
- 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/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 +28 -10
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +203 -100
- package/dist/client.js.map +1 -1
- package/dist/connect.d.ts +7 -0
- package/dist/connect.js +12 -0
- package/dist/connect.js.map +1 -1
- package/dist/core.d.ts +50 -2
- package/dist/core.js +73 -24
- 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 +2 -1
- 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/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 +5 -0
- package/dist/policy.js +78 -10
- package/dist/policy.js.map +1 -1
- package/dist/rule-catalog.d.ts +3 -1
- 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 +7 -6
- package/dist/server.js.map +1 -1
- package/dist/setup.js +26 -13
- 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-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 +2 -2
- package/package.json +6 -3
- 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(
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
//
|
|
461
|
-
//
|
|
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 =
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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" &&
|
|
549
|
+
if (decision.action === "allow" && workspace) {
|
|
502
550
|
const escaping = outsideWorkspacePaths(workspace, targetPath, command);
|
|
503
|
-
if (escaping.length) {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
//
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
//
|
|
619
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
806
|
+
// code.claude.com/docs/en/hooks for PreToolUse, UserPromptSubmit, and PostToolUse).
|
|
755
807
|
function claudeStyleHookEventName(event) {
|
|
756
|
-
return event === "UserPromptSubmit"
|
|
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 (
|
|
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
|
-
|
|
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`);
|