@shomra/agent 0.3.17 → 0.3.18

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 (156) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +57 -57
  3. package/package.json +3 -9
  4. package/shomra.mjs +9 -7168
  5. package/src/agents/hook-command.mjs +19 -0
  6. package/src/agents/hook-files.mjs +41 -0
  7. package/src/agents/installers.mjs +203 -0
  8. package/src/artifacts/matchers.mjs +59 -0
  9. package/src/artifacts/report.mjs +50 -0
  10. package/src/cli/flags.mjs +68 -0
  11. package/src/cli/help-sections.mjs +309 -0
  12. package/src/cli/help.mjs +27 -0
  13. package/src/cli/main.mjs +55 -0
  14. package/src/cli/registry.mjs +80 -0
  15. package/src/cli/suggestions.mjs +33 -0
  16. package/src/commands/add.mjs +149 -0
  17. package/src/commands/agent-identity.mjs +46 -0
  18. package/src/commands/check.mjs +194 -0
  19. package/src/commands/corpus.mjs +126 -0
  20. package/src/commands/design.mjs +168 -0
  21. package/src/commands/doctor.mjs +209 -0
  22. package/src/commands/fix.mjs +115 -0
  23. package/src/commands/gate.mjs +154 -0
  24. package/src/commands/git-hooks.mjs +163 -0
  25. package/src/commands/init.mjs +36 -0
  26. package/src/commands/install-hook.mjs +51 -0
  27. package/src/commands/llm-proxy.mjs +153 -0
  28. package/src/commands/mcp-add.mjs +185 -0
  29. package/src/commands/mcp.mjs +143 -0
  30. package/src/commands/memory-scan.mjs +181 -0
  31. package/src/commands/model-scan.mjs +99 -0
  32. package/src/commands/models.mjs +145 -0
  33. package/src/commands/new.mjs +64 -0
  34. package/src/commands/plan.mjs +87 -0
  35. package/src/commands/pr.mjs +249 -0
  36. package/src/commands/protect.mjs +38 -0
  37. package/src/commands/provenance.mjs +91 -0
  38. package/src/commands/redteam.mjs +166 -0
  39. package/src/commands/rules.mjs +220 -0
  40. package/src/commands/run.mjs +128 -0
  41. package/src/commands/scan-zip.mjs +118 -0
  42. package/src/commands/scan.mjs +102 -0
  43. package/src/commands/secrets.mjs +99 -0
  44. package/src/commands/status.mjs +50 -0
  45. package/src/commands/why.mjs +88 -0
  46. package/src/core/api-client.mjs +66 -0
  47. package/src/core/api-key.mjs +6 -0
  48. package/src/core/circuit-breaker.mjs +42 -0
  49. package/src/core/config.mjs +37 -0
  50. package/src/core/exit-codes.mjs +9 -0
  51. package/src/core/json-file.mjs +13 -0
  52. package/src/core/numbers.mjs +4 -0
  53. package/src/core/package-root.mjs +10 -0
  54. package/src/core/terminal.mjs +16 -0
  55. package/src/core/version.mjs +14 -0
  56. package/src/core/wire-limits.mjs +53 -0
  57. package/src/corpus/screening.mjs +127 -0
  58. package/{ai-usage.mjs → src/detect/ai-usage.mjs} +0 -27
  59. package/src/detect/code-sast.mjs +2 -0
  60. package/{design.mjs → src/detect/design.mjs} +17 -106
  61. package/src/detect/guard-signals.mjs +18 -0
  62. package/{model-refs.mjs → src/detect/model-refs.mjs} +18 -77
  63. package/src/detect/sast/chains.mjs +30 -0
  64. package/src/detect/sast/path-expressions.mjs +76 -0
  65. package/src/detect/sast/rules-chains.mjs +33 -0
  66. package/src/detect/sast/rules-config.mjs +51 -0
  67. package/src/detect/sast/rules-javascript.mjs +109 -0
  68. package/src/detect/sast/rules-python.mjs +292 -0
  69. package/src/detect/sast/scanner.mjs +104 -0
  70. package/src/detect/sast/source-lines.mjs +115 -0
  71. package/src/detect/sast/taint.mjs +71 -0
  72. package/src/detect/signals/artifacts.mjs +113 -0
  73. package/src/detect/signals/autonomy.mjs +55 -0
  74. package/src/detect/signals/config-markers.mjs +28 -0
  75. package/src/detect/signals/credential-harvest.mjs +64 -0
  76. package/src/detect/signals/durable-claims.mjs +73 -0
  77. package/src/detect/signals/egress.mjs +56 -0
  78. package/src/detect/signals/execution-hijack.mjs +128 -0
  79. package/src/detect/signals/gate.mjs +91 -0
  80. package/src/detect/signals/injection.mjs +55 -0
  81. package/src/detect/signals/lines.mjs +42 -0
  82. package/src/detect/signals/masking.mjs +99 -0
  83. package/src/detect/signals/memory.mjs +357 -0
  84. package/src/detect/signals/packages.mjs +45 -0
  85. package/src/detect/signals/propagation.mjs +86 -0
  86. package/src/detect/signals/prose-context.mjs +82 -0
  87. package/src/detect/signals/scan.mjs +91 -0
  88. package/src/detect/signals/secrets.mjs +85 -0
  89. package/src/detect/signals/sensitive.mjs +9 -0
  90. package/src/detect/signals/severity.mjs +10 -0
  91. package/src/detect/signals/shell.mjs +96 -0
  92. package/src/detect/signals/staged-fetch.mjs +66 -0
  93. package/src/detect/signals/text-match.mjs +35 -0
  94. package/src/gate/batch.mjs +157 -0
  95. package/src/gate/environment.mjs +122 -0
  96. package/src/gate/repo-policy.mjs +65 -0
  97. package/src/gate/result.mjs +53 -0
  98. package/src/gate/sarif.mjs +33 -0
  99. package/src/gate/sast.mjs +64 -0
  100. package/src/gate/suppressions.mjs +0 -0
  101. package/src/guard/classify.mjs +50 -0
  102. package/src/guard/emit.mjs +51 -0
  103. package/src/guard/ignore.mjs +24 -0
  104. package/src/guard/ledger.mjs +112 -0
  105. package/src/guard/model-load.mjs +50 -0
  106. package/src/guard/normalize.mjs +77 -0
  107. package/src/guard/options.mjs +10 -0
  108. package/src/guard/prompt-guard.mjs +184 -0
  109. package/src/guard/report.mjs +35 -0
  110. package/src/guard/result-guard.mjs +140 -0
  111. package/src/guard/tool-guard.mjs +166 -0
  112. package/src/inventory/agent-artifacts.mjs +5 -0
  113. package/src/inventory/agent-posture.mjs +249 -0
  114. package/src/inventory/artifacts/classify.mjs +27 -0
  115. package/src/inventory/artifacts/discover.mjs +187 -0
  116. package/src/inventory/artifacts/file-read.mjs +42 -0
  117. package/src/inventory/artifacts/hooks.mjs +14 -0
  118. package/src/inventory/artifacts/limits.mjs +37 -0
  119. package/src/inventory/artifacts/marketplaces.mjs +45 -0
  120. package/src/inventory/artifacts/roots.mjs +20 -0
  121. package/src/inventory/artifacts/walk.mjs +36 -0
  122. package/src/inventory/discovery/ai-dependencies.mjs +161 -0
  123. package/src/inventory/discovery/ai-tools.mjs +23 -0
  124. package/src/inventory/discovery/all.mjs +40 -0
  125. package/src/inventory/discovery/coding-agents.mjs +77 -0
  126. package/src/inventory/discovery/fs-read.mjs +36 -0
  127. package/src/inventory/discovery/local-runtimes.mjs +53 -0
  128. package/src/inventory/discovery/mcp-clients.mjs +67 -0
  129. package/src/inventory/discovery/mcp-servers.mjs +78 -0
  130. package/src/inventory/discovery/model-keys.mjs +97 -0
  131. package/src/inventory/discovery/platform.mjs +16 -0
  132. package/src/inventory/discovery/rules-files.mjs +25 -0
  133. package/src/inventory/discovery/vector-stores.mjs +176 -0
  134. package/src/inventory/discovery/workspace.mjs +124 -0
  135. package/src/inventory/discovery.mjs +10 -0
  136. package/src/mcp/child-process.mjs +50 -0
  137. package/src/mcp/config-wrapping.mjs +75 -0
  138. package/src/mcp/connect-gate.mjs +45 -0
  139. package/src/mcp/hosts.mjs +16 -0
  140. package/src/mcp/jsonrpc.mjs +48 -0
  141. package/src/mcp/lookup.mjs +50 -0
  142. package/src/mcp/screening.mjs +103 -0
  143. package/src/mcp/server-tools.mjs +97 -0
  144. package/src/mcp/server.mjs +102 -0
  145. package/src/mcp/shim.mjs +205 -0
  146. package/src/models/lookup.mjs +79 -0
  147. package/src/models/references.mjs +103 -0
  148. package/src/rules/context.mjs +98 -0
  149. package/src/rules/generate.mjs +103 -0
  150. package/src/rules/sections.mjs +145 -0
  151. package/src/scaffold/agent-project.mjs +185 -0
  152. package/src/scaffold/artifact-templates.mjs +35 -0
  153. package/code-sast.mjs +0 -1063
  154. package/discovery.mjs +0 -977
  155. package/guard-ledger.mjs +0 -239
  156. package/guard-signals.mjs +0 -2055
