@shomra/agent 0.3.9 → 0.3.10

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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * ─── THE FAIL-OPEN LEDGER (client half) ─────────────────────────────────────
3
+ *
4
+ * ── Why this exists ─────────────────────────────────────────────────────────
5
+ *
6
+ * The pre-tool-call guard FAILS OPEN, and it has to: an agent that hard-stops
7
+ * because a SaaS backend is unreachable is an agent nobody keeps installed. The
8
+ * breaker in `shomra.mjs` makes that cheap — once the backend times out, the
9
+ * next calls skip the round-trip entirely for a cooldown window.
10
+ *
11
+ * ⚠ THE CONSEQUENCE IS THAT AN OUTAGE IS INVISIBLE ON THE SERVER. Every runtime
12
+ * claim Shomra makes counts rows the backend WROTE, so a breaker-open window
13
+ * produces no rows at all — byte-for-byte what a quiet, clean window produces.
14
+ * "The guard was down for six hours" and "the guard saw nothing dangerous" are
15
+ * the same evidence, and a reader takes the reassuring one.
16
+ *
17
+ * This module is the other end of `src/gate/enforcement-availability.ts` in the
18
+ * backend. It remembers what this machine did while it was blind, and hands it
19
+ * over on the next call that gets through — turning an absence of evidence into
20
+ * evidence of an absence.
21
+ *
22
+ * ── ⚠ THE RULES ─────────────────────────────────────────────────────────────
23
+ *
24
+ * 1. **The envelope is sent ALWAYS, even empty.** Its PRESENCE is what tells the
25
+ * backend this client is CAPABLE of reporting an outage. An old client sends
26
+ * nothing, and nothing is also what a healthy client would send if this were
27
+ * "optimised" to omit the empty case — at which point every healthy estate
28
+ * becomes indistinguishable from an unobservable one. That change would look
29
+ * like a bandwidth win in review. It is the whole feature.
30
+ *
31
+ * 2. **Only calls that WOULD have been screened are counted.** A call the guard
32
+ * deliberately never escalates (benign, locally cleared, not policy-relevant
33
+ * — the bulk of them) is a stated design boundary, not a gap. Counting those
34
+ * would report every healthy machine as ~90% blind and the number would be
35
+ * ignored within a week.
36
+ *
37
+ * 3. **Counts are LOWER BOUNDS and are allowed to be.** Hooks run as concurrent
38
+ * short-lived processes, so two of them can read-modify-write this file at
39
+ * once and lose an increment. The backend already treats every count here as
40
+ * a floor for exactly this reason. ⚠ Do not "fix" that by making the writes
41
+ * heavier — a lock on the firewall's hot path costs more than the precision
42
+ * is worth, and the number is a floor either way.
43
+ *
44
+ * 4. **A window we cannot attest the END of closes as `null`, never as now().**
45
+ * If this machine slept, crashed, or was rebooted mid-outage, the window on
46
+ * disk is stale and we genuinely do not know when it ended. `closedAt: null`
47
+ * is the backend's "we were never told it ended" — which it grades as
48
+ * unmeasurable rather than as a zero-length blip.
49
+ *
50
+ * 5. **Nothing is ever DROPPED to stay under the cap.** Over the limit, the
51
+ * oldest windows MERGE into one aggregate that keeps their summed counts and
52
+ * spans their range. Truncating the list instead would silently delete
53
+ * evidence of blindness, which is the one direction this file must never
54
+ * fail in.
55
+ *
56
+ * PURE state machine + thin file I/O, split so the state rules are testable
57
+ * without a filesystem (`tests/guard-ledger.test.mjs`).
58
+ */
59
+ import fs from 'node:fs';
60
+ import path from 'node:path';
61
+
62
+ /** Max gaps in one envelope. Matches `@ArrayMaxSize(50)` on the backend DTO. */
63
+ export const MAX_GAPS = 50;
64
+
65
+ /**
66
+ * How long an open window may sit on disk before we stop claiming to know when
67
+ * it ended. Six hours: comfortably longer than any real outage a 30s breaker
68
+ * cooldown produces, and short enough that a laptop closed overnight does not
69
+ * come back claiming a 14-hour measured blackout.
70
+ */
71
+ export const STALE_WINDOW_MS = 6 * 60 * 60 * 1000;
72
+
73
+ /** The empty ledger. */
74
+ export const emptyLedger = () => ({ open: null, pending: [] });
75
+
76
+ /* ── The state machine (pure) ───────────────────────────────────────────── */
77
+
78
+ /**
79
+ * Begin a window, or leave an already-open one alone.
80
+ *
81
+ * ⚠ IDEMPOTENT ON PURPOSE. The breaker trips on every failed call, not just the
82
+ * first, so a naive implementation would start a fresh window per call and
83
+ * report a 200-call outage as 200 one-call outages — each with a tiny count,
84
+ * none of them showing the real shape. The FIRST failure owns the window.
85
+ */
86
+ export function openWindow(state, { at, reason }) {
87
+ const s = state ?? emptyLedger();
88
+ if (s.open) return s;
89
+ return { ...s, open: { openedAt: at, reason: String(reason || 'unknown').slice(0, 200), unscreened: 0, local: 0 } };
90
+ }
91
+
92
+ /**
93
+ * Record one call that ran without a server verdict.
94
+ *
95
+ * `kind` is `'local'` when the on-machine Tier-0 engine screened it (a weaker
96
+ * screen — no org policy, no identity, no flow, no supply chain, no intent) and
97
+ * `'unscreened'` when nothing did.
98
+ *
99
+ * ⚠ IT OPENS A WINDOW IF NONE IS OPEN. A count with nowhere to live would be
100
+ * dropped, and the paths that skip the round-trip (`breakerOpen()`) do not
101
+ * themselves fail, so they never call `openWindow` on their own.
102
+ */
103
+ export function countCall(state, { at, kind, reason }) {
104
+ const s = openWindow(state, { at, reason: reason || 'breaker-open' });
105
+ const open = { ...s.open };
106
+ if (kind === 'unscreened') open.unscreened += 1;
107
+ else open.local += 1;
108
+ return { ...s, open };
109
+ }
110
+
111
+ /**
112
+ * End the open window and move it to the outbox.
113
+ *
114
+ * ⚠ A window that recorded NOTHING is discarded rather than reported. A breaker
115
+ * that tripped on a call and healed before the next one cost no coverage, and a
116
+ * zero-call gap row would be noise that makes the real ones harder to see.
117
+ */
118
+ export function closeWindow(state, { at, staleMs = STALE_WINDOW_MS } = {}) {
119
+ const s = state ?? emptyLedger();
120
+ if (!s.open) return s;
121
+ const { openedAt, reason, unscreened, local } = s.open;
122
+ if (!unscreened && !local) return { ...s, open: null };
123
+ // Rule 4: too old to attest an end for.
124
+ const stale = at - openedAt > staleMs;
125
+ const gap = {
126
+ opened_at: new Date(openedAt).toISOString(),
127
+ ...(stale ? {} : { closed_at: new Date(at).toISOString() }),
128
+ unscreened_calls: unscreened,
129
+ locally_decided_calls: local,
130
+ reason: stale ? `${reason} (end not observed)` : reason,
131
+ };
132
+ return { open: null, pending: compact([...s.pending, gap]) };
133
+ }
134
+
135
+ /**
136
+ * Rule 5 — keep the list bounded WITHOUT losing counts.
137
+ *
138
+ * ⚠ The merged row deliberately carries `closed_at` only when every window it
139
+ * absorbed had one. An aggregate spanning a window we could not attest the end
140
+ * of is itself unattestable, and inventing a boundary for it would launder an
141
+ * unmeasurable outage into a measured one.
142
+ */
143
+ export function compact(gaps, max = MAX_GAPS) {
144
+ if (gaps.length <= max) return gaps;
145
+ const overflow = gaps.slice(0, gaps.length - max + 1);
146
+ const kept = gaps.slice(gaps.length - max + 1);
147
+ const anyOpen = overflow.some((g) => !g.closed_at);
148
+ const lastClose = overflow.reduce((acc, g) => (g.closed_at && (!acc || g.closed_at > acc) ? g.closed_at : acc), null);
149
+ const merged = {
150
+ opened_at: overflow[0].opened_at,
151
+ ...(anyOpen || !lastClose ? {} : { closed_at: lastClose }),
152
+ unscreened_calls: overflow.reduce((n, g) => n + (g.unscreened_calls || 0), 0),
153
+ locally_decided_calls: overflow.reduce((n, g) => n + (g.locally_decided_calls || 0), 0),
154
+ reason: `${overflow.length} earlier windows, merged`,
155
+ };
156
+ return [merged, ...kept];
157
+ }
158
+
159
+ /**
160
+ * The envelope to attach to a request.
161
+ *
162
+ * ⚠ ALWAYS AN OBJECT, and `gaps` is always an array — see rule 1. Returning
163
+ * `undefined` when there is nothing to report is the single change that would
164
+ * silently break the whole design.
165
+ */
166
+ export function envelope(state, { version } = {}) {
167
+ const s = state ?? emptyLedger();
168
+ return { gaps: s.pending.slice(0, MAX_GAPS), ...(version ? { client_version: String(version).slice(0, 40) } : {}) };
169
+ }
170
+
171
+ /**
172
+ * Drop the gaps a request confirmed delivery of.
173
+ *
174
+ * ⚠ Matched by `opened_at`, not by index or by count. A concurrent hook process
175
+ * can append a new gap between building the envelope and acking it, and an
176
+ * index-based drop would silently discard that one unreported. Re-sending a gap
177
+ * the backend already has is free — it dedupes on (org, machine, openedAt).
178
+ */
179
+ export function ack(state, sent) {
180
+ const s = state ?? emptyLedger();
181
+ const done = new Set((sent ?? []).map((g) => g.opened_at));
182
+ return { ...s, pending: s.pending.filter((g) => !done.has(g.opened_at)) };
183
+ }
184
+
185
+ /* ── File I/O (thin) ────────────────────────────────────────────────────── */
186
+
187
+ /**
188
+ * ⚠ EVERY OPERATION BELOW IS BEST-EFFORT AND SWALLOWS. This runs inside the
189
+ * PreToolUse hook: a ledger that threw would break a tool call the guard had
190
+ * already correctly allowed, which is a worse outcome than losing a count. The
191
+ * counts are lower bounds by rule 3 regardless.
192
+ */
193
+ export function makeLedgerStore(configDir, { version } = {}) {
194
+ const file = path.join(configDir, 'guard-ledger.json');
195
+
196
+ const read = () => {
197
+ try {
198
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
199
+ return { open: raw.open ?? null, pending: Array.isArray(raw.pending) ? raw.pending : [] };
200
+ } catch {
201
+ return emptyLedger();
202
+ }
203
+ };
204
+
205
+ const write = (state) => {
206
+ try {
207
+ fs.mkdirSync(configDir, { recursive: true });
208
+ // ⚠ Atomic rename, not a bare write. Two hook processes writing this file
209
+ // concurrently can lose an increment (rule 3, accepted) — but a torn file
210
+ // would lose the WHOLE ledger, including windows already closed and
211
+ // waiting to be reported. `.tmp` is per-process so the two cannot collide.
212
+ const tmp = `${file}.${process.pid}.tmp`;
213
+ fs.writeFileSync(tmp, JSON.stringify(state));
214
+ fs.renameSync(tmp, file);
215
+ } catch {
216
+ /* best-effort */
217
+ }
218
+ };
219
+
220
+ const update = (fn) => {
221
+ const next = fn(read());
222
+ write(next);
223
+ return next;
224
+ };
225
+
226
+ return {
227
+ file,
228
+ read,
229
+ write,
230
+ /** A call ran with no server verdict. */
231
+ count: (kind, reason) => update((s) => countCall(s, { at: Date.now(), kind, reason })),
232
+ /** The backend answered — close any window and hand back what to send. */
233
+ close: () => update((s) => closeWindow(s, { at: Date.now() })),
234
+ /** The envelope for this request. Always present (rule 1). */
235
+ envelope: () => envelope(read(), { version }),
236
+ /** Confirm delivery. */
237
+ ack: (sent) => update((s) => ack(s, sent)),
238
+ };
239
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Shomra — adversarial assurance for AI agents, as a local-first CLI. Blocks dangerous tool-calls before they run, attacks your own guardrails to prove they hold, and gates AI artifacts in your editor and CI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "shomra.mjs",
17
17
  "discovery.mjs",
18
18
  "guard-signals.mjs",
19
+ "guard-ledger.mjs",
19
20
  "code-sast.mjs",
20
21
  "model-refs.mjs",
21
22
  "ai-usage.mjs",
package/shomra.mjs CHANGED
@@ -19,6 +19,7 @@ import { execSync } from 'node:child_process';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { discoverAll } from './discovery.mjs';
21
21
  import { localScan, localGate, grade, downrankCodeContext, SECRET_PATTERNS, INVISIBLE_CHARS_RE } from './guard-signals.mjs';
22
+ import { makeLedgerStore } from './guard-ledger.mjs';
22
23
  import { scanSourceFile, isScannableSource, isModelConfig } from './code-sast.mjs';
23
24
  import { scanModelRefs, isModelRefScannable } from './model-refs.mjs';
24
25
  import { scanAiUsage, isAiUsageScannable, KNOWN_AI_PACKAGES, AI_USAGE_CATEGORY_LABEL } from './ai-usage.mjs';
@@ -139,6 +140,14 @@ function breakerReset() {
139
140
  /* ignore */
140
141
  }
141
142
  }
