@shomra/agent 0.2.11 → 0.3.1

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.
Files changed (4) hide show
  1. package/README.md +54 -4
  2. package/code-sast.mjs +150 -0
  3. package/package.json +2 -2
  4. package/shomra.mjs +394 -166
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # @shomra/agent
2
2
 
3
- **The firewall for AI agents**, as a local-first CLI. It sits inside your coding
4
- agent and CI and blocks dangerous tool-calls, shell commands and data
5
- exfiltration *before they run* — on your machine, even offline. It also vets AI
3
+ **Adversarial assurance for AI agents**, as a local-first CLI. It sits inside
4
+ your coding agent and CI and blocks dangerous tool-calls, shell commands and
5
+ data exfiltration *before they run* — on your machine, even offline. Enrolled,
6
+ it also attacks your org’s own guardrails (`shomra admin redteam`) and turns
7
+ each breach into a false-positive-gated control (`shomra harden`). It also vets AI
6
8
  artifacts (MCP configs, Skills, slash commands, hooks, rules files) before they
7
9
  install. Start with a free on-machine scan — no signup.
8
10
 
@@ -74,7 +76,14 @@ install-lure prose) runs on-machine, so you get a genuine verdict even if the
74
76
  backend is unreachable. When enrolled + reachable, your **org policy** is layered
75
77
  on top.
76
78
 
77
- **Exit codes:** `0` = allowed · `1` = blocked (or `--strict` + backend outage) · `2` = flagged with `--strict`.
79
+ **Exit codes** (one convention across every command):
80
+
81
+ | Code | Meaning |
82
+ |------|---------|
83
+ | `0` | clean / pass |
84
+ | `1` | hard fail — BLOCK, vulnerable model, secret found, FAIL verdict, below `--min`, regression (also `--strict` + backend outage) |
85
+ | `2` | soft fail — FLAG under `--strict` (REVIEW when strict) |
86
+ | `3` | usage / config error — not configured, bad flags, unknown command |
78
87
 
79
88
  **Backend outage:** by default it falls back to the on-machine verdict (org
80
89
  policy not applied). `--strict` fails closed (exit 1) because org policy can't be
@@ -153,6 +162,9 @@ runs local-first; with a key, your **org policy** (below) drives the verdict.
153
162
  with: { sarif_file: shomra.sarif }
154
163
  ```
155
164
 
165
+ `shomra pr` accepts it too: bare `--sarif` writes SARIF to stdout,
166
+ `--sarif=shomra.sarif` writes a file alongside the normal check-run output.
167
+
156
168
  ### Org policy + triage on top of CI
157
169
 
158
170
  When enrolled, the same **org policy** that governs the dashboard decides the CI
@@ -206,6 +218,36 @@ backend, behind a short timeout + circuit breaker — so a slow or down backend
206
218
  never freezes the agent. Fail-open by default; `SHOMRA_GUARD_STRICT=1` fails
207
219
  closed on the server tier.
208
220
 
221
+ ## Adopting Shomra on an existing repo
222
+
223
+ A brand-new gate on a repo with history will flag things. Three layers make
224
+ adoption friction-free — all of them re-grade the artifact, so a fully
225
+ suppressed file drops to ALLOW and never fails the build:
226
+
227
+ - **`shomra baseline`** records every current finding (line-independent
228
+ fingerprints) in `.shomra/baseline.json` — commit it so the whole team shares
229
+ it. From then on only findings introduced *after* the baseline fail; re-run it
230
+ to refresh after cleanups. Skip it per-run with `--no-baseline`.
231
+ - **`.shomraignore`** — a repo file of `path/glob` lines (skip the file) or
232
+ `path/glob :: title-substring` lines (skip one finding class in those files).
233
+ The runtime firewall honors it too, so test fixtures and detection source
234
+ aren't withheld. Silence a single finding inline with `// shomra-ignore` (or
235
+ `# shomra-ignore`) on the finding's line or the line above, or opt a whole
236
+ file out with `shomra-ignore-file` in its first lines (works in JSON as a
237
+ `"_shomra": "shomra-ignore-file"` key). `--no-suppress` ignores all of this.
238
+ - **`.shomra/policy.yml`** — policy-as-code, reviewed in PRs like any code:
239
+
240
+ ```yaml
241
+ block: high # min severity that BLOCKS (critical|high|medium|low|none)
242
+ flag: medium # min severity that FLAGS
243
+ allow: # finding-title substrings to always downgrade away
244
+ - "IPv4 address"
245
+ ```
246
+
247
+ For a local verdict the repo policy fully re-grades; when the backend
248
+ returned an org decision it can only make it *stricter* (worst-wins) — repo
249
+ config never loosens org enforcement. `--no-policy` skips it.
250
+
209
251
  ## Environment variables
