@shomra/agent 0.2.10 → 0.2.12
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 +150 -0
- package/package.json +1 -1
- package/shomra.mjs +104 -0
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shomra/agent",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
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
|
@@ -1656,6 +1656,108 @@ function printWhy(res) {
|
|
|
1656
1656
|
console.log('');
|
|
1657
1657
|
}
|
|
1658
1658
|
|
|
1659
|
+
// ── shomra provenance: which of these changed files did an AI agent write? ──
|
|
1660
|
+
//
|
|
1661
|
+
// shomra provenance [--staged | --base main] [--trailer] [--fail-on-blocked] [--json]
|
|
1662
|
+
//
|
|
1663
|
+
// Every mutating tool call the runtime firewall screened was recorded with its
|
|
1664
|
+
// target path and ALLOW/FLAG/BLOCK decision. This joins a real git diff against
|
|
1665
|
+
// that record, so a commit can carry an EVIDENCE-BACKED statement of authorship
|
|
1666
|
+
// instead of a "Co-Authored-By" line anyone can type.
|
|
1667
|
+
//
|
|
1668
|
+
// ⚠ "Unattributed" means the firewall has no record — NOT that a human wrote it.
|
|
1669
|
+
// With the hook uninstalled every file is unattributed, so the output always
|
|
1670
|
+
// states its coverage and never claims human authorship. Don't rewrite that copy.
|
|
1671
|
+
|
|
1672
|
+
/** All changed paths (not just AI artifacts) for provenance. */
|
|
1673
|
+
function gitChangedPaths(root, { staged, base }) {
|
|
1674
|
+
const run = (args) => {
|
|
1675
|
+
try {
|
|
1676
|
+
return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString();
|
|
1677
|
+
} catch {
|
|
1678
|
+
return null;
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
let out = null;
|
|
1682
|
+
if (staged) {
|
|
1683
|
+
out = run('diff --cached --name-only --relative --diff-filter=ACM');
|
|
1684
|
+
} else if (base) {
|
|
1685
|
+
for (const b of [`origin/${base}`, base]) {
|
|
1686
|
+
out = run(`diff --name-only --relative --diff-filter=ACM ${b}...HEAD`);
|
|
1687
|
+
if (out !== null) break;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
if (out === null) out = run('diff HEAD~1 --name-only --relative --diff-filter=ACM');
|
|
1691
|
+
if (out === null) return null;
|
|
1692
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
async function cmdProvenance(flags, positional) {
|
|
1696
|
+
const root = path.resolve(flags.path || positional[0] || '.');
|
|
1697
|
+
const staged = !!flags.staged;
|
|
1698
|
+
const base = flags.base || (staged ? null : process.env.GITHUB_BASE_REF || 'main');
|
|
1699
|
+
|
|
1700
|
+
const paths = gitChangedPaths(root, { staged, base });
|
|
1701
|
+
if (paths === null) {
|
|
1702
|
+
console.error(red('✗') + ' Not a git repository (or no diff available). Run inside a repo, or pass --base <ref>.');
|
|
1703
|
+
process.exit(1);
|
|
1704
|
+
}
|
|
1705
|
+
if (!paths.length) {
|
|
1706
|
+
if (flags.json) console.log(JSON.stringify({ files: [], agentAuthored: 0, coverage: 'NO_TELEMETRY', summary: 'no changed files' }, null, 2));
|
|
1707
|
+
else console.log(green('\n ✓ No changed files to attribute.\n'));
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const cfg = loadConfig();
|
|
1712
|
+
const { apiKey, url } = resolveSettings(cfg);
|
|
1713
|
+
let res;
|
|
1714
|
+
try {
|
|
1715
|
+
res = await api(url, apiKey, '/gate/provenance', {
|
|
1716
|
+
paths,
|
|
1717
|
+
repo: flags.repo || process.env.GITHUB_REPOSITORY || undefined,
|
|
1718
|
+
sessionId: flags.session || undefined,
|
|
1719
|
+
sinceHours: flags.since ? Number(flags.since) : undefined,
|
|
1720
|
+
});
|
|
1721
|
+
} catch (e) {
|
|
1722
|
+
// Provenance is an evidence lookup, not a guard — a backend outage must not
|
|
1723
|
+
// block a commit. Say so plainly instead of silently reporting "no agents".
|
|
1724
|
+
console.error(yellow('!') + ` Provenance unavailable (${e.message}). Authorship not established.`);
|
|
1725
|
+
process.exit(flags['fail-on-blocked'] ? 1 : 0);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
if (flags.json) {
|
|
1729
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1730
|
+
} else if (flags.trailer) {
|
|
1731
|
+
for (const t of res.trailers || []) console.log(t);
|
|
1732
|
+
} else {
|
|
1733
|
+
const noTel = res.coverage === 'NO_TELEMETRY';
|
|
1734
|
+
console.log('');
|
|
1735
|
+
console.log(` ${bold('Commit provenance')} ${dim(`· ${res.files.length} changed file(s)`)}`);
|
|
1736
|
+
console.log(` ${noTel ? yellow('⚠ ' + res.summary) : res.summary}`);
|
|
1737
|
+
if (noTel) {
|
|
1738
|
+
console.log(dim(' No firewall telemetry for this range — this is NOT a claim that a human wrote them.'));
|
|
1739
|
+
console.log(dim(' Install the runtime hook with ') + bold('shomra protect') + dim(' to attribute future work.'));
|
|
1740
|
+
}
|
|
1741
|
+
console.log('');
|
|
1742
|
+
for (const f of res.files.slice(0, 40)) {
|
|
1743
|
+
const tag =
|
|
1744
|
+
f.authorship === 'AGENT' ? cyan('agent') : f.authorship === 'BLOCKED_ATTEMPT' ? red('blocked') : dim('unattributed');
|
|
1745
|
+
const who = f.agents?.length ? dim(` ${f.agents.join(', ')}`) : '';
|
|
1746
|
+
const amb = f.ambiguous ? yellow(' ~ambiguous') : '';
|
|
1747
|
+
console.log(` ${tag.padEnd(22)} ${f.path}${who}${amb}`);
|
|
1748
|
+
}
|
|
1749
|
+
if (res.files.length > 40) console.log(dim(` …and ${res.files.length - 40} more`));
|
|
1750
|
+
console.log('');
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// A path the firewall BLOCKED that changed anyway is the signal worth failing
|
|
1754
|
+
// on: either the guard was bypassed, or something wrote it outside the agent.
|
|
1755
|
+
if (flags['fail-on-blocked'] && res.blockedAttempts > 0) {
|
|
1756
|
+
console.error(red('✗') + ` ${res.blockedAttempts} file(s) the firewall blocked were modified anyway.`);
|
|
1757
|
+
process.exit(1);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1659
1761
|
// ── shomra install-precommit: gate staged AI artifacts at commit time ──
|
|
1660
1762
|
//
|
|
1661
1763
|
// shomra install-precommit [dir] [--force]
|
|
@@ -3951,6 +4053,7 @@ ${bold('COMMANDS')}
|
|
|
3951
4053
|
${cyan('init')} Configure + enroll this machine ${dim('--key shm_live_… [--url <backend>]')}
|
|
3952
4054
|
${cyan('protect')} Wire the runtime firewall for every coding agent ${dim('[--local] [--force]')}
|
|
3953
4055
|
${cyan('install-hook')} Wire the runtime firewall into ONE agent ${dim('[--agent claude|cursor|windsurf|gemini|codex|copilot|cline|aider|all] [--global]')}
|
|
4056
|
+
${cyan('provenance')} Which changed files an AI agent wrote ${dim('[--staged | --base main] [--trailer] [--fail-on-blocked] [--json]')}
|
|
3954
4057
|
${cyan('install-precommit')} Gate staged AI artifacts on git commit ${dim('[dir] [--force]')}
|
|
3955
4058
|
${cyan('doctor')} ${bold('Am I safe?')} Posture of this machine's AI setup ${dim('[--json]')}
|
|
3956
4059
|
|
|
@@ -4163,6 +4266,7 @@ const COMMANDS = {
|
|
|
4163
4266
|
baseline: (f, p) => cmdBaseline(f, p),
|
|
4164
4267
|
fix: (f, p) => cmdFix(f, p),
|
|
4165
4268
|
why: (f, p) => cmdWhy(f, p),
|
|
4269
|
+
provenance: (f, p) => cmdProvenance(f, p),
|
|
4166
4270
|
'install-precommit': (f, p) => cmdInstallPrecommit(f, p),
|
|
4167
4271
|
'scan-zip': (f, p) => cmdScanZip(f, p),
|
|
4168
4272
|
'model-scan': (f, p) => cmdModelScan(f, p),
|