@shomra/agent 0.3.9 → 0.3.11
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/discovery.mjs +11 -1
- package/guard-ledger.mjs +239 -0
- package/package.json +2 -1
- package/shomra.mjs +6914 -6824
package/discovery.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import path from 'node:path';
|
|
|
21
21
|
import os from 'node:os';
|
|
22
22
|
import { execFileSync } from 'node:child_process';
|
|
23
23
|
import { scanAiUsage, rollupAiUsage, isAiUsageScannable, AI_USAGE_CATEGORY_LABEL } from './ai-usage.mjs';
|
|
24
|
+
import { clampAsset } from './wire-limits.mjs';
|
|
24
25
|
import { readVendorPosture, canonicalGrant } from './agent-posture.mjs';
|
|
25
26
|
|
|
26
27
|
const HOME = os.homedir();
|
|
@@ -953,11 +954,20 @@ export function discoverAll(roots = [process.cwd()], opts = {}) {
|
|
|
953
954
|
...discoverCodingAgents(scanRoots),
|
|
954
955
|
...discoverModelKeys(),
|
|
955
956
|
];
|
|
957
|
+
// ⚠ CLAMPED BEFORE DEDUP, and before anything leaves this function. A report is
|
|
958
|
+
// validated all-or-nothing, so ONE over-long field — an MCP server launched by
|
|
959
|
+
// an inline `node -e '<1675 chars>'` is the case that found this — rejects the
|
|
960
|
+
// whole payload and costs the machine its entire inventory. Clamping first also
|
|
961
|
+
// means the dedup key below is the key the backend will see, so a value that
|
|
962
|
+
// was abbreviated on the wire cannot dedup differently here than it does there.
|
|
963
|
+
// See wire-limits.mjs for why truncation carries a fingerprint.
|
|
964
|
+
const clamped = all.map(clampAsset);
|
|
965
|
+
|
|
956
966
|
// Final dedup by (type, identifier) — a runtime can be found by both dir and
|
|
957
967
|
// process; an env key can also appear in a .env file.
|
|
958
968
|
const seen = new Set();
|
|
959
969
|
const out = [];
|
|
960
|
-
for (const a of
|
|
970
|
+
for (const a of clamped) {
|
|
961
971
|
const key = `${a.type}::${a.identifier || a.name}`;
|
|
962
972
|
if (seen.has(key)) continue;
|
|
963
973
|
seen.add(key);
|
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.11",
|
|
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",
|