210
252
 
211
253
  | Var | Purpose |
@@ -214,9 +256,17 @@ closed on the server tier.
214
256
  | `SHOMRA_URL` | Backend URL (overrides config) |
215
257
  | `SHOMRA_API_TIMEOUT_MS` | Per-request backend timeout (default 30000) |
216
258
  | `SHOMRA_AGENT` | Agent-identity handle presented to `llm-proxy` + firewall |
259
+ | `SHOMRA_GATE_CONCURRENCY` | Parallel backend calls in batch gate / model lookups (default 8, 1–32) |
260
+ | `SHOMRA_GH_TOKEN` | GitHub token for `shomra pr` (falls back to `GITHUB_TOKEN`) |
217
261
  | `SHOMRA_GUARD_STRICT` | `1` = firewall fails closed on the server tier |
218
262
  | `SHOMRA_GUARD_LOCAL` | `0` = disable the on-machine Tier-0 guard |
263
+ | `SHOMRA_GUARD_IGNORE` | Comma-separated file globs the runtime guard treats as known-safe (adds to `.shomraignore`) |
264
+ | `SHOMRA_GUARD_ALWAYS_ESCALATE` | `1` = send every call to the server (full telemetry, higher overhead) |
219
265
  | `SHOMRA_GUARD_TIMEOUT_MS` | Firewall per-call server timeout (default 2000) |
220
266
  | `SHOMRA_GUARD_BREAKER_MS` | Skip the server this long after a failure (default 30000; `0` disables) |
267
+ | `SHOMRA_LLM_PROXY_BASE` | Proxy base URL `install-hook` writes for Aider (default `http://127.0.0.1:4141/openai/v1`) |
268
+ | `SHOMRA_MODEL_GUARD` | `0` = disable the model-load screen in the PreToolUse hook |
269
+ | `SHOMRA_MODEL_CACHE` | `0` = disable the on-machine model-index verdict cache |
270
+ | `SHOMRA_MODEL_CACHE_TTL_MS` | Model-cache freshness window (default 7 days) |
221
271
 
222
272
  Run `shomra help` for the full command reference.
