@shomra/agent 0.3.8 → 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.8",
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,
@@ -226,6 +235,9 @@ const VALUE_FLAGS = new Set([
226
235
  'scenarios', 'objectives', 'turns', 'target', 'run', 'port', 'config', 'env',
227
236
  'command', 'base', 'repo', 'pr', 'token', 'sha', 'session', 'since', 'depth',
228
237
  'scope', 'writer', 'type', 'slug', 'framework', 'chunk-size', 'manifest',
238
+ // `--fail-on <critical|high|medium>` lets CI gate below the default
239
+ // (blocked-only) exit code — e.g. fail the build on a HIGH finding.
240
+ 'fail-on',
229
241
  ]);
230
242
  const KNOWN_FLAGS = new Set([...BOOLEAN_FLAGS, ...VALUE_FLAGS]);
231
243
 
@@ -1373,6 +1385,7 @@ async function cmdGateAll(flags, positional, { apiKey, url }) {
1373
1385
  if (flags.sarif) {
1374
1386
  console.log(JSON.stringify(toSarif(results), null, 2));
1375
1387
  if (blocked > 0 || strictOutage) process.exitCode = 1;
1388
+ else if (failOnHit(flags, blocked, flagged)) process.exitCode = 1;
1376
1389
  else if (flagged > 0 && flags.strict) process.exitCode = 2;
1377
1390
  return;
1378
1391
  }
@@ -1395,6 +1408,7 @@ async function cmdGateAll(flags, positional, { apiKey, url }) {
1395
1408
 
1396
1409
  // Set exitCode (not process.exit) so pending sockets drain cleanly on Windows.
1397
1410
  if (blocked > 0 || strictOutage) process.exitCode = 1;
1411
+ else if (failOnHit(flags, blocked, flagged)) process.exitCode = 1;
1398
1412
  else if (flagged > 0 && flags.strict) process.exitCode = 2;
1399
1413
  }
1400
1414
 
@@ -1498,9 +1512,21 @@ async function cmdCheck(flags, positional) {
1498
1512
  }
1499
1513
 
1500
1514
  if (blocked > 0 || strictOutage) process.exitCode = 1;
1515
+ else if (failOnHit(flags, blocked, flagged)) process.exitCode = 1;
1501
1516
  else if (flagged > 0 && flags.strict) process.exitCode = 2;
1502
1517
  }
1503
1518
 
1519
+ // `--fail-on <critical|high|medium>` — gate CI below the default (blocked-only).
1520
+ // blocked ⇒ a CRITICAL/BLOCK finding, flagged ⇒ a HIGH/FLAG one; the mapping is
1521
+ // the same severity→decision the guard uses. Default 'critical' preserves the
1522
+ // existing behavior (HIGH exits 0 unless --strict). An unrecognised value is
1523
+ // treated as 'critical' rather than silently gating on nothing.
1524
+ function failOnHit(flags, blocked, flagged) {
1525
+ const threshold = { critical: 3, high: 2, medium: 1 }[String(flags['fail-on'] || 'critical').toLowerCase()] ?? 3;
1526
+ const worst = blocked > 0 ? 3 : flagged > 0 ? 2 : 0;
1527
+ return worst > 0 && worst >= threshold;
1528
+ }
1529
+
1504
1530
  // ── shomra baseline: accept everything here, so only NEW findings fail ───────
1505
1531
  //
1506
1532
  // shomra baseline [dir] # write .shomra/baseline.json of current findings
@@ -3435,6 +3461,21 @@ function envFlag(name) {
3435
3461
  return ['1', 'true', 'yes', 'on'].includes(String(process.env[name] ?? '').toLowerCase());
3436
3462
  }
3437
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
+
3438
3479
  /** The gate/tool-call request body, optionally carrying the Tier-0 verdict. */
