@shomra/agent 0.3.16 → 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} +18 -107
  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 -1268
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { resolveAgentIdentityHandle } from '../commands/agent-identity.mjs';
5
+ import { isMemoryPath, reportMemoryWrite } from '../commands/memory-scan.mjs';
6
+ import { breakerOpen, breakerReset, breakerTrip, guardTimeoutMs } from '../core/circuit-breaker.mjs';
7
+ import { loadConfig, resolveSettings } from '../core/config.mjs';
8
+ import { downrankCodeContext, grade, localScan } from '../detect/guard-signals.mjs';
9
+ import { WRITE_TOOLS, guardNeedsServer, guardTargetPath, guardText } from './classify.mjs';
10
+ import { emitGuardAsk, emitGuardDeny } from './emit.mjs';
11
+ import { guardPathAllowlisted } from './ignore.mjs';
12
+ import { screenModelLoad } from './model-load.mjs';
13
+ import { normalizeGuardInput } from './normalize.mjs';
14
+ import { envFlag, resolveAgentFlag } from './options.mjs';
15
+ import { buildGuardBody, reportGuardDecision } from './report.mjs';
16
+
17
+ const ALLOW_VERDICT = { verdict: 'ALLOW', top: null, findings: [] };
18
+
19
+ function readHookPayload() {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
22
+ } catch {
23
+ return process.exit(0);
24
+ }
25
+ }
26
+
27
+ function localTierDisabled() {
28
+ return process.env.SHOMRA_GUARD_LOCAL === '0'
29
+ || String(process.env.SHOMRA_GUARD_LOCAL).toLowerCase() === 'false';
30
+ }
31
+
32
+ function screenLocally(normalized, tool, input) {
33
+ const scan = localScan(guardText(tool, input));
34
+ const isWrite = WRITE_TOOLS.has(tool);
35
+ const allowlisted = isWrite && guardPathAllowlisted(normalized.cwd, guardTargetPath(normalized));
36
+
37
+ let findings = scan.findings;
38
+ if (allowlisted) findings = [];
39
+ else if (isWrite) findings = downrankCodeContext(scan.findings);
40
+
41
+ const top = findings.find((finding) => finding.severity === 'CRITICAL') || findings[0] || null;
42
+ return { ...grade(findings), top, findings };
43
+ }
44
+
45
+ function memoryWriteContent(input) {
46
+ if (typeof input.content === 'string') return input.content;
47
+ if (typeof input.new_string === 'string') return input.new_string;
48
+ return null;
49
+ }
50
+
51
+ async function recordMemoryWrite({ url, apiKey, input, normalized }) {
52
+ const memoryPath = input.file_path || input.path;
53
+ if (!memoryPath || !isMemoryPath(memoryPath) || breakerOpen()) return;
54
+
55
+ const content = memoryWriteContent(input);
56
+ if (content == null) return;
57
+
58
+ await reportMemoryWrite(url, apiKey, {
59
+ path: String(memoryPath).split(path.sep).join('/'),
60
+ name: path.basename(String(memoryPath)),
61
+ content,
62
+ writer: 'AGENT',
63
+ source: os.hostname(),
64
+ actor: os.userInfo().username,
65
+ sessionId: normalized.session_id,
66
+ });
67
+ }
68
+
69
+ function reportUnauthenticated(agent, status, strict) {
70
+ process.stderr.write(
71
+ `[shomra] guard NOT enforced: the backend rejected this API key (HTTP ${status}). `
72
+ + 'Local Tier-0 screening still ran; org policy, agent identity and flow control did not. '
73
+ + 'Re-enroll with `shomra init --key <key>`.\n',
74
+ );
75
+ if (strict) {
76
+ emitGuardDeny(agent, `Shomra guard could not authenticate (HTTP ${status}); blocked by fail-closed policy.`);
77
+ }
78
+ process.exit(0);
79
+ }
80
+
81
+ async function requestServerDecision({ url, apiKey, agentId, body, agent, strict }) {
82
+ const controller = new AbortController();
83
+ const timer = setTimeout(() => controller.abort(), guardTimeoutMs());
84
+ try {
85
+ const response = await fetch(`${url}/gate/tool-call`, {
86
+ method: 'POST',
87
+ headers: {
88
+ 'Content-Type': 'application/json',
89
+ 'X-Shomra-Key': apiKey,
90
+ ...(agentId ? { 'X-Shomra-Agent': agentId } : {}),
91
+ Connection: 'close',
92
+ },
93
+ body: JSON.stringify(body),
94
+ signal: controller.signal,
95
+ });
96
+ clearTimeout(timer);
97
+
98
+ if (!response.ok) {
99
+ if (response.status === 401 || response.status === 403) reportUnauthenticated(agent, response.status, strict);
100
+ throw new Error(`HTTP ${response.status}`);
101
+ }
102
+ const decision = await response.json();
103
+ breakerReset();
104
+ return decision;
105
+ } catch (error) {
106
+ clearTimeout(timer);
107
+ breakerTrip();
108
+ if (strict) emitGuardDeny(agent, `Shomra guard could not be reached (${error.message}); blocked by fail-closed policy.`);
109
+ return process.exit(0);
110
+ }
111
+ }
112
+
113
+ function enforceServerDecision(agent, decision) {
114
+ if (decision?.hold) {
115
+ emitGuardAsk(agent, decision.reason || 'Held for approval by Shomra - waiting on a reviewer. Retry once it’s approved.');
116
+ }
117
+ if (decision?.decision === 'BLOCK') {
118
+ emitGuardDeny(agent, decision.reason || 'Blocked by Shomra security policy.');
119
+ }
120
+ }
121
+
122
+ export async function cmdToolGuard(flags) {
123
+ const agent = resolveAgentFlag(flags);
124
+ const agentId = resolveAgentIdentityHandle(flags);
125
+ const strict = envFlag('SHOMRA_GUARD_STRICT');
126
+ const alwaysEscalate = envFlag('SHOMRA_GUARD_ALWAYS_ESCALATE');
127
+ const { apiKey, url } = resolveSettings(loadConfig());
128
+
129
+ const normalized = normalizeGuardInput(agent, readHookPayload());
130
+ const tool = (normalized.tool_name ?? '').trim();
131
+ const input = normalized.tool_input ?? {};
132
+
133
+ const local = localTierDisabled() ? ALLOW_VERDICT : screenLocally(normalized, tool, input);
134
+ if (local.verdict === 'BLOCK') {
135
+ await reportGuardDecision(url, apiKey, agentId, buildGuardBody(normalized, agent, 'BLOCK', local.top?.label));
136
+ emitGuardDeny(agent, `Blocked on-machine by Shomra: ${local.top?.label || 'dangerous tool call'}.`);
137
+ }
138
+
139
+ await screenModelLoad(agent, tool, input, url);
140
+
141
+ if (!apiKey) {
142
+ if (strict) {
143
+ emitGuardDeny(agent, 'Shomra is not configured on this machine (SHOMRA_GUARD_STRICT). Run: shomra init --key shm_…');
144
+ }
145
+ process.exit(0);
146
+ }
147
+
148
+ await recordMemoryWrite({ url, apiKey, input, normalized });
149
+
150
+ const escalate = alwaysEscalate || local.verdict === 'FLAG' || guardNeedsServer(tool, input, !!agentId);
151
+ if (!escalate) process.exit(0);
152
+ if (!strict && breakerOpen()) process.exit(0);
153
+
154
+ const flagged = local.verdict === 'FLAG';
155
+ const decision = await requestServerDecision({
156
+ url,
157
+ apiKey,
158
+ agentId,
159
+ agent,
160
+ strict,
161
+ body: buildGuardBody(normalized, agent, flagged ? 'FLAG' : undefined, flagged ? local.top?.label : undefined),
162
+ });
163
+
164
+ enforceServerDecision(agent, decision);
165
+ process.exit(0);
166
+ }
@@ -0,0 +1,5 @@
1
+ export { discoverAgentArtifacts, rollupArtifacts } from './artifacts/discover.mjs';
2
+ export { canonicalHooks } from './artifacts/hooks.mjs';
3
+ export { ARTIFACT_KINDS } from './artifacts/limits.mjs';
4
+ export { installedMarketplaces } from './artifacts/marketplaces.mjs';
5
+ export { artifactRoots } from './artifacts/roots.mjs';
@@ -0,0 +1,249 @@
1
+
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ const HOME = os.homedir();
7
+ const PLAT = process.platform;
8
+ const MAX_SETTINGS_BYTES = 512 * 1024;
9
+
10
+ export const POSTURE_TIERS = ['declared', 'flag', 'none'];
11
+
12
+ export const TIER_MEANING = {
13
+ declared: 'The vendor documents a permission grant schema; grants are parsed and reasoned about.',
14
+ flag: 'No grant schema. Auto-approval switches are recognised by name; a grant list cannot be enumerated.',
15
+ none: 'No machine-readable permission surface. This agent appears in inventory only.',
16
+ };
17
+
18
+ function readJsonc(file) {
19
+ try {
20
+ const st = fs.statSync(file);
21
+ if (!st.isFile() || st.size > MAX_SETTINGS_BYTES) return { state: 'unreadable', reason: st.isFile() ? 'oversized' : 'not-a-file' };
22
+ const raw = fs.readFileSync(file, 'utf8');
23
+
24
+ const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"])\/\/.*$/gm, '$1');
25
+ return { state: 'read', json: JSON.parse(stripped) };
26
+ } catch (err) {
27
+ if (err && err.code === 'ENOENT') return { state: 'absent' };
28
+
29
+ return { state: 'unreadable', reason: err?.code === 'EACCES' ? 'permission-denied' : 'parse-failed' };
30
+ }
31
+ }
32
+
33
+ export const VENDOR_POSTURE = {
34
+ 'claude-code': {
35
+ tier: 'declared',
36
+ label: 'Claude Code',
37
+ sources: [
38
+ { rel: ['.claude', 'settings.json'], base: 'home', scope: 'user' },
39
+ { rel: ['.claude.json'], base: 'home', scope: 'user' },
40
+ { rel: ['.claude', 'settings.local.json'], base: 'home', scope: 'user' },
41
+ { rel: ['.claude', 'settings.json'], base: 'cwd', scope: 'project' },
42
+ { rel: ['.claude', 'settings.local.json'], base: 'cwd', scope: 'project' },
43
+ ],
44
+ },
45
+ gemini: {
46
+ tier: 'declared',
47
+ label: 'Gemini CLI',
48
+ sources: [
49
+ { rel: ['.gemini', 'settings.json'], base: 'home', scope: 'user' },
50
+ { rel: ['.gemini', 'settings.json'], base: 'cwd', scope: 'project' },
51
+ ],
52
+ },
53
+ codex: {
54
+ tier: 'declared',
55
+ label: 'OpenAI Codex CLI',
56
+ sources: [
57
+ { rel: ['.codex', 'config.json'], base: 'home', scope: 'user' },
58
+ { rel: ['.codex', 'settings.json'], base: 'home', scope: 'user' },
59
+ ],
60
+ },
61
+ cursor: {
62
+ tier: 'flag',
63
+ label: 'Cursor',
64
+ sources: [
65
+ { rel: ['.cursor', 'settings.json'], base: 'home', scope: 'user' },
66
+ { rel: ['.cursor', 'mcp.json'], base: 'home', scope: 'user' },
67
+ { rel: ['.cursor', 'mcp.json'], base: 'cwd', scope: 'project' },
68
+ { rel: ['.cursor', 'settings.json'], base: 'cwd', scope: 'project' },
69
+ { rel: ['User', 'settings.json'], base: 'cursor-user', scope: 'user' },
70
+ ],
71
+ },
72
+ windsurf: {
73
+ tier: 'flag',
74
+ label: 'Windsurf',
75
+ sources: [
76
+ { rel: ['.codeium', 'windsurf', 'settings.json'], base: 'home', scope: 'user' },
77
+ { rel: ['.codeium', 'windsurf', 'mcp_config.json'], base: 'home', scope: 'user' },
78
+ { rel: ['.windsurf', 'mcp.json'], base: 'cwd', scope: 'project' },
79
+ ],
80
+ },
81
+ cline: {
82
+ tier: 'flag',
83
+ label: 'Cline',
84
+ sources: [
85
+ { rel: ['settings', 'cline_mcp_settings.json'], base: 'cline-storage', scope: 'user' },
86
+ { rel: ['.cline', 'settings.json'], base: 'home', scope: 'user' },
87
+ ],
88
+ },
89
+ roo: {
90
+ tier: 'flag',
91
+ label: 'Roo Code',
92
+ sources: [
93
+ { rel: ['settings', 'mcp_settings.json'], base: 'roo-storage', scope: 'user' },
94
+ { rel: ['.roo', 'settings.json'], base: 'cwd', scope: 'project' },
95
+ ],
96
+ },
97
+ copilot: {
98
+ tier: 'flag',
99
+ label: 'GitHub Copilot CLI',
100
+ sources: [{ rel: ['.copilot', 'config.json'], base: 'home', scope: 'user' }],
101
+ },
102
+
103
+ aider: { tier: 'none', label: 'Aider', sources: [] },
104
+ };
105
+
106
+ function vscodeUserDir(variant) {
107
+ if (PLAT === 'win32') return path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), variant, 'User');
108
+ if (PLAT === 'darwin') return path.join(HOME, 'Library', 'Application Support', variant, 'User');
109
+ return path.join(HOME, '.config', variant, 'User');
110
+ }
111
+
112
+ function resolveBase(base, cwd) {
113
+ switch (base) {
114
+ case 'home': return HOME;
115
+ case 'cwd': return cwd;
116
+ case 'cursor-user': return path.dirname(vscodeUserDir('Cursor'));
117
+ case 'cline-storage': return path.join(vscodeUserDir('Code'), 'globalStorage', 'saoudrizwan.claude-dev');
118
+ case 'roo-storage': return path.join(vscodeUserDir('Code'), 'globalStorage', 'rooveterinaryinc.roo-cline');
119
+ default: return cwd;
120
+ }
121
+ }
122
+
123
+ const AUTO_APPROVE_KEYS = [
124
+ { re: /^(yolo|yolomode|enableyolo)$/i, label: 'YOLO mode', severity: 'CRITICAL' },
125
+ { re: /^(autoapprove|alwaysallow|autoallow|autoaccept|autoconfirm)$/i, label: 'auto-approve', severity: 'HIGH' },
126
+ { re: /^(autorun|autoexecute|autoexec|runwithoutasking|executewithoutconfirmation)$/i, label: 'auto-run commands', severity: 'CRITICAL' },
127
+ { re: /^(skippermissions|bypasspermissions|disableapproval|noconfirm)$/i, label: 'approval disabled', severity: 'CRITICAL' },
128
+ { re: /^(autoapprovemcp|alwaysallowmcp|autotrustmcp|enableallprojectmcpservers)$/i, label: 'MCP auto-trust', severity: 'HIGH' },
129
+ { re: /^(alwaysallowreadonly|autoapproveread)$/i, label: 'read auto-approve', severity: 'MEDIUM' },
130
+ { re: /^(alwaysallowwrite|autoapprovewrite|autoacceptedits)$/i, label: 'write auto-approve', severity: 'HIGH' },
131
+ ];
132
+
133
+ function collectSwitches(node, out, depth = 0, trail = '') {
134
+ if (!node || typeof node !== 'object' || depth > 6 || out.length >= 40) return;
135
+ for (const [rawKey, value] of Object.entries(node)) {
136
+
137
+ const leaf = String(rawKey).split('.').pop() ?? rawKey;
138
+ const normal = leaf.replace(/[_\-\s]/g, '');
139
+ const where = trail ? `${trail}.${rawKey}` : rawKey;
140
+ const rule = AUTO_APPROVE_KEYS.find((r) => r.re.test(normal));
141
+ if (rule) {
142
+
143
+ const on = value === true || value === 'true' || value === 'always' || (Array.isArray(value) && value.length > 0);
144
+ if (on) out.push({ key: where, label: rule.label, severity: rule.severity, value: Array.isArray(value) ? `${value.length} entries` : String(value) });
145
+ }
146
+ if (value && typeof value === 'object') collectSwitches(value, out, depth + 1, where);
147
+ }
148
+ }
149
+
150
+ function collectMcpServers(json) {
151
+ const servers = json?.mcpServers ?? json?.servers ?? json?.mcp?.servers ?? null;
152
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return [];
153
+ return Object.entries(servers)
154
+ .slice(0, 60)
155
+ .map(([name, def]) => ({
156
+ name,
157
+ transport: def?.url ? 'http' : 'stdio',
158
+
159
+ autoApprove: Array.isArray(def?.autoApprove) ? def.autoApprove.slice(0, 20) : Array.isArray(def?.alwaysAllow) ? def.alwaysAllow.slice(0, 20) : [],
160
+ disabled: def?.disabled === true,
161
+ }));
162
+ }
163
+
164
+ export function readVendorPosture(vendor, cwd = process.cwd()) {
165
+ const def = VENDOR_POSTURE[vendor];
166
+ if (!def) return null;
167
+
168
+ const sources = [];
169
+ const switches = [];
170
+ const mcpServers = [];
171
+ let mode = null;
172
+ const allow = [];
173
+ const deny = [];
174
+ const ask = [];
175
+ let enableAllProjectMcpServers = null;
176
+
177
+ for (const src of def.sources) {
178
+ const file = path.join(resolveBase(src.base, cwd), ...src.rel);
179
+ const res = readJsonc(file);
180
+ sources.push({ path: file, scope: src.scope, state: res.state, reason: res.reason ?? null });
181
+ if (res.state !== 'read') continue;
182
+
183
+ const json = res.json ?? {};
184
+ const perms = json.permissions ?? {};
185
+
186
+ const m = perms.defaultMode ?? json.defaultMode ?? null;
187
+ if (m && (!mode || String(m).toLowerCase() === 'bypasspermissions')) mode = String(m);
188
+ for (const [key, sink] of [['allow', allow], ['deny', deny], ['ask', ask]]) {
189
+ const list = Array.isArray(perms[key]) ? perms[key] : key === 'allow' && Array.isArray(json.allowedTools) ? json.allowedTools : [];
190
+ for (const g of list) if (typeof g === 'string' && !sink.includes(g)) sink.push(g);
191
+ }
192
+ if (json.enableAllProjectMcpServers === true) enableAllProjectMcpServers = true;
193
+ else if (enableAllProjectMcpServers === null && json.enableAllProjectMcpServers === false) enableAllProjectMcpServers = false;
194
+
195
+ if (def.tier === 'flag') collectSwitches(json, switches);
196
+ for (const s of collectMcpServers(json)) if (!mcpServers.some((x) => x.name === s.name)) mcpServers.push(s);
197
+ }
198
+
199
+ const read = sources.filter((s) => s.state === 'read');
200
+ const holes = sources.filter((s) => s.state === 'unreadable');
201
+
202
+ return {
203
+ vendor,
204
+ tier: def.tier,
205
+ label: def.label,
206
+
207
+ readable: read.length > 0,
208
+ sources,
209
+ unreadableCount: holes.length,
210
+ mode,
211
+ allow: allow.slice(0, 60),
212
+ deny: deny.slice(0, 60),
213
+ ask: ask.slice(0, 60),
214
+ enableAllProjectMcpServers,
215
+ switches: switches.slice(0, 20),
216
+ mcpServers,
217
+
218
+ autoApprovedMcp: mcpServers.filter((s) => s.autoApprove.length > 0 && !s.disabled).map((s) => s.name),
219
+
220
+ claim: def.tier === 'declared' && read.length > 0 && holes.length === 0 ? 'complete' : 'floor',
221
+ };
222
+ }
223
+
224
+ export function canonicalGrant(posture) {
225
+ if (!posture) return null;
226
+ const doc = {};
227
+ const perms = {};
228
+ if (posture.mode) perms.defaultMode = posture.mode;
229
+ if (posture.allow.length) perms.allow = posture.allow;
230
+ if (posture.deny.length) perms.deny = posture.deny;
231
+ if (posture.ask.length) perms.ask = posture.ask;
232
+ if (Object.keys(perms).length) doc.permissions = perms;
233
+ if (posture.enableAllProjectMcpServers === true) doc.enableAllProjectMcpServers = true;
234
+ return Object.keys(doc).length ? JSON.stringify(doc, null, 2) : null;
235
+ }
236
+
237
+ export function readPostures(vendors, cwd = process.cwd()) {
238
+ const out = {};
239
+ for (const v of vendors) {
240
+ try {
241
+ const p = readVendorPosture(v, cwd);
242
+ if (p) out[v] = p;
243
+ } catch {
244
+
245
+ out[v] = { vendor: v, tier: VENDOR_POSTURE[v]?.tier ?? 'none', readable: false, sources: [], claim: 'floor', allow: [], deny: [], ask: [], switches: [], mcpServers: [], autoApprovedMcp: [], mode: null, enableAllProjectMcpServers: null, unreadableCount: 1 };
246
+ }
247
+ }
248
+ return out;
249
+ }
@@ -0,0 +1,27 @@
1
+ import path from 'node:path';
2
+ import { extOf } from './limits.mjs';
3
+ import { SETTINGS_BASENAMES } from './marketplaces.mjs';
4
+
5
+ export function classify(rel, vendor) {
6
+ const lower = rel.toLowerCase().replace(/\\/g, '/');
7
+ const base = path.basename(lower);
8
+ const ext = extOf(lower);
9
+
10
+ if (base === 'skill.md') return 'skill';
11
+ if (SETTINGS_BASENAMES.has(base)) return 'hook';
12
+ if (/(^|\/)(sub)?agents?\//.test(lower) && ext === 'md') return 'subagent';
13
+ if (/(^|\/)(commands?|prompts?|workflows?)\//.test(lower) && (ext === 'md' || ext === 'toml')) {
14
+
15
+ if (vendor === 'copilot' && !/(^|\/)(prompts|chatmodes)\//.test(lower)) return null;
16
+ return 'command';
17
+ }
18
+ if (vendor === 'copilot' && /(^|\/)chatmodes\//.test(lower) && ext === 'md') return 'subagent';
19
+ return null;
20
+ }
21
+
22
+ export function declaredName(text) {
23
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
24
+ if (!m) return null;
25
+ const n = /^name:\s*(.+)$/m.exec(m[1]);
26
+ return n ? n[1].trim().replace(/^["']|["']$/g, '').slice(0, 120) || null : null;
27
+ }
@@ -0,0 +1,187 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { clampArtifact } from '../../core/wire-limits.mjs';
4
+ import { classify, declaredName } from './classify.mjs';
5
+ import { readText } from './file-read.mjs';
6
+ import { canonicalHooks } from './hooks.mjs';
7
+ import { MAX_ARTIFACTS, MAX_BUNDLED, MAX_DIRS, MAX_TOTAL_BYTES, TEXT_EXTS, extOf } from './limits.mjs';
8
+ import { CATALOGUE_DIR_RE, PLUGIN_PATH_RE, installedMarketplaces } from './marketplaces.mjs';
9
+ import { artifactRoots } from './roots.mjs';
10
+ import { walkRoot } from './walk.mjs';
11
+
12
+ const MAX_NAME_LENGTH = 200;
13
+
14
+ function relativeToSite(site, absolutePath) {
15
+ return path.relative(path.dirname(site.dir), absolutePath).replace(/\\/g, '/');
16
+ }
17
+
18
+ function artifactName(kind, content, absolutePath) {
19
+ const declared = declaredName(content);
20
+ if (declared) return declared;
21
+ if (kind === 'skill') return path.basename(path.dirname(absolutePath));
22
+ if (kind === 'hook') return `${path.basename(absolutePath)} · hooks`;
23
+ return path.basename(absolutePath).replace(/\.(md|toml)$/i, '');
24
+ }
25
+
26
+ function resolveActivation(marketplace, installed) {
27
+ if (!marketplace) return 'active';
28
+ if (installed === null) return 'unknown';
29
+ return installed.has(marketplace) ? 'active' : 'not-installed';
30
+ }
31
+
32
+ function readArtifactContent(kind, absolutePath) {
33
+ const read = readText(absolutePath);
34
+ if (!read) return null;
35
+ if (kind !== 'hook') return { ...read, content: read.text };
36
+ const content = canonicalHooks(read.text);
37
+ return content ? { ...read, content } : null;
38
+ }
39
+
40
+ function buildArtifact({ kind, content, read, relativePath, site, project, marketplace, activation, absolutePath }) {
41
+ return {
42
+ kind,
43
+ name: String(artifactName(kind, content, absolutePath)).slice(0, MAX_NAME_LENGTH),
44
+ path: relativePath,
45
+ scope: site.scope,
46
+ vendor: site.vendor,
47
+ content,
48
+ files: [],
49
+ metadata: {
50
+ bytes: read.bytes,
51
+ truncated: read.truncated,
52
+ project: site.scope === 'project' ? project : null,
53
+ activation,
54
+ marketplace,
55
+ },
56
+ };
57
+ }
58
+
59
+ function collectFromSite({ site, files, state, project }) {
60
+ const { artifacts, capped, consumed, skills, availableBy, budget } = state;
61
+ const installed = installedMarketplaces(site.dir);
62
+
63
+ for (const absolutePath of files) {
64
+ if (artifacts.length >= MAX_ARTIFACTS) {
65
+ capped.push({ reason: 'artifact-cap', path: absolutePath });
66
+ return;
67
+ }
68
+ const relativePath = relativeToSite(site, absolutePath);
69
+ const kind = classify(relativePath, site.vendor);
70
+ if (!kind) continue;
71
+
72
+ const catalogue = CATALOGUE_DIR_RE.exec(relativePath);
73
+ if (catalogue) {
74
+ const label = `${site.vendor}:${catalogue[2].toLowerCase()}`;
75
+ availableBy.set(label, (availableBy.get(label) ?? 0) + 1);
76
+ continue;
77
+ }
78
+
79
+ const marketplace = PLUGIN_PATH_RE.exec(relativePath)?.[2] ?? null;
80
+ const activation = resolveActivation(marketplace, installed);
81
+ if (activation === 'not-installed') {
82
+ availableBy.set(marketplace, (availableBy.get(marketplace) ?? 0) + 1);
83
+ continue;
84
+ }
85
+
86
+ const read = readArtifactContent(kind, absolutePath);
87
+ if (!read) continue;
88
+ if (read.bytes > budget.bytes) {
89
+ capped.push({ reason: 'byte-budget', path: relativePath });
90
+ continue;
91
+ }
92
+
93
+ budget.bytes -= Buffer.byteLength(read.content);
94
+ const artifact = buildArtifact({
95
+ kind, content: read.content, read, relativePath, site, project, marketplace, activation, absolutePath,
96
+ });
97
+ consumed.add(absolutePath);
98
+ artifacts.push(artifact);
99
+ if (kind === 'skill') skills.push({ artifact, dir: path.dirname(absolutePath), site });
100
+ }
101
+ }
102
+
103
+ function bundleSkillFiles({ skills, walked, state }) {
104
+ const { capped, consumed, budget } = state;
105
+
106
+ for (const { artifact, dir, site } of skills) {
107
+ const prefix = dir.endsWith(path.sep) ? dir : dir + path.sep;
108
+ const siteFiles = walked.find((entry) => entry.site === site)?.files ?? [];
109
+
110
+ for (const absolutePath of siteFiles.filter((file) => file.startsWith(prefix))) {
111
+ if (consumed.has(absolutePath)) continue;
112
+ if (artifact.files.length >= MAX_BUNDLED) {
113
+ capped.push({ reason: 'bundle-cap', path: artifact.path });
114
+ break;
115
+ }
116
+ const relativePath = relativeToSite(site, absolutePath);
117
+ const read = TEXT_EXTS.has(extOf(absolutePath)) ? readText(absolutePath) : null;
118
+ if (!read) {
119
+ artifact.files.push({ path: relativePath, content: null, binary: true });
120
+ consumed.add(absolutePath);
121
+ continue;
122
+ }
123
+ if (read.bytes > budget.bytes) {
124
+ capped.push({ reason: 'byte-budget', path: relativePath });
125
+ continue;
126
+ }
127
+ budget.bytes -= Buffer.byteLength(read.text);
128
+ artifact.files.push({ path: relativePath, content: read.text, binary: false });
129
+ consumed.add(absolutePath);
130
+ }
131
+ artifact.metadata.bundledCount = artifact.files.length;
132
+ }
133
+ }
134
+
135
+ function walkSites(sites, budget) {
136
+ const walked = [];
137
+ const seenRoot = new Set();
138
+ for (const site of sites) {
139
+ let realRoot;
140
+ try {
141
+ realRoot = fs.realpathSync(site.dir);
142
+ } catch {
143
+ continue;
144
+ }
145
+ if (seenRoot.has(realRoot)) continue;
146
+ seenRoot.add(realRoot);
147
+ walked.push({ site, files: walkRoot(site.dir, budget) });
148
+ }
149
+ return walked;
150
+ }
151
+
152
+ function availableCatalogue(availableBy) {
153
+ return [...availableBy.entries()]
154
+ .map(([marketplace, count]) => ({ marketplace, count, installed: false }))
155
+ .sort((a, b) => b.count - a.count);
156
+ }
157
+
158
+ export function discoverAgentArtifacts(cwd = process.cwd(), roots = null) {
159
+ const sites = roots || artifactRoots(cwd);
160
+ const project = path.basename(path.resolve(cwd)) || null;
161
+ const state = {
162
+ budget: { dirs: MAX_DIRS, bytes: MAX_TOTAL_BYTES },
163
+ artifacts: [],
164
+ capped: [],
165
+ consumed: new Set(),
166
+ skills: [],
167
+ availableBy: new Map(),
168
+ };
169
+
170
+ const walked = walkSites(sites, state.budget);
171
+ for (const { site, files } of walked) collectFromSite({ site, files, state, project });
172
+ bundleSkillFiles({ skills: state.skills, walked, state });
173
+
174
+ if (state.budget.dirs <= 0) state.capped.push({ reason: 'walk-budget', path: null });
175
+
176
+ return {
177
+ artifacts: state.artifacts.map(clampArtifact),
178
+ capped: state.capped,
179
+ available: availableCatalogue(state.availableBy),
180
+ };
181
+ }
182
+
183
+ export function rollupArtifacts(artifacts) {
184
+ const counts = {};
185
+ for (const artifact of artifacts) counts[artifact.kind] = (counts[artifact.kind] ?? 0) + 1;
186
+ return counts;
187
+ }
@@ -0,0 +1,42 @@
1
+ import fs from 'node:fs';
2
+ import { MAX_FILE_BYTES } from './limits.mjs';
3
+
4
+ export function readJsonAt(file) {
5
+ let raw;
6
+ try {
7
+ raw = fs.readFileSync(file, 'utf8');
8
+ } catch {
9
+ return null;
10
+ }
11
+ try {
12
+ return JSON.parse(stripJsonComments(raw));
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ export function readText(file, cap = MAX_FILE_BYTES) {
19
+ try {
20
+ const st = fs.statSync(file);
21
+ if (!st.isFile()) return null;
22
+ const fd = fs.openSync(file, 'r');
23
+ try {
24
+ const len = Math.min(st.size, cap);
25
+ const buf = Buffer.alloc(len);
26
+ fs.readSync(fd, buf, 0, len, 0);
27
+
28
+ if (buf.includes(0)) return null;
29
+ return { text: buf.toString('utf8'), truncated: st.size > cap, bytes: st.size };
30
+ } finally {
31
+ fs.closeSync(fd);
32
+ }
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function stripJsonComments(s) {
39
+ return String(s)
40
+ .replace(/\/\*[\s\S]*?\*\//g, '')
41
+ .replace(/(^|[^:])\/\/.*$/gm, '$1');
42
+ }