@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
package/src/guard/tool-guard.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import { isMemoryPath, reportMemoryWrite } from '../commands/memory-scan.mjs';
|
|
|
6
6
|
import { breakerOpen, breakerReset, breakerTrip, guardTimeoutMs } from '../core/circuit-breaker.mjs';
|
|
7
7
|
import { CONFIG_DIR } from '../core/config.mjs';
|
|
8
8
|
import { makeLedgerStore } from './ledger.mjs';
|
|
9
|
+
import { memoryWritesFor, outOfBandChange, recordLedger, sha256 } from './memory-write.mjs';
|
|
10
|
+
import { gateMachine } from '../core/api-client.mjs';
|
|
9
11
|
import { VERSION } from '../core/version.mjs';
|
|
10
12
|
import { loadConfig, resolveSettings } from '../core/config.mjs';
|
|
11
13
|
import { classifyConsequence, downrankCodeContext, grade, localScan } from '../detect/guard-signals.mjs';
|
|
@@ -19,23 +21,7 @@ import { buildGuardBody, reportGuardDecision } from './report.mjs';
|
|
|
19
21
|
|
|
20
22
|
const ALLOW_VERDICT = { verdict: 'ALLOW', top: null, findings: [] };
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
* ⚠ FAILING OPEN ON EVERYTHING IS AN ENFORCEMENT BYPASS AN ATTACKER BUYS WITH A
|
|
24
|
-
* SLOW INPUT. The hook has to fail open - an agent that hard-stops on an
|
|
25
|
-
* unreachable SaaS backend is one nobody keeps installed - but "open on every
|
|
26
|
-
* call" means padding a command until the screen times out runs it unscreened,
|
|
27
|
-
* which is cheaper than any evasion in the corpus.
|
|
28
|
-
*
|
|
29
|
-
* So the rung decides. A routine or material call still flows: that is the
|
|
30
|
-
* promise that keeps the hook installed. A SEVERE one - a recursive delete, a
|
|
31
|
-
* force push over a shared branch, a write into ~/.ssh - stops and ASKS.
|
|
32
|
-
*
|
|
33
|
-
* ⚠ IT ASKS, IT DOES NOT DENY. A deny during an outage is unappealable at 3am
|
|
34
|
-
* and gets the hook uninstalled, taking every other control with it. An ask
|
|
35
|
-
* puts the human who is already sitting there in the loop and says plainly
|
|
36
|
-
* that the call was NOT screened, which is the honest sentence: we do not know
|
|
37
|
-
* that this is dangerous, we know that we could not check.
|
|
38
|
-
*/
|
|
24
|
+
|
|
39
25
|
function failOpenOnSevere() {
|
|
40
26
|
return envFlag('SHOMRA_GUARD_FAILOPEN_SEVERE');
|
|
41
27
|
}
|
|
@@ -49,34 +35,18 @@ function unscreenedSevere(normalized, tool, input) {
|
|
|
49
35
|
}) === 'severe';
|
|
50
36
|
}
|
|
51
37
|
|
|
52
|
-
|
|
53
|
-
* ⚠⚠ THE LEDGER HAD NO PRODUCER. `guard/ledger.mjs` builds the fail-open window
|
|
54
|
-
* the backend's EnforcementGap reads - and nothing in this repo ever called
|
|
55
|
-
* `countCall`, so every client reported ZERO gaps forever. The backend then
|
|
56
|
-
* asks whether a capable reporter exists, gets silence, and the estate reads
|
|
57
|
-
* either "no outages" or NOT_ATTEMPTABLE. Both are wrong and one is flattering:
|
|
58
|
-
* a smoke detector reporting no fire with a dead battery, which is the exact
|
|
59
|
-
* shape enforcement-availability.ts was written to prevent.
|
|
60
|
-
*
|
|
61
|
-
* ⚠ COUNTS ARE LOWER BOUNDS BY DESIGN - concurrent hook processes race this
|
|
62
|
-
* file, and the backend already treats them as a floor. Do not "fix" that with
|
|
63
|
-
* a lock on the firewall's hot path.
|
|
64
|
-
*/
|
|
38
|
+
|
|
65
39
|
function ledger() {
|
|
66
40
|
return makeLedgerStore(CONFIG_DIR, { version: VERSION });
|
|
67
41
|
}
|
|
68
42
|
|
|
69
|
-
/** A call that ran with no server verdict: Tier-0 screened it, or nothing did. */
|
|
70
43
|
function countUnscreened(reason) {
|
|
71
44
|
try {
|
|
72
45
|
ledger().count(localTierDisabled() ? 'unscreened' : 'local', reason);
|
|
73
46
|
} catch {
|
|
74
|
-
/* The ledger is a record, never a gate: a failure to write one must not
|
|
75
|
-
* take the firewall down. The count is a floor and this makes it lower. */
|
|
76
47
|
}
|
|
77
48
|
}
|
|
78
49
|
|
|
79
|
-
/** Close the open window and hand the backend everything not yet acknowledged. */
|
|
80
50
|
function sendLedger() {
|
|
81
51
|
try {
|
|
82
52
|
const store = ledger();
|
|
@@ -85,7 +55,6 @@ function sendLedger() {
|
|
|
85
55
|
pendingLedger = env.gaps ?? [];
|
|
86
56
|
return env;
|
|
87
57
|
} catch {
|
|
88
|
-
/* No ledger is a lower bound, not a wrong number. */
|
|
89
58
|
return undefined;
|
|
90
59
|
}
|
|
91
60
|
}
|
|
@@ -97,8 +66,6 @@ function ackLedger() {
|
|
|
97
66
|
try {
|
|
98
67
|
ledger().ack(pendingLedger);
|
|
99
68
|
} catch {
|
|
100
|
-
/* Unacknowledged gaps are re-sent next time; a duplicate is a floor read
|
|
101
|
-
* twice, which is safe. Losing one is not. */
|
|
102
69
|
}
|
|
103
70
|
pendingLedger = [];
|
|
104
71
|
}
|
|
@@ -138,28 +105,56 @@ function screenLocally(normalized, tool, input) {
|
|
|
138
105
|
return { ...grade(findings), top, findings };
|
|
139
106
|
}
|
|
140
107
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
108
|
+
const READ_TOOLS_RE = /^(read|read_file|view|open_file|cat)$/i;
|
|
109
|
+
const OUT_OF_BAND = 'changed outside any agent write the Shomra hook observed';
|
|
110
|
+
|
|
111
|
+
function memoryReportBase(filePath, normalized) {
|
|
112
|
+
const machine = gateMachine();
|
|
113
|
+
return {
|
|
114
|
+
path: path.resolve(filePath).split(path.sep).join('/'),
|
|
115
|
+
name: path.basename(String(filePath)),
|
|
116
|
+
machineId: machine.machineId,
|
|
117
|
+
hostname: machine.hostname,
|
|
118
|
+
actor: machine.username,
|
|
119
|
+
sessionId: normalized.session_id,
|
|
120
|
+
};
|
|
145
121
|
}
|
|
146
122
|
|
|
147
|
-
async function
|
|
148
|
-
|
|
149
|
-
|
|
123
|
+
async function reportOutOfBand(url, apiKey, filePath, current, normalized) {
|
|
124
|
+
if (current == null || !outOfBandChange(filePath, current)) return;
|
|
125
|
+
await reportMemoryWrite(url, apiKey, { ...memoryReportBase(filePath, normalized), content: current, writer: 'UNKNOWN', source: OUT_OF_BAND });
|
|
126
|
+
recordLedger(filePath, [sha256(current)]);
|
|
127
|
+
}
|
|
150
128
|
|
|
151
|
-
|
|
152
|
-
if (
|
|
129
|
+
async function recordMemoryWrite({ url, apiKey, tool, input, normalized }) {
|
|
130
|
+
if (breakerOpen()) return;
|
|
131
|
+
|
|
132
|
+
if (READ_TOOLS_RE.test(tool || '')) {
|
|
133
|
+
const target = input.file_path || input.path || input.target_file;
|
|
134
|
+
if (!target || !isMemoryPath(target)) return;
|
|
135
|
+
const abs = path.resolve(normalized.cwd || process.cwd(), String(target));
|
|
136
|
+
let current = null;
|
|
137
|
+
try {
|
|
138
|
+
current = fs.readFileSync(abs, 'utf8');
|
|
139
|
+
} catch {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
await reportOutOfBand(url, apiKey, abs, current, normalized);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
153
145
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
146
|
+
const writes = memoryWritesFor(tool, input, { cwd: normalized.cwd }).filter((w) => w.path && isMemoryPath(w.path));
|
|
147
|
+
for (const w of writes) {
|
|
148
|
+
await reportOutOfBand(url, apiKey, w.path, w.before, normalized);
|
|
149
|
+
await reportMemoryWrite(url, apiKey, {
|
|
150
|
+
...memoryReportBase(w.path, normalized),
|
|
151
|
+
content: w.content,
|
|
152
|
+
writer: 'AGENT',
|
|
153
|
+
source: os.hostname(),
|
|
154
|
+
contentBasis: w.basis,
|
|
155
|
+
});
|
|
156
|
+
recordLedger(w.path, [w.before != null ? sha256(w.before) : null, w.basis === 'whole' ? sha256(w.content) : null]);
|
|
157
|
+
}
|
|
163
158
|
}
|
|
164
159
|
|
|
165
160
|
function reportUnauthenticated(agent, status, strict) {
|
|
@@ -193,12 +188,6 @@ async function requestServerDecision({ url, apiKey, agentId, body, agent, strict
|
|
|
193
188
|
|
|
194
189
|
if (!response.ok) {
|
|
195
190
|
if (response.status === 401 || response.status === 403) reportUnauthenticated(agent, response.status, strict);
|
|
196
|
-
/* ⚠ A 429 IS NOT AN OUTAGE, and treating it as one was a silent
|
|
197
|
-
* enforcement bypass: tripping the breaker skips the server for the whole
|
|
198
|
-
* cooldown, so one burst past the rate limit switched org policy, agent
|
|
199
|
-
* identity and flow control off for thirty seconds - on the machine, with
|
|
200
|
-
* nothing said. It means "we are here, come back", so it is retried once
|
|
201
|
-
* against Retry-After and never counted against the breaker. */
|
|
202
191
|
if (response.status === 429) {
|
|
203
192
|
const wait = retryAfterMs(response);
|
|
204
193
|
if (wait !== null && !retried) {
|
|
@@ -223,7 +212,6 @@ async function requestServerDecision({ url, apiKey, agentId, body, agent, strict
|
|
|
223
212
|
}
|
|
224
213
|
}
|
|
225
214
|
|
|
226
|
-
/** Honours a seconds or an HTTP-date Retry-After; null when the server named none. */
|
|
227
215
|
function retryAfterMs(response) {
|
|
228
216
|
const raw = response.headers?.get?.('retry-after');
|
|
229
217
|
if (!raw) return null;
|
|
@@ -271,26 +259,16 @@ export async function cmdToolGuard(flags) {
|
|
|
271
259
|
process.exit(0);
|
|
272
260
|
}
|
|
273
261
|
|
|
274
|
-
await recordMemoryWrite({ url, apiKey, input, normalized });
|
|
262
|
+
await recordMemoryWrite({ url, apiKey, tool, input, normalized });
|
|
263
|
+
|
|
275
264
|
|
|
276
|
-
/* ⚠ A SEVERE CALL IS ALWAYS WORTH THE ROUND TRIP. `guardNeedsServer` asks
|
|
277
|
-
* which calls are worth escalating and answered NO for `git push --force
|
|
278
|
-
* origin main` and `rm -rf` alike - so the most destructive calls in the
|
|
279
|
-
* estate were graded by the offline tier and NOTHING ELSE: no org policy, no
|
|
280
|
-
* capability check, no flow control, and no gate event to read afterwards. */
|
|
281
265
|
const severe = unscreenedSevere(normalized, tool, input);
|
|
282
266
|
const escalate = alwaysEscalate || severe || local.verdict === 'FLAG' || guardNeedsServer(tool, input, !!agentId);
|
|
283
|
-
/* ⚠ A call the client CHOSE not to escalate is still a call no server graded,
|
|
284
|
-
* and the denominator has to carry it or the fail-open rate is measured over
|
|
285
|
-
* the escalated traffic alone - which flatters it by exactly the calls the
|
|
286
|
-
* client decided were dull. */
|
|
287
267
|
if (!escalate) {
|
|
288
268
|
countUnscreened('not escalated - screened by the local tier only');
|
|
289
269
|
process.exit(0);
|
|
290
270
|
}
|
|
291
271
|
|
|
292
|
-
/* Every path out of here that did NOT get a server verdict goes through this
|
|
293
|
-
* one door, so a new way of failing cannot quietly skip the rung check. */
|
|
294
272
|
const onUnreachable = (why) => {
|
|
295
273
|
countUnscreened(why);
|
|
296
274
|
if (severe) askUnscreened(agent, why);
|
|
@@ -309,9 +287,6 @@ export async function cmdToolGuard(flags) {
|
|
|
309
287
|
onUnreachable,
|
|
310
288
|
body: {
|
|
311
289
|
...buildGuardBody(normalized, agent, flagged ? 'FLAG' : undefined, flagged ? local.top?.label : undefined),
|
|
312
|
-
/* The window closes the moment a verdict arrives, and rides out on the
|
|
313
|
-
* SAME request - a separate report would be a second round trip on the
|
|
314
|
-
* firewall's hot path, and one that fails exactly when the first did. */
|
|
315
290
|
guard_ledger: sendLedger(),
|
|
316
291
|
},
|
|
317
292
|
});
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
+
import { emptyGrant, extractGrant, grantIsEmpty, parseLooseToml, parseLooseYaml, stripJsonc } from './grant-extract.mjs';
|
|
6
|
+
import { readMcpServers } from '../detect/signals/mcp-config.mjs';
|
|
7
|
+
import { CURSOR_APP_USER_KEY, INTERPRETERS, readStateKeys } from './vscode-state.mjs';
|
|
8
|
+
import { projectRoots } from './project-roots.mjs';
|
|
5
9
|
|
|
6
10
|
const HOME = os.homedir();
|
|
7
11
|
const PLAT = process.platform;
|
|
@@ -15,14 +19,17 @@ export const TIER_MEANING = {
|
|
|
15
19
|
none: 'No machine-readable permission surface. This agent appears in inventory only.',
|
|
16
20
|
};
|
|
17
21
|
|
|
18
|
-
function readJsonc(file) {
|
|
22
|
+
function readJsonc(file, maxBytes = MAX_SETTINGS_BYTES) {
|
|
19
23
|
try {
|
|
20
24
|
const st = fs.statSync(file);
|
|
21
|
-
if (!st.isFile() || st.size >
|
|
25
|
+
if (!st.isFile() || st.size > maxBytes) return { state: 'unreadable', reason: st.isFile() ? 'oversized' : 'not-a-file' };
|
|
22
26
|
const raw = fs.readFileSync(file, 'utf8');
|
|
27
|
+
if (/\.(?:toml|ya?ml)$/i.test(file)) {
|
|
28
|
+
const doc = /\.toml$/i.test(file) ? parseLooseToml(raw) : parseLooseYaml(raw);
|
|
29
|
+
return doc ? { state: 'read', json: doc } : { state: 'unreadable', reason: 'parse-failed' };
|
|
30
|
+
}
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
return { state: 'read', json: JSON.parse(stripped) };
|
|
32
|
+
return { state: 'read', json: JSON.parse(stripJsonc(raw)) };
|
|
26
33
|
} catch (err) {
|
|
27
34
|
if (err && err.code === 'ENOENT') return { state: 'absent' };
|
|
28
35
|
|
|
@@ -30,6 +37,10 @@ function readJsonc(file) {
|
|
|
30
37
|
}
|
|
31
38
|
}
|
|
32
39
|
|
|
40
|
+
const COPILOT_KEYS = ['chat.', 'github.copilot'];
|
|
41
|
+
|
|
42
|
+
const EDITOR_APPS = ['Code', 'Code - Insiders', 'Cursor', 'Windsurf'];
|
|
43
|
+
|
|
33
44
|
export const VENDOR_POSTURE = {
|
|
34
45
|
'claude-code': {
|
|
35
46
|
tier: 'declared',
|
|
@@ -40,6 +51,8 @@ export const VENDOR_POSTURE = {
|
|
|
40
51
|
{ rel: ['.claude', 'settings.local.json'], base: 'home', scope: 'user' },
|
|
41
52
|
{ rel: ['.claude', 'settings.json'], base: 'cwd', scope: 'project' },
|
|
42
53
|
{ rel: ['.claude', 'settings.local.json'], base: 'cwd', scope: 'project' },
|
|
54
|
+
{ rel: ['managed-settings.json'], base: 'claude-managed', scope: 'managed' },
|
|
55
|
+
{ rel: ['managed-settings.d'], base: 'claude-managed', scope: 'managed', dir: '.json' },
|
|
43
56
|
],
|
|
44
57
|
},
|
|
45
58
|
gemini: {
|
|
@@ -48,22 +61,43 @@ export const VENDOR_POSTURE = {
|
|
|
48
61
|
sources: [
|
|
49
62
|
{ rel: ['.gemini', 'settings.json'], base: 'home', scope: 'user' },
|
|
50
63
|
{ rel: ['.gemini', 'settings.json'], base: 'cwd', scope: 'project' },
|
|
64
|
+
{ rel: ['.gemini', 'policies'], base: 'home', scope: 'user', dir: '.toml' },
|
|
65
|
+
{ rel: ['settings.json'], base: 'gemini-system', scope: 'managed' },
|
|
66
|
+
{ rel: ['system-defaults.json'], base: 'gemini-system', scope: 'managed' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
'qwen-code': {
|
|
70
|
+
tier: 'declared',
|
|
71
|
+
label: 'Qwen Code',
|
|
72
|
+
sources: [
|
|
73
|
+
{ rel: ['.qwen', 'settings.json'], base: 'home', scope: 'user' },
|
|
74
|
+
{ rel: ['.qwen', 'settings.json'], base: 'cwd', scope: 'project' },
|
|
75
|
+
{ rel: ['settings.json'], base: 'qwen-system', scope: 'managed' },
|
|
51
76
|
],
|
|
52
77
|
},
|
|
53
78
|
codex: {
|
|
54
79
|
tier: 'declared',
|
|
55
80
|
label: 'OpenAI Codex CLI',
|
|
56
81
|
sources: [
|
|
82
|
+
{ rel: ['.codex', 'config.toml'], base: 'home', scope: 'user' },
|
|
83
|
+
{ rel: ['.codex', 'config.toml'], base: 'cwd', scope: 'project' },
|
|
57
84
|
{ rel: ['.codex', 'config.json'], base: 'home', scope: 'user' },
|
|
58
85
|
{ rel: ['.codex', 'settings.json'], base: 'home', scope: 'user' },
|
|
59
86
|
],
|
|
60
87
|
},
|
|
61
88
|
cursor: {
|
|
62
|
-
tier: '
|
|
89
|
+
tier: 'declared',
|
|
63
90
|
label: 'Cursor',
|
|
64
91
|
sources: [
|
|
92
|
+
{ stateDb: 'Cursor', dbKeys: [CURSOR_APP_USER_KEY], interpret: 'cursor', scope: 'user' },
|
|
93
|
+
{ rel: ['.cursor', 'cli-config.json'], base: 'home', scope: 'user' },
|
|
94
|
+
{ rel: ['.cursor', 'permissions.json'], base: 'home', scope: 'user' },
|
|
95
|
+
{ rel: ['.cursor', 'sandbox.json'], base: 'home', scope: 'user' },
|
|
65
96
|
{ rel: ['.cursor', 'settings.json'], base: 'home', scope: 'user' },
|
|
66
97
|
{ rel: ['.cursor', 'mcp.json'], base: 'home', scope: 'user' },
|
|
98
|
+
{ rel: ['.cursor', 'cli.json'], base: 'cwd', scope: 'project' },
|
|
99
|
+
{ rel: ['.cursor', 'permissions.json'], base: 'cwd', scope: 'project' },
|
|
100
|
+
{ rel: ['.cursor', 'sandbox.json'], base: 'cwd', scope: 'project' },
|
|
67
101
|
{ rel: ['.cursor', 'mcp.json'], base: 'cwd', scope: 'project' },
|
|
68
102
|
{ rel: ['.cursor', 'settings.json'], base: 'cwd', scope: 'project' },
|
|
69
103
|
{ rel: ['User', 'settings.json'], base: 'cursor-user', scope: 'user' },
|
|
@@ -76,31 +110,113 @@ export const VENDOR_POSTURE = {
|
|
|
76
110
|
{ rel: ['.codeium', 'windsurf', 'settings.json'], base: 'home', scope: 'user' },
|
|
77
111
|
{ rel: ['.codeium', 'windsurf', 'mcp_config.json'], base: 'home', scope: 'user' },
|
|
78
112
|
{ rel: ['.windsurf', 'mcp.json'], base: 'cwd', scope: 'project' },
|
|
113
|
+
{ rel: ['settings.json'], base: 'windsurf-user', scope: 'user', keys: ['windsurf.', 'cascade'] },
|
|
114
|
+
{ stateDb: 'Windsurf', dbKeys: ['windsurf%', 'codeium%'], interpret: 'windsurf', scope: 'user' },
|
|
79
115
|
],
|
|
80
116
|
},
|
|
81
117
|
cline: {
|
|
82
|
-
tier: '
|
|
118
|
+
tier: 'declared',
|
|
83
119
|
label: 'Cline',
|
|
84
120
|
sources: [
|
|
85
121
|
{ rel: ['settings', 'cline_mcp_settings.json'], base: 'cline-storage', scope: 'user' },
|
|
86
122
|
{ rel: ['.cline', 'settings.json'], base: 'home', scope: 'user' },
|
|
123
|
+
{ rel: ['.cline', 'data', 'globalState.json'], base: 'home', scope: 'user', interpret: 'cline', maxBytes: 8 * 1024 * 1024 },
|
|
124
|
+
{ rel: ['settings.json'], base: 'vscode-user', scope: 'user', keys: ['cline.'] },
|
|
125
|
+
...EDITOR_APPS.map((app) => ({ stateDb: app, dbKeys: ['saoudrizwan.claude-dev'], interpret: 'cline', scope: 'user' })),
|
|
87
126
|
],
|
|
88
127
|
},
|
|
89
128
|
roo: {
|
|
90
|
-
tier: '
|
|
129
|
+
tier: 'declared',
|
|
91
130
|
label: 'Roo Code',
|
|
92
131
|
sources: [
|
|
93
132
|
{ rel: ['settings', 'mcp_settings.json'], base: 'roo-storage', scope: 'user' },
|
|
94
133
|
{ rel: ['.roo', 'settings.json'], base: 'cwd', scope: 'project' },
|
|
134
|
+
{ rel: ['settings.json'], base: 'vscode-user', scope: 'user', keys: ['roo-cline.'] },
|
|
135
|
+
...EDITOR_APPS.map((app) => ({ stateDb: app, dbKeys: ['rooveterinaryinc.roo-cline'], interpret: 'roo', scope: 'user' })),
|
|
95
136
|
],
|
|
96
137
|
},
|
|
97
138
|
copilot: {
|
|
98
139
|
tier: 'flag',
|
|
99
|
-
label: 'GitHub Copilot
|
|
100
|
-
sources: [
|
|
140
|
+
label: 'GitHub Copilot',
|
|
141
|
+
sources: [
|
|
142
|
+
{ rel: ['.copilot', 'config.json'], base: 'home', scope: 'user' },
|
|
143
|
+
{ rel: ['.copilot', 'settings.json'], base: 'home', scope: 'user' },
|
|
144
|
+
{ rel: ['settings.json'], base: 'vscode-user', scope: 'user', keys: COPILOT_KEYS },
|
|
145
|
+
{ rel: ['settings.json'], base: 'vscode-insiders-user', scope: 'user', keys: COPILOT_KEYS },
|
|
146
|
+
{ rel: ['.vscode', 'settings.json'], base: 'cwd', scope: 'project', keys: COPILOT_KEYS },
|
|
147
|
+
{ rel: ['.github', 'copilot', 'settings.json'], base: 'cwd', scope: 'project' },
|
|
148
|
+
{ rel: ['.github', 'copilot', 'settings.local.json'], base: 'cwd', scope: 'project' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
kiro: {
|
|
152
|
+
tier: 'declared',
|
|
153
|
+
label: 'Kiro',
|
|
154
|
+
sources: [
|
|
155
|
+
{ rel: ['.kiro', 'settings', 'permissions.yaml'], base: 'home', scope: 'user' },
|
|
156
|
+
{ rel: ['.kiro', 'agents'], base: 'home', scope: 'user', dir: '.json' },
|
|
157
|
+
{ rel: ['.kiro', 'agents'], base: 'cwd', scope: 'project', dir: '.json' },
|
|
158
|
+
{ rel: ['.kiro', 'settings'], base: 'home', scope: 'user', dir: '.json' },
|
|
159
|
+
{ rel: ['.kiro', 'settings'], base: 'cwd', scope: 'project', dir: '.json' },
|
|
160
|
+
{ rel: ['settings.json'], base: 'vscode-user', scope: 'user', keys: ['kiroAgent.'] },
|
|
161
|
+
{ rel: ['.aws', 'amazonq', 'cli-agents'], base: 'home', scope: 'user', dir: '.json' },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
opencode: {
|
|
165
|
+
tier: 'declared',
|
|
166
|
+
label: 'OpenCode',
|
|
167
|
+
sources: [
|
|
168
|
+
{ rel: ['.config', 'opencode', 'opencode.json'], base: 'home', scope: 'user' },
|
|
169
|
+
{ rel: ['.config', 'opencode', 'opencode.jsonc'], base: 'home', scope: 'user' },
|
|
170
|
+
{ rel: ['opencode.json'], base: 'cwd', scope: 'project' },
|
|
171
|
+
{ rel: ['opencode.jsonc'], base: 'cwd', scope: 'project' },
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
zed: {
|
|
175
|
+
tier: 'declared',
|
|
176
|
+
label: 'Zed',
|
|
177
|
+
sources: [
|
|
178
|
+
{ rel: ['settings.json'], base: 'zed-config', scope: 'user' },
|
|
179
|
+
{ rel: ['.zed', 'settings.json'], base: 'cwd', scope: 'project' },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
augment: {
|
|
183
|
+
tier: 'declared',
|
|
184
|
+
label: 'Augment',
|
|
185
|
+
sources: [
|
|
186
|
+
{ rel: ['.augment', 'settings.json'], base: 'home', scope: 'user' },
|
|
187
|
+
{ rel: ['.augment', 'settings.json'], base: 'cwd', scope: 'project' },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
continue: {
|
|
191
|
+
tier: 'declared',
|
|
192
|
+
label: 'Continue',
|
|
193
|
+
sources: [{ rel: ['.continue', 'permissions.yaml'], base: 'home', scope: 'user' }],
|
|
194
|
+
},
|
|
195
|
+
goose: {
|
|
196
|
+
tier: 'flag',
|
|
197
|
+
label: 'Goose',
|
|
198
|
+
sources: [
|
|
199
|
+
{ rel: ['config.yaml'], base: 'goose-config', scope: 'user' },
|
|
200
|
+
{ rel: ['permission.yaml'], base: 'goose-config', scope: 'user' },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
amp: {
|
|
204
|
+
tier: 'flag',
|
|
205
|
+
label: 'Amp',
|
|
206
|
+
sources: [
|
|
207
|
+
{ rel: ['settings.json'], base: 'vscode-user', scope: 'user', keys: ['amp.'] },
|
|
208
|
+
{ rel: ['.config', 'amp', 'settings.json'], base: 'home', scope: 'user' },
|
|
209
|
+
{ rel: ['.vscode', 'settings.json'], base: 'cwd', scope: 'project', keys: ['amp.'] },
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
aider: {
|
|
213
|
+
tier: 'flag',
|
|
214
|
+
label: 'Aider',
|
|
215
|
+
sources: [
|
|
216
|
+
{ rel: ['.aider.conf.yml'], base: 'home', scope: 'user' },
|
|
217
|
+
{ rel: ['.aider.conf.yml'], base: 'cwd', scope: 'project' },
|
|
218
|
+
],
|
|
101
219
|
},
|
|
102
|
-
|
|
103
|
-
aider: { tier: 'none', label: 'Aider', sources: [] },
|
|
104
220
|
};
|
|
105
221
|
|
|
106
222
|
function vscodeUserDir(variant) {
|
|
@@ -109,6 +225,12 @@ function vscodeUserDir(variant) {
|
|
|
109
225
|
return path.join(HOME, '.config', variant, 'User');
|
|
110
226
|
}
|
|
111
227
|
|
|
228
|
+
const APPDATA = process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming');
|
|
229
|
+
|
|
230
|
+
function systemDir(win, mac, linux) {
|
|
231
|
+
return PLAT === 'win32' ? win : PLAT === 'darwin' ? mac : linux;
|
|
232
|
+
}
|
|
233
|
+
|
|
112
234
|
function resolveBase(base, cwd) {
|
|
113
235
|
switch (base) {
|
|
114
236
|
case 'home': return HOME;
|
|
@@ -116,10 +238,39 @@ function resolveBase(base, cwd) {
|
|
|
116
238
|
case 'cursor-user': return path.dirname(vscodeUserDir('Cursor'));
|
|
117
239
|
case 'cline-storage': return path.join(vscodeUserDir('Code'), 'globalStorage', 'saoudrizwan.claude-dev');
|
|
118
240
|
case 'roo-storage': return path.join(vscodeUserDir('Code'), 'globalStorage', 'rooveterinaryinc.roo-cline');
|
|
241
|
+
case 'vscode-user': return vscodeUserDir('Code');
|
|
242
|
+
case 'vscode-insiders-user': return vscodeUserDir('Code - Insiders');
|
|
243
|
+
case 'windsurf-user': return vscodeUserDir('Windsurf');
|
|
244
|
+
case 'claude-managed': return systemDir('C:\\Program Files\\ClaudeCode', '/Library/Application Support/ClaudeCode', '/etc/claude-code');
|
|
245
|
+
case 'gemini-system': return systemDir('C:\\ProgramData\\gemini-cli', '/Library/Application Support/GeminiCli', '/etc/gemini-cli');
|
|
246
|
+
case 'qwen-system': return systemDir('C:\\ProgramData\\qwen-code', '/Library/Application Support/QwenCode', '/etc/qwen-code');
|
|
247
|
+
case 'zed-config': return PLAT === 'win32' ? path.join(APPDATA, 'Zed') : path.join(HOME, '.config', 'zed');
|
|
248
|
+
case 'goose-config': return PLAT === 'win32' ? path.join(APPDATA, 'Block', 'goose', 'config') : path.join(HOME, '.config', 'goose');
|
|
119
249
|
default: return cwd;
|
|
120
250
|
}
|
|
121
251
|
}
|
|
122
252
|
|
|
253
|
+
const MAX_DIR_FILES = 20;
|
|
254
|
+
|
|
255
|
+
function expandSource(src, cwd) {
|
|
256
|
+
const target = path.join(resolveBase(src.base, cwd), ...src.rel);
|
|
257
|
+
if (!src.dir) return [target];
|
|
258
|
+
try {
|
|
259
|
+
return fs.readdirSync(target)
|
|
260
|
+
.filter((n) => n.toLowerCase().endsWith(src.dir))
|
|
261
|
+
.sort()
|
|
262
|
+
.slice(0, MAX_DIR_FILES)
|
|
263
|
+
.map((n) => path.join(target, n));
|
|
264
|
+
} catch {
|
|
265
|
+
return [];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function pickKeys(json, prefixes) {
|
|
270
|
+
if (!prefixes || !json || typeof json !== 'object' || Array.isArray(json)) return json;
|
|
271
|
+
return Object.fromEntries(Object.entries(json).filter(([k]) => prefixes.some((p) => k.toLowerCase().startsWith(p.toLowerCase()))));
|
|
272
|
+
}
|
|
273
|
+
|
|
123
274
|
const AUTO_APPROVE_KEYS = [
|
|
124
275
|
{ re: /^(yolo|yolomode|enableyolo)$/i, label: 'YOLO mode', severity: 'CRITICAL' },
|
|
125
276
|
{ re: /^(autoapprove|alwaysallow|autoallow|autoaccept|autoconfirm)$/i, label: 'auto-approve', severity: 'HIGH' },
|
|
@@ -147,53 +298,59 @@ function collectSwitches(node, out, depth = 0, trail = '') {
|
|
|
147
298
|
}
|
|
148
299
|
}
|
|
149
300
|
|
|
301
|
+
|
|
150
302
|
function collectMcpServers(json) {
|
|
151
|
-
const servers = json
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
.
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
autoApprove: Array.isArray(def?.autoApprove) ? def.autoApprove.slice(0, 20) : Array.isArray(def?.alwaysAllow) ? def.alwaysAllow.slice(0, 20) : [],
|
|
160
|
-
disabled: def?.disabled === true,
|
|
161
|
-
}));
|
|
303
|
+
const servers = readMcpServers(json) ?? [];
|
|
304
|
+
return servers.slice(0, 60).map((s) => ({
|
|
305
|
+
name: s.name,
|
|
306
|
+
transport: s.url ? 'http' : 'stdio',
|
|
307
|
+
autoApprove: (s.trusted ? ['*', ...(s.autoApprove ?? [])] : s.autoApprove ?? []).slice(0, 20),
|
|
308
|
+
disabled: s.disabled === true,
|
|
309
|
+
}));
|
|
162
310
|
}
|
|
163
311
|
|
|
164
|
-
|
|
312
|
+
function readSource(src, cwd) {
|
|
313
|
+
if (src.stateDb) {
|
|
314
|
+
const file = path.join(vscodeUserDir(src.stateDb), 'globalStorage', 'state.vscdb');
|
|
315
|
+
const res = readStateKeys(file, src.dbKeys);
|
|
316
|
+
const docs = res.state === 'read' ? res.values.map((v) => INTERPRETERS[src.interpret](v.json)) : [];
|
|
317
|
+
return [{ path: `${file}#${src.dbKeys.join('|')}`, res, docs }];
|
|
318
|
+
}
|
|
319
|
+
return expandSource(src, cwd).map((file) => {
|
|
320
|
+
const res = readJsonc(file, src.maxBytes);
|
|
321
|
+
const raw = res.state === 'read' ? pickKeys(res.json ?? {}, src.keys) : null;
|
|
322
|
+
const doc = raw && src.interpret ? INTERPRETERS[src.interpret](raw) : raw;
|
|
323
|
+
return { path: file, res, docs: doc ? [doc] : [] };
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function readVendorPosture(vendor, cwd = process.cwd(), roots = null) {
|
|
165
328
|
const def = VENDOR_POSTURE[vendor];
|
|
166
329
|
if (!def) return null;
|
|
167
330
|
|
|
168
331
|
const sources = [];
|
|
169
332
|
const switches = [];
|
|
170
333
|
const mcpServers = [];
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
const ask = [];
|
|
175
|
-
let enableAllProjectMcpServers = null;
|
|
334
|
+
const grant = emptyGrant();
|
|
335
|
+
const extraRoots = (roots ?? []).filter((r) => r && path.resolve(r) !== path.resolve(cwd));
|
|
336
|
+
const projectsRead = new Set();
|
|
176
337
|
|
|
177
338
|
for (const src of def.sources) {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
339
|
+
const bases = src.base === 'cwd' ? [cwd, ...extraRoots] : [cwd];
|
|
340
|
+
for (const base of bases) {
|
|
341
|
+
for (const { path: file, res, docs } of readSource(src, base)) {
|
|
342
|
+
const primary = base === cwd;
|
|
343
|
+
if (primary || res.state !== 'absent') sources.push({ path: file, scope: src.scope, state: res.state, reason: res.reason ?? null });
|
|
344
|
+
if (res.state !== 'read') continue;
|
|
345
|
+
if (src.base === 'cwd') projectsRead.add(base);
|
|
346
|
+
for (const json of docs) {
|
|
347
|
+
if ((src.keys || src.interpret) && !Object.keys(json).length) continue;
|
|
348
|
+
extractGrant(json, grant);
|
|
349
|
+
if (def.tier === 'flag') collectSwitches(json, switches);
|
|
350
|
+
for (const s of collectMcpServers(json)) if (!mcpServers.some((x) => x.name === s.name)) mcpServers.push(s);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
191
353
|
}
|
|
192
|
-
if (json.enableAllProjectMcpServers === true) enableAllProjectMcpServers = true;
|
|
193
|
-
else if (enableAllProjectMcpServers === null && json.enableAllProjectMcpServers === false) enableAllProjectMcpServers = false;
|
|
194
|
-
|
|
195
|
-
if (def.tier === 'flag') collectSwitches(json, switches);
|
|
196
|
-
for (const s of collectMcpServers(json)) if (!mcpServers.some((x) => x.name === s.name)) mcpServers.push(s);
|
|
197
354
|
}
|
|
198
355
|
|
|
199
356
|
const read = sources.filter((s) => s.state === 'read');
|
|
@@ -207,38 +364,60 @@ export function readVendorPosture(vendor, cwd = process.cwd()) {
|
|
|
207
364
|
readable: read.length > 0,
|
|
208
365
|
sources,
|
|
209
366
|
unreadableCount: holes.length,
|
|
210
|
-
mode,
|
|
211
|
-
allow: allow.slice(0, 60),
|
|
212
|
-
deny: deny.slice(0, 60),
|
|
213
|
-
ask: ask.slice(0, 60),
|
|
214
|
-
enableAllProjectMcpServers,
|
|
367
|
+
mode: grant.mode,
|
|
368
|
+
allow: grant.allow.slice(0, 60),
|
|
369
|
+
deny: grant.deny.slice(0, 60),
|
|
370
|
+
ask: grant.ask.slice(0, 60),
|
|
371
|
+
enableAllProjectMcpServers: grant.enableAllProjectMcpServers,
|
|
372
|
+
grant,
|
|
215
373
|
switches: switches.slice(0, 20),
|
|
216
374
|
mcpServers,
|
|
217
375
|
|
|
218
376
|
autoApprovedMcp: mcpServers.filter((s) => s.autoApprove.length > 0 && !s.disabled).map((s) => s.name),
|
|
219
377
|
|
|
220
|
-
|
|
378
|
+
truncated: grant.truncated === true,
|
|
379
|
+
projectsScanned: 1 + extraRoots.length,
|
|
380
|
+
projectsWithSettings: projectsRead.size,
|
|
381
|
+
claim: def.tier === 'declared' && read.length > 0 && holes.length === 0 && !grant.truncated ? 'complete' : 'floor',
|
|
221
382
|
};
|
|
222
383
|
}
|
|
223
384
|
|
|
224
385
|
export function canonicalGrant(posture) {
|
|
225
386
|
if (!posture) return null;
|
|
387
|
+
const g = posture.grant && !grantIsEmpty(posture.grant) ? posture.grant : null;
|
|
226
388
|
const doc = {};
|
|
227
389
|
const perms = {};
|
|
228
390
|
if (posture.mode) perms.defaultMode = posture.mode;
|
|
229
|
-
if (posture.allow
|
|
230
|
-
if (posture.deny
|
|
231
|
-
if (posture.ask
|
|
391
|
+
if (posture.allow?.length) perms.allow = posture.allow;
|
|
392
|
+
if (posture.deny?.length) perms.deny = posture.deny;
|
|
393
|
+
if (posture.ask?.length) perms.ask = posture.ask;
|
|
394
|
+
if (g) {
|
|
395
|
+
if (g.directories.length) perms.additionalDirectories = g.directories;
|
|
396
|
+
if (g.trustedFolders.length) perms.trustedFolders = g.trustedFolders;
|
|
397
|
+
const fsGrant = Object.fromEntries(Object.entries(g.fileSystem).filter(([, v]) => v.length));
|
|
398
|
+
if (Object.keys(fsGrant).length) perms.fileSystem = fsGrant;
|
|
399
|
+
const net = Object.fromEntries(Object.entries(g.network).filter(([, v]) => v.length));
|
|
400
|
+
if (Object.keys(net).length) perms.network = net;
|
|
401
|
+
if (g.envNames.length) perms.environmentVariables = { allowList: g.envNames };
|
|
402
|
+
if (g.commands.length) perms.tools = { allowExecution: g.commands };
|
|
403
|
+
if (g.rules.length) perms.rules = g.rules;
|
|
404
|
+
if (g.permission) perms.permission = g.permission;
|
|
405
|
+
if (g.toolPermissions && Object.keys(g.toolPermissions).length) perms.tool_permissions = g.toolPermissions;
|
|
406
|
+
if (g.unixSockets.length) perms.sandbox = { network: { allowUnixSockets: g.unixSockets } };
|
|
407
|
+
if (g.mcpAllowlist.length) perms.mcpAllowlist = g.mcpAllowlist;
|
|
408
|
+
if (g.folderTrustDisabled) perms.folderTrust = { enabled: false };
|
|
409
|
+
for (const [k, v] of Object.entries(g.flags)) if (!(k in perms)) perms[k] = v;
|
|
410
|
+
}
|
|
232
411
|
if (Object.keys(perms).length) doc.permissions = perms;
|
|
233
412
|
if (posture.enableAllProjectMcpServers === true) doc.enableAllProjectMcpServers = true;
|
|
234
413
|
return Object.keys(doc).length ? JSON.stringify(doc, null, 2) : null;
|
|
235
414
|
}
|
|
236
415
|
|
|
237
|
-
export function readPostures(vendors, cwd = process.cwd()) {
|
|
416
|
+
export function readPostures(vendors, cwd = process.cwd(), roots = projectRoots(cwd)) {
|
|
238
417
|
const out = {};
|
|
239
418
|
for (const v of vendors) {
|
|
240
419
|
try {
|
|
241
|
-
const p = readVendorPosture(v, cwd);
|
|
420
|
+
const p = readVendorPosture(v, cwd, roots);
|
|
242
421
|
if (p) out[v] = p;
|
|
243
422
|
} catch {
|
|
244
423
|
|