@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,163 @@
1
+ import { execSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { EXIT_USAGE } from '../core/exit-codes.mjs';
5
+ import { bold, dim, green, red, yellow } from '../core/terminal.mjs';
6
+
7
+ export async function cmdInstallPrecommit(flags, positional) {
8
+
9
+ if (flags['pre-receive']) return installPreReceive(flags, positional);
10
+ const root = path.resolve(positional[0] || '.');
11
+ const hooksDir = gitHooksDir(root);
12
+ if (!hooksDir) {
13
+ console.error(red('✗') + ' Not a git repository (or git unavailable). cd into your repo first.');
14
+ process.exit(EXIT_USAGE);
15
+ }
16
+ const hookPath = path.join(hooksDir, 'pre-commit');
17
+ const marker = 'shomra check --staged';
18
+ const managed = [
19
+ '#!/bin/sh',
20
+ '# Shomra - block staged AI artifacts that fail the gate before they land.',
21
+ '# Managed by `shomra install-precommit`. Delete this file to uninstall.',
22
+
23
+ 'command -v shomra >/dev/null 2>&1 || {',
24
+ ' echo "" >&2',
25
+ ' echo "!! ============================================================== !!" >&2',
26
+ ' echo "!! WARNING: shomra not on PATH - the AI-artifact gate DID NOT RUN !!" >&2',
27
+ ' echo "!! Staged MCP/skill/rules files were committed UNGATED. !!" >&2',
28
+ ' echo "!! Fix: npm i -g @shomra/agent (then re-commit to gate) !!" >&2',
29
+ ' echo "!! ============================================================== !!" >&2',
30
+ ' echo "" >&2',
31
+ ' exit 0',
32
+ '}',
33
+ 'shomra check --staged',
34
+ 'if [ "$?" -eq 1 ]; then',
35
+ ' echo "✗ Shomra blocked a staged AI artifact - run: shomra fix <file> --apply (or: git commit --no-verify to override)"',
36
+ ' exit 1',
37
+ 'fi',
38
+ 'exit 0',
39
+ '',
40
+ ].join('\n');
41
+
42
+ let existing = null;
43
+ try {
44
+ existing = fs.readFileSync(hookPath, 'utf8');
45
+ } catch {}
46
+
47
+ if (existing && existing.includes(marker) && !flags.force) {
48
+ console.log(green(' ✓') + ' Shomra pre-commit hook already installed ' + dim('→ ' + hookPath));
49
+ return;
50
+ }
51
+ if (existing && !existing.includes(marker) && !flags.force) {
52
+ console.log('\n ' + yellow('⚠') + ' A pre-commit hook already exists ' + dim('→ ' + hookPath));
53
+ console.log(' Add this line to it, or re-run with ' + bold('--force') + ' to replace it (a backup is kept):');
54
+ console.log(' ' + bold(marker) + '\n');
55
+ return;
56
+ }
57
+ if (existing && flags.force) {
58
+ try {
59
+ fs.writeFileSync(hookPath + '.bak', existing);
60
+ console.log(dim(' Backed up existing hook → ' + path.basename(hookPath) + '.bak'));
61
+ } catch {}
62
+ }
63
+ fs.writeFileSync(hookPath, managed, 'utf8');
64
+ try {
65
+ fs.chmodSync(hookPath, 0o755);
66
+ } catch {}
67
+ console.log('\n ' + green('✓ Installed') + ' Shomra pre-commit hook ' + dim('→ ' + hookPath));
68
+ console.log(dim(' Staged AI artifacts are now gated on every commit. Override once with ') + bold('git commit --no-verify') + dim('.\n'));
69
+ }
70
+
71
+ function installPreReceive(flags, positional) {
72
+ const root = path.resolve(positional[0] || flags.path || '.');
73
+
74
+ const bareHooks = path.join(root, 'hooks');
75
+ const dir = fs.existsSync(bareHooks) && fs.statSync(bareHooks).isDirectory() ? bareHooks : gitHooksDir(root);
76
+ if (!dir) {
77
+ console.error(red('✗') + ` No git hooks directory under ${root}. Point this at a BARE repository (the one the server hosts), not a working checkout.`);
78
+ process.exit(EXIT_USAGE);
79
+ }
80
+
81
+ const hookPath = path.join(dir, 'pre-receive');
82
+ const marker = 'shomra gate --all';
83
+ const managed = [
84
+ '#!/bin/sh',
85
+ '# Shomra - refuse a push that carries a blocked AI artifact.',
86
+ '# Managed by `shomra install-precommit --pre-receive`. Delete this file to uninstall.',
87
+ '#',
88
+ '# Runs on the SERVER, so unlike pre-commit it cannot be skipped with',
89
+ '# --no-verify and it covers developers who never installed anything.',
90
+ 'set -e',
91
+ '',
92
+ '# ⚠ FAIL CLOSED. The client-side hook fails open on a missing binary because',
93
+ '# blocking a local commit over a tooling problem is hostile. The opposite is',
94
+ '# true here: this is the enforcement point, so an environment that cannot run',
95
+ '# the check must refuse the push rather than wave it through - otherwise',
96
+ '# deleting the binary is the bypass.',
97
+ 'command -v shomra >/dev/null 2>&1 || {',
98
+ ' echo "" >&2',
99
+ ' echo "REJECTED: shomra is not installed on this git server, so the AI-artifact" >&2',
100
+ ' echo " gate could not run. Install it (npm i -g @shomra/agent) or" >&2',
101
+ ' echo " remove this hook deliberately." >&2',
102
+ ' exit 1',
103
+ '}',
104
+ '',
105
+ 'TMP=$(mktemp -d)',
106
+ 'trap \'rm -rf "$TMP"\' EXIT',
107
+ 'STATUS=0',
108
+ '',
109
+ '# stdin is "<old> <new> <ref>" per pushed ref. Export each ref\'s tree to a',
110
+ '# temp dir and gate it - the push is refused as a whole if any ref carries a',
111
+ '# blocked artifact.',
112
+ 'while read -r oldrev newrev refname; do',
113
+ ' # All-zero newrev = branch deletion. Nothing arrives, nothing to gate.',
114
+ ' case "$newrev" in *[!0]*) ;; *) continue ;; esac',
115
+ ' WORK="$TMP/$(echo "$refname" | tr "/" "_")"',
116
+ ' mkdir -p "$WORK"',
117
+ ' git archive "$newrev" | tar -x -C "$WORK" 2>/dev/null || continue',
118
+ ' if ! shomra gate --all "$WORK"; then',
119
+ ' echo "" >&2',
120
+ ' echo "REJECTED: $refname carries an AI artifact Shomra blocks (see above)." >&2',
121
+ ' echo " Fix it locally (shomra check --fix) and push again." >&2',
122
+ ' STATUS=1',
123
+ ' fi',
124
+ 'done',
125
+ '',
126
+ 'exit $STATUS',
127
+ '',
128
+ ].join('\n');
129
+
130
+ let existing = null;
131
+ try { existing = fs.readFileSync(hookPath, 'utf8'); } catch { }
132
+ if (existing && existing.includes(marker) && !flags.force) {
133
+ console.log(green(' ✓') + ' Shomra pre-receive hook already installed ' + dim('→ ' + hookPath));
134
+ return;
135
+ }
136
+ if (existing && !existing.includes(marker) && !flags.force) {
137
+ console.log('\n ' + yellow('⚠') + ' A pre-receive hook already exists ' + dim('→ ' + hookPath));
138
+ console.log(' Chain Shomra into it, or re-run with ' + bold('--force') + ' to replace it (a backup is kept).\n');
139
+ return;
140
+ }
141
+ if (existing && flags.force) {
142
+ try { fs.writeFileSync(hookPath + '.bak', existing); console.log(dim(' Backed up existing hook → pre-receive.bak')); } catch { }
143
+ }
144
+ fs.writeFileSync(hookPath, managed, 'utf8');
145
+ try { fs.chmodSync(hookPath, 0o755); } catch { }
146
+
147
+ console.log('\n ' + green('✓ Installed') + ' Shomra pre-receive hook ' + dim('→ ' + hookPath));
148
+ console.log(dim(' Every push is now gated server-side - no --no-verify, and no per-developer install.'));
149
+ console.log(dim(' This hook FAILS CLOSED: if shomra is missing on the server, pushes are refused.'));
150
+ console.log(dim(' GitHub.com has no server-side hooks - there, use the Action as a required status check.\n'));
151
+ }
152
+
153
+ function gitHooksDir(root) {
154
+ try {
155
+ const dir = execSync('git rev-parse --git-path hooks', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).toString().trim();
156
+ if (!dir) return null;
157
+ const abs = path.isAbsolute(dir) ? dir : path.join(root, dir);
158
+ fs.mkdirSync(abs, { recursive: true });
159
+ return abs;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
@@ -0,0 +1,36 @@
1
+ import os from 'node:os';
2
+ import { api, machineInfo } from '../core/api-client.mjs';
3
+ import { CONFIG_FILE, getMachineId, loadConfig, saveConfig } from '../core/config.mjs';
4
+ import { EXIT_USAGE } from '../core/exit-codes.mjs';
5
+ import { bold, dim, green, red } from '../core/terminal.mjs';
6
+
7
+ export async function cmdInit(flags) {
8
+ const cfg = loadConfig();
9
+ const key = flags.key || process.env.SHOMRA_API_KEY;
10
+
11
+ const url = (flags.url || cfg.url || '').replace(/\/$/, '');
12
+ if (!key) {
13
+ console.error(red('✗') + ' Missing API key. Run: ' + bold('shomra init --key shm_live_… --url <your backend>'));
14
+ process.exit(EXIT_USAGE);
15
+ }
16
+ if (!url) {
17
+ console.error(red('✗') + ' Missing backend URL. Run: ' + bold('shomra init --key shm_live_… --url <your backend>'));
18
+ process.exit(EXIT_USAGE);
19
+ }
20
+ cfg.apiKey = key;
21
+ cfg.url = url;
22
+ getMachineId(cfg);
23
+ saveConfig(cfg);
24
+ process.stdout.write(dim('Enrolling this machine… '));
25
+ try {
26
+ const res = await api(url, key, '/agent/enroll', { machine: machineInfo(cfg) });
27
+ console.log(green('done'));
28
+ console.log(` ${green('✓')} Enrolled ${bold(os.hostname())} into org ${bold(res.org?.name ?? '?')}`);
29
+ console.log(` ${dim('Config saved to ' + CONFIG_FILE)}`);
30
+ console.log(`\n Next: ${bold('shomra report')} to send your first inventory.`);
31
+ } catch (e) {
32
+ console.log(red('failed'));
33
+ console.error(` ${red('✗')} ${e.message}`);
34
+ process.exit(1);
35
+ }
36
+ }
@@ -0,0 +1,51 @@
1
+ import { AGENT_INSTALLERS, AGENT_KEYS, AGENT_LABELS } from '../agents/installers.mjs';
2
+ import { EXIT_USAGE } from '../core/exit-codes.mjs';
3
+ import { bold, dim, green, red, yellow } from '../core/terminal.mjs';
4
+ import { PROMPT_HOOK_AGENTS } from '../guard/prompt-guard.mjs';
5
+
6
+ export function cmdInstallHook(flags) {
7
+ const global = !!flags.global;
8
+ const requested = flags.agent
9
+ ? String(flags.agent).toLowerCase().split(',').map((s) => s.trim()).filter(Boolean)
10
+ : ['claude'];
11
+ const unknown = requested.filter((a) => a !== 'all' && !AGENT_KEYS.includes(a));
12
+ if (unknown.length) {
13
+ console.error(red('✗') + ` Unknown agent(s): ${unknown.join(', ')}. Supported: ${AGENT_KEYS.join(', ')}, all.`);
14
+ process.exit(EXIT_USAGE);
15
+ }
16
+ const targets = requested.includes('all') ? AGENT_KEYS : requested;
17
+ if (!flags.agent) {
18
+ console.log(dim(' No --agent given - installing for Claude Code only. Use ') + bold('--agent all') + dim(' (or ') + bold('shomra protect') + dim(') to cover every agent.'));
19
+ }
20
+
21
+ for (const agent of targets) {
22
+ const { file, changed } = AGENT_INSTALLERS[agent](global);
23
+ if (changed) {
24
+ console.log(green('✓') + ` Installed the Shomra runtime firewall for ${bold(AGENT_LABELS[agent])} → ${bold(file)}`);
25
+ } else {
26
+ console.log(yellow('•') + ` Shomra runtime firewall already installed for ${AGENT_LABELS[agent]} in ${bold(file)}`);
27
+ }
28
+ if (agent === 'windsurf') {
29
+ console.log(dim(' Note: Windsurf\'s post-hooks can flag/log but not withhold a tool result.'));
30
+ }
31
+ if (agent === 'aider') {
32
+ console.log(dim(' Note: Aider has no tool hook - this routes its model calls through the'));
33
+ console.log(dim(' Shomra LLM Guard proxy. Start it with ') + 'shomra llm-proxy' + dim(' and set your API key.'));
34
+ }
35
+ }
36
+ console.log(dim('\n PreToolUse: screens every shell command, artifact write, and MCP call BEFORE it runs -'));
37
+ console.log(dim(' and vets AI model loads the agent writes (from_pretrained / hf_hub /'));
38
+ console.log(dim(' torch.hub …) against the Shomra Model Index, so a known-vulnerable model'));
39
+ console.log(dim(' is flagged with its fix BEFORE the load lands. (SHOMRA_MODEL_GUARD=0 to silence.)'));
40
+ console.log(dim(' PostToolUse: screens content fetched pages / file reads / MCP responses bring BACK'));
41
+ console.log(dim(' into the agent context - prompt injection, exfil sinks, hidden payloads.'));
42
+ if (targets.some((a) => PROMPT_HOOK_AGENTS.has(a))) {
43
+ console.log(dim(' Prompt: screens what YOU submit before it leaves the machine - a pasted live'));
44
+ console.log(dim(' credential is refused; pasted injection text is flagged to the model as'));
45
+ console.log(dim(' untrusted data. (SHOMRA_PROMPT_GUARD_OFF=1 to disable just this one.)'));
46
+ }
47
+ console.log(dim(' Blocked calls/results are refused with a reason; every decision lands in Shomra → Gate Activity.'));
48
+ console.log(dim(' Dangerous calls (curl|sh, reverse shells, secrets, injection) are blocked ON-MACHINE with'));
49
+ console.log(dim(' no network; only policy-relevant calls escalate to the backend, so a slow/down backend'));
50
+ console.log(dim(' never freezes the agent. Tip: ') + 'SHOMRA_GUARD_STRICT=1' + dim(' also fails-closed on the server tier.'));
51
+ }
@@ -0,0 +1,153 @@
1
+ import os from 'node:os';
2
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
3
+ import { exitNotConfigured } from '../core/exit-codes.mjs';
4
+ import { bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
5
+ import { VERSION } from '../core/version.mjs';
6
+ import { resolveAgentIdentityHandle } from './agent-identity.mjs';
7
+
8
+ export const LLM_PROVIDERS = ['openai', 'anthropic', 'gemini', 'groq', 'mistral', 'xai', 'deepseek', 'openrouter', 'together'];
9
+
10
+ const OPENAI_COMPATIBLE = ['groq', 'mistral', 'xai', 'deepseek', 'openrouter', 'together'];
11
+ const DEFAULT_PORT = 4141;
12
+ const BODYLESS_METHODS = ['GET', 'HEAD'];
13
+ const HOP_BY_HOP_REQUEST_HEADERS = ['host', 'connection', 'content-length', 'accept-encoding', 'expect'];
14
+ const HOP_BY_HOP_RESPONSE_HEADERS = ['content-length', 'transfer-encoding', 'content-encoding', 'connection'];
15
+
16
+ function newSessionId() {
17
+ return `proxy-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
18
+ }
19
+
20
+ function forwardHeaders(incoming, { apiKey, actor, machineId, sessionId, project, agentId }) {
21
+ const headers = { ...incoming };
22
+ for (const name of HOP_BY_HOP_REQUEST_HEADERS) delete headers[name];
23
+
24
+ headers['x-shomra-key'] = apiKey;
25
+ headers['x-shomra-actor'] = actor;
26
+ if (machineId) headers['x-shomra-machine'] = machineId;
27
+ headers['x-shomra-source'] = 'shomra llm-proxy';
28
+ if (!headers['x-shomra-session']) headers['x-shomra-session'] = sessionId;
29
+ if (project) headers['x-shomra-project'] = project;
30
+ if (agentId && !headers['x-shomra-agent']) headers['x-shomra-agent'] = agentId;
31
+ return headers;
32
+ }
33
+
34
+ async function readBody(request) {
35
+ const chunks = [];
36
+ for await (const chunk of request) chunks.push(chunk);
37
+ return Buffer.concat(chunks);
38
+ }
39
+
40
+ function sendJson(response, status, payload) {
41
+ response.writeHead(status, { 'content-type': 'application/json' });
42
+ response.end(JSON.stringify(payload));
43
+ }
44
+
45
+ async function streamUpstreamBody(upstream, response) {
46
+ try {
47
+ if (upstream.body) for await (const chunk of upstream.body) response.write(chunk);
48
+ } catch {
49
+ return;
50
+ }
51
+ }
52
+
53
+ async function relayResponse(upstream, response) {
54
+ const headers = {};
55
+ for (const [name, value] of upstream.headers) {
56
+ if (!HOP_BY_HOP_RESPONSE_HEADERS.includes(name)) headers[name] = value;
57
+ }
58
+ response.writeHead(upstream.status, headers);
59
+ await streamUpstreamBody(upstream, response);
60
+ response.end();
61
+ }
62
+
63
+ function statusMark(status) {
64
+ if (status === 403) return red('BLOCKED');
65
+ return status >= 400 ? yellow(String(status)) : green(String(status));
66
+ }
67
+
68
+ function logCall(provider, request, suffix, status) {
69
+ const time = new Date().toTimeString().slice(0, 8);
70
+ console.log(` ${dim(time)} ${bold(provider.padEnd(10))} ${dim(request.method)} ${suffix} ${statusMark(status)}`);
71
+ }
72
+
73
+ function createProxyHandler({ url, headerContext, providerPattern }) {
74
+ return async (request, response) => {
75
+ const requestPath = String(request.url).replace(/^\/llm(?=\/)/, '');
76
+ const match = requestPath.match(providerPattern);
77
+ if (!match) {
78
+ sendJson(response, 404, {
79
+ error: { message: `Unknown route - use /<provider>/… (providers: ${LLM_PROVIDERS.join(', ')})` },
80
+ });
81
+ return;
82
+ }
83
+
84
+ const [, provider, suffix] = match;
85
+ const route = `/llm/${provider}${suffix ?? '/'}`;
86
+ const body = await readBody(request);
87
+ const headers = forwardHeaders(request.headers, headerContext);
88
+
89
+ let upstream;
90
+ try {
91
+ upstream = await fetch(`${url}${route}`, {
92
+ method: request.method,
93
+ headers,
94
+ body: BODYLESS_METHODS.includes(request.method) ? undefined : body,
95
+ });
96
+ } catch (error) {
97
+ const cause = error.cause ? ` (${error.cause.code ?? ''} ${error.cause.message ?? error.cause})` : '';
98
+ sendJson(response, 502, { error: { message: `Shomra backend unreachable at ${url}: ${error.message}${cause}` } });
99
+ console.log(` ${red('✗')} ${request.method} ${request.url} ${red('backend unreachable')}${dim(cause)} ${dim(`hdrs: ${Object.keys(headers).join(',')}`)}`);
100
+ return;
101
+ }
102
+
103
+ await relayResponse(upstream, response);
104
+ logCall(provider, request, suffix ?? '/', upstream.status);
105
+ };
106
+ }
107
+
108
+ function printStartupBanner({ port, url, project, actor }) {
109
+ console.log(bold(cyan('\n Shomra LLM Guard')) + dim(` - local proxy v${VERSION}`));
110
+ console.log(` ${green('●')} Listening on ${bold(`http://127.0.0.1:${port}`)} ${dim(`→ ${url} → provider`)}`);
111
+ if (project) console.log(` ${dim('Project ')} ${project}`);
112
+ console.log(` ${dim('Actor ')} ${actor}\n`);
113
+
114
+ console.log(bold(' Route your SDKs through the guard (no code changes):'));
115
+ console.log(dim(' OpenAI / Anthropic (PowerShell)'));
116
+ console.log(` $env:OPENAI_BASE_URL = "http://127.0.0.1:${port}/openai/v1"`);
117
+ console.log(` $env:ANTHROPIC_BASE_URL = "http://127.0.0.1:${port}/anthropic"`);
118
+ console.log(dim(' OpenAI / Anthropic (bash/zsh)'));
119
+ console.log(` export OPENAI_BASE_URL=http://127.0.0.1:${port}/openai/v1`);
120
+ console.log(` export ANTHROPIC_BASE_URL=http://127.0.0.1:${port}/anthropic`);
121
+ console.log(dim(' Gemini - point the Google GenAI SDK base URL at'));
122
+ console.log(` http://127.0.0.1:${port}/gemini`);
123
+ console.log(dim(` OpenAI-compatible (${OPENAI_COMPATIBLE.join(', ')}) - set the SDK baseURL to`));
124
+ console.log(` http://127.0.0.1:${port}/<provider>/v1`);
125
+ console.log(dim('\n Prompts and completions are screened against org policy;'));
126
+ console.log(dim(' blocked calls return HTTP 403 with the reason. Ctrl+C to stop.\n'));
127
+ }
128
+
129
+ export async function cmdLlmProxy(flags) {
130
+ const config = loadConfig();
131
+ const { apiKey, url } = resolveSettings(config);
132
+ if (!apiKey) exitNotConfigured();
133
+
134
+ const port = parseInt(flags.port, 10) || DEFAULT_PORT;
135
+ const project = flags.project ? String(flags.project) : null;
136
+ const actor = `${os.hostname()}/${os.userInfo().username}`;
137
+
138
+ const handler = createProxyHandler({
139
+ url,
140
+ providerPattern: new RegExp(`^/(${LLM_PROVIDERS.join('|')})(/.*)?$`),
141
+ headerContext: {
142
+ apiKey,
143
+ actor,
144
+ project,
145
+ machineId: config.machineId,
146
+ sessionId: newSessionId(),
147
+ agentId: resolveAgentIdentityHandle(flags),
148
+ },
149
+ });
150
+
151
+ const { createServer } = await import('node:http');
152
+ createServer(handler).listen(port, '127.0.0.1', () => printStartupBanner({ port, url, project, actor }));
153
+ }
@@ -0,0 +1,185 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
4
+ import { EXIT_USAGE } from '../core/exit-codes.mjs';
5
+ import { SEV_COLOR, bold, cyan, dim, green, red, yellow } from '../core/terminal.mjs';
6
+ import { localGate } from '../detect/guard-signals.mjs';
7
+ import { mcpIndexAlert, mcpLookup, mcpLookupId, parseEnvKV, worstMcpVerdict } from '../mcp/lookup.mjs';
8
+ import { printAlternatives } from '../models/lookup.mjs';
9
+
10
+ const MAX_FINDINGS_SHOWN = 6;
11
+ const RUNNER_NAMES = /^(npx|bunx|pnpx|uvx|uv|node|deno|bun|python3?|py|docker|podman|sh|bash|zsh|ruby|go|cargo|dotnet)$/i;
12
+
13
+ function usageExit(message, hint) {
14
+ console.error(`${red('✗')} ${message}`);
15
+ if (hint) console.error(dim(' Try: ') + bold(hint));
16
+ process.exit(EXIT_USAGE);
17
+ }
18
+
19
+ function buildServerEntry(flags, positional) {
20
+ const server = {};
21
+ if (flags.url) server.url = String(flags.url);
22
+
23
+ const tokens = flags.command ? String(flags.command).split(/\s+/) : positional.slice(2);
24
+ if (tokens.length) {
25
+ [server.command] = tokens;
26
+ if (tokens.length > 1) server.args = tokens.slice(1);
27
+ }
28
+ if (flags.env) server.env = parseEnvKV(flags.env);
29
+ return { server, tokens };
30
+ }
31
+
32
+ function assertUsableArguments(name, server, tokens) {
33
+ if (!server.command) return;
34
+ if (String(server.command).startsWith('-')) {
35
+ usageExit(
36
+ `"${server.command}" is a flag, not a command - the server NAME comes first.`,
37
+ `shomra mcp add <name> ${[server.command, ...(server.args || [])].join(' ')}`,
38
+ );
39
+ }
40
+ if (RUNNER_NAMES.test(String(name))) {
41
+ usageExit(
42
+ `"${name}" is a runner, not a server name - it looks like the NAME argument was left out.`,
43
+ `shomra mcp add <name> ${[name, ...tokens].join(' ')}`,
44
+ );
45
+ }
46
+ }
47
+
48
+ async function lookupSecurityIndex(flags, server, name) {
49
+ if (flags['no-index']) return null;
50
+ const { url } = resolveSettings(loadConfig());
51
+ if (!url) return { error: 'no backend configured - set SHOMRA_URL or run shomra init --url' };
52
+ try {
53
+ return await mcpLookup(url, mcpLookupId(server, name));
54
+ } catch (error) {
55
+ return { error: error.message };
56
+ }
57
+ }
58
+
59
+ function printIndexSummary(index) {
60
+ if (!index) return;
61
+ if (index.found && index.scanned) {
62
+ const colour = index.verdict === 'FAIL' ? red : index.verdict === 'REVIEW' ? yellow : green;
63
+ console.log(dim(' ── MCP Security Index: ') + bold(index.slug) + dim(' · verdict ') + colour(String(index.verdict)) + dim(` · risk ${index.riskScore} ──`));
64
+ for (const finding of (index.findings || []).slice(0, MAX_FINDINGS_SHOWN)) {
65
+ console.log(` ${(SEV_COLOR[finding.severity] || dim)(String(finding.severity).padEnd(8))} ${finding.title} ${dim('(index)')}`);
66
+ }
67
+ printAlternatives(index.alternatives, 'mcp', ' ');
68
+ return;
69
+ }
70
+ if (index.found && !index.scanned) {
71
+ console.log(dim(` MCP Security Index: found "${index.slug}" but it hasn't been scanned yet.`));
72
+ return;
73
+ }
74
+ if (index.error) {
75
+ console.log(dim(` MCP Security Index: unavailable (${index.error}) - using local checks only.`));
76
+ return;
77
+ }
78
+ console.log(dim(' MCP Security Index: not indexed - using local checks only.'));
79
+ }
80
+
81
+ function printVettingReport(name, localVerdict, index) {
82
+ console.log(bold(cyan(`\n Vetting MCP server "${name}"…`)));
83
+ for (const finding of localVerdict.findings.slice(0, MAX_FINDINGS_SHOWN)) {
84
+ console.log(` ${SEV_COLOR[finding.severity](String(finding.severity).padEnd(8))} ${finding.title} ${dim('(local)')}`);
85
+ }
86
+ printIndexSummary(index);
87
+ }
88
+
89
+ function readConfigOrEmpty(configFile) {
90
+ if (!fs.existsSync(configFile)) return {};
91
+ try {
92
+ return JSON.parse(fs.readFileSync(configFile, 'utf8'));
93
+ } catch {
94
+ return {};
95
+ }
96
+ }
97
+
98
+ function readConfigOrExit(configFile) {
99
+ if (!fs.existsSync(configFile)) return {};
100
+ try {
101
+ return JSON.parse(fs.readFileSync(configFile, 'utf8'));
102
+ } catch {
103
+ console.error(`${red('✗')} ${configFile} is not valid JSON.`);
104
+ return process.exit(EXIT_USAGE);
105
+ }
106
+ }
107
+
108
+ function writeServerEntry(configFile, name, server) {
109
+ const config = readConfigOrExit(configFile);
110
+ config.mcpServers = config.mcpServers || {};
111
+ const existed = !!config.mcpServers[name];
112
+ config.mcpServers[name] = server;
113
+ fs.mkdirSync(path.dirname(configFile), { recursive: true });
114
+ fs.writeFileSync(configFile, `${JSON.stringify(config, null, 2)}\n`);
115
+ return existed;
116
+ }
117
+
118
+ function outcomeNote(verdict) {
119
+ if (verdict === 'FLAG') return yellow(' (flagged - review the findings above)');
120
+ if (verdict === 'BLOCK') return red(' (forced past a BLOCK)');
121
+ return green(' ✓ clean');
122
+ }
123
+
124
+ export async function cmdMcpAdd(flags, positional, configFile) {
125
+ const name = positional[1];
126
+ if (!name) usageExit(`Usage: ${bold('shomra mcp add <name> <command…>')}`);
127
+
128
+ const { server, tokens } = buildServerEntry(flags, positional);
129
+ if (!server.url && !server.command) usageExit('Provide a launch command or --url.');
130
+ assertUsableArguments(name, server, tokens);
131
+
132
+ const candidate = JSON.stringify({ mcpServers: { [name]: server } }, null, 2);
133
+ const localVerdict = localGate(candidate, { kind: 'mcp', path: '.mcp.json' });
134
+ const index = await lookupSecurityIndex(flags, server, name);
135
+ const verdict = worstMcpVerdict(localVerdict.verdict, mcpIndexAlert(index));
136
+
137
+ if (!flags.json) printVettingReport(name, localVerdict, index);
138
+
139
+ if (verdict === 'BLOCK' && !flags.force) {
140
+ if (flags.json) {
141
+ console.log(JSON.stringify({
142
+ installed: false, verdict, local: localVerdict.verdict, index, findings: localVerdict.findings,
143
+ }, null, 2));
144
+ } else {
145
+ console.log(`\n ${red('✗ Blocked - not installed.')} ${dim('Review the findings, or override with')} ${bold('--force')}${dim('.')}\n`);
146
+ }
147
+ process.exitCode = 1;
148
+ return;
149
+ }
150
+
151
+ const existed = writeServerEntry(configFile, name, server);
152
+ const relative = path.relative(process.cwd(), configFile).split(path.sep).join('/');
153
+
154
+ if (flags.json) {
155
+ console.log(JSON.stringify({
156
+ installed: true, name, verdict, local: localVerdict.verdict, index, config: relative, updated: existed,
157
+ }, null, 2));
158
+ return;
159
+ }
160
+ console.log(`\n ${green(existed ? '✓ Updated' : '✓ Added')} MCP server ${bold(name)} ${dim(`→ ${relative}`)}${outcomeNote(verdict)}\n`);
161
+ }
162
+
163
+ export function cmdMcpList(flags, configFile) {
164
+ const config = readConfigOrEmpty(configFile);
165
+ const servers = config.mcpServers || config.servers || {};
166
+ const names = Object.keys(servers);
167
+
168
+ if (flags.json) {
169
+ console.log(JSON.stringify({ config: configFile, servers }, null, 2));
170
+ return;
171
+ }
172
+
173
+ const relative = path.relative(process.cwd(), configFile).split(path.sep).join('/');
174
+ console.log(bold(cyan('\n MCP servers')) + dim(` - ${relative}`));
175
+ if (!names.length) {
176
+ console.log(dim(' (none)\n'));
177
+ return;
178
+ }
179
+ for (const name of names) {
180
+ const entry = servers[name];
181
+ const launch = entry.url || [entry.command, ...(entry.args || [])].filter(Boolean).join(' ');
182
+ console.log(` ${green('●')} ${bold(name)} ${dim(launch)}`);
183
+ }
184
+ console.log('');
185
+ }