@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.
- package/package.json +1 -1
- package/src/agents/hook-command.mjs +1 -1
- package/src/artifacts/matchers.mjs +7 -0
- package/src/cli/flags.mjs +2 -2
- package/src/cli/help-sections.mjs +7 -0
- package/src/cli/help.mjs +1 -1
- package/src/commands/check.mjs +3 -11
- package/src/commands/gate.mjs +26 -4
- package/src/commands/git-hooks.mjs +2 -2
- package/src/commands/ledger.mjs +0 -1
- package/src/commands/mcp-add.mjs +2 -1
- package/src/commands/memory-scan.mjs +135 -47
- package/src/commands/pr.mjs +7 -10
- package/src/commands/provenance.mjs +8 -13
- package/src/commands/scan.mjs +7 -1
- package/src/commands/secrets.mjs +4 -5
- package/src/core/git-exec.mjs +79 -0
- package/src/core/yaml-lite.mjs +300 -0
- package/src/core/zip-lite.mjs +37 -0
- package/src/detect/local-redact.mjs +1 -3
- package/src/detect/sast/rules-config.mjs +1 -1
- package/src/detect/sast/scanner.mjs +1 -1
- package/src/detect/signals/agent-frameworks.mjs +231 -0
- package/src/detect/signals/agent-graph-surface.mjs +113 -0
- package/src/detect/signals/agentic-ci-surface.mjs +314 -0
- package/src/detect/signals/agentic-shim.mjs +82 -0
- package/src/detect/signals/artifacts.mjs +7 -35
- package/src/detect/signals/chat-template.mjs +211 -0
- package/src/detect/signals/ci-workflow.mjs +169 -0
- package/src/detect/signals/gate.mjs +77 -9
- package/src/detect/signals/guardrail-shape.mjs +564 -0
- package/src/detect/signals/guardrail-surface.mjs +221 -0
- package/src/detect/signals/injection.mjs +8 -0
- package/src/detect/signals/inspect-shim.mjs +7 -0
- package/src/detect/signals/instruction-paths.mjs +60 -0
- package/src/detect/signals/manifests.mjs +302 -0
- package/src/detect/signals/mcp-advisories.mjs +109 -0
- package/src/detect/signals/mcp-config.mjs +598 -0
- package/src/detect/signals/memory-directives.mjs +661 -0
- package/src/detect/signals/memory-locations.mjs +158 -0
- package/src/detect/signals/memory.mjs +47 -29
- package/src/detect/signals/model-config-rules.mjs +655 -0
- package/src/detect/signals/model-config.mjs +61 -0
- package/src/detect/signals/prose-context.mjs +6 -9
- package/src/detect/signals/scan.mjs +4 -4
- package/src/detect/signals/secret-scanner.mjs +241 -0
- package/src/detect/signals/secrets.mjs +1 -48
- package/src/detect/signals/shell.mjs +3 -3
- package/src/gate/advisories.mjs +16 -0
- package/src/gate/batch.mjs +10 -0
- package/src/gate/environment.mjs +8 -53
- package/src/guard/artifact-paths.mjs +107 -0
- package/src/guard/classify.mjs +165 -7
- package/src/guard/command-resolve.mjs +35 -5
- package/src/guard/memory-write.mjs +218 -0
- package/src/guard/prompt-guard.mjs +0 -1
- package/src/guard/tool-guard.mjs +52 -77
- package/src/inventory/agent-posture.mjs +236 -57
- package/src/inventory/artifacts/classify.mjs +10 -1
- package/src/inventory/artifacts/discover.mjs +113 -3
- package/src/inventory/artifacts/extensions.mjs +70 -0
- package/src/inventory/artifacts/hook-scripts.mjs +128 -0
- package/src/inventory/artifacts/limits.mjs +1 -1
- package/src/inventory/artifacts/plugins.mjs +105 -0
- package/src/inventory/artifacts/roots.mjs +40 -0
- package/src/inventory/discovery/ai-dependencies.mjs +39 -12
- package/src/inventory/discovery/all.mjs +4 -0
- package/src/inventory/discovery/cloud-clis.mjs +472 -0
- package/src/inventory/discovery/coding-agents.mjs +19 -4
- package/src/inventory/discovery/mcp-clients.mjs +16 -10
- package/src/inventory/discovery/mcp-servers.mjs +125 -35
- package/src/inventory/discovery/mcp-stores.mjs +207 -0
- package/src/inventory/env-redirect.mjs +148 -0
- package/src/inventory/grant-extract.mjs +463 -0
- package/src/inventory/project-roots.mjs +108 -0
- package/src/inventory/vscode-state.mjs +153 -0
- package/src/mcp/server-tools.mjs +1 -1
|
@@ -67,11 +67,35 @@ export const VECTOR_ENV = {
|
|
|
67
67
|
CHROMA_HOST: { engine: 'chroma', kind: 'endpoint' },
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
+
export const MAX_SPECS = 8;
|
|
71
|
+
|
|
72
|
+
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
73
|
+
|
|
74
|
+
const uncommented = (text) =>
|
|
75
|
+
text
|
|
76
|
+
.split(/\r?\n/)
|
|
77
|
+
.map((l) => l.replace(/(^|[ \t])#[^\n]*$/, '$1'))
|
|
78
|
+
.join('\n');
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
export function pySpecFor(text, pkg) {
|
|
82
|
+
const body = uncommented(text);
|
|
83
|
+
const name = escRe(pkg).replace(/[-_]/g, '[-_]');
|
|
84
|
+
const req = body.match(new RegExp(`(^|[^a-z0-9_.-])${name}\\s*(?:\\[[^\\]]*\\])?\\s*(===?|~=|>=|<=|!=|>|<)\\s*([\\w.*+!-]+)`, 'im'));
|
|
85
|
+
if (req) return `${req[2]}${req[3]}`;
|
|
86
|
+
|
|
87
|
+
const toml = body.match(new RegExp(`(^|\\n)\\s*["\']?${name}["\']?\\s*=\\s*(?:["\']([^"\']+)["\']|\\{[^}\\n]*version\\s*=\\s*["\']([^"\']+)["\'])`, 'i'));
|
|
88
|
+
const val = toml?.[2] ?? toml?.[3];
|
|
89
|
+
return val && val !== '*' ? val : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
function npmAiDeps(pkg) {
|
|
71
93
|
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}), ...(pkg.peerDependencies || {}), ...(pkg.optionalDependencies || {}) };
|
|
72
94
|
const hits = [];
|
|
73
|
-
for (const name of Object.
|
|
74
|
-
if (NPM_AI.has(name) || NPM_AI_PREFIX.some((p) => name.startsWith(p)))
|
|
95
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
96
|
+
if (NPM_AI.has(name) || NPM_AI_PREFIX.some((p) => name.startsWith(p))) {
|
|
97
|
+
hits.push({ name, spec: typeof spec === 'string' ? spec : null });
|
|
98
|
+
}
|
|
75
99
|
}
|
|
76
100
|
return hits;
|
|
77
101
|
}
|
|
@@ -79,8 +103,8 @@ function npmAiDeps(pkg) {
|
|
|
79
103
|
function pyAiDeps(text) {
|
|
80
104
|
const hits = [];
|
|
81
105
|
for (const pkg of PY_AI) {
|
|
82
|
-
const re = new RegExp(`(^|[^a-z0-9_.-])${pkg
|
|
83
|
-
if (re.test(text)) hits.push(pkg);
|
|
106
|
+
const re = new RegExp(`(^|[^a-z0-9_.-])${escRe(pkg)}([^a-z0-9_.-]|$)`, 'im');
|
|
107
|
+
if (re.test(text)) hits.push({ name: pkg, spec: pySpecFor(text, pkg) });
|
|
84
108
|
}
|
|
85
109
|
return hits;
|
|
86
110
|
}
|
|
@@ -88,10 +112,12 @@ function pyAiDeps(text) {
|
|
|
88
112
|
export function discoverAiDependencies(roots = [process.cwd()], files = null) {
|
|
89
113
|
const walk = files || walkWorkspace(roots);
|
|
90
114
|
const byPkg = new Map();
|
|
91
|
-
const add = (eco, pkg, manifest) => {
|
|
115
|
+
const add = (eco, pkg, manifest, spec) => {
|
|
92
116
|
const key = `${eco}:${pkg}`;
|
|
93
|
-
if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set() });
|
|
94
|
-
byPkg.get(key)
|
|
117
|
+
if (!byPkg.has(key)) byPkg.set(key, { pkg, eco, manifests: new Set(), specs: new Set() });
|
|
118
|
+
const row = byPkg.get(key);
|
|
119
|
+
row.manifests.add(manifest);
|
|
120
|
+
if (spec) row.specs.add(spec);
|
|
95
121
|
};
|
|
96
122
|
for (const { file } of walk.manifests) {
|
|
97
123
|
const base = path.basename(file);
|
|
@@ -99,18 +125,18 @@ export function discoverAiDependencies(roots = [process.cwd()], files = null) {
|
|
|
99
125
|
const json = readJson(file);
|
|
100
126
|
if (!json) continue;
|
|
101
127
|
|
|
102
|
-
for (const
|
|
128
|
+
for (const hit of npmAiDeps(json)) if (!isVectorLib(hit.name)) add('npm', hit.name, file, hit.spec);
|
|
103
129
|
} else {
|
|
104
130
|
const text = readText(file, 100_000);
|
|
105
131
|
if (text == null) continue;
|
|
106
|
-
for (const
|
|
132
|
+
for (const hit of pyAiDeps(text)) if (!isVectorLib(hit.name)) add('pip', hit.name, file, hit.spec);
|
|
107
133
|
}
|
|
108
134
|
}
|
|
109
135
|
const assets = [];
|
|
110
|
-
for (const { pkg, eco, manifests } of byPkg.values()) {
|
|
136
|
+
for (const { pkg, eco, manifests, specs } of byPkg.values()) {
|
|
111
137
|
const list = [...manifests];
|
|
112
138
|
assets.push({
|
|
113
|
-
type: '
|
|
139
|
+
type: 'AI_LIBRARY',
|
|
114
140
|
name: `${pkg} (${eco})`,
|
|
115
141
|
identifier: `dep:${eco}:${pkg}`,
|
|
116
142
|
vendor: 'ai-sdk',
|
|
@@ -118,6 +144,7 @@ export function discoverAiDependencies(roots = [process.cwd()], files = null) {
|
|
|
118
144
|
category: 'dependency',
|
|
119
145
|
ecosystem: eco,
|
|
120
146
|
package: pkg,
|
|
147
|
+
specs: [...specs].slice(0, MAX_SPECS),
|
|
121
148
|
usedInProjects: list.length,
|
|
122
149
|
manifests: list.slice(0, 10),
|
|
123
150
|
},
|
|
@@ -139,7 +166,7 @@ export function discoverAiUsageInCode(roots = [process.cwd()], files = null) {
|
|
|
139
166
|
for (const row of rollupAiUsage(usages)) {
|
|
140
167
|
const site = row.firstSite;
|
|
141
168
|
assets.push({
|
|
142
|
-
type: '
|
|
169
|
+
type: 'AI_LIBRARY',
|
|
143
170
|
name: `${row.label} (in code)`,
|
|
144
171
|
identifier: `ai-usage:${row.provider}`,
|
|
145
172
|
vendor: 'ai-sdk',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { clampAsset } from '../../core/wire-limits.mjs';
|
|
2
2
|
import { discoverAiDependencies, discoverAiUsageInCode } from './ai-dependencies.mjs';
|
|
3
3
|
import { discoverAiTools } from './ai-tools.mjs';
|
|
4
|
+
import { discoverCloudClis } from './cloud-clis.mjs';
|
|
4
5
|
import { discoverCodingAgents } from './coding-agents.mjs';
|
|
5
6
|
import { discoverMcpClients } from './mcp-clients.mjs';
|
|
6
7
|
import { discoverMcpServers } from './mcp-servers.mjs';
|
|
@@ -24,6 +25,9 @@ export function discoverAll(roots = [process.cwd()], opts = {}) {
|
|
|
24
25
|
...discoverAiTools(),
|
|
25
26
|
...discoverCodingAgents(scanRoots),
|
|
26
27
|
...discoverModelKeys(),
|
|
28
|
+
// ⚠ What this HOST is logged into - the reach an agent with a shell
|
|
29
|
+
// inherits and no agent policy granted. See `cloud-clis.mjs`.
|
|
30
|
+
...discoverCloudClis(),
|
|
27
31
|
];
|
|
28
32
|
|
|
29
33
|
const clamped = all.map(clampAsset);
|
|
@@ -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
|
|
29
|
-
{ vendor: '
|
|
30
|
-
{ vendor: '
|
|
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
|
-
|
|
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
|
}
|