@shomra/agent 0.3.17 → 0.3.19

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 +45 -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,168 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { MAX_ARTIFACT_BYTES, SKIP_DIRS } from '../artifacts/matchers.mjs';
4
+ import { api } from '../core/api-client.mjs';
5
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
6
+ import { EXIT_USAGE, exitNotConfigured } from '../core/exit-codes.mjs';
7
+ import { SEV_COLOR, bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
8
+ import { CAP_LABEL, analyzeDesign, designChecklist } from '../detect/design.mjs';
9
+
10
+ export async function cmdDesign(flags, positional) {
11
+ const target = positional[0] || flags.path;
12
+ if (!target) {
13
+ console.error(red('✗') + ' Usage: ' + bold('shomra design <file|dir|->') + dim(' (use - to read a ticket/RFC on stdin)'));
14
+ console.error(dim(' e.g. ') + 'gh issue view 42 --json body -q .body | shomra design -');
15
+ process.exit(EXIT_USAGE);
16
+ }
17
+
18
+ const docs = [];
19
+ if (target === '-' || flags.stdin) {
20
+ docs.push({ name: flags.name ? String(flags.name) : 'stdin', text: fs.readFileSync(0, 'utf8') });
21
+ } else {
22
+ const abs = path.resolve(String(target));
23
+ if (!fs.existsSync(abs)) {
24
+ console.error(red('✗') + ` Not found: ${target}`);
25
+ process.exit(EXIT_USAGE);
26
+ }
27
+ if (fs.statSync(abs).isDirectory()) {
28
+ for (const f of walkDesignDocs(abs)) {
29
+ try { if (fs.statSync(f.full).size <= MAX_ARTIFACT_BYTES) docs.push({ name: f.rel, text: fs.readFileSync(f.full, 'utf8') }); } catch { }
30
+ }
31
+ if (!docs.length) {
32
+ console.error(red('✗') + ` No design documents (.md / .txt / .rst) found under ${target}.`);
33
+ process.exit(EXIT_USAGE);
34
+ }
35
+ } else {
36
+ docs.push({ name: path.relative(process.cwd(), abs).split(path.sep).join('/'), text: fs.readFileSync(abs, 'utf8') });
37
+ }
38
+ }
39
+
40
+ const results = docs.map((d) => analyzeDesign(d.text, { name: d.name }));
41
+ const open = results.filter((r) => r.verdict === 'OPEN_PATH');
42
+ const critical = results.filter((r) => r.worst === 'CRITICAL');
43
+
44
+ if (flags.json) {
45
+ console.log(JSON.stringify({ documents: results.length, openPaths: open.length, critical: critical.length, results }, null, 2));
46
+ } else if (flags.checklist) {
47
+
48
+ console.log(results.map(designChecklist).join('\n---\n\n'));
49
+ } else {
50
+ for (const r of results) printDesign(r);
51
+ if (results.length > 1) {
52
+ console.log(
53
+ ` ${open.length ? red(`✗ ${open.length} of ${results.length} documents describe a closed attack path`) : yellow(`• no closed path described in ${results.length} documents`)}\n`,
54
+ );
55
+ }
56
+ }
57
+
58
+ if (flags.save) await saveDesign(results, flags);
59
+
60
+ if (critical.length) process.exitCode = 1;
61
+ else if (open.length && flags.strict) process.exitCode = 2;
62
+ }
63
+
64
+ async function saveDesign(results, flags) {
65
+ const { apiKey, url } = resolveSettings(loadConfig());
66
+ const subject = String(flags.subject ?? '').trim();
67
+ const m = /^(AGENT|PROJECT|DESIGN):(.+)$/i.exec(subject);
68
+ if (!m) {
69
+ console.error(red('✗') + ' --save needs ' + bold('--subject KIND:id') + dim(' (AGENT | PROJECT | DESIGN)'));
70
+ console.error(dim(' e.g. ') + 'shomra design docs/rfc.md --save --subject DESIGN:inbox-agent');
71
+ process.exit(EXIT_USAGE);
72
+ }
73
+ if (results.length !== 1) {
74
+ console.error(red('✗') + ` --save takes one document; ${results.length} were modelled.`);
75
+ console.error(dim(' Save each against its own subject.'));
76
+ process.exit(EXIT_USAGE);
77
+ }
78
+ if (!apiKey || !url) exitNotConfigured();
79
+
80
+ const [, kind, id] = m;
81
+ const r = results[0];
82
+ process.stdout.write(dim(' Saving to the platform… '));
83
+ try {
84
+ const res = await api(url, apiKey, '/threat-models/agent-author', {
85
+ subjectKind: kind.toUpperCase(),
86
+ subjectId: id,
87
+ title: flags.title ? String(flags.title) : r.name,
88
+ analysis: r,
89
+ note: flags.note ? String(flags.note) : `Modelled from ${r.name}.`,
90
+ actor: flags.actor ? String(flags.actor) : undefined,
91
+ });
92
+ const v = res?.version;
93
+ const c = res?.coverage;
94
+ console.log(green('saved') + dim(` - v${v?.seq ?? '?'}, ${v?.reviewState ?? 'IN_REVIEW'}`));
95
+
96
+ console.log(dim(' It is not approved yet - a threat model must be reviewed by someone other than its author.'));
97
+ if (c?.headline) console.log(dim(' ') + c.headline);
98
+ } catch (e) {
99
+ console.log(red('failed'));
100
+ console.error(dim(' ') + (e?.message || String(e)));
101
+ process.exitCode = 1;
102
+ }
103
+ }
104
+
105
+ const DESIGN_DOC_RE = /\.(md|markdown|txt|rst|adoc)$/i;
106
+
107
+ const DESIGN_MAX_DOCS = 50;
108
+
109
+ function walkDesignDocs(root) {
110
+ const found = [];
111
+ const stack = [root];
112
+ while (stack.length && found.length < DESIGN_MAX_DOCS) {
113
+ const dir = stack.pop();
114
+ let entries;
115
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
116
+ for (const ent of entries) {
117
+ const full = path.join(dir, ent.name);
118
+ if (ent.isDirectory()) { if (!SKIP_DIRS.has(ent.name)) stack.push(full); continue; }
119
+ if (!DESIGN_DOC_RE.test(ent.name)) continue;
120
+ found.push({ full, rel: path.relative(root, full).split(path.sep).join('/') });
121
+ if (found.length >= DESIGN_MAX_DOCS) break;
122
+ }
123
+ }
124
+ return found;
125
+ }
126
+
127
+ function printDesign(r) {
128
+ const vColor = r.verdict === 'OPEN_PATH' ? red : yellow;
129
+ console.log(bold(cyan('\n Shomra design')) + dim(` - ${r.name}`));
130
+
131
+ if (r.verdict === 'NOT_DESCRIBED') {
132
+ console.log(`\n ${yellow('• Nothing recognised')} ${dim('- no untrusted input, sensitive data, or agent action was described here.')}`);
133
+ console.log(dim(' That is a statement about the document, not about the system. If the agent will read'));
134
+ console.log(dim(' anything untrusted or take any action, write that down and re-run.\n'));
135
+ return;
136
+ }
137
+
138
+ const capLine = (list, kind) =>
139
+ list.length
140
+ ? ` ${bold(kind)} ${list.map((c) => CAP_LABEL[c]).join(dim(' · '))}`
141
+ : ` ${bold(kind)} ${dim('none described')}`;
142
+ console.log('');
143
+ console.log(capLine(r.sources, 'Sources'));
144
+ console.log(capLine(r.sinks, 'Sinks '));
145
+
146
+ if (r.verdict === 'PARTIAL') {
147
+ console.log(`\n ${yellow('• Only one side of a path is described.')} ${dim('No closed path - yet.')}`);
148
+ console.log(dim(' This is not a clean result: the other side may simply be unwritten, or land next sprint.\n'));
149
+ return;
150
+ }
151
+
152
+ console.log(`\n ${vColor(`✗ ${r.paths.length} attack path${r.paths.length === 1 ? '' : 's'} closed by this design:`)}\n`);
153
+ for (const p of r.paths.slice(0, 6)) {
154
+ const sc = SEV_COLOR[p.severity] || dim;
155
+ console.log(` ${sc(String(p.severity).padEnd(8))} ${bold(CAP_LABEL[p.source])} ${dim('→')} ${bold(CAP_LABEL[p.sink])}`);
156
+ console.log(` ${p.story}`);
157
+ if (p.sourceEvidence) console.log(dim(` ↳ line ${p.sourceEvidence.line}: "${p.sourceEvidence.quote}"`));
158
+ }
159
+ if (r.paths.length > 6) console.log(dim(` … and ${r.paths.length - 6} more (run with --json for all)`));
160
+
161
+ console.log(`\n ${bold('Conditions to satisfy before this ships')}`);
162
+ for (const c of r.controls.slice(0, 8)) console.log(` ${dim('☐')} ${c.text}`);
163
+ if (r.controls.length > 8) console.log(dim(` … and ${r.controls.length - 8} more`));
164
+
165
+ console.log(dim('\n Paste these into the ticket: ') + bold(`shomra design ${r.name} --checklist`));
166
+ console.log(dim(' It reads prose - it sees only what was written down. A capability nobody documented'));
167
+ console.log(dim(' is not a capability you do not have.\n'));
168
+ }
@@ -0,0 +1,209 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import { loadConfig } from '../core/config.mjs';
4
+ import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
5
+ import { localGate } from '../detect/guard-signals.mjs';
6
+ import { discoverAll } from '../inventory/discovery.mjs';
7
+
8
+ const MAX_ISSUES_SHOWN = 6;
9
+ const UNGUARDED_AGENT_PENALTY = 8;
10
+ const MAX_UNGUARDED_PENALTY = 40;
11
+ const BLOCK_PENALTY = 15;
12
+ const FLAG_PENALTY = 5;
13
+ const DOTENV_KEY_PENALTY = 10;
14
+ const MAX_DOTENV_PENALTY = 30;
15
+
16
+ function assetPath(asset) {
17
+ return asset.identifier || asset.metadata?.configFile || asset.metadata?.file || asset.name;
18
+ }
19
+
20
+ function riskyArtifactsIn(assets, kind) {
21
+ const risky = [];
22
+ for (const asset of assets) {
23
+ const content = asset.content || asset.metadata?.content;
24
+ if (!content) continue;
25
+ const location = assetPath(asset);
26
+ const verdict = localGate(content, { kind, path: location });
27
+ if (verdict.verdict === 'ALLOW') continue;
28
+ risky.push({
29
+ name: asset.name,
30
+ kind,
31
+ decision: verdict.verdict,
32
+ riskScore: verdict.riskScore,
33
+ top: (verdict.findings[0] || {}).title,
34
+ path: location,
35
+ });
36
+ }
37
+ return risky;
38
+ }
39
+
40
+ function dedupeRisky(risky) {
41
+ const seen = new Set();
42
+ return risky.filter((entry) => {
43
+ const key = `${entry.path}|${entry.decision}|${entry.top}`;
44
+ if (seen.has(key)) return false;
45
+ seen.add(key);
46
+ return true;
47
+ });
48
+ }
49
+
50
+ function groupIntoIssues(risky) {
51
+ const byIssue = new Map();
52
+ for (const entry of risky) {
53
+ const key = `${entry.name}|${entry.kind}|${entry.decision}|${entry.top}`;
54
+ const group = byIssue.get(key) ?? {
55
+ name: entry.name, kind: entry.kind, decision: entry.decision, top: entry.top, riskScore: entry.riskScore, paths: [],
56
+ };
57
+ group.paths.push(entry.path);
58
+ byIssue.set(key, group);
59
+ }
60
+ return [...byIssue.values()];
61
+ }
62
+
63
+ function postureScore({ unguarded, issues, dotenvKeys }) {
64
+ let score = 100;
65
+ score -= Math.min(MAX_UNGUARDED_PENALTY, unguarded.length * UNGUARDED_AGENT_PENALTY);
66
+ for (const issue of issues) score -= issue.decision === 'BLOCK' ? BLOCK_PENALTY : FLAG_PENALTY;
67
+ score -= Math.min(MAX_DOTENV_PENALTY, dotenvKeys.length * DOTENV_KEY_PENALTY);
68
+ return Math.max(0, Math.round(score));
69
+ }
70
+
71
+ function postureGrade(score) {
72
+ if (score >= 90) return 'A';
73
+ if (score >= 75) return 'B';
74
+ if (score >= 60) return 'C';
75
+ if (score >= 40) return 'D';
76
+ return 'F';
77
+ }
78
+
79
+ function scoreColour(score) {
80
+ if (score >= 75) return green;
81
+ if (score >= 50) return yellow;
82
+ return red;
83
+ }
84
+
85
+ function collectInventory() {
86
+ const assets = discoverAll();
87
+ const ofType = (type) => assets.filter((asset) => asset.type === type);
88
+ return {
89
+ agents: ofType('AI_AGENT'),
90
+ mcpServers: ofType('MCP_SERVER'),
91
+ rulesFiles: ofType('AI_RULES'),
92
+ modelKeys: ofType('MODEL_KEY'),
93
+ aiTools: ofType('AI_TOOL'),
94
+ };
95
+ }
96
+
97
+ function printCountRow(label, count, note) {
98
+ console.log(` ${dim(String(label).padEnd(16))} ${bold(String(count).padStart(3))}${note ? ` ${note}` : ''}`);
99
+ }
100
+
101
+ function printInventoryRows({ inventory, risky, unguarded, dotenvKeys }) {
102
+ const riskyOfKind = (kind) => risky.filter((entry) => entry.kind === kind).length;
103
+ const { agents, mcpServers, rulesFiles, modelKeys, aiTools } = inventory;
104
+
105
+ printCountRow('Coding agents', agents.length, unguarded.length
106
+ ? red(`${unguarded.length} UNGUARDED`) + dim(` · ${agents.length - unguarded.length} protected`)
107
+ : green('all protected'));
108
+ printCountRow('MCP servers', mcpServers.length, riskyOfKind('mcp') ? yellow(`${riskyOfKind('mcp')} risky`) : '');
109
+ printCountRow('Rules files', rulesFiles.length, riskyOfKind('rules') ? yellow(`${riskyOfKind('rules')} risky`) : '');
110
+ printCountRow('Model keys', modelKeys.length, dotenvKeys.length ? yellow(`${dotenvKeys.length} in .env files`) : '');
111
+ printCountRow('AI tools', aiTools.length, '');
112
+ }
113
+
114
+ function printRiskyIssues(issues) {
115
+ if (!issues.length) return;
116
+ console.log(dim('\n Risky artifacts:'));
117
+
118
+ const shortDir = (target) => path.dirname(String(target || '')).replace(os.homedir(), '~').split(path.sep).join('/');
119
+ const shown = issues.slice(0, MAX_ISSUES_SHOWN);
120
+ for (const issue of shown) {
121
+ const colour = issue.decision === 'BLOCK' ? red : yellow;
122
+ const location = shortDir(issue.paths[0]);
123
+ const where = issue.paths.length > 1
124
+ ? dim(`×${issue.paths.length}`) + dim(` · ${location}, …`)
125
+ : dim(location);
126
+ console.log(` ${colour('●')} ${bold(issue.name)} ${where} ${dim(`(${issue.kind})`)} ${colour(issue.decision)} ${dim(issue.top || '')}`);
127
+ }
128
+
129
+ const remaining = issues.length - shown.length;
130
+ if (remaining > 0) console.log(dim(` …and ${remaining} more distinct issue${remaining > 1 ? 's' : ''}`));
131
+ }
132
+
133
+ function topFixes({ unguarded, issues, risky, dotenvKeys }) {
134
+ const fixes = [];
135
+ if (unguarded.length) {
136
+ fixes.push(`${red('!')} ${unguarded.length} coding agent${unguarded.length > 1 ? 's have' : ' has'} no runtime firewall → ${bold('shomra protect')}`);
137
+ }
138
+ if (issues.length) {
139
+ const spread = risky.length > issues.length ? dim(` across ${risky.length} files`) : '';
140
+ fixes.push(`${yellow('!')} ${issues.length} risky MCP/rules issue${issues.length > 1 ? 's' : ''}${spread} → ${bold('shomra check')} ${dim('or')} ${bold('shomra gate <file>')}`);
141
+ }
142
+ if (dotenvKeys.length) {
143
+ fixes.push(`${yellow('!')} ${dotenvKeys.length} model key${dotenvKeys.length > 1 ? 's' : ''} in .env file${dotenvKeys.length > 1 ? 's' : ''} → rotate + ensure .gitignore covers them`);
144
+ }
145
+ return fixes;
146
+ }
147
+
148
+ function printFixes(fixes) {
149
+ if (!fixes.length) {
150
+ console.log(green('\n ✓ No urgent fixes - nice posture.'));
151
+ return;
152
+ }
153
+ console.log(bold('\n Top fixes:'));
154
+ for (const fix of fixes) console.log(` ${fix}`);
155
+ }
156
+
157
+ function printEnrollmentHint() {
158
+ const enrolled = !!loadConfig().apiKey;
159
+ const message = enrolled
160
+ ? `Enrolled - run ${bold('shomra report')}${dim(' to sync this to your Shomra org.')}`
161
+ : `Run ${bold('shomra init')}${dim(' to apply org policy and sync posture.')}`;
162
+ console.log(`${dim(`\n ${message}`)}\n`);
163
+ }
164
+
165
+ function jsonReport({ inventory, score, grade, risky, issues, unguarded, dotenvKeys }) {
166
+ return {
167
+ score,
168
+ grade,
169
+ hostname: os.hostname(),
170
+ codingAgents: inventory.agents.length,
171
+ unguarded: unguarded.length,
172
+ mcpServers: inventory.mcpServers.length,
173
+ rulesFiles: inventory.rulesFiles.length,
174
+ aiTools: inventory.aiTools.length,
175
+ modelKeys: inventory.modelKeys.length,
176
+ modelKeysInDotenv: dotenvKeys.length,
177
+ riskyArtifacts: risky.length,
178
+ riskyIssues: issues.length,
179
+ risky,
180
+ };
181
+ }
182
+
183
+ export function cmdDoctor(flags) {
184
+ const inventory = collectInventory();
185
+ const risky = dedupeRisky([
186
+ ...riskyArtifactsIn(inventory.mcpServers, 'mcp'),
187
+ ...riskyArtifactsIn(inventory.rulesFiles, 'rules'),
188
+ ]);
189
+ const issues = groupIntoIssues(risky);
190
+ const unguarded = inventory.agents.filter((agent) => !agent.metadata?.guarded);
191
+ const dotenvKeys = inventory.modelKeys.filter((key) => key.metadata?.source === 'dotenv');
192
+
193
+ const score = postureScore({ unguarded, issues, dotenvKeys });
194
+ const grade = postureGrade(score);
195
+
196
+ if (flags.json) {
197
+ console.log(JSON.stringify(jsonReport({ inventory, score, grade, risky, issues, unguarded, dotenvKeys }), null, 2));
198
+ return;
199
+ }
200
+
201
+ const colour = scoreColour(score);
202
+ console.log(bold(cyan('\n Shomra doctor')) + dim(` - ${os.hostname()}`));
203
+ console.log(`\n ${bold('Posture')} ${colour(bold(`${score}/100`))} ${dim('· grade')} ${colour(bold(grade))}\n`);
204
+
205
+ printInventoryRows({ inventory, risky, unguarded, dotenvKeys });
206
+ printRiskyIssues(issues);
207
+ printFixes(topFixes({ unguarded, issues, risky, dotenvKeys }));
208
+ printEnrollmentHint();
209
+ }
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { api } from '../core/api-client.mjs';
4
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
5
+ import { EXIT_USAGE } from '../core/exit-codes.mjs';
6
+ import { SEV_COLOR, bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
7
+ import { GATE_KINDS } from '../gate/environment.mjs';
8
+
9
+ export async function cmdFix(flags, positional) {
10
+ const file = positional[0];
11
+ if (!file) {
12
+ console.error(red('✗') + ' Usage: ' + bold('shomra fix <file> [--apply] [--kind mcp|skill|command|subagent|hook|rules] [--json]'));
13
+ process.exit(EXIT_USAGE);
14
+ }
15
+ const cfg = loadConfig();
16
+ const { apiKey, url } = resolveSettings(cfg);
17
+ if (!apiKey) {
18
+ console.error('\n' + red('✗') + ' ' + bold('shomra fix') + ' needs enrollment - the fix is generated on the platform with your org AI key.');
19
+ console.error(' ' + dim('Run ') + bold('shomra init --key shm_live_…') + dim(', or apply the guidance from ') + bold('shomra check') + dim(' by hand.\n'));
20
+ process.exit(EXIT_USAGE);
21
+ }
22
+ let target = path.resolve(String(file));
23
+ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
24
+ const skillMd = path.join(target, 'SKILL.md');
25
+ if (!fs.existsSync(skillMd)) {
26
+ console.error(red('✗') + ` ${file} is a directory with no SKILL.md - point at a file instead.`);
27
+ process.exit(EXIT_USAGE);
28
+ }
29
+ target = skillMd;
30
+ }
31
+ if (!fs.existsSync(target)) {
32
+ console.error(red('✗') + ` File not found: ${file}`);
33
+ process.exit(EXIT_USAGE);
34
+ }
35
+ await fixOneFile(target, { apiKey, url, flags });
36
+ }
37
+
38
+ export async function fixOneFile(target, { apiKey, url, flags }) {
39
+ const rel = path.relative(process.cwd(), target).split(path.sep).join('/');
40
+ let content;
41
+ try {
42
+ content = fs.readFileSync(target, 'utf8');
43
+ } catch (e) {
44
+ if (!flags.json) console.error(` ${red('✗')} cannot read ${rel}: ${e.message}`);
45
+ return false;
46
+ }
47
+ const kind = flags.kind && GATE_KINDS.includes(String(flags.kind)) ? String(flags.kind) : undefined;
48
+
49
+ let res;
50
+ try {
51
+ if (!flags.json && !flags.quiet) process.stdout.write(dim(` Generating fix for ${rel}… `));
52
+ res = await api(url, apiKey, '/gate/fix', {
53
+ ...(kind ? { kind } : {}),
54
+ path: rel,
55
+ name: rel.split('/').pop(),
56
+ content,
57
+ });
58
+ if (!flags.json && !flags.quiet) console.log('');
59
+ } catch (e) {
60
+ if (flags.json) console.log(JSON.stringify({ path: rel, canFix: false, error: e.message }, null, 2));
61
+ else console.error(` ${red('✗')} ${e.message}`);
62
+ return false;
63
+ }
64
+
65
+ if (flags.json) console.log(JSON.stringify({ path: rel, ...res }, null, 2));
66
+
67
+ if (!res.canFix) {
68
+ if (!flags.json) {
69
+ if (res.reason === 'clean') console.log(` ${green('✓')} ${dim(rel + ' - nothing to fix.')}`);
70
+ else if (res.reason === 'ai-disabled') {
71
+ console.log(` ${yellow('⚠')} ${res.message}`);
72
+ for (const g of res.guidance || []) {
73
+ console.log(` ${SEV_COLOR[g.severity](String(g.severity).padEnd(8))} ${g.title}`);
74
+ if (g.remediationText) console.log(` ${dim('fix: ' + g.remediationText)}`);
75
+ }
76
+ console.log('');
77
+ } else console.log(` ${yellow('⚠')} ${dim(rel + ' - ')}${res.message || 'no fix produced.'}`);
78
+ }
79
+ return false;
80
+ }
81
+
82
+ if (!flags.json) {
83
+ printDiff(res.diff);
84
+ if (res.explanation) console.log(` ${dim(res.explanation)}`);
85
+ const conf = res.confidence != null ? ` ${dim('confidence ' + Math.round(res.confidence * 100) + '%')}` : '';
86
+ if (conf) console.log(conf);
87
+ console.log('');
88
+ }
89
+
90
+ const apply = flags.apply || flags.write || flags.yes;
91
+ if (!apply) {
92
+ if (!flags.json) console.log(` ${dim('Preview only - re-run with')} ${bold('--apply')} ${dim('to write this fix to ' + rel + '.')}\n`);
93
+ return true;
94
+ }
95
+ try {
96
+ fs.writeFileSync(target, res.fixedContent, 'utf8');
97
+ if (!flags.json && !flags.quiet) {
98
+ console.log(` ${green('✓ Applied')} ${dim('→ ' + rel + ' (' + (res.findingCount || (res.findings || []).length) + ' finding(s) addressed)')}\n`);
99
+ }
100
+ return true;
101
+ } catch (e) {
102
+ if (!flags.json) console.error(` ${red('✗')} could not write ${rel}: ${e.message}`);
103
+ return false;
104
+ }
105
+ }
106
+
107
+ function printDiff(diff) {
108
+ if (!diff) return;
109
+ for (const line of String(diff).split('\n')) {
110
+ if (line.startsWith('+') && !line.startsWith('+++')) console.log(' ' + green(line));
111
+ else if (line.startsWith('-') && !line.startsWith('---')) console.log(' ' + red(line));
112
+ else if (line.startsWith('@@')) console.log(' ' + cyan(line));
113
+ else console.log(' ' + dim(line));
114
+ }
115
+ }
@@ -0,0 +1,154 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { walkArtifacts } from '../artifacts/matchers.mjs';
4
+ import { api, gateMachine } from '../core/api-client.mjs';
5
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
6
+ import { EXIT_USAGE } from '../core/exit-codes.mjs';
7
+ import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
8
+ import { localGate } from '../detect/guard-signals.mjs';
9
+ import { failOnHit, gateArtifactList } from '../gate/batch.mjs';
10
+ import { GATE_KINDS, collectSiblings, detectEnv } from '../gate/environment.mjs';
11
+ import { localAsGateResult, printGateResult } from '../gate/result.mjs';
12
+ import { toSarif } from '../gate/sarif.mjs';
13
+ import { collectLocalSast, mergeSastIntoResult } from '../gate/sast.mjs';
14
+
15
+ const GATE_USAGE = 'shomra gate <file> [--kind mcp|skill|command|subagent|hook|rules|agent-card|memory] [--name x] [--strict] [--json]';
16
+
17
+ function resolveGateFile(file) {
18
+ let target = path.resolve(String(file));
19
+
20
+ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
21
+ const skillFile = path.join(target, 'SKILL.md');
22
+ if (!fs.existsSync(skillFile)) {
23
+ console.error(`${red('✗')} ${file} is a directory with no SKILL.md - point at a file instead.`);
24
+ process.exit(EXIT_USAGE);
25
+ }
26
+ target = skillFile;
27
+ }
28
+ if (!fs.existsSync(target)) {
29
+ console.error(`${red('✗')} File not found: ${file}`);
30
+ process.exit(EXIT_USAGE);
31
+ }
32
+ return target;
33
+ }
34
+
35
+ function readGateTarget(flags, positional) {
36
+ if (flags.stdin) {
37
+ return { content: fs.readFileSync(0, 'utf8'), relPath: flags.path || null, fullTarget: null };
38
+ }
39
+
40
+ const file = positional[0];
41
+ if (!file) {
42
+ console.error(`${red('✗')} Usage: ${bold(GATE_USAGE)}`);
43
+ process.exit(EXIT_USAGE);
44
+ }
45
+ const target = resolveGateFile(file);
46
+ return {
47
+ content: fs.readFileSync(target, 'utf8'),
48
+ relPath: path.relative(process.cwd(), target).split(path.sep).join('/'),
49
+ fullTarget: target,
50
+ };
51
+ }
52
+
53
+ async function requestGateVerdict({ url, apiKey, flags, kind, relPath, content, fullTarget }) {
54
+ if (!flags.json) process.stdout.write(dim(' Checking with Shomra gate… '));
55
+ try {
56
+ const siblings = collectSiblings(fullTarget, relPath);
57
+ const verdict = await api(url, apiKey, '/gate/check', {
58
+ ...(kind ? { kind } : {}),
59
+ ...(flags.name ? { name: String(flags.name) } : {}),
60
+ ...(relPath ? { path: relPath } : {}),
61
+ content,
62
+ ...(siblings.length ? { siblings } : {}),
63
+ machine: gateMachine(),
64
+ env: detectEnv(),
65
+ ...(flags.project ? { projectId: String(flags.project) } : {}),
66
+ });
67
+ if (!flags.json) console.log('');
68
+ return { verdict };
69
+ } catch (error) {
70
+ if (!flags.json) {
71
+ console.log(yellow('backend unavailable'));
72
+ console.error(` ${yellow('⚠')} ${error.message} ${dim('- falling back to on-machine analysis')}`);
73
+ }
74
+ return { verdict: null };
75
+ }
76
+ }
77
+
78
+ export async function cmdGate(flags, positional) {
79
+ const { apiKey, url } = resolveSettings(loadConfig());
80
+ if (flags.all) return cmdGateAll(flags, positional, { apiKey, url });
81
+
82
+ const { content, relPath, fullTarget } = readGateTarget(flags, positional);
83
+ const kind = flags.kind && GATE_KINDS.includes(String(flags.kind)) ? String(flags.kind) : undefined;
84
+ const name = flags.name ? String(flags.name) : (relPath ? relPath.split('/').pop() : 'artifact');
85
+ const local = localGate(content, { kind, path: relPath });
86
+
87
+ let serverVerdict = null;
88
+ if (apiKey) {
89
+ ({ verdict: serverVerdict } = await requestGateVerdict({ url, apiKey, flags, kind, relPath, content, fullTarget }));
90
+ if (!serverVerdict && flags.strict) {
91
+ printGateResult(localAsGateResult(local, name, kind), 'local', flags);
92
+ if (!flags.json) console.log(` ${red('✗ Failing closed (--strict): backend unreachable, org policy unverified.')}\n`);
93
+ process.exitCode = 1;
94
+ return;
95
+ }
96
+ } else if (!flags.json) {
97
+ console.error(` ${dim('Not enrolled - on-machine analysis only. Run')} ${bold('shomra init')} ${dim('to also apply org policy.')}`);
98
+ }
99
+
100
+ const base = serverVerdict || localAsGateResult(local, name, kind);
101
+ const result = mergeSastIntoResult(base, collectLocalSast({ fullPath: fullTarget, relPath, kind, content }));
102
+ printGateResult(result, serverVerdict ? 'server' : 'local', flags);
103
+
104
+ if (result.decision === 'BLOCK') process.exitCode = 1;
105
+ else if (result.decision === 'FLAG' && flags.strict) process.exitCode = 2;
106
+ }
107
+
108
+ async function cmdGateAll(flags, positional, { apiKey, url }) {
109
+
110
+ const dirArg = typeof flags.all === 'string' ? flags.all : positional[0] || '.';
111
+ const root = path.resolve(dirArg);
112
+ const env = detectEnv();
113
+ const artifacts = walkArtifacts(root);
114
+
115
+ if (!artifacts.length) {
116
+ if (flags.json) console.log(JSON.stringify({ scanned: 0, results: [] }, null, 2));
117
+ else console.log(dim(`\n No AI artifacts found under ${root}. Nothing to gate.\n`));
118
+ return;
119
+ }
120
+
121
+ if (!flags.json && !flags.sarif) console.log(bold(cyan('\n Shomra gate')) + dim(` - batch (${artifacts.length} artifact${artifacts.length > 1 ? 's' : ''} · ${env.environment}${env.ciProvider ? ' · ' + env.ciProvider : ''})`));
122
+
123
+ const { results, blocked, flagged, suppressed, backendDown, rejected } = await gateArtifactList(artifacts, { apiKey, url, env, flags, root });
124
+
125
+ const strictOutage = backendDown && flags.strict;
126
+
127
+ if (flags.sarif) {
128
+ console.log(JSON.stringify(toSarif(results), null, 2));
129
+ if (blocked > 0 || strictOutage) process.exitCode = 1;
130
+ else if (failOnHit(flags, blocked, flagged)) process.exitCode = 1;
131
+ else if (flagged > 0 && flags.strict) process.exitCode = 2;
132
+ return;
133
+ }
134
+ if (flags.json) {
135
+ console.log(JSON.stringify({ scanned: results.length, blocked, flagged, suppressed, backendDown, rejected, environment: env.environment, results }, null, 2));
136
+ } else {
137
+ console.log(
138
+ '\n ' +
139
+ (blocked > 0
140
+ ? red(`✗ ${blocked} blocked`) + dim(` · ${flagged} flagged · ${results.length - blocked - flagged} allowed`)
141
+ : flagged > 0
142
+ ? yellow(`⚠ ${flagged} flagged`) + dim(` · ${results.length - flagged} allowed`)
143
+ : green(`✓ All ${results.length} artifacts allowed.`)) +
144
+ (suppressed ? dim(` · ${suppressed} suppressed`) : '') +
145
+ (backendDown ? yellow(' (on-machine analysis - org policy not applied)') : dim(' - full activity in the Shomra dashboard → Gate Activity')) +
146
+ '\n',
147
+ );
148
+ if (strictOutage) console.log(` ${red('✗ Failing closed (--strict): backend unreachable, org policy unverified.')}\n`);
149
+ }
150
+
151
+ if (blocked > 0 || strictOutage) process.exitCode = 1;
152
+ else if (failOnHit(flags, blocked, flagged)) process.exitCode = 1;
153
+ else if (flagged > 0 && flags.strict) process.exitCode = 2;
154
+ }