@shomra/agent 0.3.22 → 0.3.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
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": {
@@ -1,5 +1,6 @@
1
1
  export { autonomySeverity, localAutonomy } from './signals/autonomy.mjs';
2
2
  export { RISKY_CONFIG_MARKERS } from './signals/config-markers.mjs';
3
+ export { classifyConsequence } from './signals/consequence.mjs';
3
4
  export { detectCredentialHarvest } from './signals/credential-harvest.mjs';
4
5
  export { claimSeverity, detectDurableClaims } from './signals/durable-claims.mjs';
5
6
  export { SUSPICIOUS_EGRESS_HOSTS, egressHost } from './signals/egress.mjs';
@@ -0,0 +1,56 @@
1
+ /**
2
+ * How much a tool call COSTS if it is wrong - the ladder the backend grades on,
3
+ * mirrored here so the client can decide it OFFLINE.
4
+ *
5
+ * ⚠ THIS IS THE FAIL-CLOSED KEY. The hook fails open by design: an agent that
6
+ * hard-stops on an unreachable SaaS backend is one nobody keeps installed. But
7
+ * failing open on EVERY call means an attacker buys a bypass with a slow input
8
+ * or a rate limit. Grading the consequence on-machine is what lets the hook
9
+ * keep ordinary work flowing while still refusing a destructive call it could
10
+ * not get screened.
11
+ *
12
+ * ⚠ It is a MIRROR of src/modules/runtime/intent/domain/consequence.ts and must
13
+ * stay one - pinned by the backend's test/parity/consequence-mirror-bench.mjs.
14
+ * Drift here is asymmetric in the same way every other mirror is: stricter than
15
+ * the server blocks work no server verdict would have blocked, and looser puts
16
+ * a hole in the floor at exactly the moment the floor is all there is.
17
+ */
18
+ const SEVERE_VERB = /\b(delete|destroy|drop|purge|revoke|terminate|shutdown|wipe|erase|truncate|force[-_]?push|rm)\b/i;
19
+
20
+ const MATERIAL_VERB =
21
+ /\b(transfer|pay|payment|refund|charge|invoice|wire|send|email|post|publish|deploy|release|merge|approve|grant|invite|share|upload|export)\b/i;
22
+
23
+ const AUTHORITY_GRANT =
24
+ /\b(assume|impersonate|escalate|elevate|sudo|attach|grant|bind|add|put|set|create|assign)\s+(?:\w+\s+){0,2}(role|member|policy|binding|permission|principal|group|user|iam|owner|admin|access)\b/i;
25
+
26
+ const PRIVILEGED_TARGET =
27
+ /\b(admin|administrator|owner|owners|root|superuser|orgadmin|administratoraccess|poweruser|cluster[-_]?admin|iam:\*)\b/i;
28
+
29
+ const PERSISTENCE_TARGET =
30
+ /(^|\/)\.(ssh|aws|kube)\/|authorized_keys|\.bashrc|\.zshrc|\.profile|crontab|\/etc\/(sudoers|passwd|shadow)|systemd|(^|\/)\.env(\.|$)/i;
31
+
32
+ const PRODUCTION = /\b(prod|production|live|main|master|release)\b/i;
33
+
34
+ /* ⚠ `force[-_]?push` never matched the way anybody writes it, so `git push
35
+ * --force origin main` graded MATERIAL and the fail-closed rung never covered
36
+ * it. --force-with-lease is included on purpose: it refuses when the remote
37
+ * moved, which makes it safer, not less destructive. */
38
+ const FORCE_PUSH = /\bgit\s+push\b[^|;&\n]*?\s--?f(?:orce(?:-with-lease)?)?\b/i;
39
+
40
+ export function classifyConsequence(input) {
41
+ const leaf = String(input.tool ?? '').replace(/^mcp__[^_]+__/, '');
42
+ const raw = `${leaf} ${input.args ?? ''}`;
43
+ const blob = raw.replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2');
44
+
45
+ if (typeof input.amount === 'number' && input.amount > 0) {
46
+ return SEVERE_VERB.test(blob) ? 'severe' : 'material';
47
+ }
48
+ if (PERSISTENCE_TARGET.test(raw)) return 'severe';
49
+ if (SEVERE_VERB.test(blob) || FORCE_PUSH.test(raw)) return 'severe';
50
+ if (AUTHORITY_GRANT.test(blob)) return PRIVILEGED_TARGET.test(blob) ? 'severe' : 'material';
51
+ if (input.isShell) return 'material';
52
+ if (MATERIAL_VERB.test(blob)) return 'material';
53
+ if (input.isEgress) return 'material';
54
+ if (input.actionId && PRODUCTION.test(blob) && MATERIAL_VERB.test(input.actionId)) return 'severe';
55
+ return 'routine';
56
+ }
@@ -4,8 +4,11 @@ import path from 'node:path';
4
4
  import { resolveAgentIdentityHandle } from '../commands/agent-identity.mjs';
