@shomra/agent 0.3.5 → 0.3.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/code-sast.mjs CHANGED
@@ -278,15 +278,25 @@ const PY_RULES = [
278
278
  },
279
279
  {
280
280
  id: 'python.network_egress',
281
- title: 'Network egress from model code',
282
- severity: 'HIGH',
281
+ title: 'Network egress',
282
+ // ⚠ MEDIUM, and titled 'Network egress' NOT 'from model code' — in lockstep
283
+ // with checks/code-sast.ts (python.network_egress). This rule fires on ANY
284
+ // `requests.get`/`httpx`/`socket`, and this SAST tier runs over EVERY source
285
+ // file in a repo, not just model loaders — so an ordinary RAG/app file that
286
+ // makes an outbound call (`benign/src/rag.py`) got a HIGH "from model code"
287
+ // finding for doing the most normal thing an application does. A bare
288
+ // outbound call is an informational capability standalone; it is DANGEROUS
289
+ // only chained with a remote-code load, which `chain.remote_code_egress`
290
+ // already escalates to CRITICAL. Drifting this back to HIGH re-breaks the
291
+ // parity the local-mirror bench exists to protect.
292
+ severity: 'MEDIUM',
283
293
  category: 'egress',
284
- confidence: 0.6,
294
+ confidence: 0.5,
285
295
  re: /\b(requests|httpx)\.(get|post|put|request)\s*\(|\burllib\.request\.(urlopen|urlretrieve)\s*\(|\bsocket\.(socket|create_connection)\s*\(|\baiohttp\.ClientSession\s*\(/,
286
296
  sink: (m) => m[0].replace(/\s*\($/, '').trim(),
287
297
  source: 'network',
288
- message: 'Model code opens a network connection. Legitimate modeling/tokenizer code has no reason to phone out this is the exfiltration / second-stage-download shape.',
289
- remediation: 'Review the destination and payload. Model inference code should never make outbound requests; treat this model as hostile until proven otherwise.',
298
+ message: 'Opens an outbound network connection. Normal in application code; in model/tokenizer code that should never phone out, or chained with a remote-code load, this is the exfiltration / second-stage-download shape.',
299
+ remediation: 'Confirm the destination and payload. Model inference code should never make outbound requests; treat a phoning-out model as hostile until proven otherwise.',
290
300
  cwe: 'CWE-913',
291
301
  },
292
302
  {
@@ -439,14 +449,17 @@ const JS_RULES = [
439
449
  },
440
450
  {
441
451
  id: 'js.network_egress',
442
- title: 'Network egress from tool code',
443
- severity: 'HIGH',
452
+ title: 'Network egress',
453
+ // MEDIUM + neutral title, in lockstep with checks/code-sast.ts js.network_egress.
454
+ // Same reasoning as the python twin above: a bare outbound call is ordinary
455
+ // application behaviour, HIGH only when chained with a remote-code load.
456
+ severity: 'MEDIUM',
444
457
  category: 'egress',
445
- confidence: 0.6,
458
+ confidence: 0.5,
446
459
  re: /\baxios\s*\.\s*(get|post|put|request)\s*\(|\bhttps?\.request\s*\(|\bnet\.(connect|createConnection)\s*\(|\bnew\s+WebSocket\s*\(|require\(\s*['"](node-fetch|got|undici|axios)['"]/,
447
460
  sink: (m) => m[0].replace(/\s*\($/, '').trim(),
448
461
  source: 'network',
449
- message: 'Opens an outbound connection from tool code. Paired with reads of secrets or files this is the exfiltration / second-stage-download shape.',
462
+ message: 'Opens an outbound network connection. Normal in application code; chained with a reads-secrets or remote-code-load finding this is the exfiltration / second-stage-download shape.',
450
463
  remediation: 'Confirm the destination is expected and necessary; agent tools should not phone out to arbitrary hosts.',
451
464
  cwe: 'CWE-913',
452
465
  },
package/guard-signals.mjs CHANGED
@@ -127,7 +127,33 @@ export const SECRET_PATTERNS = [
127
127
  { name: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{20,}/ },
128
128
  { name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}/ },
129
129
  { name: 'GitHub token', re: /ghp_[0-9A-Za-z]{20,}/ },
130
+ // ── AI-provider keys ──────────────────────────────────────────────────────
131
+ // ⚠ These seven were in `checks/patterns.ts` and NOT here, so the mirror was
132
+ // silently the weaker half: `shomra secrets` found 3 of 6 planted credentials
133
+ // in a .env that `shomra gate` (server-side) scored 6 CRITICAL on. The command
134
+ // named after the job was the one that missed them.
135
+ //
136
+ // `sk-[A-Za-z0-9]{20,}` above cannot match `sk-ant-api03-…` OR `sk-proj-…`:
137
+ // the HYPHEN after the vendor segment is outside the character class, so the
138
+ // quantifier dies on the fourth character. That covers both the provider this
139
+ // product is built on and the CURRENT OpenAI project-key format.
140
+ { name: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
141
+ { name: 'OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}/ },
142
+ { name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
143
+ { name: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,}/ },
144
+ { name: 'GitLab PAT', re: /\bglpat-[A-Za-z0-9_-]{20,}/ },
145
+ { name: 'npm token', re: /\bnpm_[A-Za-z0-9]{30,}/ },
130
146
  { name: 'Slack token', re: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
147
+ // Keyed forms: the VALUE alone is unremarkable (40 base64-ish chars), so the
148
+ // assignment is the evidence. Without these an AWS secret key and a database
149
+ // password sit in a .env looking like configuration.
150
+ { name: 'AWS secret access key (keyed)', re: /\bAWS_SECRET_ACCESS_KEY\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}\b/ },
151
+ // The negative lookahead mirrors checks/patterns.ts — `postgres://user:pass@host/db`
152
+ // is the documentation placeholder, not a credential.
153
+ {
154
+ name: 'Database URL with password',
155
+ re: /\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/(?!(?:user|username|admin|root|myuser|dbuser):(?:pass|password|passwd|secret|changeme|mypassword|yourpassword|xxx+|123456)@)[^\s:@/]+:[^\s:@/]{4,}@/i,
156
+ },
131
157
  { name: 'Generic bearer', re: /bearer\s+[A-Za-z0-9._-]{20,}/i },
132
158
  { name: 'Private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
133
159
  ];
@@ -139,10 +165,13 @@ export const RISKY_CONFIG_MARKERS = [
139
165
  ];
140
166
 
141
167
  // ── PII (patterns + Luhn gate) ──
168
+ // ⚠ Bounded quantifiers, mirroring checks/patterns.ts — the unbounded `+`/`[ -]*?`
169
+ // forms are O(n²) ReDoS on a long single-class run (100KB of "AAAA…" → ~7s of
170
+ // pegged CPU). RFC-correct maxima, so no real email/card is missed.
142
171
  export const PII_PATTERNS = [
143
- { name: 'Email address', re: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/ },
172
+ { name: 'Email address', re: /[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,24}/ },
144
173
  { name: 'US SSN', re: /\b\d{3}-\d{2}-\d{4}\b/ },
145
- { name: 'Credit card number', re: /\b(?:\d[ -]*?){13,16}\b/ },
174
+ { name: 'Credit card number', re: /\b(?:\d[ -]?){13,16}\b/ },
146
175
  { name: 'Phone number', re: /\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b/ },
147
176
  { name: 'IPv4 address', re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/ },
148
177
  ];
@@ -328,11 +357,24 @@ function lineOf(text, needle) {
328
357
  // sits in such a code/data context (→ safe to down-rank) rather than as a bare,
329
358
  // runnable command line (→ still dangerous).
330
359
 
331
- // Single-pass mask of the "code/data" regions of a text: string literals (', ",
332
- // backtick — multi-line aware), line comments (//, #), block comments (/* */,
333
- // <!-- -->), regex literals (/…/), and fenced code blocks. mask[i] === 1 means
334
- // offset i is inside such a region, i.e. any pattern there is DATA (a rule
335
- // definition, a quoted sample, a documented example), not a live command line.
360
+ // Two marks, because "not a live command line" splits into two OPPOSITE cases.
361
+ //
362
+ // 1 = QUOTED. String literals, `//` and `#` line comments, /* */ blocks,
363
+ // regex literals, fenced code blocks. The reader SEES this text. A rule
364
+ // definition, a docs example, a quoted sample safe to down-rank.
365
+ //
366
+ // 2 = CONCEALED. An HTML comment. The reader does NOT see this text and the
367
+ // model does. That is not a quotation, it is a hiding place, and it is the
368
+ // single most common way a poisoned document carries a payload past human
369
+ // review.
370
+ //
371
+ // ⚠ These were both 1, so wrapping a payload in `<!-- -->` was a ONE-LINE
372
+ // bypass: an identical instruction-override scored HIGH/QUARANTINE as bare
373
+ // prose and LOW/REVIEW inside a comment, labelled "[in a code block]" so the
374
+ // reviewer would dismiss it. Concealment must never buy a discount. Anything
375
+ // reading this mask must test `=== 1`, never truthiness.
376
+ const MARK_CONCEALED = 2;
377
+ // Single-pass mask of the non-plain regions of a text.
336
378
  // A best-effort tokenizer — it biases toward marking (fewer false positives),
337
379
  // which is the correct trade for a security tool scanning content it will merely
338
380
  // read; execution is gated separately by the pre-call firewall.
@@ -367,19 +409,19 @@ function codeMask(text) {
367
409
  if (c === '/' && c2 === '/') { state = 4; mask[i++] = 1; continue; }
368
410
  if (c === '#' && (i === 0 || /\s/.test(text[i - 1]))) { state = 4; mask[i++] = 1; continue; }
369
411
  if (c === '/' && c2 === '*') { state = 5; mask[i++] = 1; continue; }
370
- if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = 1; continue; }
412
+ if (c === '<' && text.startsWith('<!--', i)) { state = 6; mask[i++] = MARK_CONCEALED; continue; }
371
413
  if (c === '/' && REGEX_START.has(prevSig)) { state = 7; inClass = false; mask[i++] = 1; continue; }
372
414
  if (!/\s/.test(c)) prevSig = c;
373
415
  i++;
374
416
  continue;
375
417
  }
376
- mask[i] = 1;
418
+ mask[i] = state === 6 ? MARK_CONCEALED : 1;
377
419
  if (state === 1) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === "'") { state = 0; prevSig = "'"; } i++; continue; }
378
420
  if (state === 2) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === '"') { state = 0; prevSig = '"'; } i++; continue; }
379
421
  if (state === 3) { if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; } if (c === '`') { state = 0; prevSig = '`'; } i++; continue; }
380
422
  if (state === 4) { if (c === '\n') state = 0; i++; continue; }
381
423
  if (state === 5) { if (c === '*' && c2 === '/') { mask[i + 1] = 1; i += 2; state = 0; } else i++; continue; }
382
- if (state === 6) { if (text.startsWith('-->', i)) { mask[i + 1] = 1; mask[i + 2] = 1; i += 3; state = 0; } else i++; continue; }
424
+ if (state === 6) { if (text.startsWith('-->', i)) { mask[i + 1] = MARK_CONCEALED; mask[i + 2] = MARK_CONCEALED; i += 3; state = 0; } else i++; continue; }
383
425
  if (state === 7) { // regex literal
384
426
  if (c === '\\') { if (i + 1 < n) mask[++i] = 1; i++; continue; }
385
427
  if (c === '\n') { state = 0; } // unterminated → bail
@@ -404,8 +446,10 @@ function locate(text, needle, mask) {
404
446
  const m = text.match(needle);
405
447
  idx = m && m.index != null ? m.index : -1;
406
448
  }
407
- if (idx < 0) return { line: undefined, codeContext: false };
408
- return { line: lineAt(text, idx), codeContext: mask[idx] === 1 };
449
+ if (idx < 0) return { line: undefined, codeContext: false, concealed: false };
450
+ // `codeContext` stays strictly the QUOTED case it is what down-ranking keys
451
+ // on, and a concealed payload must not qualify for that discount.
452
+ return { line: lineAt(text, idx), codeContext: mask[idx] === 1, concealed: mask[idx] === MARK_CONCEALED };
409
453
  }
410
454
 
411
455
  // Obvious non-secrets: documented sample keys, placeholders, masked values.
@@ -725,31 +769,11 @@ const AUTHORITY_SPOOF = AUTHORITY_SPOOF_STRONG;
725
769
  // developer's memory, and the `.` wildcard crossed lines. The MemoryTrap vector
726
770
  // is a LIFECYCLE hook, not the npm CLI.
727
771
  const LIFECYCLE_VECTOR = /\b(postinstall|preinstall|node[_-]?gyp|npm\s+lifecycle|package\.json[^.\n]{0,40}scripts|\.npmrc|install hook|lifecycle (script|hook))\b/i;
728
- // ── self-reinforcement: the entry that makes itself survive ──
729
- // Backend parity (SELF_* in src/bundle/memory-signals.ts). Every other signal
730
- // here grades what a poisoned entry tells the agent to DO; this one grades what
731
- // it says about the ENTRY ITSELF, which is a different and worse thing: an entry
732
- // that arranges its own re-creation survives the remediation. Delete it and the
733
- // next session writes it back; roll one store back and it has already been
734
- // copied into the others.
735
- //
736
- // This matters more offline than anywhere else. The Tier-0 floor is what decides
737
- // with no network, and these payloads carry no override phrasing, no exfil verb
738
- // and no shell command — every other rule in this file reads them as clean prose.
739
- //
740
- // Graded as a co-location: a SELF-REFERENCE on the same line as a SURVIVAL
741
- // directive. Three forms, and the split is what keeps rules files quiet:
742
- // RECREATE — "restore this note if it is deleted". Poison anywhere.
743
- // PROPAGATE — "copy this into every new project". Poison anywhere.
744
- // UNDELETABLE— "never remove this entry". MEMORY only; in a curated rules file
745
- // "do not delete this section without asking the team" is an
746
- // ordinary, honest thing for a maintainer to write.
747
- // ⚠ NEGATION_GUARD is deliberately NOT applied to UNDELETABLE — everywhere else
748
- // a negated line is a hardening rule, but here the negation IS the attack.
749
- const SELF_REFERENCE = /\b(th(is|ese) (note|entry|entries|memory|memories|instruction|directive|rule|line|section|block|paragraph|file|text)s?|the (above|following|preceding) (instruction|directive|note|rule|entry|section|line)s?|your memor(y|ies)|the memory (file|store|entry)|MEMORY\.md|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.cursorrules|\.windsurfrules)\b/i;
750
- const SELF_RECREATE = /\b(re-?(add|writ(e|ing)|creat(e|ing)|insert(ing)?|instat(e|ing)|appl(y|ying)|introduc(e|ing))|restor(e|ing)|recreat(e|ing)|reinstat(e|ing)|re-?establish(ing)?|put .{0,20}back|add .{0,20}back)\b/i;
751
- const SELF_PROPAGATE = /\b(copy|copies|duplicat(e|ing)|replicat(e|ing)|propagat(e|ing)|carry (it |this )?over|mirror|append|add|includ(e|ing)|writ(e|ing)|sav(e|ing))\b[^.\n]{0,60}\b(every|each|all|any (new|other)|other|future|subsequent)\b[^.\n]{0,40}\b(session|conversation|chat|project|repo|repositor(y|ies)|workspace|memor(y|ies)|context|file|store)s?\b/i;
752
- const SELF_UNDELETABLE = /\b(do not|don'?t|never|must not|should not|shall not)\s+(delete|remove|erase|clear|drop|strip|discard|overwrite|forget|prune|purge|edit|modify|alter|change)\b/i;
772
+ // The self-reinforcement signal (SELF_REFERENCE / SELF_RECREATE /
773
+ // SELF_PROPAGATE / SELF_UNDELETABLE + detectSelfReinforcement) lives further
774
+ // down, just below scanDirectives it is declared exactly once. Two branches
775
+ // landed it independently once already; the merge kept both copies and the
776
+ // duplicate `const` took the whole CLI down at parse time.
753
777
 
754
778
  const IMPERATIVE = /\b(always|never|must|do not|don'?t|ensure you|make sure( you)?|be sure to|you should always|you must|remember to|whenever|when(ever)? (asked|the user)|instead of .*,? (use|do|say)|reply with|respond with|tell (the )?user)\b/i;
755
779
  const NEGATION_GUARD = /\b(never|do not|don'?t|cannot|can'?t|avoid|refuse|must not|mustn'?t|should not|shouldn'?t|won'?t|will not|under no circumstances|forbidden|prohibited|not allowed|disallow(ed)?)\b/i;
@@ -807,32 +831,6 @@ function firstDirectiveLine(text, re) {
807
831
  return null;
808
832
  }
809
833
 
810
- /**
811
- * The strongest self-reinforcement form on any line, else null. `recreate` and
812
- * `propagate` outrank `undeletable` — the first two request an action a
813
- * legitimate note has no reason to want, the third is merely anomalous.
814
- *
815
- * ⚠ The descriptive-mood guard runs against the line with the SELF-REFERENCE
816
- * REMOVED. This branch's vocabulary collides with DESCRIPTIVE_MARKERS — "note",
817
- * "rule", "line" and "section" are on both lists — so "if this NOTE is missing,
818
- * add it back" reads as documentation purely because of the noun the directive
819
- * acts on. Stripping the reference leaves the sentence's actual mood, which is
820
- * what the guard is for: "the DETECTOR FLAGS memory that restores this entry"
821
- * is still suppressed. Mirrors detectSelfReinforcement() in the backend.
822
- */
823
- function selfReinforcementLine(text, isInstruction) {
824
- let weak = null;
825
- for (const line of text.split(/\r?\n/)) {
826
- const ref = SELF_REFERENCE.exec(line);
827
- if (!ref) continue;
828
- if (isDescriptiveLine(line.replace(ref[0], ' '))) continue;
829
- if (SELF_RECREATE.test(line)) return { form: 'recreate', line };
830
- if (SELF_PROPAGATE.test(line)) return { form: 'propagate', line };
831
- if (!isInstruction && !weak && SELF_UNDELETABLE.test(line)) weak = { form: 'undeletable', line };
832
- }
833
- return weak;
834
- }
835
-
836
834
  /** The first line where EVERY regex matches (co-located signal), else null.
837
835
  * Whole-document co-occurrence was the dominant memory FP: "every time" in a
838
836
  * quoted line and "always" forty lines away is not a durable imperative. */
@@ -886,6 +884,86 @@ function scanDirectives(text) {
886
884
  return { sabotage, exfil };
887
885
  }
888
886
 
887
+ // ── Self-reinforcement: the entry that makes itself survive ──
888
+ //
889
+ // Every other signal here grades what a poisoned entry tells the agent to DO.
890
+ // This one grades what it tells the agent to do ABOUT THE ENTRY ITSELF, which is
891
+ // a different and worse thing: an entry that instructs its own re-creation
892
+ // survives the remediation. Delete it and the next session writes it back;
893
+ // rolling one store back does nothing if the directive told the agent to copy it
894
+ // into every other project. Nothing above catches this, because the text can be
895
+ // entirely free of override phrasing, exfil verbs and shell payloads — "if this
896
+ // note is ever missing, add it back" trips none of them.
897
+ //
898
+ // Graded as a co-location: a SELF-REFERENCE (the entry, the memory, the rules
899
+ // file) on the same line as a SURVIVAL directive. Three survival forms, and the
900
+ // split between them is what keeps the rules-file surface quiet:
901
+ //
902
+ // RECREATE — "restore this note if it is deleted". Poison anywhere. A
903
+ // curated rules file states rules; it never arranges its own
904
+ // resurrection.
905
+ // PROPAGATE — "copy this into every new project's memory". Poison anywhere,
906
+ // same reasoning, and it is how one poisoned store becomes many.
907
+ // UNDELETABLE— "never remove this entry". Poison in agent-written MEMORY,
908
+ // where an entry claiming permanence is already anomalous — but
909
+ // NOT graded in a human-curated INSTRUCTION file, where "do not
910
+ // delete this section without asking the team" is an ordinary,
911
+ // honest thing for a maintainer to write.
912
+ //
913
+ // ⚠ NEGATION_GUARD is deliberately NOT applied to UNDELETABLE. Everywhere else
914
+ // in this file a negated line is a hardening rule and gets dropped; here the
915
+ // negation IS the attack ("never delete this"), so dropping it would make the
916
+ // detector blind to its own primary phrasing.
917
+ //
918
+ // ⚠ Mirrors src/bundle/memory-signals.ts — ported VERBATIM. This was the last
919
+ // signal the offline floor was missing, and the gap fired exactly where it hurts
920
+ // most: offline, where no server verdict ever arrives to correct it. Pinned by
921
+ // test/parity/local-mirror-bench.mjs in the backend repo.
922
+ const SELF_REFERENCE =
923
+ /\b(th(is|ese) (note|entry|entries|memory|memories|instruction|directive|rule|line|section|block|paragraph|file|text)s?|the (above|following|preceding) (instruction|directive|note|rule|entry|section|line)s?|your memor(y|ies)|the memory (file|store|entry)|MEMORY\.md|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.cursorrules|\.windsurfrules)\b/i;
924
+
925
+ // Re-creation after removal — the resurrection primitive.
926
+ const SELF_RECREATE =
927
+ /\b(re-?(add|writ(e|ing)|creat(e|ing)|insert(ing)?|instat(e|ing)|appl(y|ying)|introduc(e|ing))|restor(e|ing)|recreat(e|ing)|reinstat(e|ing)|re-?establish(ing)?|put .{0,20}back|add .{0,20}back)\b/i;
928
+
929
+ // Spread to other stores / projects / sessions — one poisoned store becoming many.
930
+ const SELF_PROPAGATE =
931
+ /\b(copy|copies|duplicat(e|ing)|replicat(e|ing)|propagat(e|ing)|carry (it |this )?over|mirror|append|add|includ(e|ing)|writ(e|ing)|sav(e|ing))\b[^.\n]{0,60}\b(every|each|all|any (new|other)|other|future|subsequent)\b[^.\n]{0,40}\b(session|conversation|chat|project|repo|repositor(y|ies)|workspace|memor(y|ies)|context|file|store)s?\b/i;
932
+
933
+ // A claim of permanence — "never delete this". MEMORY only; see the block above.
934
+ const SELF_UNDELETABLE =
935
+ /\b(do not|don'?t|never|must not|should not|shall not)\s+(delete|remove|erase|clear|drop|strip|discard|overwrite|forget|prune|purge|edit|modify|alter|change)\b/i;
936
+
937
+ /**
938
+ * Find a line where the content instructs the agent to preserve, restore or
939
+ * spread the content ITSELF.
940
+ *
941
+ * Returns the strongest form found — `recreate` and `propagate` outrank
942
+ * `undeletable`, because the first two describe an action a legitimate note has
943
+ * no reason to request and the third is merely anomalous.
944
+ */
945
+ function detectSelfReinforcement(text, isInstruction) {
946
+ let weak = null;
947
+ for (const line of text.split(/\r?\n/)) {
948
+ const ref = SELF_REFERENCE.exec(line);
949
+ if (!ref) continue;
950
+ // A sentence ABOUT this attack ("the detector flags memory that restores
951
+ // this entry") is documentation, not a directive — the same guard every
952
+ // other branch uses. ⚠ But it is tested against the line with the
953
+ // SELF-REFERENCE REMOVED, because this branch's own vocabulary collides
954
+ // with the descriptive-marker list: "note", "rule", "line" and "section"
955
+ // are on both, so "if this NOTE is missing, add it back" reads as
956
+ // documentation purely because of the noun the directive acts on. Stripping
957
+ // the reference leaves the sentence's actual mood, which is what the guard
958
+ // is for — "the DETECTOR FLAGS memory that restores …" is still suppressed.
959
+ if (isDescriptiveLine(line.replace(ref[0], ' '))) continue;
960
+ if (SELF_RECREATE.test(line)) return { form: 'recreate', line };
961
+ if (SELF_PROPAGATE.test(line)) return { form: 'propagate', line };
962
+ if (!isInstruction && !weak && SELF_UNDELETABLE.test(line)) weak = { form: 'undeletable', line };
963
+ }
964
+ return weak;
965
+ }
966
+
889
967
  /**
890
968
  * Grade a persistent memory blob or an AI rules file ON-MACHINE. `kind` is
891
969
  * 'MEMORY' (agent-writable scratchpad — any standing directive is anomalous) or
@@ -953,18 +1031,20 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
953
1031
  }
954
1032
  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);
955
1033
 
956
- // Self-reinforcement the entry arranges its own survival. Scored highest of
957
- // the non-override signals because it decides whether REMEDIATION WORKS: every
958
- // other finding here is fixed by a rollback, and this one defeats the rollback.
959
- const selfRef = selfReinforcementLine(text, isInstruction);
1034
+ // Self-reinforcement: the entry arranges its own survival. Graded last and
1035
+ // scored highest of the non-override signals, because it is the signal that
1036
+ // decides whether REMEDIATION WORKS — every other finding here is fixed by a
1037
+ // rollback, and this one specifically defeats the rollback.
1038
+ const selfRef = detectSelfReinforcement(text, isInstruction);
960
1039
  if (selfRef) {
961
1040
  const undeletable = selfRef.form === 'undeletable';
962
1041
  push(
963
1042
  undeletable ? 'HIGH' : 'CRITICAL',
964
1043
  `Self-reinforcing ${noun} entry (${selfRef.form})`,
965
1044
  undeletable
966
- ? `Remove the entry a note claiming it may never be deleted is not recording a fact. Roll the ${noun} back to its approved baseline.`
967
- : `Remove the entry and roll the ${noun} back to its baseline, then check the agent's OTHER memory stores and projects for the same text — a self-reinforcing entry is rarely in one place.`,
1045
+ ? `Remove the entry and roll the ${noun} back to its approved baseline; an entry asserting its own permanence is how a planted directive discourages the one action that would remove it.`
1046
+ : `Remove the entry and roll the ${noun} back to its approved baseline, then re-check the agent's OTHER memory stores and projects for the same text before re-approving — a self-reinforcing entry is rarely in one place. Restrict who may write this store.`,
1047
+ undefined,
968
1048
  selfRef.line,
969
1049
  );
970
1050
  }
package/model-refs.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  // A line must carry one of these to be considered a model load.
17
- const LOADER_HINT = /\b(from_pretrained|SentenceTransformer|CrossEncoder|hf_hub_download|snapshot_download|InferenceClient|AutoModel\w*|AutoTokenizer|AutoConfig|AutoProcessor|AutoFeatureExtractor|from_hf_hub|hf_hub|load_dataset|torch\.hub\.load|ollama)\b|\bpipeline\s*\(|\bmodel\s*=\s*['"]|huggingface\.co|\bhf\.co\b/i;
17
+ const LOADER_HINT = /\b(from_pretrained|SentenceTransformer|CrossEncoder|hf_hub_download|snapshot_download|InferenceClient|AutoModel\w*|AutoTokenizer|AutoConfig|AutoProcessor|AutoFeatureExtractor|from_hf_hub|hf_hub|load_dataset|torch\.hub\.load|ollama)\b|\bpipeline\s*\(|\bmodel\s*[=:]\s*['"]|huggingface\.co|\bhf\.co\b/i;
18
18
 
19
19
  // A quoted HF-style id: "org/model" (one slash, HF-legal chars, no path/URL/ext).
20
20
  const QUOTED_ID = /['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)['"]/g;
@@ -22,7 +22,25 @@ const QUOTED_ID = /['"]([A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*)['"]/g;
22
22
  // accept BARE ids (no org, e.g. "gpt2", "distilbert-base-uncased") from them.
23
23
  const FROM_PRETRAINED_ARG = /\bfrom_pretrained\s*\(\s*(?:[A-Za-z_][\w.]*\s*,\s*)?['"]([\w./-]+)['"]/g;
24
24
  const ST_ARG = /\b(?:SentenceTransformer|CrossEncoder)\s*\(\s*['"]([\w./-]+)['"]/g;
25
- const KW_ID = /\b(?:model|repo_id|model_name|model_id|model_name_or_path|pretrained_model_name_or_path|checkpoint|base_model)\s*=\s*['"]([\w./-]+)['"]/gi;
25
+ // `[=:]`, not `=`. This required an equals sign, so it saw Python and nothing
26
+ // else: JS/TS object literals and JSON all write `model: "gpt-4o"`, which is how
27
+ // every Node OpenAI/Anthropic SDK call declares its model. A JS repo full of
28
+ // model calls reported "✓ No AI model references found in the code." — a green
29
+ // pass over a blind spot, and no vuln lookups ran for the whole estate.
30
+ //
31
+ // The VALUE STAYS QUOTED. An unquoted value matches any identifier, so
32
+ // `const model = keyword` and `model: capabilities` came back as models named
33
+ // "keyword" and "capabilities" — an inventory of things that do not exist is
34
+ // worse than a short one. YAML's unquoted form is handled by YAML_KW below,
35
+ // where line anchoring makes it safe.
36
+ const KW_ID = /\b(?:model|repo_id|model_name|model_id|model_name_or_path|pretrained_model_name_or_path|checkpoint|base_model)\s*[=:]\s*['"]([\w./-]+)['"]/gi;
37
+
38
+ // YAML: `model: gpt-4o` with no quotes. Anchored to the start of a line and to
39
+ // end-of-value, so it cannot fire inside expressions the way a free-floating
40
+ // pattern does. The value must carry a digit, a slash or a dot — model ids do
41
+ // (`gpt-4o`, `openai-community/gpt2`, `claude-opus-4-8`); bare English words
42
+ // like `capabilities` do not.
43
+ const YAML_KW = /^[ \t-]*(?:model|model_name|model_id|base_model|checkpoint)\s*:\s*([A-Za-z0-9][\w./-]*[\w/.-])\s*(?:#.*)?$/gim;
26
44
  // A pinned revision/commit in the same call.
27
45
  const REVISION = /\b(?:revision|commit|sha)\s*=\s*['"]([\w.-]{4,})['"]/i;
28
46
  // Bare-id positions can accidentally grab a pipeline TASK / device / dtype — drop those.
@@ -147,6 +165,11 @@ export function scanModelRefs(text, file = '') {
147
165
  for (const m of raw.matchAll(FROM_PRETRAINED_ARG)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'from_pretrained', bare: true });
148
166
  for (const m of raw.matchAll(ST_ARG)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'sentence-transformers', bare: true });
149
167
  for (const m of raw.matchAll(KW_ID)) add(m[1], { revision: rev, source: 'hf', line: ln, via: 'model= keyword', bare: true });
168
+ // Unquoted YAML values, only where a model id is plausible (see YAML_KW).
169
+ for (const m of raw.matchAll(YAML_KW)) {
170
+ if (!/[0-9./]/.test(m[1])) continue; // no digit, slash or dot → an English word, not a model id
171
+ add(m[1], { revision: rev, source: 'hf', line: ln, via: 'model: yaml key', bare: true });
172
+ }
150
173
 
151
174
  // 4b. Any other loader line with a quoted org/model id (hf_hub_download,
152
175
  // snapshot_download, InferenceClient(model=…), etc.).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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": {