@shomra/agent 0.2.5 → 0.2.7
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-signals.mjs +104 -15
- package/package.json +1 -1
- package/shomra.mjs +63 -4
package/guard-signals.mjs
CHANGED
|
@@ -117,9 +117,12 @@ export const INVISIBLE_CHARS_RE = /[ᅟᅠ---
|
|
|
117
117
|
|
|
118
118
|
// ── secrets ──
|
|
119
119
|
export const SECRET_PATTERNS = [
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
// Prefix-style keys are \b-anchored (backend parity, checks/patterns.ts): a
|
|
121
|
+
// slug that merely CONTAINS the prefix ("task-0123456789abcdefghij",
|
|
122
|
+
// "disk-…") must not read as a live credential — these are CRITICAL and BLOCK.
|
|
123
|
+
{ name: 'Stripe live key', re: /\bsk_live_[0-9a-zA-Z]{16,}/ },
|
|
124
|
+
{ name: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{20,}/ },
|
|
125
|
+
{ name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}/ },
|
|
123
126
|
{ name: 'GitHub token', re: /ghp_[0-9A-Za-z]{20,}/ },
|
|
124
127
|
{ name: 'Slack token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
|
|
125
128
|
{ name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
|
|
@@ -175,6 +178,65 @@ export function containsAny(haystack, needles) {
|
|
|
175
178
|
return null;
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
// Like containsAny, but the needle must START at a word boundary — 'aws' must
|
|
182
|
+
// not fire inside "flaws", 'cat ' inside "concat ", 'token' is fine ("tokens"
|
|
183
|
+
// still hits: only the START is guarded, because these lists match prose where
|
|
184
|
+
// words inflect at the end). Mirrors the backend's containsWord.
|
|
185
|
+
const WORD_RE_CACHE = new Map();
|
|
186
|
+
function leadingBoundaryRe(needle) {
|
|
187
|
+
let re = WORD_RE_CACHE.get(needle);
|
|
188
|
+
if (!re) {
|
|
189
|
+
const esc = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
190
|
+
re = new RegExp(/^\w/.test(needle) ? `(?<!\\w)${esc}` : esc, 'i');
|
|
191
|
+
WORD_RE_CACHE.set(needle, re);
|
|
192
|
+
}
|
|
193
|
+
return re;
|
|
194
|
+
}
|
|
195
|
+
export function containsWord(haystack, needles) {
|
|
196
|
+
const h = String(haystack ?? '');
|
|
197
|
+
for (const n of needles) if (leadingBoundaryRe(n).test(h)) return n;
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── risky-config: mention vs configuration ──
|
|
202
|
+
// `\w`-only boundaries, NOT `[\w-]`: markers legitimately butt against dashes
|
|
203
|
+
// (--dangerously-skip-permissions), so excluding '-' would suppress the flag
|
|
204
|
+
// form; excluding `\w` is what stops 'dangerously' firing on
|
|
205
|
+
// dangerouslySetInnerHTML. Mirrors the backend (checks/text-inspector.ts).
|
|
206
|
+
const MARKER_RE_CACHE = new Map();
|
|
207
|
+
function markerRe(marker) {
|
|
208
|
+
let re = MARKER_RE_CACHE.get(marker);
|
|
209
|
+
if (!re) {
|
|
210
|
+
re = new RegExp(`(?<!\\w)${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?!\\w)`, 'gi');
|
|
211
|
+
MARKER_RE_CACHE.set(marker, re);
|
|
212
|
+
}
|
|
213
|
+
re.lastIndex = 0; // shared instance: an early return leaves lastIndex dirty
|
|
214
|
+
return re;
|
|
215
|
+
}
|
|
216
|
+
const FLAG_BEFORE = /(?:^|\s)--?[\w-]*$/; // --yolo, --dangerously-skip-permissions
|
|
217
|
+
const ENABLE_AFTER = /^["'`\]]?\s*[:=]/; // "yolo": true, AUTO_APPROVE=1
|
|
218
|
+
const ENABLE_BEFORE = /[:=]\s*["'`\[]?\s*$/; // "mode": "unrestricted" — one delimiter; two (`= ['`) is a definition LIST
|
|
219
|
+
function isEnablement(text, at, len) {
|
|
220
|
+
const before = text.slice(Math.max(0, at - 24), at);
|
|
221
|
+
const after = text.slice(at + len, at + len + 12);
|
|
222
|
+
return FLAG_BEFORE.test(before) || ENABLE_AFTER.test(after) || ENABLE_BEFORE.test(before);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* First occurrence of a risky-config marker that reads as a setting being
|
|
226
|
+
* ENABLED (word-bounded + enablement-shaped), or null. Unlike the backend twin
|
|
227
|
+
* this does NOT suppress on the mask: the CLI mask is binary (string ≡ comment),
|
|
228
|
+
* and JSON config keys ARE string literals — the hooks' codeContext downrank
|
|
229
|
+
* handles the literal/comment case instead.
|
|
230
|
+
*/
|
|
231
|
+
function riskyConfigHit(text, marker) {
|
|
232
|
+
const re = markerRe(marker);
|
|
233
|
+
let m;
|
|
234
|
+
while ((m = re.exec(text)) !== null) {
|
|
235
|
+
if (isEnablement(text, m.index, m[0].length)) return { start: m.index, end: m.index + m[0].length };
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
178
240
|
// Attacker-controlled data sinks — a tool call/result referencing one is an
|
|
179
241
|
// exfiltration endpoint.
|
|
180
242
|
export const SUSPICIOUS_EGRESS_HOSTS = [
|
|
@@ -206,11 +268,26 @@ function deobfuscate(text) {
|
|
|
206
268
|
return { text: decoded.length ? `${text}\n${decoded.join('\n')}` : text, decodedPayload: decoded.length > 0 };
|
|
207
269
|
}
|
|
208
270
|
|
|
209
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Reference to a known exfiltration sink host, or null. Host-boundary matched,
|
|
273
|
+
* NOT a raw substring — `includes('ix.io')` fired inside "matrix.io" and
|
|
274
|
+
* `includes('file.io')` inside "profile.io", and this feeds a HIGH/FLAG on live
|
|
275
|
+
* tool calls. The char before must not be a host label char (a leading '.' IS
|
|
276
|
+
* allowed so "paste.c-net.org" still hits); the char after must end the host.
|
|
277
|
+
*/
|
|
278
|
+
const EGRESS_RE_CACHE = new Map();
|
|
279
|
+
function egressHostRe(host) {
|
|
280
|
+
let re = EGRESS_RE_CACHE.get(host);
|
|
281
|
+
if (!re) {
|
|
282
|
+
re = new RegExp(`(^|[^a-z0-9-])${host.replace(/[.]/g, '\\.')}($|[^a-z0-9.-])`, 'i');
|
|
283
|
+
EGRESS_RE_CACHE.set(host, re);
|
|
284
|
+
}
|
|
285
|
+
return re;
|
|
286
|
+
}
|
|
210
287
|
export function egressHost(text) {
|
|
211
288
|
if (!text) return null;
|
|
212
289
|
const low = text.toLowerCase();
|
|
213
|
-
return SUSPICIOUS_EGRESS_HOSTS.find((h) =>
|
|
290
|
+
return SUSPICIOUS_EGRESS_HOSTS.find((h) => egressHostRe(h).test(low)) ?? null;
|
|
214
291
|
}
|
|
215
292
|
|
|
216
293
|
/** 1-based line number of a character offset inside `text`. */
|
|
@@ -267,13 +344,8 @@ function codeMask(text) {
|
|
|
267
344
|
while (i < n) {
|
|
268
345
|
const c = text[i], c2 = text[i + 1];
|
|
269
346
|
if (state === 0) {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (c === '`') { state = 3; mask[i++] = 1; continue; }
|
|
273
|
-
if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
|
|
274
|
-
if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
|
|
275
|
-
if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
|
|
276
|
-
if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = 1; continue; }
|
|
347
|
+
// The fence test MUST precede the backtick-string test, or ``` is consumed
|
|
348
|
+
// as a template-literal opener and the fence handler below never runs.
|
|
277
349
|
if (text.startsWith('```', i) || text.startsWith('~~~', i)) { // fenced block → mask the whole span, delimiters included
|
|
278
350
|
const fence = text.slice(i, i + 3);
|
|
279
351
|
const nl = text.indexOf('\n', i);
|
|
@@ -286,6 +358,13 @@ function codeMask(text) {
|
|
|
286
358
|
for (let k = i; k < end; k++) mask[k] = 1;
|
|
287
359
|
prevSig = ''; i = end; continue;
|
|
288
360
|
}
|
|
361
|
+
if (c === "'") { state = 1; mask[i++] = 1; continue; }
|
|
362
|
+
if (c === '"') { state = 2; mask[i++] = 1; continue; }
|
|
363
|
+
if (c === '`') { state = 3; mask[i++] = 1; continue; }
|
|
364
|
+
if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
|
|
365
|
+
if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
|
|
366
|
+
if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
|
|
367
|
+
if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = 1; continue; }
|
|
289
368
|
if (c === '/' && REGEX_START.has(prevSig)) { state = 7; inClass = false; mask[i++] = 1; continue; }
|
|
290
369
|
if (!/\s/.test(c)) prevSig = c;
|
|
291
370
|
i++;
|
|
@@ -400,8 +479,18 @@ export function localScan(text, opts = {}) {
|
|
|
400
479
|
}
|
|
401
480
|
}
|
|
402
481
|
if (cats.includes('config')) {
|
|
403
|
-
|
|
404
|
-
|
|
482
|
+
// A marker counts only where a setting is being TURNED ON — `"yolo": true`,
|
|
483
|
+
// AUTO_APPROVE=1, --dangerously-skip-permissions — not merely named:
|
|
484
|
+
// 'dangerously' inside dangerouslySetInnerHTML, a marker-definition array
|
|
485
|
+
// (this very file), "yolo mode" in prose. Word-bounded + enablement-gated,
|
|
486
|
+
// skipping comment/fence mentions; mirrors the backend's riskyConfigHit.
|
|
487
|
+
for (const m of RISKY_CONFIG_MARKERS) {
|
|
488
|
+
const hit = riskyConfigHit(t, m);
|
|
489
|
+
if (hit) {
|
|
490
|
+
findings.push({ label: `Risky setting: "${m}"`, severity: 'MEDIUM', category: 'config', line: lineAt(t, hit.start), codeContext: mask[hit.start] === 1 });
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
405
494
|
}
|
|
406
495
|
if (cats.includes('egress')) {
|
|
407
496
|
const h = egressHost(t);
|
|
@@ -700,7 +789,7 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
|
|
|
700
789
|
for (const sig of DANGEROUS_SHELL) if (matchesShellSignal(sig, text)) { push(sig.severity === 'MEDIUM' || sig.severity === 'LOW' ? 'HIGH' : 'CRITICAL', `Executable payload staged in ${noun}: ${sig.name}`, `Delete the command from the ${noun}; treat the writer as untrusted.`, sig.re); break; }
|
|
701
790
|
const host = egressHost(text);
|
|
702
791
|
if (host) push('HIGH', `${isInstruction ? 'Rules file' : 'Memory'} references a data-exfiltration host (${host})`, 'Remove the reference and roll back to the approved baseline.', host);
|
|
703
|
-
if (hasImperative &&
|
|
792
|
+
if (hasImperative && containsWord(text, SENSITIVE_READ) && containsWord(text, NETWORK_VERBS)) {
|
|
704
793
|
push('HIGH', `Toxic instruction in ${noun}: reads sensitive data + reaches the network`, 'Remove the entry; gate any network step behind explicit approval and an egress allow-list.');
|
|
705
794
|
}
|
|
706
795
|
if (LIFECYCLE_VECTOR.test(text)) push('MEDIUM', `${isInstruction ? 'Rules file' : 'Memory'} references a package-lifecycle hook (MemoryTrap vector)`, 'Verify no dependency writes to this store during install; pin dependencies and audit lifecycle scripts.', LIFECYCLE_VECTOR);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Shomra — a local-first security scanner and runtime firewall for AI agents, MCP servers, prompts, and models. Gates AI artifacts in your editor and CI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/shomra.mjs
CHANGED
|
@@ -15,12 +15,25 @@ import path from 'node:path';
|
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import crypto from 'node:crypto';
|
|
17
17
|
import { execSync } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import { discoverAll } from './discovery.mjs';
|
|
19
20
|
import { localScan, localGate, grade, downrankCodeContext, SECRET_PATTERNS } from './guard-signals.mjs';
|
|
20
21
|
import { scanSourceFile, isScannableSource, isModelConfig } from './code-sast.mjs';
|
|
21
22
|
import { scanModelRefs, isModelRefScannable } from './model-refs.mjs';
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
// Read from package.json rather than hardcoding: the two spellings drifted (this
|
|
25
|
+
// const said 0.2.0 while the package was already 0.2.4), so `shomra --version`
|
|
26
|
+
// and the `x-shomra-agent` header both under-reported the running build — which
|
|
27
|
+
// is exactly the value you need to trust when triaging a bad scan in the field.
|
|
28
|
+
// Falls back to the package version being unreadable rather than crashing the CLI.
|
|
29
|
+
const VERSION = (() => {
|
|
30
|
+
try {
|
|
31
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
return JSON.parse(fs.readFileSync(path.join(here, 'package.json'), 'utf8')).version ?? '0.0.0';
|
|
33
|
+
} catch {
|
|
34
|
+
return '0.0.0';
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
24
37
|
const CONFIG_DIR = path.join(os.homedir(), '.shomra');
|
|
25
38
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
26
39
|
|
|
@@ -2830,9 +2843,17 @@ async function screenModelLoad(agent, tool, input, url) {
|
|
|
2830
2843
|
if (!refs.length) return; // modelLookup is cache-first + breaker-aware, so don't bail here
|
|
2831
2844
|
|
|
2832
2845
|
const flagged = [];
|
|
2846
|
+
// ONE budget for the whole screen, not one per ref. This runs inside the
|
|
2847
|
+
// PreToolUse hook, so its cost is added to a tool call the dev is watching: a
|
|
2848
|
+
// file citing five uncached models must not be able to spend 5× the guard
|
|
2849
|
+
// timeout. Cache hits and a tripped breaker short-circuit before any network,
|
|
2850
|
+
// so the common path never touches this.
|
|
2851
|
+
const deadline = Date.now() + guardTimeoutMs();
|
|
2833
2852
|
for (const r of refs) {
|
|
2853
|
+
const left = deadline - Date.now();
|
|
2854
|
+
if (left <= 0) break; // budget spent — flag what we screened, never stall the call
|
|
2834
2855
|
let lk;
|
|
2835
|
-
try { lk = await modelLookup(url, r.id, r.revision); } catch { return; } // uncached + backend down → can't judge, stay silent
|
|
2856
|
+
try { lk = await modelLookup(url, r.id, r.revision, left); } catch { return; } // uncached + backend down → can't judge, stay silent
|
|
2836
2857
|
const findings = (lk && lk.findings) || [];
|
|
2837
2858
|
const worst = findings.reduce((m, f) => Math.max(m, MODEL_SEV_RANK[f.severity] || 0), 0);
|
|
2838
2859
|
const bad = lk && lk.found && (lk.verdict === 'FAIL' || lk.verdict === 'REVIEW' || worst >= MODEL_SEV_RANK.HIGH);
|
|
@@ -3003,6 +3024,26 @@ async function cmdToolGuard(flags) {
|
|
|
3003
3024
|
signal: ctrl.signal,
|
|
3004
3025
|
});
|
|
3005
3026
|
clearTimeout(timer);
|
|
3027
|
+
// fetch() does NOT reject on 4xx/5xx. Without this check a rejected key
|
|
3028
|
+
// returned its error body, r.json() parsed it happily, breakerReset() marked
|
|
3029
|
+
// the backend healthy, res.decision came back undefined — and every
|
|
3030
|
+
// escalated call silently ALLOWed. Org policy off, no error, no breaker, no
|
|
3031
|
+
// signal, indefinitely. Non-2xx must reach the failure path below.
|
|
3032
|
+
if (!r.ok) {
|
|
3033
|
+
// An auth failure is a misconfiguration, not an outage: it will not heal
|
|
3034
|
+
// on its own, so it gets a visible line rather than a 30s breaker cooldown
|
|
3035
|
+
// that would hide it (and skip even this warning on the calls after it).
|
|
3036
|
+
if (r.status === 401 || r.status === 403) {
|
|
3037
|
+
process.stderr.write(
|
|
3038
|
+
`[shomra] guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
|
|
3039
|
+
`Local Tier-0 screening still ran; org policy, agent identity and flow control did not. ` +
|
|
3040
|
+
`Re-enroll with \`shomra init --key <key>\`.\n`,
|
|
3041
|
+
);
|
|
3042
|
+
if (strict) emitGuardDeny(agent, `Shomra guard could not authenticate (HTTP ${r.status}); blocked by fail-closed policy.`);
|
|
3043
|
+
process.exit(0);
|
|
3044
|
+
}
|
|
3045
|
+
throw new Error(`HTTP ${r.status}`); // 5xx / 429 → a real outage, trip the breaker
|
|
3046
|
+
}
|
|
3006
3047
|
res = await r.json();
|
|
3007
3048
|
breakerReset(); // healthy response — clear any tripped breaker
|
|
3008
3049
|
} catch (e) {
|
|
@@ -3095,6 +3136,20 @@ async function cmdResultGuard(flags) {
|
|
|
3095
3136
|
signal: ctrl.signal,
|
|
3096
3137
|
});
|
|
3097
3138
|
clearTimeout(timer);
|
|
3139
|
+
// See cmdToolGuard: fetch() does not reject on 4xx, so an error body would
|
|
3140
|
+
// parse cleanly and `res.decision` would be undefined → silent fail-open.
|
|
3141
|
+
if (!r.ok) {
|
|
3142
|
+
if (r.status === 401 || r.status === 403) {
|
|
3143
|
+
process.stderr.write(
|
|
3144
|
+
`[shomra] result-guard NOT enforced: the backend rejected this API key (HTTP ${r.status}). ` +
|
|
3145
|
+
`Local Tier-0 screening still ran; server-side flow taint did not. ` +
|
|
3146
|
+
`Re-enroll with \`shomra init --key <key>\`.\n`,
|
|
3147
|
+
);
|
|
3148
|
+
if (strict) emitResultBlock(agent, `Shomra result-guard could not authenticate (HTTP ${r.status}); blocked by fail-closed policy.`);
|
|
3149
|
+
process.exit(0);
|
|
3150
|
+
}
|
|
3151
|
+
throw new Error(`HTTP ${r.status}`); // 5xx / 429 → a real outage, trip the breaker
|
|
3152
|
+
}
|
|
3098
3153
|
res = await r.json();
|
|
3099
3154
|
breakerReset();
|
|
3100
3155
|
} catch (e) {
|
|
@@ -3678,7 +3733,11 @@ function modelCacheOff() { return process.env.SHOMRA_MODEL_CACHE === '0' || Stri
|
|
|
3678
3733
|
function loadModelCache() { try { return JSON.parse(fs.readFileSync(MODEL_CACHE_FILE, 'utf8')) || {}; } catch { return {}; } }
|
|
3679
3734
|
function saveModelCache(c) { try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); fs.writeFileSync(MODEL_CACHE_FILE, JSON.stringify(c)); } catch { /* cache is best-effort */ } }
|
|
3680
3735
|
|
|
3681
|
-
|
|
3736
|
+
// `timeoutMs` overrides the interactive API budget. The PreToolUse hook MUST
|
|
3737
|
+
// pass the guard budget: this function's default is sized for a human waiting on
|
|
3738
|
+
// `shomra models`, and inheriting it on the hot path froze a dev's terminal for
|
|
3739
|
+
// 15s per uncached ref against a cold backend.
|
|
3740
|
+
async function modelLookup(url, id, sha, timeoutMs) {
|
|
3682
3741
|
const key = `${id}@${sha || 'latest'}`;
|
|
3683
3742
|
const ttl = clampInt(process.env.SHOMRA_MODEL_CACHE_TTL_MS, 7 * 24 * 3600 * 1000, 0, 365 * 24 * 3600 * 1000);
|
|
3684
3743
|
const cache = modelCacheOff() ? {} : loadModelCache();
|
|
@@ -3699,7 +3758,7 @@ async function modelLookup(url, id, sha) {
|
|
|
3699
3758
|
|
|
3700
3759
|
const q = `id=${encodeURIComponent(id)}${sha ? `&sha=${encodeURIComponent(sha)}` : ''}`;
|
|
3701
3760
|
const ctrl = new AbortController();
|
|
3702
|
-
const timer = setTimeout(() => ctrl.abort(), clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 15000, 1000, 60000));
|
|
3761
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs ?? clampInt(process.env.SHOMRA_API_TIMEOUT_MS, 15000, 1000, 60000));
|
|
3703
3762
|
try {
|
|
3704
3763
|
const res = await fetch(`${url}/models/lookup?${q}`, { signal: ctrl.signal, headers: { Accept: 'application/json', 'User-Agent': 'shomra-agent' } });
|
|
3705
3764
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|