@@ -0,0 +1,149 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { didYouMean, levenshtein } from '../cli/suggestions.mjs';
4
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
5
+ import { EXIT_USAGE } from '../core/exit-codes.mjs';
6
+ import { SEV_COLOR, bold, dim, green, red, yellow } from '../core/terminal.mjs';
7
+ import { AI_USAGE_CATEGORY_LABEL, KNOWN_AI_PACKAGES } from '../detect/ai-usage.mjs';
8
+ import { localGate } from '../detect/guard-signals.mjs';
9
+ import { collectLocalSast, mergeSastIntoResult } from '../gate/sast.mjs';
10
+ import { MODEL_SEV_RANK, modelFixPlan, modelLookup, printAlternatives } from '../models/lookup.mjs';
11
+ import { cmdMcp } from './mcp.mjs';
12
+
13
+ const ADD_KINDS = ['mcp', 'skill', 'model', 'package'];
14
+
15
+ export async function cmdAdd(flags, positional) {
16
+ const kind = String(positional[0] || '').toLowerCase();
17
+ if (!ADD_KINDS.includes(kind)) {
18
+ const near = didYouMean(kind, ADD_KINDS);
19
+ console.error(red('✗') + ` Usage: ${bold('shomra add ' + ADD_KINDS.join('|') + ' <ref>')}` + (near ? dim(` (did you mean ${near}?)`) : ''));
20
+ console.error(dim(' mcp ') + 'shomra add mcp files npx -y @modelcontextprotocol/server-filesystem /tmp');
21
+ console.error(dim(' skill ') + 'shomra add skill ./downloaded-skill');
22
+ console.error(dim(' model ') + 'shomra add model openai-community/gpt2');
23
+ console.error(dim(' package ') + 'shomra add package langchain --type pypi');
24
+ process.exit(EXIT_USAGE);
25
+ }
26
+
27
+ if (kind === 'mcp') return cmdMcp(flags, ['add', ...positional.slice(1)]);
28
+ if (kind === 'skill') return addSkill(flags, positional.slice(1));
29
+ if (kind === 'model') return addModel(flags, positional.slice(1));
30
+ return addPackage(flags, positional.slice(1));
31
+ }
32
+
33
+ function finishAdd(kind, ref, verdict, lines, flags, extra = {}) {
34
+ if (flags.json) {
35
+ console.log(JSON.stringify({ kind, ref, verdict, accepted: verdict !== 'BLOCK' || !!flags.force, ...extra }, null, 2));
36
+ } else {
37
+ const vc = verdict === 'BLOCK' ? red : verdict === 'FLAG' ? yellow : green;
38
+ console.log(`\n ${vc(verdict === 'BLOCK' ? '✗ BLOCK' : verdict === 'FLAG' ? '⚠ FLAG' : '✓ ALLOW')} ${bold(ref)} ${dim('· ' + kind)}`);
39
+ for (const l of lines) console.log(' ' + l);
40
+ if (verdict === 'BLOCK' && !flags.force) console.log(`\n ${red('Not acquired.')} ${dim('Review the findings, or override deliberately with')} ${bold('--force')}${dim('.')}`);
41
+ else if (verdict === 'BLOCK') console.log(`\n ${yellow('Forced past a BLOCK.')} ${dim('This is recorded as a deliberate override.')}`);
42
+ console.log('');
43
+ }
44
+ if (verdict === 'BLOCK' && !flags.force) process.exitCode = 1;
45
+ else if (verdict === 'FLAG' && flags.strict) process.exitCode = 2;
46
+ }
47
+
48
+ async function addSkill(flags, positional) {
49
+ const ref = positional[0];
50
+ if (!ref) { console.error(red('✗') + ' Usage: ' + bold('shomra add skill <path>')); process.exit(EXIT_USAGE); }
51
+ let target = path.resolve(String(ref));
52
+ if (!fs.existsSync(target)) { console.error(red('✗') + ` Not found: ${ref}`); process.exit(EXIT_USAGE); }
53
+ if (fs.statSync(target).isDirectory()) {
54
+ const md = path.join(target, 'SKILL.md');
55
+ if (!fs.existsSync(md)) { console.error(red('✗') + ` ${ref} has no SKILL.md - point at the skill's directory or its SKILL.md.`); process.exit(EXIT_USAGE); }
56
+ target = md;
57
+ }
58
+ const rel = path.relative(process.cwd(), target).split(path.sep).join('/');
59
+ const content = fs.readFileSync(target, 'utf8');
60
+
61
+ const merged = mergeSastIntoResult(
62
+ { ...localGate(content, { kind: 'skill', path: rel }), decision: localGate(content, { kind: 'skill', path: rel }).verdict },
63
+ collectLocalSast({ fullPath: target, relPath: rel, kind: 'skill', content }),
64
+ );
65
+ const findings = merged.findings || [];
66
+ const lines = findings.slice(0, 8).map((f) => `${(SEV_COLOR[f.severity] || dim)(String(f.severity).padEnd(8))} ${f.title}${f.line ? dim(' (line ' + f.line + ')') : ''}`);
67
+ if (!findings.length) lines.push(dim('no findings - manifest and bundled scripts both clean'));
68
+ finishAdd('skill', rel, merged.decision, lines, flags, { findings, riskScore: merged.riskScore });
69
+ }
70
+
71
+ async function addModel(flags, positional) {
72
+ const raw = String(positional[0] || '');
73
+ if (!raw) { console.error(red('✗') + ' Usage: ' + bold('shomra add model <owner/model[@revision]>')); process.exit(EXIT_USAGE); }
74
+ const [id, revision] = raw.split('@');
75
+ const { url } = resolveSettings(loadConfig());
76
+
77
+ let lk;
78
+ try { lk = await modelLookup(url, id, revision); } catch (e) {
79
+
80
+ return finishAdd('model', raw, 'FLAG', [
81
+ yellow('could not check the Model Index') + dim(` - ${e.message}`),
82
+ dim('This is unverified, not clean. Re-run when the index is reachable, or accept the risk explicitly.'),
83
+ ], flags, { checked: false, error: e.message });
84
+ }
85
+ if (!lk || !lk.found) {
86
+ return finishAdd('model', raw, 'FLAG', [
87
+ yellow('not in the Model Index') + dim(' - nobody has scanned this model'),
88
+ dim('Unscanned is not safe. `shomra admin model-scan ' + id + '` scans it on the platform.'),
89
+ ], flags, { checked: true, found: false });
90
+ }
91
+
92
+ const findings = lk.findings || [];
93
+ const worst = findings.reduce((m, f) => Math.max(m, MODEL_SEV_RANK[f.severity] || 0), 0);
94
+ const verdict = lk.verdict === 'FAIL' || worst >= MODEL_SEV_RANK.CRITICAL ? 'BLOCK' : lk.verdict === 'REVIEW' || worst >= MODEL_SEV_RANK.HIGH ? 'FLAG' : 'ALLOW';
95
+ const lines = [
96
+ `${dim('index verdict')} ${lk.verdict === 'FAIL' ? red(lk.verdict) : lk.verdict === 'REVIEW' ? yellow(lk.verdict) : green(lk.verdict)} ${dim('· risk ' + (lk.riskScore ?? '?') + '/100')}${lk.cached ? dim(lk.stale ? ' · cached (stale)' : ' · cached') : ''}`,
97
+ ...findings.slice(0, 6).map((f) => `${(SEV_COLOR[f.severity] || dim)(String(f.severity).padEnd(8))} ${f.title}`),
98
+ ];
99
+ const fix = modelFixPlan(findings, lk.sha);
100
+ if (fix) lines.push(dim('load it safely with: ') + fix.kwargs.map((k) => `${k.name}=${k.value}`).join(', '));
101
+ finishAdd('model', raw, verdict, lines, flags, { checked: true, found: true, indexVerdict: lk.verdict, riskScore: lk.riskScore, findings, fix });
102
+ if (!flags.json) printAlternatives(lk.alternatives, 'model', ' ');
103
+ }
104
+
105
+ const TYPOSQUAT_MAX_DISTANCE = 2;
106
+
107
+ async function addPackage(flags, positional) {
108
+ const name = String(positional[0] || '').trim();
109
+ if (!name) { console.error(red('✗') + ' Usage: ' + bold('shomra add package <name> [--type npm|pypi]')); process.exit(EXIT_USAGE); }
110
+ const type = flags.type ? String(flags.type).toLowerCase() : null;
111
+ if (type && type !== 'npm' && type !== 'pypi') { console.error(red('✗') + ' --type must be npm or pypi.'); process.exit(EXIT_USAGE); }
112
+
113
+ const pool = KNOWN_AI_PACKAGES.filter((p) => !type || p.ecosystem === type);
114
+ const exact = pool.find((p) => p.name.toLowerCase() === name.toLowerCase());
115
+
116
+ const near = exact || name.length <= 4
117
+ ? []
118
+ : pool
119
+ .map((p) => ({ p, d: levenshtein(name.toLowerCase(), p.name.toLowerCase()) }))
120
+ .filter((x) => x.d > 0 && x.d <= TYPOSQUAT_MAX_DISTANCE)
121
+ .sort((a, b) => a.d - b.d)
122
+ .slice(0, 3);
123
+
124
+ const otherEco = exact ? null : KNOWN_AI_PACKAGES.find((p) => p.name.toLowerCase() === name.toLowerCase());
125
+
126
+ let verdict = 'ALLOW';
127
+ const lines = [];
128
+ if (near.length) {
129
+ verdict = 'BLOCK';
130
+ lines.push(red('possible typosquat') + dim(` - ${near.length === 1 ? 'this is' : 'these are'} ${near.map((x) => `${x.d} edit${x.d === 1 ? '' : 's'} from ${bold(x.p.name)} (${x.p.label}, ${x.p.ecosystem})`).join('; ')}`));
131
+ lines.push(dim('If you meant the real package, install that exact name. If this IS a distinct package, --force.'));
132
+ } else if (otherEco && type) {
133
+ verdict = 'FLAG';
134
+ lines.push(yellow(`"${name}" is a known ${otherEco.ecosystem} package (${otherEco.label}), not ${type}`));
135
+ lines.push(dim(`A ${type} package under a ${otherEco.ecosystem} project's name is a common squat. Confirm the publisher before installing.`));
136
+ } else if (exact) {
137
+ lines.push(green('known AI package') + dim(` - ${exact.label} · ${AI_USAGE_CATEGORY_LABEL[exact.category] || exact.category} · ${exact.ecosystem}`));
138
+ lines.push(dim('Name recognised. That is not a supply-chain review: pin the version and check the publisher.'));
139
+ } else {
140
+
141
+ verdict = 'FLAG';
142
+ lines.push(yellow('not in the AI package catalog') + dim(' - no typosquat signal, and no verification either'));
143
+ lines.push(dim('Shomra knows AI packages by name only. Check the publisher, the download count, and the repo link yourself.'));
144
+ }
145
+ finishAdd('package', name + (type ? ` (${type})` : ''), verdict, lines, flags, {
146
+ known: !!exact, ecosystem: exact ? exact.ecosystem : otherEco ? otherEco.ecosystem : null,
147
+ nearMatches: near.map((x) => ({ name: x.p.name, distance: x.d, ecosystem: x.p.ecosystem, label: x.p.label })),
148
+ });
149
+ }
@@ -0,0 +1,46 @@
1
+ import { api } from '../core/api-client.mjs';
2
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
3
+ import { EXIT_USAGE, exitNotConfigured } from '../core/exit-codes.mjs';
4
+ import { bold, cyan, dim, green, red } from '../core/terminal.mjs';
5
+
6
+ export async function cmdAgentIdentity(flags, positional) {
7
+ const sub = (positional[0] || 'register').toLowerCase();
8
+ const cfg = loadConfig();
9
+ const { apiKey, url } = resolveSettings(cfg);
10
+ if (!apiKey) {
11
+ exitNotConfigured();
12
+ }
13
+ if (sub !== 'register') {
14
+ console.error(`\n ${red('✗')} Unknown subcommand "${sub}". Use: ${bold('shomra agent-identity register --name "…" --type coding-agent')}`);
15
+ console.error(dim(' (List / govern / revoke identities in the dashboard → Agent Identities.)\n'));
16
+ process.exit(EXIT_USAGE);
17
+ }
18
+ let res;
19
+ try {
20
+ res = await api(url, apiKey, '/agents/register', {
21
+ name: flags.name ? String(flags.name) : undefined,
22
+ slug: flags.slug ? String(flags.slug) : undefined,
23
+ type: flags.type ? String(flags.type) : undefined,
24
+ });
25
+ } catch (e) {
26
+ console.error(`\n ${red('✗')} ${e.message}\n`);
27
+ process.exit(1);
28
+ }
29
+ if (flags.json) {
30
+ console.log(JSON.stringify(res, null, 2));
31
+ return;
32
+ }
33
+ console.log(`\n ${green('✓')} Registered agent identity ${bold(res.name)} ${dim('(' + res.slug + ' · ' + res.type + ')')}`);
34
+ if (res.credential) {
35
+ console.log(`\n ${bold('Credential')} ${dim('(shown once - store it securely):')}`);
36
+ console.log(` ${cyan(res.credential)}`);
37
+ }
38
+ console.log(`\n Present this identity so every call is authorized as it:`);
39
+ console.log(dim(` export SHOMRA_AGENT=${res.slug} # or use the credential above`));
40
+ console.log(dim(` Then set its least-privilege capabilities in the dashboard → Agent Identities.\n`));
41
+ }
42
+
43
+ export function resolveAgentIdentityHandle(flags) {
44
+ const v = (flags && flags['agent-id'] && String(flags['agent-id'])) || process.env.SHOMRA_AGENT || '';
45
+ return v && String(v).trim() ? String(v).trim() : null;
46
+ }
@@ -0,0 +1,194 @@
1
+ import { execSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ARTIFACT_MATCHERS, walkArtifacts } from '../artifacts/matchers.mjs';
5
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
6
+ import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
7
+ import { VERSION } from '../core/version.mjs';
8
+ import { failOnHit, gateArtifactList } from '../gate/batch.mjs';
9
+ import { detectEnv } from '../gate/environment.mjs';
10
+ import { toSarif } from '../gate/sarif.mjs';
11
+ import { findingFingerprint } from '../gate/suppressions.mjs';
12
+ import { fixOneFile } from './fix.mjs';
13
+
14
+ const GIT_TIMEOUT_MS = 3000;
15
+
16
+ function gitChangedArtifacts(root, { staged }) {
17
+ const run = (args) => {
18
+ try {
19
+ return execSync(`git ${args}`, { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: GIT_TIMEOUT_MS }).toString();
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+
25
+ const output = staged
26
+ ? run('diff --cached --name-only --relative --diff-filter=ACM')
27
+ : run('diff HEAD --name-only --relative --diff-filter=ACM');
28
+ if (output === null) return null;
29
+
30
+ return output
31
+ .split('\n')
32
+ .map((line) => line.trim())
33
+ .filter(Boolean)
34
+ .filter((relativePath) => ARTIFACT_MATCHERS.some((matcher) => matcher.re.test(relativePath)));
35
+ }
36
+
37
+ function narrowToChanged(artifacts, root, flags) {
38
+ if (!flags.staged && !flags.changed) return artifacts;
39
+
40
+ const changed = gitChangedArtifacts(root, { staged: !!flags.staged });
41
+ if (changed === null) {
42
+ if (!flags.json) console.error(` ${yellow('⚠')} ${dim('not a git repo (or git unavailable) - checking the whole tree')}`);
43
+ return artifacts;
44
+ }
45
+ const changedSet = new Set(changed);
46
+ return artifacts.filter((artifact) => changedSet.has(artifact.rel));
47
+ }
48
+
49
+ function printNothingToCheck(flags, root) {
50
+ if (flags.json) {
51
+ console.log(JSON.stringify({ scanned: 0, blocked: 0, flagged: 0, results: [] }, null, 2));
52
+ return;
53
+ }
54
+ const where = flags.staged || flags.changed ? ' in the changed set.' : ` under ${root}.`;
55
+ console.log(`${green('\n ✓ No AI artifacts to check')}${dim(where)}\n`);
56
+ }
57
+
58
+ function printHeader({ flags, artifacts, env, apiKey }) {
59
+ if (flags.json || flags.sarif) return;
60
+
61
+ const scope = flags.staged ? 'staged' : flags.changed ? 'changed' : env.environment;
62
+ const provider = env.ciProvider ? ` · ${env.ciProvider}` : '';
63
+ console.log(bold(cyan('\n Shomra check')) + dim(` - ${artifacts.length} artifact${artifacts.length > 1 ? 's' : ''} · ${scope}${provider}`));
64
+ if (!apiKey) console.error(` ${dim('On-machine analysis only - run')} ${bold('shomra init')} ${dim('to also apply org policy.')}`);
65
+ }
66
+
67
+ async function applyFixes({ results, flags, apiKey, url, blocked, flagged }) {
68
+ if (!flags.fix || !(blocked || flagged)) return 0;
69
+
70
+ if (!apiKey) {
71
+ if (!flags.json) {
72
+ console.error(` ${yellow('⚠')} ${dim('--fix needs enrollment (the fix runs on the platform). Run')} ${bold('shomra init')}${dim('.')}`);
73
+ }
74
+ return 0;
75
+ }
76
+
77
+ if (!flags.json) console.log(dim('\n Fixing flagged artifacts…'));
78
+ let fixed = 0;
79
+ for (const result of results) {
80
+ if (result.decision === 'ALLOW') continue;
81
+ const done = await fixOneFile(result.full, { apiKey, url, flags: { ...flags, apply: true, quiet: flags.json } });
82
+ if (done) fixed += 1;
83
+ }
84
+ return fixed;
85
+ }
86
+
87
+ function verdictLine({ blocked, flagged, suppressed, total }) {
88
+ if (blocked) return red(`✗ ${blocked} blocked`) + dim(` · ${flagged} flagged · ${total - blocked - flagged} clean`);
89
+ if (flagged) return yellow(`⚠ ${flagged} flagged`) + dim(` · ${total - flagged} clean`);
90
+ if (suppressed) return green(`✓ ${total} passing`) + dim(' - nothing NEW; previously accepted findings are still there');
91
+ return green(`✓ All ${total} clean.`);
92
+ }
93
+
94
+ function printSummary({ results, blocked, flagged, suppressed, backendDown, rejected, fixed, flags, strictOutage }) {
95
+ const suppressedNote = suppressed ? dim(` · ${suppressed} accepted by baseline/ignore`) : '';
96
+ const outageNote = backendDown ? yellow(' (on-machine only - org policy not applied)') : '';
97
+ const rejectedNote = rejected.length
98
+ ? yellow(` (${rejected.length} artifact(s) the backend refused - org policy not applied to those)`)
99
+ : '';
100
+
101
+ console.log(`\n ${verdictLine({ blocked, flagged, suppressed, total: results.length })}${suppressedNote}${outageNote}${rejectedNote}`);
102
+
103
+ for (const entry of rejected) console.log(` ${yellow('!')} ${dim(`${entry.path} (${entry.kind}) - ${entry.reason}`)}`);
104
+
105
+ if (flags.fix && fixed) {
106
+ console.log(` ${green('✓')} ${dim(`applied ${fixed} fix${fixed > 1 ? 'es' : ''} - re-run`)} ${bold('shomra check')} ${dim('to confirm.')}`);
107
+ } else if (!flags.fix && (blocked || flagged)) {
108
+ console.log(dim(' Run ') + bold('shomra fix <file>') + dim(' or ') + bold('shomra check --fix') + dim(' to remediate.'));
109
+ }
110
+ if (strictOutage) console.log(` ${red('✗ Failing closed (--strict): backend unreachable, org policy unverified.')}`);
111
+ console.log('');
112
+ }
113
+
114
+ export async function cmdCheck(flags, positional) {
115
+ const { apiKey, url } = resolveSettings(loadConfig());
116
+ const root = path.resolve(positional[0] || flags.path || '.');
117
+ const env = detectEnv();
118
+
119
+ const artifacts = narrowToChanged(walkArtifacts(root), root, flags);
120
+ if (!artifacts.length) {
121
+ printNothingToCheck(flags, root);
122
+ return;
123
+ }
124
+
125
+ printHeader({ flags, artifacts, env, apiKey });
126
+ const { results, blocked, flagged, suppressed, backendDown, rejected } =
127
+ await gateArtifactList(artifacts, { apiKey, url, env, flags, root });
128
+
129
+ if (flags.sarif) {
130
+ console.log(JSON.stringify(toSarif(results), null, 2));
131
+ if (blocked) process.exitCode = 1;
132
+ else if (flagged && flags.strict) process.exitCode = 2;
133
+ return;
134
+ }
135
+
136
+ const fixed = await applyFixes({ results, flags, apiKey, url, blocked, flagged });
137
+ const strictOutage = backendDown && flags.strict;
138
+
139
+ if (flags.json) {
140
+ console.log(JSON.stringify({
141
+ scanned: results.length, blocked, flagged, suppressed, fixed, backendDown, rejected,
142
+ environment: env.environment, results,
143
+ }, null, 2));
144
+ } else {
145
+ printSummary({ results, blocked, flagged, suppressed, backendDown, rejected, fixed, flags, strictOutage });
146
+ }
147
+
148
+ if (blocked > 0 || strictOutage) process.exitCode = 1;
149
+ else if (failOnHit(flags, blocked, flagged)) process.exitCode = 1;
150
+ else if (flagged > 0 && flags.strict) process.exitCode = 2;
151
+ }
152
+
153
+ export async function cmdBaseline(flags, positional) {
154
+ const { apiKey, url } = resolveSettings(loadConfig());
155
+ const root = path.resolve(positional[0] || flags.path || '.');
156
+ const env = detectEnv();
157
+
158
+ const artifacts = walkArtifacts(root);
159
+ if (!artifacts.length) {
160
+ console.log(dim(`\n No AI artifacts under ${root} - nothing to baseline.\n`));
161
+ return;
162
+ }
163
+ if (!flags.json) {
164
+ process.stdout.write(dim(` Scanning ${artifacts.length} artifact${artifacts.length > 1 ? 's' : ''} to baseline… `));
165
+ }
166
+
167
+ const { results } = await gateArtifactList(artifacts, {
168
+ apiKey, url, env, root, flags: { ...flags, json: true, 'no-suppress': true },
169
+ });
170
+
171
+ const fingerprints = new Set();
172
+ for (const result of results) {
173
+ for (const finding of result.findings || []) fingerprints.add(findingFingerprint(result.path, finding));
174
+ }
175
+
176
+ const directory = path.join(root, '.shomra');
177
+ fs.mkdirSync(directory, { recursive: true });
178
+ const file = path.join(directory, 'baseline.json');
179
+ fs.writeFileSync(file, JSON.stringify({
180
+ createdAt: new Date().toISOString(),
181
+ agentVersion: VERSION,
182
+ count: fingerprints.size,
183
+ fingerprints: [...fingerprints],
184
+ }, null, 2));
185
+
186
+ const relative = path.relative(process.cwd(), file).split(path.sep).join('/');
187
+ if (flags.json) {
188
+ console.log(JSON.stringify({ baseline: relative, count: fingerprints.size, artifacts: results.length }, null, 2));
189
+ return;
190
+ }
191
+ console.log(green('done'));
192
+ console.log(`\n ${green('✓ Baseline written')} ${dim(`- ${fingerprints.size} finding(s) across ${results.length} artifact(s) accepted.`)}`);
193
+ console.log(`${dim(` ${relative} - commit it so your team shares the baseline. Only NEW findings will fail now.`)}\n`);
194
+ }
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { CORPUS_DEFAULT_CHUNK, collectCorpusFiles, screenCorpus } from '../corpus/screening.mjs';
4
+ import { EXIT_USAGE } from '../core/exit-codes.mjs';
5
+ import { clampInt } from '../core/numbers.mjs';
6
+ import { SEV_COLOR, bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
7
+
8
+ const MIN_CHUNK = 100;
9
+ const MAX_CHUNK = 100000;
10
+
11
+ const plural = (count, word) => `${word}${count === 1 ? '' : 's'}`;
12
+
13
+ function usageExit() {
14
+ console.error(`${red('✗')} Usage: ${bold('shomra corpus <dir|file> [--chunk-size 1200] [--manifest <file>] [--strict]')}`);
15
+ console.error(dim(' Screens documents BEFORE they are embedded, so a poisoned one never enters the index.'));
16
+ process.exit(EXIT_USAGE);
17
+ }
18
+
19
+ function resolveTarget(flags, positional) {
20
+ const target = positional[0] || flags.path;
21
+ if (!target) usageExit();
22
+
23
+ const absolutePath = path.resolve(String(target));
24
+ if (!fs.existsSync(absolutePath)) {
25
+ console.error(`${red('✗')} Not found: ${target}`);
26
+ process.exit(EXIT_USAGE);
27
+ }
28
+ return { absolutePath, isDirectory: fs.statSync(absolutePath).isDirectory() };
29
+ }
30
+
31
+ function buildManifest({ absolutePath, isDirectory, chunkSize, results, unreadable, quarantined }) {
32
+ return {
33
+ root: isDirectory ? absolutePath : path.dirname(absolutePath),
34
+ chunkSize,
35
+ screened: results.length,
36
+ unreadable: unreadable.length,
37
+ quarantine: quarantined.map(({ path: file, verdict, findings }) => ({ path: file, verdict, findings })),
38
+ unreadableFiles: unreadable.map(({ rel, reason }) => ({ path: rel, reason })),
39
+ };
40
+ }
41
+
42
+ function writeManifest(flags, manifest) {
43
+ if (!flags.manifest) return;
44
+ const target = path.resolve(String(flags.manifest));
45
+ fs.mkdirSync(path.dirname(target), { recursive: true });
46
+ fs.writeFileSync(target, `${JSON.stringify(manifest, null, 2)}\n`);
47
+ }
48
+
49
+ function findingContext(finding) {
50
+ if (finding.concealed) return yellow(' [hidden in an HTML comment - invisible to a reader, read by the model]');
51
+ if (finding.codeContext) return dim(' [quoted in a code block]');
52
+ return '';
53
+ }
54
+
55
+ function findingLocation(finding) {
56
+ if (finding.chunk !== null && finding.chunk !== undefined) return dim(` (line ${finding.line} · chunk ${finding.chunk})`);
57
+ return finding.line ? dim(` (line ${finding.line})`) : '';
58
+ }
59
+
60
+ function printQuarantined(quarantined) {
61
+ for (const document of quarantined) {
62
+ const colour = document.verdict === 'BLOCK' ? red : yellow;
63
+ console.log(`\n ${colour(document.verdict === 'BLOCK' ? '✗ QUARANTINE' : '⚠ REVIEW')} ${bold(document.path)}`);
64
+ for (const finding of document.findings) {
65
+ const severity = (SEV_COLOR[finding.severity] || dim)(String(finding.severity).padEnd(8));
66
+ console.log(` ${severity} ${finding.label}${findingLocation(finding)}${findingContext(finding)}`);
67
+ }
68
+ }
69
+ }
70
+
71
+ function summaryLine({ blocked, flagged, total }) {
72
+ if (blocked.length) {
73
+ const clean = total - blocked.length - flagged.length;
74
+ return red(`✗ ${blocked.length} ${plural(blocked.length, 'document')} must not be indexed`)
75
+ + dim(` · ${flagged.length} to review · ${clean} clean`);
76
+ }
77
+ if (flagged.length) {
78
+ return yellow(`⚠ ${flagged.length} to review`) + dim(` · ${total - flagged.length} clean`);
79
+ }
80
+ return green(`✓ All ${total} screened documents clean.`);
81
+ }
82
+
83
+ function printUnreadable(unreadable) {
84
+ if (!unreadable.length) return;
85
+ const heading = `${unreadable.length} ${plural(unreadable.length, 'file')} could not be read`;
86
+ console.log(` ${yellow('⚠')} ${bold(heading)} ${dim('- they are NOT covered by the result above:')}`);
87
+
88
+ const byReason = new Map();
89
+ for (const entry of unreadable) byReason.set(entry.reason, (byReason.get(entry.reason) || 0) + 1);
90
+ for (const [reason, count] of byReason) console.log(dim(` ${count} × ${reason}`));
91
+ console.log(dim(' Extract them to text and re-run, or exclude them from the index.'));
92
+ }
93
+
94
+ function printReport({ flags, chunkSize, results, unreadable, blocked, flagged, quarantined }) {
95
+ console.log(bold(cyan('\n Shomra corpus')) + dim(` - ${results.length} ${plural(results.length, 'document')} · chunk size ${chunkSize}`));
96
+ printQuarantined(quarantined);
97
+ console.log('');
98
+ console.log(` ${summaryLine({ blocked, flagged, total: results.length })}`);
99
+ printUnreadable(unreadable);
100
+ if (flags.manifest) console.log(dim(` Quarantine manifest → ${flags.manifest}`));
101
+ console.log(dim(' Feed the manifest to your ingestion job so a quarantined document is never embedded.\n'));
102
+ }
103
+
104
+ export async function cmdCorpus(flags, positional) {
105
+ const { absolutePath, isDirectory } = resolveTarget(flags, positional);
106
+ const chunkSize = clampInt(flags['chunk-size'], CORPUS_DEFAULT_CHUNK, MIN_CHUNK, MAX_CHUNK);
107
+
108
+ const { files, opaque } = collectCorpusFiles(absolutePath, isDirectory);
109
+ const { results, unreadable } = screenCorpus(files, opaque, chunkSize);
110
+
111
+ const blocked = results.filter((document) => document.verdict === 'BLOCK');
112
+ const flagged = results.filter((document) => document.verdict === 'FLAG');
113
+ const quarantined = [...blocked, ...flagged];
114
+
115
+ const manifest = buildManifest({ absolutePath, isDirectory, chunkSize, results, unreadable, quarantined });
116
+ writeManifest(flags, manifest);
117
+
118
+ if (flags.json) {
119
+ console.log(JSON.stringify({ ...manifest, blocked: blocked.length, flagged: flagged.length, results }, null, 2));
120
+ } else {
121
+ printReport({ flags, chunkSize, results, unreadable, blocked, flagged, quarantined });
122
+ }
123
+
124
+ if (blocked.length) process.exitCode = 1;
125
+ else if ((flagged.length || unreadable.length) && flags.strict) process.exitCode = 2;
126
+ }