@shomra/agent 0.3.28 → 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 (84) 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/rules-javascript.mjs +16 -3
  23. package/src/detect/sast/rules-python.mjs +8 -6
  24. package/src/detect/sast/scanner.mjs +1 -1
  25. package/src/detect/signals/agent-frameworks.mjs +231 -0
  26. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  27. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  28. package/src/detect/signals/agentic-shim.mjs +82 -0
  29. package/src/detect/signals/artifacts.mjs +8 -36
  30. package/src/detect/signals/autonomy.mjs +9 -1
  31. package/src/detect/signals/chat-template.mjs +211 -0
  32. package/src/detect/signals/ci-workflow.mjs +169 -0
  33. package/src/detect/signals/credential-harvest.mjs +1 -1
  34. package/src/detect/signals/execution-hijack.mjs +4 -2
  35. package/src/detect/signals/gate.mjs +84 -11
  36. package/src/detect/signals/guardrail-shape.mjs +564 -0
  37. package/src/detect/signals/guardrail-surface.mjs +221 -0
  38. package/src/detect/signals/injection.mjs +8 -0
  39. package/src/detect/signals/inspect-shim.mjs +7 -0
  40. package/src/detect/signals/instruction-paths.mjs +60 -0
  41. package/src/detect/signals/manifests.mjs +302 -0
  42. package/src/detect/signals/masking.mjs +14 -1
  43. package/src/detect/signals/mcp-advisories.mjs +109 -0
  44. package/src/detect/signals/mcp-config.mjs +598 -0
  45. package/src/detect/signals/memory-directives.mjs +661 -0
  46. package/src/detect/signals/memory-locations.mjs +158 -0
  47. package/src/detect/signals/memory.mjs +56 -31
  48. package/src/detect/signals/model-config-rules.mjs +655 -0
  49. package/src/detect/signals/model-config.mjs +61 -0
  50. package/src/detect/signals/packages.mjs +2 -2
  51. package/src/detect/signals/prose-context.mjs +6 -9
  52. package/src/detect/signals/scan.mjs +4 -4
  53. package/src/detect/signals/secret-scanner.mjs +241 -0
  54. package/src/detect/signals/secrets.mjs +1 -48
  55. package/src/detect/signals/shell.mjs +10 -10
  56. package/src/gate/advisories.mjs +16 -0
  57. package/src/gate/batch.mjs +10 -0
  58. package/src/gate/environment.mjs +8 -53
  59. package/src/guard/artifact-paths.mjs +107 -0
  60. package/src/guard/classify.mjs +165 -7
  61. package/src/guard/command-resolve.mjs +35 -5
  62. package/src/guard/memory-write.mjs +218 -0
  63. package/src/guard/prompt-guard.mjs +0 -1
  64. package/src/guard/tool-guard.mjs +52 -77
  65. package/src/inventory/agent-posture.mjs +236 -57
  66. package/src/inventory/artifacts/classify.mjs +10 -1
  67. package/src/inventory/artifacts/discover.mjs +113 -3
  68. package/src/inventory/artifacts/extensions.mjs +70 -0
  69. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  70. package/src/inventory/artifacts/limits.mjs +1 -1
  71. package/src/inventory/artifacts/plugins.mjs +105 -0
  72. package/src/inventory/artifacts/roots.mjs +40 -0
  73. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  74. package/src/inventory/discovery/all.mjs +4 -0
  75. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  76. package/src/inventory/discovery/coding-agents.mjs +19 -4
  77. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  78. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  79. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  80. package/src/inventory/env-redirect.mjs +148 -0
  81. package/src/inventory/grant-extract.mjs +463 -0
  82. package/src/inventory/project-roots.mjs +108 -0
  83. package/src/inventory/vscode-state.mjs +153 -0
  84. package/src/mcp/server-tools.mjs +1 -1
