@shomra/agent 0.3.29 → 0.3.30

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 (77) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/scanner.mjs +1 -1
  23. package/src/detect/signals/agent-frameworks.mjs +231 -0
  24. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  25. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  26. package/src/detect/signals/agentic-shim.mjs +82 -0
  27. package/src/detect/signals/artifacts.mjs +7 -35
  28. package/src/detect/signals/chat-template.mjs +211 -0
  29. package/src/detect/signals/ci-workflow.mjs +169 -0
  30. package/src/detect/signals/gate.mjs +77 -9
  31. package/src/detect/signals/guardrail-shape.mjs +564 -0
  32. package/src/detect/signals/guardrail-surface.mjs +221 -0
  33. package/src/detect/signals/injection.mjs +8 -0
  34. package/src/detect/signals/inspect-shim.mjs +7 -0
  35. package/src/detect/signals/instruction-paths.mjs +60 -0
  36. package/src/detect/signals/manifests.mjs +302 -0
  37. package/src/detect/signals/mcp-advisories.mjs +109 -0
  38. package/src/detect/signals/mcp-config.mjs +598 -0
  39. package/src/detect/signals/memory-directives.mjs +661 -0
  40. package/src/detect/signals/memory-locations.mjs +158 -0
  41. package/src/detect/signals/memory.mjs +47 -29
  42. package/src/detect/signals/model-config-rules.mjs +655 -0
  43. package/src/detect/signals/model-config.mjs +61 -0
  44. package/src/detect/signals/prose-context.mjs +6 -9
  45. package/src/detect/signals/scan.mjs +4 -4
  46. package/src/detect/signals/secret-scanner.mjs +241 -0
  47. package/src/detect/signals/secrets.mjs +1 -48
  48. package/src/detect/signals/shell.mjs +3 -3
  49. package/src/gate/advisories.mjs +16 -0
  50. package/src/gate/batch.mjs +10 -0
  51. package/src/gate/environment.mjs +8 -53
  52. package/src/guard/artifact-paths.mjs +107 -0
  53. package/src/guard/classify.mjs +165 -7
  54. package/src/guard/command-resolve.mjs +35 -5
  55. package/src/guard/memory-write.mjs +218 -0
  56. package/src/guard/prompt-guard.mjs +0 -1
  57. package/src/guard/tool-guard.mjs +52 -77
  58. package/src/inventory/agent-posture.mjs +236 -57
  59. package/src/inventory/artifacts/classify.mjs +10 -1
  60. package/src/inventory/artifacts/discover.mjs +113 -3
  61. package/src/inventory/artifacts/extensions.mjs +70 -0
  62. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  63. package/src/inventory/artifacts/limits.mjs +1 -1
  64. package/src/inventory/artifacts/plugins.mjs +105 -0
  65. package/src/inventory/artifacts/roots.mjs +40 -0
  66. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  67. package/src/inventory/discovery/all.mjs +4 -0
  68. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  69. package/src/inventory/discovery/coding-agents.mjs +19 -4
  70. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  71. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  72. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  73. package/src/inventory/env-redirect.mjs +148 -0
  74. package/src/inventory/grant-extract.mjs +463 -0
  75. package/src/inventory/project-roots.mjs +108 -0
  76. package/src/inventory/vscode-state.mjs +153 -0
  77. package/src/mcp/server-tools.mjs +1 -1
@@ -1,21 +1,30 @@
1
1
  import path from 'node:path';
2
2
  import { extOf } from './limits.mjs';
3
3
  import { SETTINGS_BASENAMES } from './marketplaces.mjs';
4
+ import { isInstructionPath } from '../../detect/signals/instruction-paths.mjs';
5
+ import { PLUGIN_MANIFEST_RE } from './plugins.mjs';
6
+ import { EXTENSION_MANIFEST_RE } from './extensions.mjs';
7
+
8
+ export const COMMAND_PATH_RE = /(^|\/)commands?\/|(^|\/)\.[\w-]+\/prompts?\/|(^|\/)\.(?:windsurf|devin|clinerules)\/workflows\//;
4
9
 
