@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.
- package/package.json +1 -1
- package/src/agents/hook-command.mjs +1 -1
- package/src/artifacts/matchers.mjs +7 -0
- package/src/cli/flags.mjs +2 -2
- package/src/cli/help-sections.mjs +7 -0
- package/src/cli/help.mjs +1 -1
- package/src/commands/check.mjs +3 -11
- package/src/commands/gate.mjs +26 -4
- package/src/commands/git-hooks.mjs +2 -2
- package/src/commands/ledger.mjs +0 -1
- package/src/commands/mcp-add.mjs +2 -1
- package/src/commands/memory-scan.mjs +135 -47
- package/src/commands/pr.mjs +7 -10
- package/src/commands/provenance.mjs +8 -13
- package/src/commands/scan.mjs +7 -1
- package/src/commands/secrets.mjs +4 -5
- package/src/core/git-exec.mjs +79 -0
- package/src/core/yaml-lite.mjs +300 -0
- package/src/core/zip-lite.mjs +37 -0
- package/src/detect/local-redact.mjs +1 -3
- package/src/detect/sast/rules-config.mjs +1 -1
- package/src/detect/sast/rules-javascript.mjs +16 -3
- package/src/detect/sast/rules-python.mjs +8 -6
- package/src/detect/sast/scanner.mjs +1 -1
- package/src/detect/signals/agent-frameworks.mjs +231 -0
- package/src/detect/signals/agent-graph-surface.mjs +113 -0
- package/src/detect/signals/agentic-ci-surface.mjs +314 -0
- package/src/detect/signals/agentic-shim.mjs +82 -0
- package/src/detect/signals/artifacts.mjs +8 -36
- package/src/detect/signals/autonomy.mjs +9 -1
- package/src/detect/signals/chat-template.mjs +211 -0
- package/src/detect/signals/ci-workflow.mjs +169 -0
- package/src/detect/signals/credential-harvest.mjs +1 -1
- package/src/detect/signals/execution-hijack.mjs +4 -2
- package/src/detect/signals/gate.mjs +84 -11
- package/src/detect/signals/guardrail-shape.mjs +564 -0
- package/src/detect/signals/guardrail-surface.mjs +221 -0
- package/src/detect/signals/injection.mjs +8 -0
- package/src/detect/signals/inspect-shim.mjs +7 -0
- package/src/detect/signals/instruction-paths.mjs +60 -0
- package/src/detect/signals/manifests.mjs +302 -0
- package/src/detect/signals/masking.mjs +14 -1
- package/src/detect/signals/mcp-advisories.mjs +109 -0
- package/src/detect/signals/mcp-config.mjs +598 -0
- package/src/detect/signals/memory-directives.mjs +661 -0
- package/src/detect/signals/memory-locations.mjs +158 -0
- package/src/detect/signals/memory.mjs +56 -31
- package/src/detect/signals/model-config-rules.mjs +655 -0
- package/src/detect/signals/model-config.mjs +61 -0
- package/src/detect/signals/packages.mjs +2 -2
- package/src/detect/signals/prose-context.mjs +6 -9
- package/src/detect/signals/scan.mjs +4 -4
- package/src/detect/signals/secret-scanner.mjs +241 -0
- package/src/detect/signals/secrets.mjs +1 -48
- package/src/detect/signals/shell.mjs +10 -10
- package/src/gate/advisories.mjs +16 -0
- package/src/gate/batch.mjs +10 -0
- package/src/gate/environment.mjs +8 -53
- package/src/guard/artifact-paths.mjs +107 -0
- package/src/guard/classify.mjs +165 -7
- package/src/guard/command-resolve.mjs +35 -5
- package/src/guard/memory-write.mjs +218 -0
- package/src/guard/prompt-guard.mjs +0 -1
- package/src/guard/tool-guard.mjs +52 -77
- package/src/inventory/agent-posture.mjs +236 -57
- package/src/inventory/artifacts/classify.mjs +10 -1
- package/src/inventory/artifacts/discover.mjs +113 -3
- package/src/inventory/artifacts/extensions.mjs +70 -0
- package/src/inventory/artifacts/hook-scripts.mjs +128 -0
- package/src/inventory/artifacts/limits.mjs +1 -1
- package/src/inventory/artifacts/plugins.mjs +105 -0
- package/src/inventory/artifacts/roots.mjs +40 -0
- package/src/inventory/discovery/ai-dependencies.mjs +39 -12
- package/src/inventory/discovery/all.mjs +4 -0
- package/src/inventory/discovery/cloud-clis.mjs +472 -0
- package/src/inventory/discovery/coding-agents.mjs +19 -4
- package/src/inventory/discovery/mcp-clients.mjs +16 -10
- package/src/inventory/discovery/mcp-servers.mjs +125 -35
- package/src/inventory/discovery/mcp-stores.mjs +207 -0
- package/src/inventory/env-redirect.mjs +148 -0
- package/src/inventory/grant-extract.mjs +463 -0
- package/src/inventory/project-roots.mjs +108 -0
- package/src/inventory/vscode-state.mjs +153 -0
- 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
|
+
}
|
package/src/guard/tool-guard.mjs
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
if (
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
});
|