@@ -0,0 +1,472 @@
1
+ import path from 'node:path';
2
+ import { exists, readJson, readText } from './fs-read.mjs';
3
+ import { HOME } from './platform.mjs';
4
+
5
+ /**
6
+ * WHAT THIS MACHINE IS LOGGED INTO.
7
+ *
8
+ * THE ASSET IS THE AMBIENT CREDENTIAL, NOT THE BINARY. "az is installed" is
9
+ * worth nothing. "az on this host is authenticated as `sp-jamesr-automation`
10
+ * against subscription `prod-01`" is the whole finding, because an agent with a
11
+ * shell on this host inherits exactly that - a reach nothing in the agent's own
12
+ * policy granted, that YOUR credential rotation does not touch, and that
13
+ * SUSPENDING THE AGENT DOES NOT REVOKE.
14
+ *
15
+ * NOTHING HERE EXECUTES A COMMAND. `az account show` would be the obvious
16
+ * way and it is the wrong one: spawning cloud CLIs on a customer's machine is
17
+ * slow, can trigger interactive re-auth, and can itself mint or refresh tokens -
18
+ * a collector that changes the estate it measures. Every fact below is read out
19
+ * of a config file the CLI already wrote.
20
+ *
21
+ * AND IT NEVER READS SECRET MATERIAL. `~/.aws/credentials` holds
22
+ * `aws_secret_access_key`; `gh` holds an OAuth token; gcloud's `credentials.db`
23
+ * holds refresh tokens. This parses the IDENTITY-BEARING keys only and is
24
+ * written so a secret cannot reach the payload even by accident - see
25
+ * `SECRET_KEYS` and the profile-name-only parse of the credentials file.
26
+ */
27
+
28
+ /**
29
+ * Keys that carry secret material. A parser that meets one drops the VALUE and
30
+ * keeps only the fact that it was present.
31
+ *
32
+ * ⚠ Presence is a finding ("this host holds a long-lived key"); the value is
33
+ * never ours to move.
34
+ */
35
+ const SECRET_KEYS =
36
+ /^(aws_secret_access_key|aws_session_token|password|client_secret|oauth_token|token|refresh_token|access_token|private_key|id_token)$/i;
37
+
38
+ /** ⚠ The one place a value is allowed to leave this file. */
39
+ function safeValue(key, value) {
40
+ if (SECRET_KEYS.test(String(key ?? '').trim())) return null;
41
+ const v = String(value ?? '').trim();
42
+ return v.length > 200 ? v.slice(0, 200) : v;
43
+ }
44
+
45
+ /** A minimal INI reader - `~/.aws/config` and `~/.aws/credentials`. */
46
+ function parseIni(text) {
47
+ const out = {};
48
+ let section = '';
49
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
50
+ const line = raw.trim();
51
+ if (!line || line.startsWith('#') || line.startsWith(';')) continue;
52
+ const head = /^\[(.+)\]$/.exec(line);
53
+ if (head) {
54
+ section = head[1].trim();
55
+ out[section] = out[section] ?? {};
56
+ continue;
57
+ }
58
+ const eq = line.indexOf('=');
59
+ if (eq < 0 || !section) continue;
60
+ const key = line.slice(0, eq).trim();
61
+ const value = safeValue(key, line.slice(eq + 1));
62
+ out[section] = out[section] ?? {};
63
+ // ⚠ The KEY is kept even when the value is dropped, so "this profile has a
64
+ // long-lived secret key" survives without the key itself.
65
+ out[section][key] = value;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /** `key: value` out of a shallow YAML file, values sanitised. Never a parser. */
71
+ function shallowYaml(text) {
72
+ const out = {};
73
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
74
+ const m = /^\s{0,4}([A-Za-z0-9_.-]+):\s*(.*)$/.exec(raw);
75
+ if (!m) continue;
76
+ const v = m[2].replace(/^["']|["']$/g, '');
77
+ out[m[1]] = safeValue(m[1], v);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * EVERY VENDOR THIS FILE CAN EMIT, DECLARED IN ONE PLACE.
84
+ *
85
+ * THE VENDOR VOCABULARY IS A GATED SURFACE. `vendor-vocabulary-bench`
86
+ * greps the agent for `vendor: '…'` and requires every slug to land in exactly
87
+ * one bucket, so a new vendor fails loudly instead of arriving unclassified.
88
+ * Most vendors here are passed POSITIONALLY to `asset()`, which that grep cannot
89
+ * see - so it caught four of the twenty and stayed silent about the rest. This
90
+ * list exists so the gate sees ALL of them.
91
+ *
92
+ * ⚠ THESE ARE NOT AI VENDORS. They answer "what is this host logged into",
93
+ * not "which AI destination does this org use" - see `CLOUD_PLATFORM_VENDORS`
94
+ * on the backend, the bucket that says so.
95
+ */
96
+ export const CLOUD_CLI_VENDORS = [
97
+ { vendor: '1password' }, { vendor: 'aws' }, { vendor: 'azure' }, { vendor: 'cargo' },
98
+ { vendor: 'databricks' }, { vendor: 'digitalocean' }, { vendor: 'docker' }, { vendor: 'env' },
99
+ { vendor: 'gcp' }, { vendor: 'github' }, { vendor: 'kubernetes' }, { vendor: 'mysql' },
100
+ { vendor: 'netrc' }, { vendor: 'npm' }, { vendor: 'oracle' }, { vendor: 'postgres' },
101
+ { vendor: 'pypi' }, { vendor: 'snowflake' }, { vendor: 'ssh' }, { vendor: 'terraform' },
102
+ { vendor: 'vault' },
103
+ ];
104
+
105
+ const asset = (name, vendor, identifier, metadata) => ({
106
+ type: 'CLOUD_CLI',
107
+ name,
108
+ identifier,
109
+ vendor,
110
+ metadata: { category: 'cloud-cli', ...metadata },
111
+ });
112
+
113
+ /**
114
+ * Azure. `azureProfile.json` names every subscription the CLI is logged into
115
+ * AND the principal it authenticated as - `user` or `servicePrincipal`.
116
+ */
117
+ function azure() {
118
+ const file = path.join(HOME, '.azure', 'azureProfile.json');
119
+ if (!exists(file)) return [];
120
+ const profile = readJson(file);
121
+ const subs = Array.isArray(profile?.subscriptions) ? profile.subscriptions : [];
122
+ if (!subs.length) return [asset('Azure CLI', 'azure', file, { provider: 'azure', principal: null, note: 'installed, no subscription in its profile' })];
123
+
124
+ return subs.slice(0, 20).map((s) =>
125
+ asset('Azure CLI', 'azure', `${file}#${s.id ?? s.name ?? ''}`, {
126
+ provider: 'azure',
127
+ principal: s?.user?.name ?? null,
128
+ // ⚠ `servicePrincipal` is the one that matters most: it is a NON-HUMAN
129
+ // credential sitting on a developer's laptop, and nobody is prompted.
130
+ principalType: s?.user?.type ?? null,
131
+ account: s?.name ?? null,
132
+ accountId: s?.id ?? null,
133
+ isDefault: !!s?.isDefault,
134
+ state: s?.state ?? null,
135
+ tenantId: s?.tenantId ?? null,
136
+ }),
137
+ );
138
+ }
139
+
140
+ /** AWS. Profiles, the account/role they assume, and whether a static key sits here. */
141
+ function aws() {
142
+ const cfgFile = path.join(HOME, '.aws', 'config');
143
+ const credFile = path.join(HOME, '.aws', 'credentials');
144
+ if (!exists(cfgFile) && !exists(credFile)) return [];
145
+
146
+ const cfg = parseIni(readText(cfgFile) ?? '');
147
+ const creds = parseIni(readText(credFile) ?? '');
148
+ const names = new Set([
149
+ ...Object.keys(cfg).map((k) => k.replace(/^profile\s+/, '')),
150
+ ...Object.keys(creds),
151
+ ]);
152
+
153
+ return [...names].slice(0, 20).map((profileName) => {
154
+ const c = cfg[`profile ${profileName}`] ?? cfg[profileName] ?? {};
155
+ const k = creds[profileName] ?? {};
156
+ return asset('AWS CLI', 'aws', `${credFile}#${profileName}`, {
157
+ provider: 'aws',
158
+ profile: profileName,
159
+ principal: c.role_arn ?? c.sso_role_name ?? null,
160
+ accountId: c.sso_account_id ?? null,
161
+ account: c.sso_start_url ?? c.region ?? null,
162
+ ssoSession: c.sso_session ?? null,
163
+ /*
164
+ * ⚠ A LONG-LIVED STATIC KEY, reported as a PRESENCE and never as a value.
165
+ * It is the worst shape on this list: no expiry, no device binding, and
166
+ * an agent with a shell reads it as easily as the CLI does.
167
+ */
168
+ hasStaticKey: Object.prototype.hasOwnProperty.call(k, 'aws_access_key_id'),
169
+ });
170
+ });
171
+ }
172
+
173
+ /** Google Cloud. The active account and project out of the config file. */
174
+ function gcp() {
175
+ const file = path.join(HOME, '.config', 'gcloud', 'configurations', 'config_default');
176
+ if (!exists(file)) return [];
177
+ const ini = parseIni(readText(file) ?? '');
178
+ const core = ini.core ?? {};
179
+ /*
180
+ * `gcp`, NOT `google`. The shadow-AI vocabulary aliases `google` to
181
+ * `google-ai`, so emitting it here would have turned "this host has gcloud
182
+ * logged in" into "this org uses Google AI" - an AI-usage claim manufactured
183
+ * from a cloud CLI that has never served a token.
184
+ */
185
+ return [
186
+ asset('gcloud CLI', 'gcp', file, {
187
+ provider: 'gcp',
188
+ principal: core.account ?? null,
189
+ account: core.project ?? null,
190
+ accountId: core.project ?? null,
191
+ }),
192
+ ];
193
+ }
194
+
195
+ /**
196
+ * Kubernetes. The CURRENT context is the one a bare `kubectl` acts on, which is
197
+ * the only one an agent reaches without naming anything.
198
+ */
199
+ function kubernetes() {
200
+ const file = path.join(HOME, '.kube', 'config');
201
+ if (!exists(file)) return [];
202
+ const y = shallowYaml(readText(file) ?? '');
203
+ const current = y['current-context'] ?? null;
204
+ return [
205
+ asset('kubectl', 'kubernetes', file, {
206
+ provider: 'kubernetes',
207
+ principal: y.user ?? null,
208
+ account: current,
209
+ accountId: current,
210
+ // ⚠ A context this file names but does not point at is reach the agent has
211
+ // only if it asks for it - a weaker claim, and reported as one.
212
+ currentContext: current,
213
+ }),
214
+ ];
215
+ }
216
+
217
+ /** GitHub CLI. The user it is logged in as; never the token. */
218
+ function github() {
219
+ const file = path.join(HOME, '.config', 'gh', 'hosts.yml');
220
+ if (!exists(file)) return [];
221
+ const y = shallowYaml(readText(file) ?? '');
222
+ return [
223
+ asset('GitHub CLI', 'github', file, {
224
+ provider: 'github',
225
+ principal: y.user ?? null,
226
+ account: y.git_protocol ? 'github.com' : null,
227
+ hasStaticKey: /oauth_token:/.test(readText(file) ?? ''),
228
+ }),
229
+ ];
230
+ }
231
+
232
+ /**
233
+ * TERRAFORM. The state backend and the Terraform Cloud token.
234
+ *
235
+ * ⚠ The highest-consequence entry on this list for infrastructure: a `terraform
236
+ * apply` is a control-plane call with the blast radius of a whole workspace, and
237
+ * it authenticates with credentials that are usually NOT the ones `az` or `aws`
238
+ * hold - so an estate that governs those two still has this one open.
239
+ */
240
+ function terraform() {
241
+ const rc = [path.join(HOME, '.terraformrc'), path.join(HOME, 'terraform.rc')].find(exists);
242
+ const tfc = path.join(HOME, '.terraform.d', 'credentials.tfrc.json');
243
+ const out = [];
244
+ if (rc) out.push(asset('Terraform', 'terraform', rc, { provider: 'terraform', principal: null, hasStaticKey: /token\s*=/.test(readText(rc) ?? '') }));
245
+ if (exists(tfc)) {
246
+ const j = readJson(tfc);
247
+ for (const host of Object.keys(j?.credentials ?? {}).slice(0, 10)) {
248
+ out.push(asset('Terraform Cloud', 'terraform', tfc + '#' + host, { provider: 'terraform', account: host, principal: null, hasStaticKey: true }));
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
254
+ /** Docker/OCI registries this host can push to. Registry NAMES, never the auth blob. */
255
+ function docker() {
256
+ const file = path.join(HOME, '.docker', 'config.json');
257
+ if (!exists(file)) return [];
258
+ const j = readJson(file);
259
+ const regs = Object.keys(j?.auths ?? {}).slice(0, 15);
260
+ if (!regs.length) return [asset('Docker', 'docker', file, { provider: 'registry', principal: null })];
261
+ // ⚠ A registry login is a PUBLISH path - the supply chain, outbound.
262
+ return regs.map((r) => asset('Docker registry', 'docker', file + '#' + r, { provider: 'registry', account: r, principal: null, hasStaticKey: true }));
263
+ }
264
+
265
+ /**
266
+ * Package registries this host can PUBLISH to.
267
+ *
268
+ * ⚠ An agent that can publish is an agent that can ship code to everyone who
269
+ * installs it. It is inventory for the same reason the cloud entries are.
270
+ */
271
+ function packageRegistries() {
272
+ const out = [];
273
+ const npmrc = path.join(HOME, '.npmrc');
274
+ if (exists(npmrc)) {
275
+ const t = readText(npmrc) ?? '';
276
+ const reg = /registry\s*=\s*(\S+)/.exec(t);
277
+ out.push(asset('npm', 'npm', npmrc, { provider: 'registry', account: reg ? reg[1] : 'registry.npmjs.org', principal: null, hasStaticKey: /_authToken\s*=/.test(t) }));
278
+ }
279
+ const pypirc = path.join(HOME, '.pypirc');
280
+ if (exists(pypirc)) {
281
+ const ini = parseIni(readText(pypirc) ?? '');
282
+ for (const name of Object.keys(ini).filter((k) => k !== 'distutils').slice(0, 6)) {
283
+ const sect = ini[name] ?? {};
284
+ out.push(asset('PyPI', 'pypi', pypirc + '#' + name, { provider: 'registry', account: name, principal: sect.username ?? null, hasStaticKey: 'password' in sect }));
285
+ }
286
+ }
287
+ const cargo = path.join(HOME, '.cargo', 'credentials.toml');
288
+ if (exists(cargo)) out.push(asset('crates.io', 'cargo', cargo, { provider: 'registry', principal: null, hasStaticKey: true }));
289
+ return out;
290
+ }
291
+
292
+ /**
293
+ * Databases this host has stored credentials for.
294
+ *
295
+ * `.pgpass` IS `host:port:db:user:password` AND THE LAST FIELD IS THE SECRET.
296
+ * It is split positionally and only the first four fields are ever named - the
297
+ * one place in this file where a naive read would put a production database
298
+ * password into a payload.
299
+ */
300
+ function databases() {
301
+ const out = [];
302
+ const pgpass = path.join(HOME, '.pgpass');
303
+ if (exists(pgpass)) {
304
+ for (const line of (readText(pgpass) ?? '').split(/\r?\n/).slice(0, 40)) {
305
+ const t = line.trim();
306
+ if (!t || t.startsWith('#')) continue;
307
+ const parts = t.split(':');
308
+ const [host, port, db, user] = parts;
309
+ out.push(
310
+ asset('PostgreSQL', 'postgres', pgpass + '#' + host + ':' + port + ':' + db, {
311
+ provider: 'database',
312
+ account: (host ?? '') + ':' + (port ?? '') + '/' + (db ?? ''),
313
+ principal: user ?? null,
314
+ hasStaticKey: parts.length >= 5,
315
+ }),
316
+ );
317
+ }
318
+ }
319
+ const mycnf = path.join(HOME, '.my.cnf');
320
+ if (exists(mycnf)) {
321
+ const c = parseIni(readText(mycnf) ?? '').client ?? {};
322
+ out.push(asset('MySQL', 'mysql', mycnf, { provider: 'database', account: c.host ?? null, principal: c.user ?? null, hasStaticKey: 'password' in c }));
323
+ }
324
+ return out;
325
+ }
326
+
327
+ /**
328
+ * SSH - the oldest lateral-movement path on the machine, and one an agent with a
329
+ * shell uses with no CLI at all.
330
+ *
331
+ * ⚠ Host aliases and key PRESENCE only. A private key is never read, and a key
332
+ * with no passphrase cannot be told from one with a passphrase without reading
333
+ * it - so this reports that a key exists and nothing more.
334
+ */
335
+ function ssh() {
336
+ const dir = path.join(HOME, '.ssh');
337
+ if (!exists(dir)) return [];
338
+ const hosts = [];
339
+ for (const line of (readText(path.join(dir, 'config')) ?? '').split(/\r?\n/)) {
340
+ const m = /^\s*Host\s+(.+)$/i.exec(line);
341
+ if (m) hosts.push(...m[1].trim().split(/\s+/).filter((h) => h && h !== '*'));
342
+ }
343
+ const keys = ['id_rsa', 'id_ed25519', 'id_ecdsa'].filter((k) => exists(path.join(dir, k)));
344
+ if (!hosts.length && !keys.length) return [];
345
+ return [
346
+ asset('SSH', 'ssh', dir, {
347
+ provider: 'ssh',
348
+ principal: null,
349
+ account: hosts.slice(0, 15).join(', ') || null,
350
+ hostCount: hosts.length,
351
+ hasStaticKey: keys.length > 0,
352
+ }),
353
+ ];
354
+ }
355
+
356
+ /**
357
+ * THE CREDENTIAL BROKERS.
358
+ *
359
+ * Worse than any single credential on this list, because a broker token is a
360
+ * key to every other key - and it is the one entry whose blast radius does not
361
+ * shrink when you tidy up the others.
362
+ */
363
+ function brokers() {
364
+ const out = [];
365
+ const vault = path.join(HOME, '.vault-token');
366
+ if (exists(vault)) out.push(asset('HashiCorp Vault', 'vault', vault, { provider: 'broker', principal: null, hasStaticKey: true }));
367
+ const op = path.join(HOME, '.config', 'op', 'config');
368
+ if (exists(op)) {
369
+ const j = readJson(op);
370
+ const acct = Array.isArray(j?.accounts) ? j.accounts[0] : null;
371
+ out.push(asset('1Password CLI', '1password', op, { provider: 'broker', principal: acct?.email ?? null, account: acct?.url ?? null }));
372
+ }
373
+ return out;
374
+ }
375
+
376
+ /** The rest, all the same shape: a config file naming an account. */
377
+ function otherClouds() {
378
+ const out = [];
379
+ const simple = [
380
+ { name: 'Databricks', vendor: 'databricks', file: path.join(HOME, '.databrickscfg'), provider: 'databricks' },
381
+ { name: 'Snowflake CLI', vendor: 'snowflake', file: path.join(HOME, '.snowflake', 'config'), provider: 'snowflake' },
382
+ { name: 'OCI CLI', vendor: 'oracle', file: path.join(HOME, '.oci', 'config'), provider: 'oci' },
383
+ { name: 'doctl', vendor: 'digitalocean', file: path.join(HOME, '.config', 'doctl', 'config.yaml'), provider: 'digitalocean' },
384
+ ];
385
+ for (const c of simple) {
386
+ if (!exists(c.file)) continue;
387
+ const ini = parseIni(readText(c.file) ?? '');
388
+ const first = Object.keys(ini)[0];
389
+ const sect = ini[first] ?? {};
390
+ out.push(
391
+ asset(c.name, c.vendor, c.file, {
392
+ provider: c.provider,
393
+ account: sect.host ?? sect.tenancy ?? first ?? null,
394
+ principal: sect.user ?? sect.username ?? null,
395
+ hasStaticKey: 'token' in sect || 'password' in sect || 'key_file' in sect,
396
+ }),
397
+ );
398
+ }
399
+ // `.netrc` backs heroku, fly and everything else that never wrote its own file.
400
+ const netrc = [path.join(HOME, '.netrc'), path.join(HOME, '_netrc')].find(exists);
401
+ if (netrc) {
402
+ const machines = [...(readText(netrc) ?? '').matchAll(/machine\s+(\S+)/gi)].map((m) => m[1]).slice(0, 15);
403
+ for (const m of machines) out.push(asset('netrc credential', 'netrc', netrc + '#' + m, { provider: 'netrc', account: m, principal: null, hasStaticKey: true }));
404
+ }
405
+ return out;
406
+ }
407
+
408
+ /**
409
+ * ENV-VAR AUTH - the case a file scan STRUCTURALLY cannot see.
410
+ *
411
+ * `AWS_ACCESS_KEY_ID` exported in a shell profile writes NO config file, so
412
+ * every collector above returns nothing and that reads identically to "this host
413
+ * reaches no cloud". This looks where the export actually lives. It STILL misses
414
+ * a variable injected by CI, a container env, or an IMDS role - which is exactly
415
+ * why the surface must say "no config was found", never "no cloud".
416
+ *
417
+ * ⚠ NAMES ONLY. The value beside an export is a live credential; the pattern
418
+ * captures the variable NAME and never looks right of the `=`.
419
+ */
420
+ const ENV_CRED_NAMES =
421
+ /\b(AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|AWS_PROFILE|AZURE_CLIENT_ID|AZURE_CLIENT_SECRET|AZURE_TENANT_ID|GOOGLE_APPLICATION_CREDENTIALS|GCLOUD_PROJECT|KUBECONFIG|VAULT_TOKEN|DATABRICKS_TOKEN|SNOWFLAKE_ACCOUNT|DIGITALOCEAN_ACCESS_TOKEN|GITHUB_TOKEN|GH_TOKEN|NPM_TOKEN|DOCKER_PASSWORD)\b/g;
422
+
423
+ function envAuth() {
424
+ const names = new Set();
425
+ for (const f of ['.bashrc', '.bash_profile', '.zshrc', '.profile', '.zprofile'].map((n) => path.join(HOME, n))) {
426
+ if (!exists(f)) continue;
427
+ for (const m of (readText(f) ?? '').matchAll(ENV_CRED_NAMES)) names.add(m[1]);
428
+ }
429
+ // The live process env is the other half - a variable set by whatever launched
430
+ // this scan, which no profile file records.
431
+ for (const k of Object.keys(process.env)) {
432
+ ENV_CRED_NAMES.lastIndex = 0;
433
+ if (ENV_CRED_NAMES.test(k)) names.add(k);
434
+ }
435
+ if (!names.size) return [];
436
+ return [
437
+ asset('Environment credentials', 'env', 'env://cloud-credentials', {
438
+ provider: 'env',
439
+ principal: null,
440
+ variables: [...names].sort().slice(0, 30),
441
+ hasStaticKey: true,
442
+ }),
443
+ ];
444
+ }
445
+
446
+ /**
447
+ * Every cloud CLI this host is authenticated to.
448
+ *
449
+ * ⚠ AN EMPTY RESULT IS "NO CONFIG FILE WAS FOUND", never "this host reaches no
450
+ * cloud". A CLI authenticated purely through an environment variable, an IMDS
451
+ * role or a mounted service-account token writes no config and appears nowhere
452
+ * here - the collector reports what it read, and the surface says so.
453
+ */
454
+ export function discoverCloudClis() {
455
+ return [
456
+ ...azure(),
457
+ ...aws(),
458
+ ...gcp(),
459
+ ...kubernetes(),
460
+ ...github(),
461
+ ...terraform(),
462
+ ...docker(),
463
+ ...packageRegistries(),
464
+ ...databases(),
465
+ ...ssh(),
466
+ ...brokers(),
467
+ ...otherClouds(),
468
+ ...envAuth(),
469
+ ];
470
+ }
471
+
472
+ export const __test = { parseIni, shallowYaml, safeValue, SECRET_KEYS, ENV_CRED_NAMES };
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { canonicalGrant, readVendorPosture } from '../agent-posture.mjs';
3
+ import { projectRoots } from '../project-roots.mjs';
4
+ import { readEnvRedirects } from '../env-redirect.mjs';
3
5
  import { exists, readText } from './fs-read.mjs';
4
6
  import { HOME, PLAT, vscodeUserDir } from './platform.mjs';
5
7
  import { execFileSync } from 'node:child_process';
@@ -25,12 +27,21 @@ export function discoverCodingAgents(roots = [process.cwd()]) {
25
27
  { vendor: 'windsurf', name: 'Windsurf', probes: [path.join(HOME, '.codeium', 'windsurf')], hookFiles: [path.join(HOME, '.codeium', 'windsurf', 'hooks.json'), path.join(cwd, '.windsurf', 'hooks.json')] },
26
28
  { vendor: 'gemini', name: 'Gemini CLI', probes: [path.join(HOME, '.gemini')], hookFiles: [path.join(HOME, '.gemini', 'settings.json'), path.join(cwd, '.gemini', 'settings.json')] },
27
29
  { vendor: 'codex', name: 'OpenAI Codex CLI', probes: [path.join(HOME, '.codex')], hookFiles: [path.join(HOME, '.codex', 'hooks.json'), path.join(cwd, '.codex', 'hooks.json')] },
28
- { vendor: 'copilot', name: 'GitHub Copilot CLI', probes: [path.join(HOME, '.copilot')], hookFiles: [path.join(HOME, '.copilot', 'hooks', 'shomra.json'), path.join(cwd, '.github', 'hooks', 'shomra.json')] },
29
- { vendor: 'cline', name: 'Cline', probes: [path.join(vscodeUserDir(), 'globalStorage', 'saoudrizwan.claude-dev')], hookFiles: [path.join(HOME, '.cline', 'hooks.json'), path.join(cwd, '.cline', 'hooks.json')] },
30
- { vendor: 'roo', name: 'Roo Code', probes: [path.join(vscodeUserDir(), 'globalStorage', 'rooveterinaryinc.roo-cline')], hookFiles: [path.join(cwd, '.roo', 'hooks.json')] },
30
+ { vendor: 'copilot', name: 'GitHub Copilot', probes: [path.join(HOME, '.copilot'), path.join(vscodeUserDir(), 'globalStorage', 'github.copilot-chat')], hookFiles: [path.join(HOME, '.copilot', 'hooks', 'shomra.json'), path.join(cwd, '.github', 'hooks', 'shomra.json')] },
31
+ { vendor: 'qwen-code', name: 'Qwen Code', probes: [path.join(HOME, '.qwen')], hookFiles: [path.join(HOME, '.qwen', 'settings.json'), path.join(cwd, '.qwen', 'settings.json')] },
32
+ { vendor: 'kiro', name: 'Kiro', probes: [path.join(HOME, '.kiro'), path.join(HOME, '.aws', 'amazonq')], hookFiles: [] },
33
+ { vendor: 'opencode', name: 'OpenCode', probes: [path.join(HOME, '.config', 'opencode'), path.join(HOME, '.local', 'share', 'opencode')], hookFiles: [] },
34
+ { vendor: 'goose', name: 'Goose', probes: [path.join(HOME, '.config', 'goose'), path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), 'Block', 'goose')], hookFiles: [] },
35
+ { vendor: 'zed', name: 'Zed', probes: [path.join(HOME, '.config', 'zed'), path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), 'Zed')], hookFiles: [] },
36
+ { vendor: 'augment', name: 'Augment', probes: [path.join(HOME, '.augment')], hookFiles: [] },
37
+ { vendor: 'continue', name: 'Continue', probes: [path.join(HOME, '.continue')], hookFiles: [] },
38
+ { vendor: 'amp', name: 'Amp', probes: [path.join(HOME, '.config', 'amp'), path.join(vscodeUserDir(), 'globalStorage', 'sourcegraph.amp')], hookFiles: [] },
39
+ { vendor: 'cline', name: 'Cline', probes: [...['Code', 'Cursor', 'Windsurf'].map((app) => path.join(vscodeUserDir(app), 'globalStorage', 'saoudrizwan.claude-dev')), path.join(HOME, '.cline', 'data')], hookFiles: [path.join(HOME, '.cline', 'hooks.json'), path.join(cwd, '.cline', 'hooks.json')] },
40
+ { vendor: 'roo', name: 'Roo Code', probes: ['Code', 'Cursor', 'Windsurf'].map((app) => path.join(vscodeUserDir(app), 'globalStorage', 'rooveterinaryinc.roo-cline')), hookFiles: [path.join(cwd, '.roo', 'hooks.json')] },
31
41
  { vendor: 'aider', name: 'Aider', probes: [path.join(HOME, '.aider.conf.yml'), path.join(cwd, '.aider.conf.yml'), path.join(HOME, '.aider')], hookFiles: [path.join(HOME, '.aider.conf.yml'), path.join(cwd, '.aider.conf.yml')] },
32
42
  ];
33
43
  const assets = [];
44
+ let projects = null;
34
45
  for (const a of agents) {
35
46
  const installedAt = a.probes.find((p) => exists(p));
36
47
  if (!installedAt) continue;
@@ -39,7 +50,8 @@ export function discoverCodingAgents(roots = [process.cwd()]) {
39
50
  return t != null && /shomra/i.test(t);
40
51
  });
41
52
 
42
- const posture = readVendorPosture(a.vendor, cwd);
53
+ projects ??= projectRoots(cwd, roots);
54
+ const posture = readVendorPosture(a.vendor, cwd, projects);
43
55
  const grant = canonicalGrant(posture);
44
56
  assets.push({
45
57
  type: 'AI_AGENT',
@@ -51,6 +63,7 @@ export function discoverCodingAgents(roots = [process.cwd()]) {
51
63
  detectedAt: installedAt,
52
64
  guarded: !!guardFile,
53
65
  guardFile: guardFile || null,
66
+ envRedirects: readEnvRedirects(a.vendor, cwd),
54
67
  posture: posture
55
68
  ? {
56
69
  tier: posture.tier,
@@ -66,6 +79,8 @@ export function discoverCodingAgents(roots = [process.cwd()]) {
66
79
  mcpServerCount: posture.mcpServers.length,
67
80
  autoApprovedMcp: posture.autoApprovedMcp,
68
81
  unreadableCount: posture.unreadableCount,
82
+ projectsScanned: posture.projectsScanned,
83
+ projectsWithSettings: posture.projectsWithSettings,
69
84
 
70
85
  sources: posture.sources.map((s) => ({ path: s.path, scope: s.scope, state: s.state, reason: s.reason })),
71
86
  }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { MAX_SPECS, pySpecFor } from './ai-dependencies.mjs';
2
3
  import { readJson, readText } from './fs-read.mjs';
3
4
  import { walkWorkspace } from './workspace.mjs';
4
5
 
@@ -11,8 +12,10 @@ const PY_MCP_CLIENT = ['mcp', 'fastmcp', 'mcp-use', 'mcpadapt', 'langchain-mcp-a
11
12
  function npmMcpClientDeps(pkg) {
12
13
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}), ...(pkg.optionalDependencies || {}) };
13
14
  const hits = [];
14
- for (const name of Object.keys(deps)) {
15
- if (NPM_MCP_CLIENT.has(name) || NPM_MCP_CLIENT_PREFIX.some((p) => name.startsWith(p))) hits.push(name);
15
+ for (const [name, spec] of Object.entries(deps)) {
16
+ if (NPM_MCP_CLIENT.has(name) || NPM_MCP_CLIENT_PREFIX.some((p) => name.startsWith(p))) {
17
+ hits.push({ name, spec: typeof spec === 'string' ? spec : null });
18
+ }
16
19
  }
17
20
  return hits;
18
21
  }
@@ -21,7 +24,7 @@ function pyMcpClientDeps(text) {
21
24
  const hits = [];
22
25
  for (const pkg of PY_MCP_CLIENT) {
23
26
  const re = new RegExp(`(^|[^a-z0-9_.-])${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9_.-]|$)`, 'im');
24
- if (re.test(text)) hits.push(pkg);
27
+ if (re.test(text)) hits.push({ name: pkg, spec: pySpecFor(text, pkg) });
25
28
  }
26
29
  return hits;
27
30
  }
@@ -29,28 +32,30 @@ function pyMcpClientDeps(text) {
29
32
  export function discoverMcpClients(roots = [process.cwd()], files = null) {
30
33
  const walk = files || walkWorkspace(roots);
31
34
  const byPkg = new Map();
32
- const add = (eco, pkg, manifest) => {
35
+ const add = (eco, pkg, manifest, spec) => {
33
36
  const key = `${eco}:${pkg}`;
34
- if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set() });
35
- byPkg.get(key).manifests.add(manifest);
37
+ if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set(), specs: new Set() });
38
+ const row = byPkg.get(key);
39
+ row.manifests.add(manifest);
40
+ if (spec) row.specs.add(spec);
36
41
  };
37
42
  for (const { file } of walk.manifests) {
38
43
  const base = path.basename(file);
39
44
  if (base === 'package.json') {
40
45
  const json = readJson(file);
41
46
  if (!json) continue;
42
- for (const pkg of npmMcpClientDeps(json)) add('npm', pkg, file);
47
+ for (const hit of npmMcpClientDeps(json)) add('npm', hit.name, file, hit.spec);
43
48
  } else {
44
49
  const text = readText(file, 100_000);
45
50
  if (text == null) continue;
46
- for (const pkg of pyMcpClientDeps(text)) add('pip', pkg, file);
51
+ for (const hit of pyMcpClientDeps(text)) add('pip', hit.name, file, hit.spec);
47
52
  }
48
53
  }
49
54
  const assets = [];
50
- for (const { pkg, eco, manifests } of byPkg.values()) {
55
+ for (const { pkg, eco, manifests, specs } of byPkg.values()) {
51
56
  const list = [...manifests];
52
57
  assets.push({
53
- type: 'AI_TOOL',
58
+ type: 'AI_LIBRARY',
54
59
  name: `${pkg} (${eco})`,
55
60
  identifier: `mcp-client:${eco}:${pkg}`,
56
61
  vendor: 'mcp-client',
@@ -58,6 +63,7 @@ export function discoverMcpClients(roots = [process.cwd()], files = null) {
58
63
  category: 'mcp-client',
59
64
  ecosystem: eco,
60
65
  package: pkg,
66
+ specs: [...specs].slice(0, MAX_SPECS),
61
67
  usedInProjects: list.length,
62
68
  manifests: list.slice(0, 10),
63
69
  },