@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 CHANGED
@@ -117,9 +117,12 @@ export const INVISIBLE_CHARS_RE = /[؜ᅟᅠ᠎​‌‎‏‪-‮⁠-⁤⁦-⁩
117
117
 
118
118
  // ── secrets ──
119
119
  export const SECRET_PATTERNS = [
120
- { name: 'Stripe live key', re: /sk_live_[0-9a-zA-Z]{16,}/ },
121
- { name: 'OpenAI key', re: /sk-[A-Za-z0-9]{20,}/ },
122
- { name: 'AWS access key id', re: /AKIA[0-9A-Z]{16}/ },
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
- /** Reference to a known exfiltration sink host, or null. */
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) => low.includes(h)) ?? null;
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
- if (c === "'") { state = 1; mask[i++] = 1; continue; }
271
- if (c === '"') { state = 2; mask[i++] = 1; continue; }
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
- const low = t.toLowerCase();
404
- for (const m of RISKY_CONFIG_MARKERS) if (low.includes(m)) { findings.push({ label: `Risky setting: "${m}"`, severity: 'MEDIUM', category: 'config', ...locate(t, m, mask) }); break; }
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 && containsAny(text, SENSITIVE_READ) && containsAny(text, NETWORK_VERBS)) {
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.5",
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
- const VERSION = '0.2.0';
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
- async function modelLookup(url, id, sha) {
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}`);