@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.
- package/guard-ledger.mjs +239 -0
- package/package.json +2 -1
- package/shomra.mjs +104 -2
package/guard-ledger.mjs
ADDED
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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,
|