package/code-sast.mjs CHANGED
@@ -424,6 +424,14 @@ const JS_RULES = [
424
424
  category: 'obfuscation',
425
425
  confidence: 0.6,
426
426
  re: /(?<![.\w])require\s*\(\s*[^'"\s)]|(?<![.\w])import\s*\(\s*[^'"\s)]/,
427
+ // …but a path BUILT from literals and __dirname is a literal spelled across
428
+ // path.join — it conceals nothing. `require(path.join(__dirname,'..','generated','prisma'))`
429
+ // (a Prisma client import) was the shape that made this rule noisy. The CLI has
430
+ // no AST tier, so without this veto that FP lands at HIGH — a blocking severity.
431
+ suppress: (m, unitText, ctx) => {
432
+ const arg = callArgText(unitText, m.index);
433
+ return isNotAModuleLoad(m, unitText, arg) || isStaticPathExpr(arg, ctx.pathNs, ctx.constPaths);
434
+ },
427
435
  sink: (m) => m[0].trim(),
428
436
  message: 'Loads a module chosen at runtime rather than a string literal, often to conceal which dangerous module is imported.',
429
437
  remediation: 'Import modules by string literal so the dependency is statically reviewable; remove runtime-computed requires.',
@@ -632,6 +640,141 @@ function escapeRe(s) {
632
640
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
633
641
  }
634
642
 
643
+ /**
644
+ * The argument text of the call whose `(` follows `from`, read with balanced
645
+ * brackets and string-aware so a `)` inside a literal doesn't end it early.
646
+ * Empty string when the call is unterminated inside the logical line.
647
+ */
648
+ function callArgText(text, from) {
649
+ const open = text.indexOf('(', from);
650
+ if (open < 0) return '';
651
+ let depth = 0;
652
+ let quote = '';
653
+ for (let i = open; i < text.length; i++) {
654
+ const ch = text[i];
655
+ if (quote) {
656
+ if (ch === '\\') i++;
657
+ else if (ch === quote) quote = '';
658
+ continue;
659
+ }
660
+ if (ch === '"' || ch === "'" || ch === '`') quote = ch;
661
+ else if (ch === '(') depth++;
662
+ else if (ch === ')') {
663
+ depth--;
664
+ if (depth === 0) return text.slice(open + 1, i);
665
+ }
666
+ }
667
+ return '';
668
+ }
669
+
670
+ /**
671
+ * True when the `require(`/`import(` the regex matched is not Node's module
672
+ * loader at all. Two shapes, both found in first-party code at HIGH severity:
673
+ *
674
+ * • A DECLARATION of something named `require` — `private async require(orgId, id)`
675
+ * is a repository helper, not a module load. The Python rules already carry the
676
+ * equivalent `(?<!def )` guard; the JS rule never got one.
677
+ * • A call with more than one top-level argument. `require()` takes exactly one.
678
+ * (NOT applied to `import()`, which legitimately takes import attributes as a
679
+ * second argument.)
680
+ */
681
+ const DECL_PREFIX_RE = /\b(?:function|async|get|set|static|private|public|protected|readonly)\s*\*?\s*$/;
682
+ function isNotAModuleLoad(m, unitText, argText) {
683
+ if (DECL_PREFIX_RE.test(unitText.slice(Math.max(0, m.index - 24), m.index))) return true;
684
+ if (/^\s*import\b/.test(m[0])) return false; // import attributes are a real 2nd arg
685
+ let depth = 0;
686
+ let quote = '';
687
+ for (let i = 0; i < argText.length; i++) {
688
+ const ch = argText[i];
689
+ if (quote) {
690
+ if (ch === '\\') i++;
691
+ else if (ch === quote) quote = '';
692
+ continue;
693
+ }
694
+ if (ch === '"' || ch === "'" || ch === '`') quote = ch;
695
+ else if ('([{'.includes(ch)) depth++;
696
+ else if (')]}'.includes(ch)) depth--;
697
+ else if (ch === ',' && depth === 0) return true;
698
+ }
699
+ return false;
700
+ }
701
+
702
+ /**
703
+ * Local names bound to the `path` module in this file. The builder call is
704
+ * `path.join` only by convention — `const p = require('node:path')` is just as
705
+ * common, and keying the constant-folder off the literal name "path" missed it.
706
+ */
707
+ const PATH_BIND_RE =
708
+ /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"](?:node:)?path(?:\/(?:posix|win32))?['"]\s*\)|import\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s*['"](?:node:)?path(?:\/(?:posix|win32))?['"]/g;
709
+
710
+ function pathBindings(text) {
711
+ const ns = new Set(['path']);
712
+ PATH_BIND_RE.lastIndex = 0;
713
+ for (let m = PATH_BIND_RE.exec(text); m; m = PATH_BIND_RE.exec(text)) ns.add(m[1] || m[2]);
714
+ return ns;
715
+ }
716
+
717
+ /**
718
+ * Identifiers whose declaration folds to a constant path. Requires the RHS to
719
+ * mention a path-shaped token — a bare `const m = 'child_process'` must stay
720
+ * unfolded so `require(m)` still reads as a hidden dangerous import. Two rounds,
721
+ * so `const ROOT = …; const SRC = `${ROOT}/src`` both land.
722
+ */
723
+ const CONST_DECL_RE = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n;]+)/g;
724
+ const PATHISH_RE = /__dirname|__filename|import\.meta\.url|process\.cwd|os\.(?:homedir|tmpdir)|fileURLToPath|new\s+URL|\.(?:join|resolve|normalize)\s*\(/;
725
+
726
+ function constPathBindings(text, pathNs) {
727
+ const found = new Set();
728
+ for (let round = 0; round < 2; round++) {
729
+ CONST_DECL_RE.lastIndex = 0;
730
+ for (let m = CONST_DECL_RE.exec(text); m; m = CONST_DECL_RE.exec(text)) {
731
+ const [, name, rhsRaw] = m;
732
+ if (found.has(name)) continue;
733
+ const rhs = rhsRaw.replace(/[,;]\s*$/, '').trim();
734
+ // Must be path-shaped, OR built on a constant this pass already proved —
735
+ // `const SRC = `${ROOT}src/`` inherits ROOT's provenance. Anything else
736
+ // (a plain string constant) stays unfolded on purpose.
737
+ const buildsOnKnown = [...found].some((n) => new RegExp(`\\b${escapeRe(n)}\\b`).test(rhs));
738
+ if (!PATHISH_RE.test(rhs) && !buildsOnKnown) continue;
739
+ if (isStaticPathExpr(rhs, pathNs, found)) found.add(name);
740
+ }
741
+ }
742
+ return found;
743
+ }
744
+
745
+ /** Pure path builders: constant arguments in ⇒ one constant path out. */
746
+ const PATH_FNS = 'join|resolve|normalize|relative|dirname|basename|extname';
747
+
748
+ /**
749
+ * True when `argText` provably evaluates to one fixed path: nothing survives
750
+ * after removing string literals and the build-time tokens (pure `path.*`
751
+ * builders, `__dirname`, `import.meta.url`, …). Conservative — any identifier it
752
+ * does not recognise (a parameter, a config value, a model result) leaves a
753
+ * residue and the hit stands. Mirrors `isStaticPathExpr` in the backend's
754
+ * code-sast.ts, and the structural `isStaticModulePath` in code-ast.ts.
755
+ */
756
+ export function isStaticPathExpr(argText, pathNs = new Set(['path']), constPaths = new Set()) {
757
+ if (!argText.trim()) return false;
758
+ const ns = [...pathNs].map(escapeRe).join('|');
759
+ const consts = constPaths.size ? `|${[...constPaths].map(escapeRe).join('|')}` : '';
760
+ const staticTokens = new RegExp(
761
+ `\\b(?:(?:${ns})(?:\\.(?:posix|win32))?\\.(?:${PATH_FNS})|__dirname|__filename|import\\.meta\\.url|process\\.cwd|os\\.(?:homedir|tmpdir)|fileURLToPath|require\\.resolve|new\\s+URL|String\\.raw${consts})\\b`,
762
+ 'g',
763
+ );
764
+ // A member read off whatever remains — `new URL(…).href`, `.toString()`. Applied
765
+ // as a token strip, so `cfg.modulePath` still leaves `cfg` behind and reports.
766
+ const pureMembers = /\.(?:href|pathname|toString|toLowerCase|toUpperCase|trim|normalize|valueOf)\b/g;
767
+ // A template literal reduces to its ${…} expressions — those must be constant
768
+ // too; its fixed text is just a literal. Plain literals collapse away entirely.
769
+ let t = argText
770
+ .replace(/`(?:[^`\\]|\\.)*`/g, (lit) => ` ${[...lit.matchAll(/\$\{([^{}]*)\}/g)].map((x) => x[1]).join(' , ')} `)
771
+ .replace(/'(?:[^'\\]|\\.)*'/g, ' ')
772
+ .replace(/"(?:[^"\\]|\\.)*"/g, ' ');
773
+ t = t.replace(pureMembers, ' ').replace(staticTokens, ' ');
774
+ // Structure-only residue (separators, concatenation, empty call parens) is fine.
775
+ return !/[A-Za-z0-9_$]/.test(t.replace(/[\s(),.+[\]/\\:-]/g, ''));
776
+ }
777
+
635
778
  /**
636
779
  * Whether byte `offset` in `text` falls inside a string literal — tracks single/
637
780
  * double/backtick + triple quotes, honouring escapes. Drops `codeOnly` rule
@@ -802,6 +945,9 @@ function scanLines(text, file, rules, taintCfg) {
802
945
  const units = logicalLines(lines);
803
946
  const out = [];
804
947
  const seen = new Set(); // dedupe by ruleId@line
948
+ // Whole-file facts a `suppress` predicate may consult; computed once per scan.
949
+ const pathNs = pathBindings(text);
950
+ const ctx = { pathNs, constPaths: constPathBindings(text, pathNs) };
805
951
  for (const unit of units) {
806
952
  for (const rule of rules) {
807
953
  rule.re.lastIndex = 0;
@@ -810,6 +956,10 @@ function scanLines(text, file, rules, taintCfg) {
810
956
  // Drop code-construct rules whose match lands inside a string literal
811
957
  // (docstring / log message / usage example) — the Falcon-class FP.
812
958
  if (rule.codeOnly && isInsideString(unit.text, m.index)) continue;
959
+ // Last-word veto on a match the regex accepted: the signal is real code but
960
+ // the ARGUMENT proves it benign — needs balanced-bracket reading a regex
961
+ // cannot express. Returning true drops the hit.
962
+ if (rule.suppress && rule.suppress(m, unit.text, ctx)) continue;
813
963
  const idx = physicalIdx(unit, m.index);
814
964
  const trimmed = (lines[idx] ?? '').trim();
815
965
  if (!trimmed || isCommentLine(trimmed)) continue;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shomra/agent",
3
- "version": "0.2.11",
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.",
3
+ "version": "0.3.1",
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": {
7
7
  "shomra": "./shomra.mjs"