@shomra/agent 0.3.28 → 0.3.30

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.
Files changed (84) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/rules-javascript.mjs +16 -3
  23. package/src/detect/sast/rules-python.mjs +8 -6
  24. package/src/detect/sast/scanner.mjs +1 -1
  25. package/src/detect/signals/agent-frameworks.mjs +231 -0
  26. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  27. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  28. package/src/detect/signals/agentic-shim.mjs +82 -0
  29. package/src/detect/signals/artifacts.mjs +8 -36
  30. package/src/detect/signals/autonomy.mjs +9 -1
  31. package/src/detect/signals/chat-template.mjs +211 -0
  32. package/src/detect/signals/ci-workflow.mjs +169 -0
  33. package/src/detect/signals/credential-harvest.mjs +1 -1
  34. package/src/detect/signals/execution-hijack.mjs +4 -2
  35. package/src/detect/signals/gate.mjs +84 -11
  36. package/src/detect/signals/guardrail-shape.mjs +564 -0
  37. package/src/detect/signals/guardrail-surface.mjs +221 -0
  38. package/src/detect/signals/injection.mjs +8 -0
  39. package/src/detect/signals/inspect-shim.mjs +7 -0
  40. package/src/detect/signals/instruction-paths.mjs +60 -0
  41. package/src/detect/signals/manifests.mjs +302 -0
  42. package/src/detect/signals/masking.mjs +14 -1
  43. package/src/detect/signals/mcp-advisories.mjs +109 -0
  44. package/src/detect/signals/mcp-config.mjs +598 -0
  45. package/src/detect/signals/memory-directives.mjs +661 -0
  46. package/src/detect/signals/memory-locations.mjs +158 -0
  47. package/src/detect/signals/memory.mjs +56 -31
  48. package/src/detect/signals/model-config-rules.mjs +655 -0
  49. package/src/detect/signals/model-config.mjs +61 -0
  50. package/src/detect/signals/packages.mjs +2 -2
  51. package/src/detect/signals/prose-context.mjs +6 -9
  52. package/src/detect/signals/scan.mjs +4 -4
  53. package/src/detect/signals/secret-scanner.mjs +241 -0
  54. package/src/detect/signals/secrets.mjs +1 -48
  55. package/src/detect/signals/shell.mjs +10 -10
  56. package/src/gate/advisories.mjs +16 -0
  57. package/src/gate/batch.mjs +10 -0
  58. package/src/gate/environment.mjs +8 -53
  59. package/src/guard/artifact-paths.mjs +107 -0
  60. package/src/guard/classify.mjs +165 -7
  61. package/src/guard/command-resolve.mjs +35 -5
  62. package/src/guard/memory-write.mjs +218 -0
  63. package/src/guard/prompt-guard.mjs +0 -1
  64. package/src/guard/tool-guard.mjs +52 -77
  65. package/src/inventory/agent-posture.mjs +236 -57
  66. package/src/inventory/artifacts/classify.mjs +10 -1
  67. package/src/inventory/artifacts/discover.mjs +113 -3
  68. package/src/inventory/artifacts/extensions.mjs +70 -0
  69. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  70. package/src/inventory/artifacts/limits.mjs +1 -1
  71. package/src/inventory/artifacts/plugins.mjs +105 -0
  72. package/src/inventory/artifacts/roots.mjs +40 -0
  73. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  74. package/src/inventory/discovery/all.mjs +4 -0
  75. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  76. package/src/inventory/discovery/coding-agents.mjs +19 -4
  77. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  78. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  79. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  80. package/src/inventory/env-redirect.mjs +148 -0
  81. package/src/inventory/grant-extract.mjs +463 -0
  82. package/src/inventory/project-roots.mjs +108 -0
  83. package/src/inventory/vscode-state.mjs +153 -0
  84. package/src/mcp/server-tools.mjs +1 -1