5
5
  import { isMemoryPath, reportMemoryWrite } from '../commands/memory-scan.mjs';
6
6
  import { breakerOpen, breakerReset, breakerTrip, guardTimeoutMs } from '../core/circuit-breaker.mjs';
7
+ import { CONFIG_DIR } from '../core/config.mjs';
8
+ import { makeLedgerStore } from './ledger.mjs';
9
+ import { VERSION } from '../core/version.mjs';
7
10
  import { loadConfig, resolveSettings } from '../core/config.mjs';
8
- import { downrankCodeContext, grade, localScan } from '../detect/guard-signals.mjs';
11
+ import { classifyConsequence, downrankCodeContext, grade, localScan } from '../detect/guard-signals.mjs';
9
12
  import { WRITE_TOOLS, guardNeedsServer, guardTargetPath, guardText } from './classify.mjs';
10
13
  import { emitGuardAsk, emitGuardDeny } from './emit.mjs';
11
14
  import { guardPathAllowlisted } from './ignore.mjs';
@@ -16,6 +19,99 @@ import { buildGuardBody, reportGuardDecision } from './report.mjs';
16
19
 
17
20
  const ALLOW_VERDICT = { verdict: 'ALLOW', top: null, findings: [] };
18
21
 
22
+ /**
23
+ * ⚠ FAILING OPEN ON EVERYTHING IS AN ENFORCEMENT BYPASS AN ATTACKER BUYS WITH A
24
+ * SLOW INPUT. The hook has to fail open - an agent that hard-stops on an
25
+ * unreachable SaaS backend is one nobody keeps installed - but "open on every
26
+ * call" means padding a command until the screen times out runs it unscreened,
27
+ * which is cheaper than any evasion in the corpus.
28
+ *
29
+ * So the rung decides. A routine or material call still flows: that is the
30
+ * promise that keeps the hook installed. A SEVERE one - a recursive delete, a
31
+ * force push over a shared branch, a write into ~/.ssh - stops and ASKS.
32
+ *
33
+ * ⚠ IT ASKS, IT DOES NOT DENY. A deny during an outage is unappealable at 3am
34
+ * and gets the hook uninstalled, taking every other control with it. An ask
35
+ * puts the human who is already sitting there in the loop and says plainly
36
+ * that the call was NOT screened, which is the honest sentence: we do not know
37
+ * that this is dangerous, we know that we could not check.
38
+ */
39
+ function failOpenOnSevere() {
40
+ return envFlag('SHOMRA_GUARD_FAILOPEN_SEVERE');
41
+ }
42
+
43
+ function unscreenedSevere(normalized, tool, input) {
44
+ if (failOpenOnSevere()) return false;
45
+ return classifyConsequence({
46
+ tool,
47
+ args: guardText(tool, input),
48
+ isShell: !WRITE_TOOLS.has(tool) && typeof input?.command === 'string',
49
+ }) === 'severe';
50
+ }
51
+
52
+ /**
53
+ * ⚠⚠ THE LEDGER HAD NO PRODUCER. `guard/ledger.mjs` builds the fail-open window
54
+ * the backend's EnforcementGap reads - and nothing in this repo ever called
55
+ * `countCall`, so every client reported ZERO gaps forever. The backend then
56
+ * asks whether a capable reporter exists, gets silence, and the estate reads
57
+ * either "no outages" or NOT_ATTEMPTABLE. Both are wrong and one is flattering:
58
+ * a smoke detector reporting no fire with a dead battery, which is the exact
59
+ * shape enforcement-availability.ts was written to prevent.
60
+ *
61
+ * ⚠ COUNTS ARE LOWER BOUNDS BY DESIGN - concurrent hook processes race this
62
+ * file, and the backend already treats them as a floor. Do not "fix" that with
63
+ * a lock on the firewall's hot path.
64
+ */
65
+ function ledger() {
66
+ return makeLedgerStore(CONFIG_DIR, { version: VERSION });
67
+ }
68
+
69
+ /** A call that ran with no server verdict: Tier-0 screened it, or nothing did. */
70
+ function countUnscreened(reason) {
71
+ try {
72
+ ledger().count(localTierDisabled() ? 'unscreened' : 'local', reason);
73
+ } catch {
74
+ /* The ledger is a record, never a gate: a failure to write one must not
75
+ * take the firewall down. The count is a floor and this makes it lower. */
76
+ }
77
+ }
78
+
79
+ /** Close the open window and hand the backend everything not yet acknowledged. */
80
+ function sendLedger() {
81
+ try {
82
+ const store = ledger();
83
+ store.close();
84
+ const env = store.envelope();
85
+ pendingLedger = env.gaps ?? [];
86
+ return env;
87
+ } catch {
88
+ /* No ledger is a lower bound, not a wrong number. */
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ let pendingLedger = [];
94
+
95
+ function ackLedger() {
96
+ if (!pendingLedger.length) return;
97
+ try {
98
+ ledger().ack(pendingLedger);
99
+ } catch {
100
+ /* Unacknowledged gaps are re-sent next time; a duplicate is a floor read
101
+ * twice, which is safe. Losing one is not. */
102
+ }
103
+ pendingLedger = [];
104
+ }
105
+
106
+ function askUnscreened(agent, why) {
107
+ emitGuardAsk(
108
+ agent,
109
+ `Shomra could not screen this call (${why}), and it is a destructive one - a delete, a force push, `
110
+ + 'or a write to a file that survives the session. Nothing has judged it: approve it only if you meant it. '
111
+ + 'Set SHOMRA_GUARD_FAILOPEN_SEVERE=1 to let these through unscreened.',
112
+ );
113
+ }
114
+
19
115
  function readHookPayload() {
20
116
  try {
21
117
  return JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
@@ -78,7 +174,7 @@ function reportUnauthenticated(agent, status, strict) {
78
174
  process.exit(0);
79
175
  }
80
176
 
81
- async function requestServerDecision({ url, apiKey, agentId, body, agent, strict }) {
177
+ async function requestServerDecision({ url, apiKey, agentId, body, agent, strict, retried, onUnreachable }) {
82
178
  const controller = new AbortController();
83
179
  const timer = setTimeout(() => controller.abort(), guardTimeoutMs());
84
180
  try {
@@ -97,19 +193,48 @@ async function requestServerDecision({ url, apiKey, agentId, body, agent, strict
97
193
 
98
194
  if (!response.ok) {
99
195
  if (response.status === 401 || response.status === 403) reportUnauthenticated(agent, response.status, strict);
196
+ /* ⚠ A 429 IS NOT AN OUTAGE, and treating it as one was a silent
197
+ * enforcement bypass: tripping the breaker skips the server for the whole
198
+ * cooldown, so one burst past the rate limit switched org policy, agent
199
+ * identity and flow control off for thirty seconds - on the machine, with
200
+ * nothing said. It means "we are here, come back", so it is retried once
201
+ * against Retry-After and never counted against the breaker. */
202
+ if (response.status === 429) {
203
+ const wait = retryAfterMs(response);
204
+ if (wait !== null && !retried) {
205
+ clearTimeout(timer);
206
+ await sleep(wait);
207
+ return requestServerDecision({ url, apiKey, agentId, body, agent, strict, retried: true, onUnreachable });
208
+ }
209
+ clearTimeout(timer);
210
+ return onUnreachable('rate limited', { breaker: false });
211
+ }
100
212
  throw new Error(`HTTP ${response.status}`);
101
213
  }
102
214
  const decision = await response.json();
103
215
  breakerReset();
216
+ ackLedger();
104
217
  return decision;
105
218
  } catch (error) {
106
219
  clearTimeout(timer);
107
220
  breakerTrip();
108
221
  if (strict) emitGuardDeny(agent, `Shomra guard could not be reached (${error.message}); blocked by fail-closed policy.`);
109
- return process.exit(0);
222
+ return onUnreachable(error.message, { breaker: true });
110
223
  }
111
224
  }
112
225
 
226
+ /** Honours a seconds or an HTTP-date Retry-After; null when the server named none. */
227
+ function retryAfterMs(response) {
228
+ const raw = response.headers?.get?.('retry-after');
229
+ if (!raw) return null;
230
+ const secs = Number(raw);
231
+ if (Number.isFinite(secs)) return Math.min(Math.max(secs, 0), 5) * 1000;
232
+ const when = Date.parse(raw);
233
+ return Number.isFinite(when) ? Math.min(Math.max(when - Date.now(), 0), 5000) : null;
234
+ }
235
+
236
+ const sleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
237
+
113
238
  function enforceServerDecision(agent, decision) {
114
239
  if (decision?.hold) {
115
240
  emitGuardAsk(agent, decision.reason || 'Held for approval by Shomra - waiting on a reviewer. Retry once it’s approved.');
@@ -142,14 +267,37 @@ export async function cmdToolGuard(flags) {
142
267
  if (strict) {
143
268
  emitGuardDeny(agent, 'Shomra is not configured on this machine (SHOMRA_GUARD_STRICT). Run: shomra init --key shm_…');
144
269
  }
270
+ if (unscreenedSevere(normalized, tool, input)) askUnscreened(agent, 'Shomra is not configured on this machine');
145
271
  process.exit(0);
146
272
  }
147
273
 
148
274
  await recordMemoryWrite({ url, apiKey, input, normalized });
149
275
 
150
- const escalate = alwaysEscalate || local.verdict === 'FLAG' || guardNeedsServer(tool, input, !!agentId);
151
- if (!escalate) process.exit(0);
152
- if (!strict && breakerOpen()) process.exit(0);
276
+ /* A SEVERE CALL IS ALWAYS WORTH THE ROUND TRIP. `guardNeedsServer` asks
277
+ * which calls are worth escalating and answered NO for `git push --force
278
+ * origin main` and `rm -rf` alike - so the most destructive calls in the
279
+ * estate were graded by the offline tier and NOTHING ELSE: no org policy, no
280
+ * capability check, no flow control, and no gate event to read afterwards. */
281
+ const severe = unscreenedSevere(normalized, tool, input);
282
+ const escalate = alwaysEscalate || severe || local.verdict === 'FLAG' || guardNeedsServer(tool, input, !!agentId);
283
+ /* ⚠ A call the client CHOSE not to escalate is still a call no server graded,
284
+ * and the denominator has to carry it or the fail-open rate is measured over
285
+ * the escalated traffic alone - which flatters it by exactly the calls the
286
+ * client decided were dull. */
287
+ if (!escalate) {
288
+ countUnscreened('not escalated - screened by the local tier only');
289
+ process.exit(0);
290
+ }
291
+
292
+ /* Every path out of here that did NOT get a server verdict goes through this
293
+ * one door, so a new way of failing cannot quietly skip the rung check. */
294
+ const onUnreachable = (why) => {
295
+ countUnscreened(why);
296
+ if (severe) askUnscreened(agent, why);
297
+ return process.exit(0);
298
+ };
299
+
300
+ if (!strict && breakerOpen()) onUnreachable('the guard is in its backoff window after an earlier failure');
153
301
 
154
302
  const flagged = local.verdict === 'FLAG';
155
303
  const decision = await requestServerDecision({
@@ -158,7 +306,14 @@ export async function cmdToolGuard(flags) {
158
306
  agentId,
159
307
  agent,
160
308
  strict,
161
- body: buildGuardBody(normalized, agent, flagged ? 'FLAG' : undefined, flagged ? local.top?.label : undefined),
309
+ onUnreachable,
310
+ body: {
311
+ ...buildGuardBody(normalized, agent, flagged ? 'FLAG' : undefined, flagged ? local.top?.label : undefined),
312
+ /* The window closes the moment a verdict arrives, and rides out on the
313
+ * SAME request - a separate report would be a second round trip on the
314
+ * firewall's hot path, and one that fails exactly when the first did. */
315
+ guard_ledger: sendLedger(),
316
+ },
162
317
  });
163
318
 
164
319
  enforceServerDecision(agent, decision);