5
10
  export function classify(rel, vendor) {
6
11
  const lower = rel.toLowerCase().replace(/\\/g, '/');
7
12
  const base = path.basename(lower);
8
13
  const ext = extOf(lower);
9
14
 
15
+ if (vendor === 'claude-desktop') return EXTENSION_MANIFEST_RE.test(lower) ? 'extension' : null;
16
+ if (PLUGIN_MANIFEST_RE.test(lower)) return 'plugin';
10
17
  if (base === 'skill.md') return 'skill';
11
18
  if (SETTINGS_BASENAMES.has(base)) return 'hook';
19
+ if (vendor === 'copilot' && ext === 'json' && /(^|\/)hooks\/[^/]+\.json$/.test(lower)) return 'hook';
12
20
  if (/(^|\/)(sub)?agents?\//.test(lower) && ext === 'md') return 'subagent';
13
- if (/(^|\/)(commands?|prompts?|workflows?)\//.test(lower) && (ext === 'md' || ext === 'toml')) {
21
+ if (COMMAND_PATH_RE.test(lower) && !/(^|\/)\.github\/workflows\//.test(lower) && (ext === 'md' || ext === 'toml')) {
14
22
 
15
23
  if (vendor === 'copilot' && !/(^|\/)(prompts|chatmodes)\//.test(lower)) return null;
16
24
  return 'command';
17
25
  }
18
26
  if (vendor === 'copilot' && /(^|\/)chatmodes\//.test(lower) && ext === 'md') return 'subagent';
27
+ if (lower.split('/').length <= 5 && !/(^|\/)(extensions|plugins|marketplaces|node_modules|cache|projects)\//.test(lower) && isInstructionPath(lower)) return 'rules';
19
28
  return null;
20
29
  }
21
30
 
@@ -4,8 +4,12 @@ import { clampArtifact } from '../../core/wire-limits.mjs';
4
4
  import { classify, declaredName } from './classify.mjs';
5
5
  import { readText } from './file-read.mjs';
6
6
  import { canonicalHooks } from './hooks.mjs';
7
- import { MAX_ARTIFACTS, MAX_BUNDLED, MAX_DIRS, MAX_TOTAL_BYTES, TEXT_EXTS, extOf } from './limits.mjs';
7
+ import { bundleHookScripts } from './hook-scripts.mjs';
8
+ import { HOME, MAX_ARTIFACTS, MAX_BUNDLED, MAX_DIRS, MAX_TOTAL_BYTES, TEXT_EXTS, extOf } from './limits.mjs';
9
+ import { extractInstructionImports } from '../../detect/signals/instruction-paths.mjs';
8
10
  import { CATALOGUE_DIR_RE, PLUGIN_PATH_RE, installedMarketplaces } from './marketplaces.mjs';
11
+ import { MARKETPLACE_ROOT_RE, MAX_MANIFEST_BYTES, bundlePluginComponents, installedPluginManifests, manifestName, pluginRootOf } from './plugins.mjs';
12
+ import { bundleExtensionSource } from './extensions.mjs';
9
13
  import { artifactRoots } from './roots.mjs';
10
14
  import { walkRoot } from './walk.mjs';
11
15
 
@@ -16,10 +20,13 @@ function relativeToSite(site, absolutePath) {
16
20
  }
17
21
 
18
22
  function artifactName(kind, content, absolutePath) {
23
+ if (kind === 'plugin') return manifestName(content, path.basename(pluginRootOf(absolutePath)) || path.basename(absolutePath));
24
+ if (kind === 'extension') return manifestName(content, path.basename(path.dirname(absolutePath)));
19
25
  const declared = declaredName(content);
20
26
  if (declared) return declared;
21
27
  if (kind === 'skill') return path.basename(path.dirname(absolutePath));
22
28
  if (kind === 'hook') return `${path.basename(absolutePath)} · hooks`;
29
+ if (kind === 'rules') return path.basename(absolutePath);
23
30
  return path.basename(absolutePath).replace(/\.(md|toml)$/i, '');
24
31
  }
25
32
 
@@ -30,8 +37,10 @@ function resolveActivation(marketplace, installed) {
30
37
  }
31
38
 
32
39
  function readArtifactContent(kind, absolutePath) {
33
- const read = readText(absolutePath);
40
+ const manifest = kind === 'plugin' || kind === 'extension';
41
+ const read = readText(absolutePath, manifest ? MAX_MANIFEST_BYTES : undefined);
34
42
  if (!read) return null;
43
+ if (manifest && read.truncated) return { ...read, content: null, oversize: true };
35
44
  if (kind !== 'hook') return { ...read, content: read.text };
36
45
  const content = canonicalHooks(read.text);
37
46
  return content ? { ...read, content } : null;
@@ -77,7 +86,12 @@ function collectFromSite({ site, files, state, project }) {
77
86
  }
78
87
 
79
88
  const marketplace = PLUGIN_PATH_RE.exec(relativePath)?.[2] ?? null;
80
- const activation = resolveActivation(marketplace, installed);
89
+
90
+ if (kind === 'plugin' && marketplace && !MARKETPLACE_ROOT_RE.test(relativePath)) {
91
+ availableBy.set(marketplace, (availableBy.get(marketplace) ?? 0) + 1);
92
+ continue;
93
+ }
94
+ const activation = kind === 'plugin' && marketplace ? 'active' : resolveActivation(marketplace, installed);
81
95
  if (activation === 'not-installed') {
82
96
  availableBy.set(marketplace, (availableBy.get(marketplace) ?? 0) + 1);
83
97
  continue;
@@ -85,6 +99,10 @@ function collectFromSite({ site, files, state, project }) {
85
99
 
86
100
  const read = readArtifactContent(kind, absolutePath);
87
101
  if (!read) continue;
102
+ if (read.oversize) {
103
+ capped.push({ reason: 'manifest-too-large', path: relativePath });
104
+ continue;
105
+ }
88
106
  if (read.bytes > budget.bytes) {
89
107
  capped.push({ reason: 'byte-budget', path: relativePath });
90
108
  continue;
@@ -97,6 +115,76 @@ function collectFromSite({ site, files, state, project }) {
97
115
  consumed.add(absolutePath);
98
116
  artifacts.push(artifact);
99
117
  if (kind === 'skill') skills.push({ artifact, dir: path.dirname(absolutePath), site });
118
+ if (kind === 'rules') state.rules.push({ artifact, absolutePath });
119
+ if (kind === 'plugin') state.plugins.push({ artifact, absolutePath, site });
120
+ if (kind === 'extension') state.extensions.push({ artifact, absolutePath, site });
121
+ if (kind === 'hook') (state.hooks ??= []).push({ artifact, absolutePath, site });
122
+ }
123
+ }
124
+
125
+ function collectInstalledPlugins({ sites, state, project }) {
126
+ const { artifacts, capped, consumed, budget } = state;
127
+ for (const site of sites) {
128
+ if (site.vendor !== 'claude-code' || site.scope !== 'user') continue;
129
+ for (const { manifest, marketplace } of installedPluginManifests(site.dir)) {
130
+ if (consumed.has(manifest)) continue;
131
+ if (artifacts.length >= MAX_ARTIFACTS) { capped.push({ reason: 'artifact-cap', path: manifest }); return; }
132
+ const relativePath = relativeToSite(site, manifest);
133
+ const read = readArtifactContent('plugin', manifest);
134
+ if (!read) continue;
135
+ if (read.oversize) { capped.push({ reason: 'manifest-too-large', path: relativePath }); continue; }
136
+ if (read.bytes > budget.bytes) { capped.push({ reason: 'byte-budget', path: relativePath }); continue; }
137
+ budget.bytes -= Buffer.byteLength(read.content);
138
+ const artifact = buildArtifact({
139
+ kind: 'plugin', content: read.content, read, relativePath, site, project, marketplace, activation: 'active', absolutePath: manifest,
140
+ });
141
+ consumed.add(manifest);
142
+ artifacts.push(artifact);
143
+ state.plugins.push({ artifact, absolutePath: manifest, site });
144
+ }
145
+ }
146
+ }
147
+
148
+ const IMPORT_READABLE_RE = /\.(?:md|mdx|markdown|mdc|rst|txt)$/i;
149
+ const IMPORT_SECRET_RE = /(?:^|[\\/])(?:\.env|\.ssh|\.aws|\.gnupg|\.netrc|\.npmrc)|id_(?:rsa|dsa|ecdsa|ed25519)|secret|credential|password|\.pem$|\.key$/i;
150
+ const MAX_IMPORT_DEPTH = 5;
151
+
152
+ function resolveImport(ref, fromFile) {
153
+ if (ref.startsWith('~/')) return path.join(HOME, ref.slice(2));
154
+ if (path.isAbsolute(ref)) return ref;
155
+ return path.resolve(path.dirname(fromFile), ref);
156
+ }
157
+
158
+ function bundleRuleImports(state) {
159
+ const { capped, budget } = state;
160
+ for (const { artifact, absolutePath } of state.rules) {
161
+ const seen = new Set([absolutePath]);
162
+ const queue = [{ file: absolutePath, text: artifact.content, depth: 0 }];
163
+ while (queue.length) {
164
+ const { file, text, depth } = queue.shift();
165
+ if (depth >= MAX_IMPORT_DEPTH) continue;
166
+ for (const ref of extractInstructionImports(text)) {
167
+ if (!IMPORT_READABLE_RE.test(ref) || IMPORT_SECRET_RE.test(ref)) continue;
168
+ const target = resolveImport(ref, file);
169
+ if (seen.has(target)) continue;
170
+ seen.add(target);
171
+ if (artifact.files.length >= MAX_BUNDLED) {
172
+ capped.push({ reason: 'bundle-cap', path: artifact.path });
173
+ queue.length = 0;
174
+ break;
175
+ }
176
+ const read = readText(target);
177
+ if (!read) continue;
178
+ if (read.bytes > budget.bytes) {
179
+ capped.push({ reason: 'byte-budget', path: ref });
180
+ continue;
181
+ }
182
+ budget.bytes -= Buffer.byteLength(read.text);
183
+ artifact.files.push({ path: ref.replace(/^[~.]*\//, ''), content: read.text, binary: false });
184
+ queue.push({ file: target, text: read.text, depth: depth + 1 });
185
+ }
186
+ }
187
+ if (artifact.files.length) artifact.metadata.importCount = artifact.files.length;
100
188
  }
101
189
  }
102
190
 
@@ -164,12 +252,34 @@ export function discoverAgentArtifacts(cwd = process.cwd(), roots = null) {
164
252
  capped: [],
165
253
  consumed: new Set(),
166
254
  skills: [],
255
+ rules: [],
256
+ plugins: [],
257
+ extensions: [],
167
258
  availableBy: new Map(),
168
259
  };
169
260
 
170
261
  const walked = walkSites(sites, state.budget);
171
262
  for (const { site, files } of walked) collectFromSite({ site, files, state, project });
263
+ collectInstalledPlugins({ sites, state, project });
172
264
  bundleSkillFiles({ skills: state.skills, walked, state });
265
+ bundleRuleImports(state);
266
+ for (const { artifact, absolutePath, site } of state.hooks ?? []) {
267
+ bundleHookScripts(artifact, absolutePath, {
268
+ projectDir: site.scope === 'project' ? path.dirname(site.dir) : cwd,
269
+ relPath: (abs) => {
270
+ const rel = relativeToSite(site, abs);
271
+ return rel.startsWith('..') || path.isAbsolute(rel) ? null : rel;
272
+ },
273
+ budget: state.budget,
274
+ capped: state.capped,
275
+ });
276
+ }
277
+ for (const { artifact, absolutePath, site } of state.extensions) {
278
+ bundleExtensionSource(artifact, absolutePath, (abs) => relativeToSite(site, abs), state.budget, state.capped);
279
+ }
280
+ for (const { artifact, absolutePath, site } of state.plugins) {
281
+ bundlePluginComponents(artifact, absolutePath, (abs) => relativeToSite(site, abs), state.budget, state.capped);
282
+ }
173
283
 
174
284
  if (state.budget.dirs <= 0) state.capped.push({ reason: 'walk-budget', path: null });
175
285
 
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readText } from './file-read.mjs';
4
+ import { HOME } from './limits.mjs';
5
+
6
+ const MAX_SOURCE_FILES = 20;
7
+ const MAX_SOURCE_BYTES = 200_000;
8
+ const MAX_WALK_DEPTH = 4;
9
+ const CODE_RE = /\.(?:[cm]?js|jsx|ts|tsx|py|pyw)$/i;
10
+ const SKIP_DIRS = new Set(['node_modules', '.venv', 'venv', 'site-packages', '__pycache__', 'dist-packages', '.git']);
11
+
12
+ export const EXTENSIONS_DIR_NAME = 'Claude Extensions';
13
+
14
+ export function claudeDesktopDataDir(platform = process.platform) {
15
+ if (platform === 'darwin') return path.join(HOME, 'Library', 'Application Support', 'Claude');
16
+ if (platform === 'win32') return path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), 'Claude');
17
+ return path.join(HOME, '.config', 'Claude');
18
+ }
19
+
20
+ export const EXTENSION_MANIFEST_RE = /^claude extensions\/[^/]+\/manifest\.json$/i;
21
+
22
+ function inside(root, target) {
23
+ const rel = path.relative(root, target);
24
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
25
+ }
26
+
27
+ function realInside(root, target) {
28
+ try { return inside(fs.realpathSync(root), fs.realpathSync(target)); } catch { return false; }
29
+ }
30
+
31
+ function sourceFiles(dir, depth = 0, out = []) {
32
+ if (depth > MAX_WALK_DEPTH || out.length >= MAX_SOURCE_FILES * 3) return out;
33
+ let entries = [];
34
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
35
+ for (const e of entries) {
36
+ const full = path.join(dir, e.name);
37
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) sourceFiles(full, depth + 1, out); }
38
+ else if (e.isFile() && CODE_RE.test(e.name)) out.push(full);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ export function bundleExtensionSource(artifact, absManifest, toRel, budget, capped) {
44
+ const root = path.dirname(absManifest);
45
+ let doc = null;
46
+ try { doc = JSON.parse(artifact.content); } catch { return; }
47
+ const entry = typeof doc?.server?.entry_point === 'string' ? doc.server.entry_point.replace(/^\$\{__dirname\}[\\/]?/, '') : null;
48
+ const entryAbs = entry ? path.resolve(root, entry) : null;
49
+ const candidates = [
50
+ ...(entryAbs && inside(root, entryAbs) ? [entryAbs] : []),
51
+ ...sourceFiles(root).filter((f) => f !== entryAbs),
52
+ ];
53
+ for (const abs of candidates) {
54
+ if (artifact.files.length >= MAX_SOURCE_FILES) { capped.push({ reason: 'bundle-cap', path: artifact.path }); break; }
55
+ if (!realInside(root, abs)) continue;
56
+ const read = readText(abs, MAX_SOURCE_BYTES);
57
+ if (!read || read.truncated) continue;
58
+ if (Buffer.byteLength(read.text) > budget.bytes) { capped.push({ reason: 'byte-budget', path: toRel(abs) }); continue; }
59
+ budget.bytes -= Buffer.byteLength(read.text);
60
+ artifact.files.push({ path: toRel(abs), content: read.text, binary: false });
61
+ }
62
+ artifact.metadata.bundledCount = artifact.files.length;
63
+
64
+ const id = path.basename(root);
65
+ try {
66
+ const dataDir = path.dirname(path.dirname(root));
67
+ const settings = JSON.parse(fs.readFileSync(path.join(dataDir, 'Claude Extensions Settings', `${id}.json`), 'utf8'));
68
+ if (settings?.isEnabled === false) artifact.metadata.activation = 'disabled';
69
+ } catch { }
70
+ }
@@ -0,0 +1,128 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { loadConfig } from '../../core/config.mjs';
4
+ import { redactLocally } from '../../detect/local-redact.mjs';
5
+ import { readText } from './file-read.mjs';
6
+ import { HOME, MAX_BUNDLED } from './limits.mjs';
7
+
8
+ export const HOOK_SCRIPT_MODES = ['full', 'hash', 'off'];
9
+
10
+ const SCRIPT_EXT = /\.(?:sh|bash|zsh|ps1|psm1|bat|cmd|py|js|mjs|cjs|ts|rb|pl|php|lua)$/i;
11
+ const SECRET_PATH = /(?:^|[\\/])(?:\.env|\.ssh|\.aws|\.gnupg|\.netrc|\.npmrc|\.kube|\.docker)(?:[\\/]|$)|id_(?:rsa|dsa|ecdsa|ed25519)|secret|credential|password|\.pem$|\.key$/i;
12
+ const COMMAND_KEYS = new Set(['command', 'bash', 'sh', 'powershell', 'pwsh', 'windows', 'linux', 'osx']);
13
+ const EXTS = 'sh|bash|zsh|ps1|psm1|bat|cmd|py|js|mjs|cjs|ts|rb|pl|php|lua';
14
+ const REF_RE = new RegExp(`(?:^|["'\\s=;&|(\`])((?:\\$\\{?[A-Za-z_]\\w*\\}?|%[A-Za-z_]\\w*%|~|[A-Za-z]:)?[\\\\/]?(?:[\\w.@-]+[\\\\/])*[\\w.@-]+\\.(?:${EXTS}))(?=["'\\s;|&)\`]|$)`, 'g');
15
+ const QUOTED_REF_RE = new RegExp(`(["'])((?:\\$\\{?[A-Za-z_]\\w*\\}?|%[A-Za-z_]\\w*%|~|[A-Za-z]:)?[^"'\\n]*[\\\\/][^"'\\n]*?\\.(?:${EXTS}))\\1`, 'g');
16
+ const SHOMRA_GUARD_RE = /(?:^|[\\/])(?:shomra(?:-agent)?\.m?js|@shomra[\\/]agent[\\/].*)$/i;
17
+ const MAX_REFS = 40;
18
+
19
+ export function hookScriptMode(env = process.env, cfg = null) {
20
+ const raw = String(env.SHOMRA_HOOK_SCRIPTS ?? (cfg ?? safeConfig()).hookScripts ?? 'full').trim().toLowerCase();
21
+ return HOOK_SCRIPT_MODES.includes(raw) ? raw : 'full';
22
+ }
23
+
24
+ function safeConfig() {
25
+ try {
26
+ return loadConfig();
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ export function hookCommands(canonical) {
33
+ let doc;
34
+ try {
35
+ doc = JSON.parse(canonical);
36
+ } catch {
37
+ return [];
38
+ }
39
+ const out = [];
40
+ const walk = (n, depth) => {
41
+ if (!n || depth > 12 || out.length > 200) return;
42
+ if (Array.isArray(n)) return n.forEach((v) => walk(v, depth + 1));
43
+ if (typeof n !== 'object') return;
44
+ for (const [k, v] of Object.entries(n)) {
45
+ if (COMMAND_KEYS.has(k) && typeof v === 'string') out.push(v);
46
+ else walk(v, depth + 1);
47
+ }
48
+ };
49
+ walk(doc?.hooks, 0);
50
+ return out;
51
+ }
52
+
53
+ export function scriptRefs(command) {
54
+ const out = new Set();
55
+ const cmd = String(command).replace(/(["'])(\$\{?[A-Za-z_]\w*\}?|%[A-Za-z_]\w*%)\1(?=[\\/])/g, '$2');
56
+ for (const m of cmd.matchAll(QUOTED_REF_RE)) if (out.size < MAX_REFS) out.add(m[2].trim());
57
+ const unquoted = cmd.replace(QUOTED_REF_RE, ' ');
58
+ for (const m of unquoted.matchAll(REF_RE)) {
59
+ if (out.size >= MAX_REFS) break;
60
+ out.add(m[1].trim());
61
+ }
62
+ return [...out];
63
+ }
64
+
65
+ export function pluginRootOf(hookFile) {
66
+ const norm = hookFile.replace(/\\/g, '/');
67
+ if (/\/hooks\/hooks\.json$/i.test(norm)) return path.dirname(path.dirname(hookFile));
68
+ if (/\/\.claude-plugin\/plugin\.json$/i.test(norm)) return path.dirname(path.dirname(hookFile));
69
+ return path.dirname(hookFile);
70
+ }
71
+
72
+ export function resolveRef(ref, { hookFile, projectDir }) {
73
+ const m = /^(\$\{?([A-Za-z_]\w*)\}?|%([A-Za-z_]\w*)%|~)([\\/].*)$/.exec(ref);
74
+ if (m) {
75
+ const name = (m[2] ?? m[3] ?? '').toUpperCase();
76
+ const rest = m[4].replace(/^[\\/]/, '');
77
+ const base =
78
+ m[1] === '~' || name === 'HOME' || name === 'USERPROFILE' ? HOME
79
+ : name === 'CLAUDE_PLUGIN_ROOT' || name === 'EXTENSIONPATH' ? pluginRootOf(hookFile)
80
+ : /^(?:CLAUDE|GEMINI|CURSOR|QWEN)_PROJECT_DIR$|^PWD$/.test(name) ? projectDir
81
+ : null;
82
+ return base ? [path.join(base, rest)] : [];
83
+ }
84
+ if (path.isAbsolute(ref) || /^[A-Za-z]:[\\/]/.test(ref)) return [ref];
85
+ return [...new Set([path.resolve(projectDir, ref), path.resolve(path.dirname(hookFile), ref)])];
86
+ }
87
+
88
+ const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
89
+
90
+ export function bundleHookScripts(artifact, hookFile, { projectDir, relPath, budget, capped, mode = hookScriptMode() }) {
91
+ if (mode === 'off') return;
92
+ const refs = [...new Set(hookCommands(artifact.content).flatMap(scriptRefs))].filter((r) => SCRIPT_EXT.test(r));
93
+ const scripts = [];
94
+ for (const ref of refs) {
95
+ if (SECRET_PATH.test(ref)) continue;
96
+ if (SHOMRA_GUARD_RE.test(ref)) {
97
+ scripts.push({ ref, state: 'shomra-guard' });
98
+ continue;
99
+ }
100
+ if (artifact.files.length >= MAX_BUNDLED) {
101
+ capped.push({ reason: 'bundle-cap', path: artifact.path });
102
+ break;
103
+ }
104
+ const target = resolveRef(ref, { hookFile, projectDir }).find((p) => readText(p) != null);
105
+ if (!target || SECRET_PATH.test(target)) {
106
+ scripts.push({ ref, state: 'missing' });
107
+ continue;
108
+ }
109
+ const read = readText(target);
110
+ const digest = sha256(read.text);
111
+ const wirePath = relPath(target) ?? ref.replace(/^(?:\$\{?\w+\}?|%\w+%|~)?[\\/]*/, '').replace(/\\/g, '/');
112
+ if (mode === 'hash') {
113
+ artifact.files.push({ path: wirePath, content: null, binary: false, sha256: digest, withheld: true });
114
+ scripts.push({ ref, path: wirePath, sha256: digest, bytes: read.bytes, state: 'withheld' });
115
+ continue;
116
+ }
117
+ if (read.bytes > budget.bytes) {
118
+ capped.push({ reason: 'byte-budget', path: wirePath });
119
+ scripts.push({ ref, path: wirePath, sha256: digest, bytes: read.bytes, state: 'over-budget' });
120
+ continue;
121
+ }
122
+ const masked = redactLocally(read.text, { categories: ['secret'] });
123
+ budget.bytes -= Buffer.byteLength(masked.text);
124
+ artifact.files.push({ path: wirePath, content: masked.text, binary: false, sha256: digest, ...(read.truncated ? { truncated: true } : {}) });
125
+ scripts.push({ ref, path: wirePath, sha256: digest, bytes: read.bytes, state: 'sent', maskedSecrets: masked.masked.length });
126
+ }
127
+ if (refs.length) artifact.metadata.hookScripts = { mode, scripts: scripts.slice(0, MAX_BUNDLED) };
128
+ }
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  export const HOME = os.homedir();
5
5
 
6
- export const ARTIFACT_KINDS = ['skill', 'command', 'subagent', 'hook'];
6
+ export const ARTIFACT_KINDS = ['skill', 'command', 'subagent', 'hook', 'rules', 'plugin', 'extension'];
7
7
 
8
8
  export const MAX_DEPTH = 6;
9
9
 
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJsonAt, readText, stripJsonComments } from './file-read.mjs';
4
+ import { canonicalHooks } from './hooks.mjs';
5
+ import { HOME } from './limits.mjs';
6
+
7
+ export { PLUGIN_MANIFEST_RE } from '../../detect/signals/manifests.mjs';
8
+ export const MARKETPLACE_ROOT_RE = /(^|\/)plugins\/marketplaces\/[^/]+\/\.claude-plugin\/marketplace\.json$/i;
9
+ export const MAX_MANIFEST_BYTES = 200_000;
10
+
11
+ const MAX_COMPONENT_FILES = 12;
12
+ const MAX_BIN_NAMES = 40;
13
+
14
+ export function pluginRootOf(absManifest) {
15
+ const dir = path.dirname(absManifest);
16
+ const base = path.basename(dir).toLowerCase();
17
+ if (/^\.(claude|codex|cursor|copilot|github)-plugin$|^\.plugin$/.test(base)) return path.dirname(dir);
18
+ if (base === 'plugin' && path.basename(path.dirname(dir)).toLowerCase() === '.github') return path.dirname(path.dirname(dir));
19
+ if (base === 'plugins' && path.basename(path.dirname(dir)).toLowerCase() === '.agents') return path.dirname(path.dirname(dir));
20
+ return dir;
21
+ }
22
+
23
+ export function manifestName(text, fallback) {
24
+ try {
25
+ const doc = JSON.parse(stripJsonComments(text));
26
+ const n = doc?.name ?? doc?.id;
27
+ if (typeof n === 'string' && n.trim()) return n.trim().slice(0, 200);
28
+ } catch { }
29
+ return fallback;
30
+ }
31
+
32
+ function inside(root, target) {
33
+ const rel = path.relative(root, target);
34
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
35
+ }
36
+
37
+ function insideReal(root, target) {
38
+ try {
39
+ return inside(fs.realpathSync(root), fs.realpathSync(target));
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ function declaredComponentPaths(text) {
46
+ let doc;
47
+ try { doc = JSON.parse(stripJsonComments(text)); } catch { return []; }
48
+ const out = [];
49
+ for (const key of ['hooks', 'mcpServers', 'lspServers', 'monitors']) {
50
+ const v = doc?.[key];
51
+ for (const p of typeof v === 'string' ? [v] : Array.isArray(v) ? v : []) {
52
+ if (typeof p === 'string' && p.trim()) out.push(p.trim().replace(/^\$\{?(?:CLAUDE_PLUGIN_ROOT|PLUGIN_ROOT)\}?\/?/, './'));
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ export function bundlePluginComponents(artifact, absManifest, toRel, budget, capped) {
59
+ const root = pluginRootOf(absManifest);
60
+ const wanted = new Set(['hooks/hooks.json', '.mcp.json', 'mcp.json', '.lsp.json', 'monitors/monitors.json']);
61
+ for (const p of declaredComponentPaths(artifact.content)) wanted.add(p.replace(/^\.\//, ''));
62
+
63
+ for (const rel of wanted) {
64
+ if (artifact.files.length >= MAX_COMPONENT_FILES) { capped.push({ reason: 'bundle-cap', path: artifact.path }); break; }
65
+ const abs = path.resolve(root, rel);
66
+ if (!inside(root, abs) || !/\.json$/i.test(abs) || !insideReal(root, abs)) continue;
67
+ const read = readText(abs, MAX_MANIFEST_BYTES);
68
+ if (!read || read.truncated) continue;
69
+ const content = /hooks[^/\\]*\.json$/i.test(abs) ? canonicalHooks(read.text) : read.text;
70
+ if (!content) continue;
71
+ if (Buffer.byteLength(content) > budget.bytes) { capped.push({ reason: 'byte-budget', path: toRel(abs) }); continue; }
72
+ budget.bytes -= Buffer.byteLength(content);
73
+ artifact.files.push({ path: toRel(abs), content, binary: false });
74
+ }
75
+
76
+ let bin = [];
77
+ try { bin = fs.readdirSync(path.join(root, 'bin'), { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name); } catch { }
78
+ for (const name of bin.slice(0, MAX_BIN_NAMES)) artifact.files.push({ path: toRel(path.join(root, 'bin', name)), content: null, binary: true });
79
+
80
+ artifact.metadata.bundledCount = artifact.files.length;
81
+ }
82
+
83
+ export function installedPluginManifests(claudeDir) {
84
+ const doc = readJsonAt(path.join(claudeDir, 'plugins', 'installed_plugins.json'));
85
+ if (!doc || typeof doc !== 'object') return [];
86
+ const out = [];
87
+ const seen = new Set();
88
+ for (const [key, value] of Object.entries(doc.plugins ?? {})) {
89
+ const records = Array.isArray(value) ? value : [value];
90
+ for (const r of records) {
91
+ const installPath = typeof r?.installPath === 'string' ? r.installPath : null;
92
+ if (!installPath) continue;
93
+ const abs = path.resolve(installPath);
94
+ if (!inside(HOME, abs)) continue;
95
+ for (const candidate of [path.join(abs, '.claude-plugin', 'plugin.json'), path.join(abs, 'plugin.json')]) {
96
+ if (seen.has(candidate) || !fs.existsSync(candidate) || !insideReal(HOME, candidate)) continue;
97
+ seen.add(candidate);
98
+ const at = key.indexOf('@');
99
+ out.push({ manifest: candidate, marketplace: at > -1 ? key.slice(at + 1) : null });
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ return out;
105
+ }
@@ -1,8 +1,45 @@
1
1
  import path from 'node:path';
2
2
  import { HOME } from './limits.mjs';
3
+ import { EXTENSIONS_DIR_NAME, claudeDesktopDataDir } from './extensions.mjs';
4
+
5
+ const PLAT = process.platform;
6
+ const APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
7
+ const XDG = PLAT === 'win32' ? APPDATA : path.join(HOME, '.config');
8
+ const CLAUDE_MANAGED = PLAT === 'win32' ? 'C:\\Program Files\\ClaudeCode' : PLAT === 'darwin' ? '/Library/Application Support/ClaudeCode' : '/etc/claude-code';
9
+
10
+ export function rulesRoots() {
11
+ return [
12
+ { vendor: 'claude-code', scope: 'user', dir: CLAUDE_MANAGED, managed: true },
13
+ { vendor: 'qwen-code', scope: 'user', dir: path.join(HOME, '.qwen') },
14
+ { vendor: 'roo', scope: 'user', dir: path.join(HOME, '.roo') },
15
+ { vendor: 'kiro', scope: 'user', dir: path.join(HOME, '.kiro') },
16
+ { vendor: 'augment', scope: 'user', dir: path.join(HOME, '.augment') },
17
+ { vendor: 'junie', scope: 'user', dir: path.join(HOME, '.junie') },
18
+ { vendor: 'cline', scope: 'user', dir: path.join(HOME, 'Documents', 'Cline') },
19
+ { vendor: 'zed', scope: 'user', dir: path.join(XDG, PLAT === 'win32' ? 'Zed' : 'zed') },
20
+ { vendor: 'goose', scope: 'user', dir: PLAT === 'win32' ? path.join(APPDATA, 'Block', 'goose', 'config') : path.join(HOME, '.config', 'goose') },
21
+ { vendor: 'amp', scope: 'user', dir: path.join(HOME, '.config', 'amp') },
22
+ { vendor: 'opencode', scope: 'user', dir: path.join(HOME, '.config', 'opencode') },
23
+ { vendor: 'agents-md', scope: 'user', dir: path.join(HOME, '.agents') },
24
+ { vendor: 'continue', scope: 'user', dir: path.join(HOME, '.continue') },
25
+ ];
26
+ }
27
+
28
+ export function projectVendorRoots(cwd = process.cwd()) {
29
+ return [
30
+ { vendor: 'roo', scope: 'project', dir: path.join(cwd, '.roo') },
31
+ { vendor: 'qwen-code', scope: 'project', dir: path.join(cwd, '.qwen') },
32
+ { vendor: 'kiro', scope: 'project', dir: path.join(cwd, '.kiro') },
33
+ { vendor: 'cline', scope: 'project', dir: path.join(cwd, '.clinerules') },
34
+ { vendor: 'continue', scope: 'project', dir: path.join(cwd, '.continue') },
35
+ { vendor: 'agents-md', scope: 'project', dir: path.join(cwd, '.agents') },
36
+ ];
37
+ }
3
38
 
4
39
  export function artifactRoots(cwd = process.cwd()) {
5
40
  return [
41
+ ...rulesRoots(),
42
+ ...projectVendorRoots(cwd),
6
43
  { vendor: 'claude-code', scope: 'user', dir: path.join(HOME, '.claude') },
7
44
  { vendor: 'claude-code', scope: 'project', dir: path.join(cwd, '.claude') },
8
45
  { vendor: 'cursor', scope: 'user', dir: path.join(HOME, '.cursor') },
@@ -11,10 +48,13 @@ export function artifactRoots(cwd = process.cwd()) {
11
48
  { vendor: 'codex', scope: 'project', dir: path.join(cwd, '.codex') },
12
49
  { vendor: 'gemini', scope: 'user', dir: path.join(HOME, '.gemini') },
13
50
  { vendor: 'gemini', scope: 'project', dir: path.join(cwd, '.gemini') },
51
+ { vendor: 'windsurf', scope: 'user', dir: path.join(HOME, '.codeium', 'windsurf') },
14
52
  { vendor: 'windsurf', scope: 'project', dir: path.join(cwd, '.windsurf') },
15
53
  { vendor: 'opencode', scope: 'user', dir: path.join(HOME, '.opencode') },
16
54
  { vendor: 'opencode', scope: 'project', dir: path.join(cwd, '.opencode') },
17
55
 
56
+ { vendor: 'copilot', scope: 'user', dir: path.join(HOME, '.copilot') },
18
57
  { vendor: 'copilot', scope: 'project', dir: path.join(cwd, '.github') },
58
+ { vendor: 'claude-desktop', scope: 'user', dir: path.join(claudeDesktopDataDir(), EXTENSIONS_DIR_NAME) },
19
59
  ];
20
60
  }