@@ -0,0 +1,218 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { CONFIG_DIR } from '../core/config.mjs';
6
+
7
+ const SECTION_TOOLS = [
8
+ { re: /^save_memory$/i, file: () => path.join(os.homedir(), '.gemini', 'GEMINI.md'), heading: '## Gemini Added Memories' },
9
+ { re: /^qwen[_-]?save_memory$/i, file: () => path.join(os.homedir(), '.qwen', 'QWEN.md'), heading: '## Qwen Added Memories' },
10
+ ];
11
+
12
+ const readOr = (read, p) => {
13
+ try {
14
+ return read(p);
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
19
+
20
+ const readUtf8 = (p) => fs.readFileSync(p, 'utf8');
21
+
22
+ function replaceOnce(text, oldStr, newStr, all) {
23
+ if (typeof oldStr !== 'string' || typeof newStr !== 'string') return null;
24
+ if (oldStr === '') return text === '' ? newStr : null;
25
+ if (!text.includes(oldStr)) return null;
26
+ return all ? text.split(oldStr).join(newStr) : text.replace(oldStr, () => newStr);
27
+ }
28
+
29
+ function applySearchReplace(text, diff) {
30
+ const blocks = [...String(diff).matchAll(/(?:-{3,} SEARCH|<{3,} SEARCH)\r?\n([\s\S]*?)\r?\n={3,}\r?\n([\s\S]*?)\r?\n(?:\+{3,} REPLACE|>{3,} REPLACE)/g)];
31
+ if (!blocks.length) return null;
32
+ let out = text;
33
+ for (const [, search, replace] of blocks) {
34
+ const next = replaceOnce(out, search, replace, false);
35
+ if (next == null) return null;
36
+ out = next;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function insertAt(text, line, insert) {
42
+ if (typeof insert !== 'string') return null;
43
+ const lines = text.split('\n');
44
+ const at = Math.max(0, Math.min(Number(line) || 0, lines.length));
45
+ lines.splice(at, 0, ...insert.split('\n'));
46
+ return lines.join('\n');
47
+ }
48
+
49
+ function parsePatch(patch) {
50
+ const files = [];
51
+ let cur = null;
52
+ for (const raw of String(patch).split(/\r?\n/)) {
53
+ const add = /^\*\*\* Add File: (.+)$/.exec(raw);
54
+ const upd = /^\*\*\* Update File: (.+)$/.exec(raw);
55
+ const del = /^\*\*\* Delete File: (.+)$/.exec(raw);
56
+ if (add || upd || del) {
57
+ cur = { path: (add || upd || del)[1].trim(), op: add ? 'add' : upd ? 'update' : 'delete', hunks: [], lines: [] };
58
+ files.push(cur);
59
+ continue;
60
+ }
61
+ if (!cur || /^\*\*\* (Begin|End) Patch/.test(raw)) continue;
62
+ if (cur.op === 'add') {
63
+ if (raw.startsWith('+')) cur.lines.push(raw.slice(1));
64
+ continue;
65
+ }
66
+ if (raw.startsWith('@@')) {
67
+ cur.hunks.push({ before: [], after: [] });
68
+ continue;
69
+ }
70
+ if (!cur.hunks.length) cur.hunks.push({ before: [], after: [] });
71
+ const h = cur.hunks[cur.hunks.length - 1];
72
+ if (raw.startsWith('-')) h.before.push(raw.slice(1));
73
+ else if (raw.startsWith('+')) h.after.push(raw.slice(1));
74
+ else {
75
+ const ctx = raw.startsWith(' ') ? raw.slice(1) : raw;
76
+ h.before.push(ctx);
77
+ h.after.push(ctx);
78
+ }
79
+ }
80
+ return files;
81
+ }
82
+
83
+ function applyHunks(text, hunks) {
84
+ let out = text;
85
+ for (const h of hunks) {
86
+ const before = h.before.join('\n');
87
+ const after = h.after.join('\n');
88
+ const next = before ? replaceOnce(out, before, after, false) : out + (out.endsWith('\n') || !out ? '' : '\n') + after;
89
+ if (next == null) return null;
90
+ out = next;
91
+ }
92
+ return out;
93
+ }
94
+
95
+ function pathOf(input) {
96
+ const p = input.file_path ?? input.path ?? input.target_file ?? input.filename ?? null;
97
+ return typeof p === 'string' && p.trim() ? p : null;
98
+ }
99
+
100
+ function resolveAgainst(p, cwd) {
101
+ if (!p) return null;
102
+ if (p.startsWith('~/') || p.startsWith('~\\')) return path.join(os.homedir(), p.slice(2));
103
+ return path.isAbsolute(p) ? p : path.resolve(cwd || process.cwd(), p);
104
+ }
105
+
106
+ export function memoryWritesFor(tool, input, { cwd, read = readUtf8 } = {}) {
107
+ const name = String(tool ?? '');
108
+ const i = input ?? {};
109
+
110
+ const section = SECTION_TOOLS.find((s) => s.re.test(name));
111
+ if (section) {
112
+ const fact = typeof i.fact === 'string' ? i.fact : typeof i.content === 'string' ? i.content : null;
113
+ if (!fact) return [];
114
+ const file = section.file();
115
+ const current = readOr(read, file) ?? '';
116
+ const bullet = `- ${fact.trim()}`;
117
+ const content = current.includes(section.heading)
118
+ ? current.replace(section.heading, `${section.heading}\n${bullet}`)
119
+ : `${current}${current && !current.endsWith('\n') ? '\n' : ''}\n${section.heading}\n${bullet}\n`;
120
+ return [{ path: file, content, before: current, basis: 'whole' }];
121
+ }
122
+
123
+ if (/^apply_patch$/i.test(name)) {
124
+ const patch = typeof i.patch === 'string' ? i.patch : typeof i.input === 'string' ? i.input : typeof i.command === 'string' ? i.command : Array.isArray(i.command) ? i.command.join('\n') : null;
125
+ if (!patch) return [];
126
+ const out = [];
127
+ for (const f of parsePatch(patch)) {
128
+ const abs = resolveAgainst(f.path, cwd);
129
+ if (f.op === 'delete') continue;
130
+ if (f.op === 'add') {
131
+ out.push({ path: abs, content: f.lines.join('\n'), before: readOr(read, abs), basis: 'whole' });
132
+ continue;
133
+ }
134
+ const current = readOr(read, abs);
135
+ const next = current == null ? null : applyHunks(current, f.hunks);
136
+ if (next != null) out.push({ path: abs, content: next, before: current, basis: 'whole' });
137
+ else out.push({ path: abs, content: f.hunks.map((h) => h.after.join('\n')).join('\n'), before: current, basis: 'fragment' });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ const target = resolveAgainst(pathOf(i), cwd);
143
+ if (!target) return [];
144
+ const command = typeof i.command === 'string' ? i.command : null;
145
+
146
+ if (typeof i.content === 'string' && !Array.isArray(i.edits)) return [{ path: target, content: i.content, before: readOr(read, target), basis: 'whole' }];
147
+ if (typeof i.file_text === 'string' && (!command || command === 'create')) return [{ path: target, content: i.file_text, before: readOr(read, target), basis: 'whole' }];
148
+
149
+ const current = readOr(read, target);
150
+ const whole = (content) => [{ path: target, content, before: current, basis: 'whole' }];
151
+ const fragment = (content) => (typeof content === 'string' && content ? [{ path: target, content, before: current, basis: 'fragment' }] : []);
152
+
153
+ if (typeof i.new_string === 'string') {
154
+ const next = current == null ? null : replaceOnce(current, i.old_string, i.new_string, !!i.replace_all);
155
+ return next != null ? whole(next) : fragment(i.new_string);
156
+ }
157
+ if (Array.isArray(i.edits)) {
158
+ let next = current;
159
+ for (const e of i.edits) {
160
+ next = next == null ? null : replaceOnce(next, e?.old_string, e?.new_string, !!e?.replace_all);
161
+ }
162
+ return next != null ? whole(next) : fragment(i.edits.map((e) => e?.new_string ?? '').join('\n'));
163
+ }
164
+ if (command === 'str_replace' || typeof i.new_str === 'string') {
165
+ const next = current == null ? null : replaceOnce(current, i.old_str, i.new_str ?? '', false);
166
+ return next != null ? whole(next) : fragment(i.new_str);
167
+ }
168
+ if (command === 'insert') {
169
+ const text = i.insert_text ?? i.new_str;
170
+ const next = current == null ? null : insertAt(current, i.insert_line, text);
171
+ return next != null ? whole(next) : fragment(text);
172
+ }
173
+ if (typeof i.diff === 'string') {
174
+ const next = current == null ? null : applySearchReplace(current, i.diff);
175
+ return next != null ? whole(next) : fragment(i.diff);
176
+ }
177
+ if (typeof i.code_edit === 'string') return fragment(i.code_edit);
178
+ return [];
179
+ }
180
+
181
+ export const MEMORY_LEDGER_DIR = path.join(CONFIG_DIR, 'memory-ledger');
182
+
183
+ export const sha256 = (s) => crypto.createHash('sha256').update(String(s ?? ''), 'utf8').digest('hex');
184
+
185
+ function ledgerFile(p, dir) {
186
+ const key = crypto.createHash('sha1').update(path.resolve(p).toLowerCase()).digest('hex');
187
+ return path.join(dir, `${key}.json`);
188
+ }
189
+
190
+ export function readLedger(p, dir = MEMORY_LEDGER_DIR) {
191
+ try {
192
+ const row = JSON.parse(fs.readFileSync(ledgerFile(p, dir), 'utf8'));
193
+ return row && Array.isArray(row.hashes) ? row : null;
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ export function recordLedger(p, hashes, dir = MEMORY_LEDGER_DIR) {
200
+ try {
201
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
202
+ const prev = readLedger(p, dir);
203
+ const merged = [...new Set([...hashes.filter(Boolean), ...(prev?.hashes ?? [])])].slice(0, 6);
204
+ const file = ledgerFile(p, dir);
205
+ const tmp = `${file}.${process.pid}.tmp`;
206
+ fs.writeFileSync(tmp, JSON.stringify({ path: path.resolve(p), hashes: merged, at: new Date().toISOString() }), { mode: 0o600 });
207
+ fs.renameSync(tmp, file);
208
+ } catch {
209
+ return;
210
+ }
211
+ }
212
+
213
+ export function outOfBandChange(p, currentContent, dir = MEMORY_LEDGER_DIR) {
214
+ if (currentContent == null) return false;
215
+ const row = readLedger(p, dir);
216
+ if (!row) return false;
217
+ return !row.hashes.includes(sha256(currentContent));
218
+ }
@@ -170,7 +170,6 @@ function promptInjectionNote(injection) {
170
170
  );
171
171
  }
172
172
 
173
-
174
173
  function buildPromptGuardBody(norm, agent, clientDecision, clientReason) {
175
174
  const redaction = localTierDisabled()
176
175
  ? { text: norm.prompt, masked: [], changed: false }
@@ -6,6 +6,8 @@ import { isMemoryPath, reportMemoryWrite } from '../commands/memory-scan.mjs';
6
6
  import { breakerOpen, breakerReset, breakerTrip, guardTimeoutMs } from '../core/circuit-breaker.mjs';
7
7
  import { CONFIG_DIR } from '../core/config.mjs';
8
8
  import { makeLedgerStore } from './ledger.mjs';
9
+ import { memoryWritesFor, outOfBandChange, recordLedger, sha256 } from './memory-write.mjs';
10
+ import { gateMachine } from '../core/api-client.mjs';
9
11
  import { VERSION } from '../core/version.mjs';
10
12
  import { loadConfig, resolveSettings } from '../core/config.mjs';
11
13
  import { classifyConsequence, downrankCodeContext, grade, localScan } from '../detect/guard-signals.mjs';
@@ -19,23 +21,7 @@ import { buildGuardBody, reportGuardDecision } from './report.mjs';
19
21
 
20
22
  const ALLOW_VERDICT = { verdict: 'ALLOW', top: null, findings: [] };
21
23
 
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
- */
24
+
39
25
  function failOpenOnSevere() {
40
26
  return envFlag('SHOMRA_GUARD_FAILOPEN_SEVERE');
41
27
  }
@@ -49,34 +35,18 @@ function unscreenedSevere(normalized, tool, input) {
49
35
  }) === 'severe';
50
36
  }
51
37
 
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
- */
38
+
65
39
  function ledger() {
66
40
  return makeLedgerStore(CONFIG_DIR, { version: VERSION });
67
41
  }
68
42
 
69
- /** A call that ran with no server verdict: Tier-0 screened it, or nothing did. */
70
43
  function countUnscreened(reason) {
71
44
  try {
72
45
  ledger().count(localTierDisabled() ? 'unscreened' : 'local', reason);
73
46
  } 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
47
  }
77
48
  }
78
49
 
79
- /** Close the open window and hand the backend everything not yet acknowledged. */
80
50
  function sendLedger() {
81
51
  try {
82
52
  const store = ledger();
@@ -85,7 +55,6 @@ function sendLedger() {
85
55
  pendingLedger = env.gaps ?? [];
86
56
  return env;
87
57
  } catch {
88
- /* No ledger is a lower bound, not a wrong number. */
89
58
  return undefined;
90
59
  }
91
60
  }
@@ -97,8 +66,6 @@ function ackLedger() {
97
66
  try {
98
67
  ledger().ack(pendingLedger);
99
68
  } 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
69
  }
103
70
  pendingLedger = [];
104
71
  }
@@ -138,28 +105,56 @@ function screenLocally(normalized, tool, input) {
138
105
  return { ...grade(findings), top, findings };
139
106
  }
140
107
 
141
- function memoryWriteContent(input) {
142
- if (typeof input.content === 'string') return input.content;
143
- if (typeof input.new_string === 'string') return input.new_string;
144
- return null;
108
+ const READ_TOOLS_RE = /^(read|read_file|view|open_file|cat)$/i;
109
+ const OUT_OF_BAND = 'changed outside any agent write the Shomra hook observed';
110
+
111
+ function memoryReportBase(filePath, normalized) {
112
+ const machine = gateMachine();
113
+ return {
114
+ path: path.resolve(filePath).split(path.sep).join('/'),
115
+ name: path.basename(String(filePath)),
116
+ machineId: machine.machineId,
117
+ hostname: machine.hostname,
118
+ actor: machine.username,
119
+ sessionId: normalized.session_id,
120
+ };
145
121
  }
146
122
 
147
- async function recordMemoryWrite({ url, apiKey, input, normalized }) {
148
- const memoryPath = input.file_path || input.path;
149
- if (!memoryPath || !isMemoryPath(memoryPath) || breakerOpen()) return;
123
+ async function reportOutOfBand(url, apiKey, filePath, current, normalized) {
124
+ if (current == null || !outOfBandChange(filePath, current)) return;
125
+ await reportMemoryWrite(url, apiKey, { ...memoryReportBase(filePath, normalized), content: current, writer: 'UNKNOWN', source: OUT_OF_BAND });
126
+ recordLedger(filePath, [sha256(current)]);
127
+ }
150
128
 
151
- const content = memoryWriteContent(input);
152
- if (content == null) return;
129
+ async function recordMemoryWrite({ url, apiKey, tool, input, normalized }) {
130
+ if (breakerOpen()) return;
131
+
132
+ if (READ_TOOLS_RE.test(tool || '')) {
133
+ const target = input.file_path || input.path || input.target_file;
134
+ if (!target || !isMemoryPath(target)) return;
135
+ const abs = path.resolve(normalized.cwd || process.cwd(), String(target));
136
+ let current = null;
137
+ try {
138
+ current = fs.readFileSync(abs, 'utf8');
139
+ } catch {
140
+ return;
141
+ }
142
+ await reportOutOfBand(url, apiKey, abs, current, normalized);
143
+ return;
144
+ }
153
145
 
154
- await reportMemoryWrite(url, apiKey, {
155
- path: String(memoryPath).split(path.sep).join('/'),
156
- name: path.basename(String(memoryPath)),
157
- content,
158
- writer: 'AGENT',
159
- source: os.hostname(),
160
- actor: os.userInfo().username,
161
- sessionId: normalized.session_id,
162
- });
146
+ const writes = memoryWritesFor(tool, input, { cwd: normalized.cwd }).filter((w) => w.path && isMemoryPath(w.path));
147
+ for (const w of writes) {
148
+ await reportOutOfBand(url, apiKey, w.path, w.before, normalized);
149
+ await reportMemoryWrite(url, apiKey, {
150
+ ...memoryReportBase(w.path, normalized),
151
+ content: w.content,
152
+ writer: 'AGENT',
153
+ source: os.hostname(),
154
+ contentBasis: w.basis,
155
+ });
156
+ recordLedger(w.path, [w.before != null ? sha256(w.before) : null, w.basis === 'whole' ? sha256(w.content) : null]);
157
+ }
163
158
  }
164
159
 
165
160
  function reportUnauthenticated(agent, status, strict) {
@@ -193,12 +188,6 @@ async function requestServerDecision({ url, apiKey, agentId, body, agent, strict
193
188
 
194
189
  if (!response.ok) {
195
190
  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
191
  if (response.status === 429) {
203
192
  const wait = retryAfterMs(response);
204
193
  if (wait !== null && !retried) {
@@ -223,7 +212,6 @@ async function requestServerDecision({ url, apiKey, agentId, body, agent, strict
223
212
  }
224
213
  }
225
214
 
226
- /** Honours a seconds or an HTTP-date Retry-After; null when the server named none. */
227
215
  function retryAfterMs(response) {
228
216
  const raw = response.headers?.get?.('retry-after');
229
217
  if (!raw) return null;
@@ -271,26 +259,16 @@ export async function cmdToolGuard(flags) {
271
259
  process.exit(0);
272
260
  }
273
261
 
274
- await recordMemoryWrite({ url, apiKey, input, normalized });
262
+ await recordMemoryWrite({ url, apiKey, tool, input, normalized });
263
+
275
264
 
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
265
  const severe = unscreenedSevere(normalized, tool, input);
282
266
  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
267
  if (!escalate) {
288
268
  countUnscreened('not escalated - screened by the local tier only');
289
269
  process.exit(0);
290
270
  }
291
271
 
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
272
  const onUnreachable = (why) => {
295
273
  countUnscreened(why);
296
274
  if (severe) askUnscreened(agent, why);
@@ -309,9 +287,6 @@ export async function cmdToolGuard(flags) {
309
287
  onUnreachable,
310
288
  body: {
311
289
  ...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
290
  guard_ledger: sendLedger(),
316
291
  },
317
292
  });