@shomra/agent 0.3.29 → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/scanner.mjs +1 -1
  23. package/src/detect/signals/agent-frameworks.mjs +231 -0
  24. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  25. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  26. package/src/detect/signals/agentic-shim.mjs +82 -0
  27. package/src/detect/signals/artifacts.mjs +7 -35
  28. package/src/detect/signals/chat-template.mjs +211 -0
  29. package/src/detect/signals/ci-workflow.mjs +169 -0
  30. package/src/detect/signals/gate.mjs +77 -9
  31. package/src/detect/signals/guardrail-shape.mjs +564 -0
  32. package/src/detect/signals/guardrail-surface.mjs +221 -0
  33. package/src/detect/signals/injection.mjs +8 -0
  34. package/src/detect/signals/inspect-shim.mjs +7 -0
  35. package/src/detect/signals/instruction-paths.mjs +60 -0
  36. package/src/detect/signals/manifests.mjs +302 -0
  37. package/src/detect/signals/mcp-advisories.mjs +109 -0
  38. package/src/detect/signals/mcp-config.mjs +598 -0
  39. package/src/detect/signals/memory-directives.mjs +661 -0
  40. package/src/detect/signals/memory-locations.mjs +158 -0
  41. package/src/detect/signals/memory.mjs +47 -29
  42. package/src/detect/signals/model-config-rules.mjs +655 -0
  43. package/src/detect/signals/model-config.mjs +61 -0
  44. package/src/detect/signals/prose-context.mjs +6 -9
  45. package/src/detect/signals/scan.mjs +4 -4
  46. package/src/detect/signals/secret-scanner.mjs +241 -0
  47. package/src/detect/signals/secrets.mjs +1 -48
  48. package/src/detect/signals/shell.mjs +3 -3
  49. package/src/gate/advisories.mjs +16 -0
  50. package/src/gate/batch.mjs +10 -0
  51. package/src/gate/environment.mjs +8 -53
  52. package/src/guard/artifact-paths.mjs +107 -0
  53. package/src/guard/classify.mjs +165 -7
  54. package/src/guard/command-resolve.mjs +35 -5
  55. package/src/guard/memory-write.mjs +218 -0
  56. package/src/guard/prompt-guard.mjs +0 -1
  57. package/src/guard/tool-guard.mjs +52 -77
  58. package/src/inventory/agent-posture.mjs +236 -57
  59. package/src/inventory/artifacts/classify.mjs +10 -1
  60. package/src/inventory/artifacts/discover.mjs +113 -3
  61. package/src/inventory/artifacts/extensions.mjs +70 -0
  62. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  63. package/src/inventory/artifacts/limits.mjs +1 -1
  64. package/src/inventory/artifacts/plugins.mjs +105 -0
  65. package/src/inventory/artifacts/roots.mjs +40 -0
  66. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  67. package/src/inventory/discovery/all.mjs +4 -0
  68. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  69. package/src/inventory/discovery/coding-agents.mjs +19 -4
  70. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  71. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  72. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  73. package/src/inventory/env-redirect.mjs +148 -0
  74. package/src/inventory/grant-extract.mjs +463 -0
  75. package/src/inventory/project-roots.mjs +108 -0
  76. package/src/inventory/vscode-state.mjs +153 -0
  77. package/src/mcp/server-tools.mjs +1 -1
@@ -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
- function memoryWriteContent(input) {
142
- if (typeof input.content === 'string') return input.content;
143
- if (typeof input.new_string === 'string') return input.new_string;
144
- return null;
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 recordMemoryWrite({ url, apiKey, input, normalized }) {
148
- const memoryPath = input.file_path || input.path;
149
- if (!memoryPath || !isMemoryPath(memoryPath) || breakerOpen()) return;
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
- const content = memoryWriteContent(input);
152
- if (content == null) return;
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
- await reportMemoryWrite(url, apiKey, {
155
- path: String(memoryPath).split(path.sep).join('/'),
156
- name: path.basename(String(memoryPath)),
157
- content,
158
- writer: 'AGENT',
159
- source: os.hostname(),
160
- actor: os.userInfo().username,
161
- sessionId: normalized.session_id,
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 > MAX_SETTINGS_BYTES) return { state: 'unreadable', reason: st.isFile() ? 'oversized' : 'not-a-file' };
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
- const stripped = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"])\/\/.*$/gm, '$1');
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: 'flag',
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: 'flag',
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: 'flag',
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 CLI',
100
- sources: [{ rel: ['.copilot', 'config.json'], base: 'home', scope: 'user' }],
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?.mcpServers ?? json?.servers ?? json?.mcp?.servers ?? null;
152
- if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return [];
153
- return Object.entries(servers)
154
- .slice(0, 60)
155
- .map(([name, def]) => ({
156
- name,
157
- transport: def?.url ? 'http' : 'stdio',
158
-
159
- autoApprove: Array.isArray(def?.autoApprove) ? def.autoApprove.slice(0, 20) : Array.isArray(def?.alwaysAllow) ? def.alwaysAllow.slice(0, 20) : [],
160
- disabled: def?.disabled === true,
161
- }));
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
- export function readVendorPosture(vendor, cwd = process.cwd()) {
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
- let mode = null;
172
- const allow = [];
173
- const deny = [];
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 file = path.join(resolveBase(src.base, cwd), ...src.rel);
179
- const res = readJsonc(file);
180
- sources.push({ path: file, scope: src.scope, state: res.state, reason: res.reason ?? null });
181
- if (res.state !== 'read') continue;
182
-
183
- const json = res.json ?? {};
184
- const perms = json.permissions ?? {};
185
-
186
- const m = perms.defaultMode ?? json.defaultMode ?? null;
187
- if (m && (!mode || String(m).toLowerCase() === 'bypasspermissions')) mode = String(m);
188
- for (const [key, sink] of [['allow', allow], ['deny', deny], ['ask', ask]]) {
189
- const list = Array.isArray(perms[key]) ? perms[key] : key === 'allow' && Array.isArray(json.allowedTools) ? json.allowedTools : [];
190
- for (const g of list) if (typeof g === 'string' && !sink.includes(g)) sink.push(g);
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
- claim: def.tier === 'declared' && read.length > 0 && holes.length === 0 ? 'complete' : 'floor',
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.length) perms.allow = posture.allow;
230
- if (posture.deny.length) perms.deny = posture.deny;
231
- if (posture.ask.length) perms.ask = 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