143
+
144
+ // ── the fail-open ledger ─────────────────────────────────────────────
145
+ // The breaker above is what MAKES failing open cheap; this is what makes it
146
+ // VISIBLE. A breaker-open window produces no rows on the backend, which is
147
+ // byte-for-byte what a quiet, clean window produces — so without this, "the
148
+ // guard was unreachable" and "the guard found nothing" are the same evidence.
149
+ // See guard-ledger.mjs, and src/gate/enforcement-availability.ts in the backend.
150
+ const guardLedger = makeLedgerStore(CONFIG_DIR, { version: VERSION });
142
151
  // Machine identity attached to gate / guard / proxy calls so the backend can
143
152
  // attribute the activity to this enrolled machine. Unlike machineInfo() it does
144
153
  // NOT generate/persist a machineId — an unenrolled machine simply reports none,
@@ -3452,6 +3461,21 @@ function envFlag(name) {
3452
3461
  return ['1', 'true', 'yes', 'on'].includes(String(process.env[name] ?? '').toLowerCase());
3453
3462
  }
3454
3463
 
3464
+ /**
3465
+ * Why a guard call did not reach a verdict, in the vocabulary the backend's
3466
+ * availability view groups on. ⚠ Coarse ON PURPOSE: this string is stored and
3467
+ * shown to an operator, and an error message can carry a proxy URL, an internal
3468
+ * hostname, or a token in a query string. `e.message` never goes in it.
3469
+ */
3470
+ function guardFailureReason(e) {
3471
+ const name = String(e?.name ?? '');
3472
+ const msg = String(e?.message ?? '');
3473
+ if (name === 'AbortError' || /abort|timeout/i.test(msg)) return 'timeout';
3474
+ const http = msg.match(/^HTTP (\d{3})$/);
3475
+ if (http) return `http-${http[1]}`;
3476
+ return 'network';
3477
+ }
3478
+
3455
3479
  /** The gate/tool-call request body, optionally carrying the Tier-0 verdict. */
