@shomra/agent 0.3.3 → 0.3.5

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/discovery.mjs CHANGED
@@ -21,6 +21,7 @@ import path from 'node:path';
21
21
  import os from 'node:os';
22
22
  import { execFileSync } from 'node:child_process';
23
23
  import { scanAiUsage, rollupAiUsage, isAiUsageScannable, AI_USAGE_CATEGORY_LABEL } from './ai-usage.mjs';
24
+ import { readVendorPosture, canonicalGrant } from './agent-posture.mjs';
24
25
 
25
26
  const HOME = os.homedir();
26
27
  const APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
@@ -860,12 +861,45 @@ export function discoverCodingAgents(roots = [process.cwd()]) {
860
861
  const t = readText(f, 20_000);
861
862
  return t != null && /shomra/i.test(t);
862
863
  });
864
+ // PERMISSION POSTURE — what this agent may do without asking. The user-scope
865
+ // settings read here govern every project on the machine and live in no
866
+ // repository, so this is the one surface a repo scan structurally cannot
867
+ // reach. `content` carries ONLY the canonical grant document (see
868
+ // agent-posture.mjs); the settings files themselves never leave the machine.
869
+ const posture = readVendorPosture(a.vendor, cwd);
870
+ const grant = canonicalGrant(posture);
863
871
  assets.push({
864
872
  type: 'AI_AGENT',
865
873
  name: a.name,
866
874
  identifier: `agent:${a.vendor}`,
867
875
  vendor: a.vendor,
868
- metadata: { detectedAt: installedAt, guarded: !!guardFile, guardFile: guardFile || null },
876
+ ...(grant ? { content: grant } : {}),
877
+ metadata: {
878
+ detectedAt: installedAt,
879
+ guarded: !!guardFile,
880
+ guardFile: guardFile || null,
881
+ posture: posture
882
+ ? {
883
+ tier: posture.tier,
884
+ readable: posture.readable,
885
+ claim: posture.claim,
886
+ mode: posture.mode,
887
+ allowCount: posture.allow.length,
888
+ denyCount: posture.deny.length,
889
+ askCount: posture.ask.length,
890
+ allow: posture.allow.slice(0, 25),
891
+ enableAllProjectMcpServers: posture.enableAllProjectMcpServers,
892
+ switches: posture.switches,
893
+ mcpServerCount: posture.mcpServers.length,
894
+ autoApprovedMcp: posture.autoApprovedMcp,
895
+ unreadableCount: posture.unreadableCount,
896
+ // Paths only — which files were consulted and whether each parsed.
897
+ // Needed so an operator can tell "configured safely" from "we could
898
+ // not open the file that decides it".
899
+ sources: posture.sources.map((s) => ({ path: s.path, scope: s.scope, state: s.state, reason: s.reason })),
900
+ }
901
+ : null,
902
+ },
869
903
  });
870
904
  }
871
905
  return assets;
package/guard-signals.mjs CHANGED
@@ -725,6 +725,32 @@ const AUTHORITY_SPOOF = AUTHORITY_SPOOF_STRONG;
725
725
  // developer's memory, and the `.` wildcard crossed lines. The MemoryTrap vector
726
726
  // is a LIFECYCLE hook, not the npm CLI.
727
727
  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;
753
+
728
754
  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;
729
755
  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;
730
756
  const SABOTAGE_RULES = [
@@ -781,6 +807,32 @@ function firstDirectiveLine(text, re) {
781
807
  return null;
782
808
  }
783
809
 
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
+
784
836
  /** The first line where EVERY regex matches (co-located signal), else null.
785
837
  * Whole-document co-occurrence was the dominant memory FP: "every time" in a
786
838
  * quoted line and "always" forty lines away is not a durable imperative. */
@@ -901,6 +953,22 @@ export function localMemory(content, { kind = 'MEMORY' } = {}) {
901
953
  }
902
954
  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);
903
955
 
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);
960
+ if (selfRef) {
961
+ const undeletable = selfRef.form === 'undeletable';
962
+ push(
963
+ undeletable ? 'HIGH' : 'CRITICAL',
964
+ `Self-reinforcing ${noun} entry (${selfRef.form})`,
965
+ 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.`,
968
+ selfRef.line,
969
+ );
970
+ }
971
+
904
972
  // Fold in shared injection / secret / PII (deduped against the directive
905
973
  // findings above so injection isn't double-counted).
906
974
  const seenInjection = hasOverride || hasAuthority || (!isInstruction && hasPersistence && hasImperative);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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": {