3439
3480
  function buildGuardBody(norm, agent, clientDecision, clientReason) {
3440
3481
  return {
@@ -3446,6 +3487,13 @@ function buildGuardBody(norm, agent, clientDecision, clientReason) {
3446
3487
  env: detectEnv(),
3447
3488
  agent,
3448
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(),
3449
3497
  };
3450
3498
  }
3451
3499
 
@@ -3559,7 +3607,17 @@ async function cmdToolGuard(flags) {
3559
3607
 
3560
3608
  // Breaker: skip the round-trip while the backend is known-down (fail-open —
3561
3609
  // Tier 0 already caught the dangerous cases). Strict opts out to stay closed.
3562
- 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
+ }
3563
3621
 
3564
3622
  const body = buildGuardBody(
3565
3623
  norm,
@@ -3588,6 +3646,12 @@ async function cmdToolGuard(flags) {
3588
3646
  // on its own, so it gets a visible line rather than a 30s breaker cooldown
3589
3647
  // that would hide it (and skip even this warning on the calls after it).
3590
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}`);
3591
3655
  process.stderr.write(
3592
3656
  `[shomra] guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
3593
3657
  `Local Tier-0 screening still ran; org policy, agent identity and flow control did not. ` +
@@ -3600,8 +3664,19 @@ async function cmdToolGuard(flags) {
3600
3664
  }
3601
3665
  res = await r.json();
3602
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();
3603
3674
  } catch (e) {
3604
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));
3605
3680
  if (strict) emitGuardDeny(agent, `Shomra guard could not be reached (${e.message}); blocked by fail-closed policy.`);
3606
3681
  process.exit(0); // fail-open (Tier 0 already screened the dangerous patterns)
3607
3682
  }
@@ -3701,6 +3776,13 @@ async function cmdResultGuard(flags) {
3701
3776
  // parse cleanly and `res.decision` would be undefined → silent fail-open.
3702
3777
  if (!r.ok) {
3703
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.
3704
3786
  process.stderr.write(
3705
3787
  `[shomra] result-guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
3706
3788
  `Local Tier-0 screening still ran; server-side flow taint did not. ` +
@@ -3834,6 +3916,10 @@ async function cmdPromptGuard(flags) {
3834
3916
  process.exit(0);
3835
3917
  }
3836
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');
3837
3923
  if (injection.length) emitPromptContext(agent, promptInjectionNote(injection));
3838
3924
  process.exit(0);
3839
3925
  }
@@ -3841,18 +3927,27 @@ async function cmdPromptGuard(flags) {
3841
3927
  // ── Tier 2: org policy on the prompt channel (DLP-shaped rules the local floor
3842
3928
  // deliberately doesn't carry — customer identifiers, regulated data classes).
3843
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);
3844
3933
  try {
3845
3934
  const ctrl = new AbortController();
3846
3935
  const timer = setTimeout(() => ctrl.abort(), guardTimeoutMs());
3847
3936
  const r = await fetch(`${url}/gate/tool-call`, {
3848
3937
  method: 'POST',
3849
3938
  headers: { 'Content-Type': 'application/json', 'X-Shomra-Key': apiKey, Connection: 'close' },
3850
- body: JSON.stringify(buildPromptGuardBody(norm, agent)),
3939
+ body: JSON.stringify(promptBody),
3851
3940
  signal: ctrl.signal,
3852
3941
  });
3853
3942
  clearTimeout(timer);
3854
3943
  if (!r.ok) {
3855
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}`);
3856
3951
  process.stderr.write(`[shomra] prompt-guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). Local screening still ran.\n`);
3857
3952
  if (strict) emitPromptDeny(agent, `Shomra prompt-guard could not authenticate (HTTP ${r.status}); blocked by fail-closed policy.`);
3858
3953
  process.exit(0);
@@ -3861,8 +3956,11 @@ async function cmdPromptGuard(flags) {
3861
3956
  }
3862
3957
  res = await r.json();
3863
3958
  breakerReset();
3959
+ guardLedger.ack(promptBody.guard_ledger?.gaps);
3960
+ guardLedger.close();
3864
3961
  } catch (e) {
3865
3962
  breakerTrip();
3963
+ guardLedger.count(localOff ? 'unscreened' : 'local', guardFailureReason(e));
3866
3964
  if (injection.length) emitPromptContext(agent, promptInjectionNote(injection));
3867
3965
  if (strict) emitPromptDeny(agent, `Shomra prompt-guard could not be reached (${e.message}); blocked by fail-closed policy.`);
3868
3966
  process.exit(0);
@@ -3889,6 +3987,10 @@ function promptInjectionNote(injection) {
3889
3987
  * speaks — so it lands in Gate Activity with no schema change. */
3890
3988
  function buildPromptGuardBody(norm, agent, clientDecision, clientReason) {
3891
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(),
3892
3994
  tool_name: 'UserPromptSubmit',
3893
3995
  tool_input: { prompt: norm.prompt },
3894
3996
  cwd: norm.cwd,