3456
3480
  function buildGuardBody(norm, agent, clientDecision, clientReason) {
3457
3481
  return {
@@ -3463,6 +3487,13 @@ function buildGuardBody(norm, agent, clientDecision, clientReason) {
3463
3487
  env: detectEnv(),
3464
3488
  agent,
3465
3489
  ...(clientDecision ? { client_decision: clientDecision, client_reason: clientReason } : {}),
3490
+ // ⚠ UNCONDITIONAL, INCLUDING WHEN IT IS EMPTY. The envelope's PRESENCE is
3491
+ // what tells the backend this client is capable of reporting an outage at
3492
+ // all; its contents are the outages themselves. Omitting it when there is
3493
+ // nothing to report — which reads as an obvious optimisation — makes a
3494
+ // healthy machine indistinguishable from one that could never have spoken,
3495
+ // and drops the whole estate to NOT_ATTEMPTABLE. See guard-ledger.mjs rule 1.
3496
+ guard_ledger: guardLedger.envelope(),
3466
3497
  };
3467
3498
  }
3468
3499
 
@@ -3576,7 +3607,17 @@ async function cmdToolGuard(flags) {
3576
3607
 
3577
3608
  // Breaker: skip the round-trip while the backend is known-down (fail-open —
3578
3609
  // Tier 0 already caught the dangerous cases). Strict opts out to stay closed.
3579
- if (!strict && breakerOpen()) process.exit(0);
3610
+ //
3611
+ // ⚠ THIS IS THE FAIL-OPEN WINDOW, and it is the one that costs the most
3612
+ // coverage: it is silent, instant, and lasts a whole cooldown. The call is
3613
+ // about to run having been screened by Tier 0 alone (no org policy, no
3614
+ // identity, no flow, no supply chain, no intent) — or by nothing at all if
3615
+ // the local engine is switched off. Record which, so the backend can tell an
3616
+ // outage from a quiet afternoon.
3617
+ if (!strict && breakerOpen()) {
3618
+ guardLedger.count(localOff ? 'unscreened' : 'local', 'breaker-open');
3619
+ process.exit(0);
3620
+ }
3580
3621
 
3581
3622
  const body = buildGuardBody(
3582
3623
  norm,
@@ -3605,6 +3646,12 @@ async function cmdToolGuard(flags) {
3605
3646
  // on its own, so it gets a visible line rather than a 30s breaker cooldown
3606
3647
  // that would hide it (and skip even this warning on the calls after it).
3607
3648
  if (r.status === 401 || r.status === 403) {
3649
+ // ⚠ An unenforced call, and the stderr line below already says so — but
3650
+ // a warning nobody is reading is not evidence. It accrues to the ledger
3651
+ // like any other window; it simply cannot be FLUSHED until the key is
3652
+ // fixed, which is the correct behaviour: the gap persists exactly as
3653
+ // long as the misconfiguration does.
3654
+ guardLedger.count(localOff ? 'unscreened' : 'local', `auth-${r.status}`);
3608
3655
  process.stderr.write(
3609
3656
  `[shomra] guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
3610
3657
  `Local Tier-0 screening still ran; org policy, agent identity and flow control did not. ` +
@@ -3617,8 +3664,19 @@ async function cmdToolGuard(flags) {
3617
3664
  }
3618
3665
  res = await r.json();
3619
3666
  breakerReset(); // healthy response — clear any tripped breaker
3667
+ // ⚠ ACK BEFORE CLOSE, and both only after a 2xx. `ack` drops what THIS
3668
+ // request carried (matched by opened_at, so a gap a concurrent hook appended
3669
+ // meanwhile survives); `close` then seals any window this success just
3670
+ // ended. Reversed, the window closed here would be added to `pending` and
3671
+ // then immediately acked away without ever having been sent.
3672
+ guardLedger.ack(body.guard_ledger?.gaps);
3673
+ guardLedger.close();
3620
3674
  } catch (e) {
3621
3675
  breakerTrip(); // remember this failure so the next calls skip the wait
3676
+ // The first failure of a window, and every subsequent one that still pays
3677
+ // the timeout. `countCall` opens the window on the first and leaves it
3678
+ // alone after — one outage is one row, not one row per call.
3679
+ guardLedger.count(localOff ? 'unscreened' : 'local', guardFailureReason(e));
3622
3680
  if (strict) emitGuardDeny(agent, `Shomra guard could not be reached (${e.message}); blocked by fail-closed policy.`);
3623
3681
  process.exit(0); // fail-open (Tier 0 already screened the dangerous patterns)
3624
3682
  }
@@ -3718,6 +3776,13 @@ async function cmdResultGuard(flags) {
3718
3776
  // parse cleanly and `res.decision` would be undefined → silent fail-open.
3719
3777
  if (!r.ok) {
3720
3778
  if (r.status === 401 || r.status === 403) {
3779
+ // ⚠ DELIBERATELY NOT COUNTED IN THE FAIL-OPEN LEDGER, though it is just
3780
+ // as unenforced. That ledger's denominator is GateEvent — the tool-CALL
3781
+ // channel — and this guard posts to /gate/tool-result, which writes no
3782
+ // GateEvent. Folding it in would put a numerator from one population
3783
+ // over a denominator from another and publish the quotient, which is the
3784
+ // exact error the availability module refuses elsewhere. The result
3785
+ // channel needs its own ledger, not a share of this one.
3721
3786
  process.stderr.write(
3722
3787
  `[shomra] result-guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
3723
3788
  `Local Tier-0 screening still ran; server-side flow taint did not. ` +
@@ -3851,6 +3916,10 @@ async function cmdPromptGuard(flags) {
3851
3916
  process.exit(0);
3852
3917
  }
3853
3918
  if (!strict && breakerOpen()) {
3919
+ // The prompt channel posts to /gate/tool-call and therefore writes a
3920
+ // GateEvent, so an unscreened submission belongs in the same ledger and the
3921
+ // same denominator as an unscreened tool call.
3922
+ guardLedger.count(localOff ? 'unscreened' : 'local', 'breaker-open');
3854
3923
  if (injection.length) emitPromptContext(agent, promptInjectionNote(injection));
3855
3924
  process.exit(0);
3856
3925
  }
@@ -3858,18 +3927,27 @@ async function cmdPromptGuard(flags) {
3858
3927
  // ── Tier 2: org policy on the prompt channel (DLP-shaped rules the local floor
3859
3928
  // deliberately doesn't carry — customer identifiers, regulated data classes).
3860
3929
  let res;
3930
+ // Built once and held, so the ack below drops exactly the gaps THIS request
3931
+ // carried rather than whatever the file happens to hold afterwards.
3932
+ const promptBody = buildPromptGuardBody(norm, agent);
3861
3933
  try {
3862
3934
  const ctrl = new AbortController();
3863
3935
  const timer = setTimeout(() => ctrl.abort(), guardTimeoutMs());
3864
3936
  const r = await fetch(`${url}/gate/tool-call`, {
3865
3937
  method: 'POST',
3866
3938
  headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': apiKey, Connection: 'close' },
3867
- body: JSON.stringify(buildPromptGuardBody(norm, agent)),
3939
+ body: JSON.stringify(promptBody),
3868
3940
  signal: ctrl.signal,
3869
3941
  });
3870
3942
  clearTimeout(timer);
3871
3943
  if (!r.ok) {
3872
3944
  if (r.status === 401 || r.status === 403) {
3945
+ // ⚠ An unenforced call, and the stderr line below already says so — but
3946
+ // a warning nobody is reading is not evidence. It accrues to the ledger
3947
+ // like any other window; it simply cannot be FLUSHED until the key is
3948
+ // fixed, which is the correct behaviour: the gap persists exactly as
3949
+ // long as the misconfiguration does.
3950
+ guardLedger.count(localOff ? 'unscreened' : 'local', `auth-${r.status}`);
3873
3951
  process.stderr.write(`[shomra] prompt-guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). Local screening still ran.\n`);
3874
3952
  if (strict) emitPromptDeny(agent, `Shomra prompt-guard could not authenticate (HTTP ${r.status}); blocked by fail-closed policy.`);
3875
3953
  process.exit(0);
@@ -3878,8 +3956,11 @@ async function cmdPromptGuard(flags) {
3878
3956
  }
3879
3957
  res = await r.json();
3880
3958
  breakerReset();
3959
+ guardLedger.ack(promptBody.guard_ledger?.gaps);
3960
+ guardLedger.close();
3881
3961
  } catch (e) {
3882
3962
  breakerTrip();
3963
+ guardLedger.count(localOff ? 'unscreened' : 'local', guardFailureReason(e));
3883
3964
  if (injection.length) emitPromptContext(agent, promptInjectionNote(injection));
3884
3965
  if (strict) emitPromptDeny(agent, `Shomra prompt-guard could not be reached (${e.message}); blocked by fail-closed policy.`);
3885
3966
  process.exit(0);
@@ -3906,6 +3987,10 @@ function promptInjectionNote(injection) {
3906
3987
  * speaks — so it lands in Gate Activity with no schema change. */
3907
3988
  function buildPromptGuardBody(norm, agent, clientDecision, clientReason) {
3908
3989
  return {
3990
+ // Same channel as the tool firewall (/gate/tool-call), so the same envelope
3991
+ // rides along — a machine whose operator only ever submits prompts still
3992
+ // needs its capability witness stamped, or its silence is unreadable.
3993
+ guard_ledger: guardLedger.envelope(),
3909
3994
  tool_name: 'UserPromptSubmit',
3910
3995
  tool_input: { prompt: norm.prompt },
3911
3996
  cwd: norm.cwd,