atris 3.82.3 → 3.82.5

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.
@@ -0,0 +1,584 @@
1
+ 'use strict';
2
+
3
+ // atris sentinel agents: a fixed-rule check of a coding agent setup. No
4
+ // model, no network, no cost. It reads the files that tell an agent what to
5
+ // do and what it may run, and flags five kinds of risk:
6
+ //
7
+ // secrets keys pasted into instruction, settings, hook or MCP files
8
+ // permissions settings that let the agent run any command or skip asking
9
+ // hooks hooks that download code and run it
10
+ // mcp MCP servers launched from unpinned packages or plain http
11
+ // hidden invisible characters, override phrases and instructions
12
+ // tucked inside HTML comments in instruction or skill text
13
+ //
14
+ // Findings never hold a secret value. Locations are file:line on this
15
+ // machine; only fixed sentences leave in events (lib/sentinel-events.js).
16
+
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const path = require('path');
20
+ const events = require('./sentinel-events');
21
+
22
+ const AREAS = ['secrets', 'permissions', 'hooks', 'mcp', 'hidden'];
23
+ const AREA_LABEL = {
24
+ secrets: 'secrets in agent files',
25
+ permissions: 'agent permissions',
26
+ hooks: 'hooks',
27
+ mcp: 'mcp servers',
28
+ hidden: 'hidden instructions',
29
+ };
30
+ const MAX_FILE_BYTES = 512 * 1024;
31
+ const MAX_FILES = 4000;
32
+ const SKIP_DIRS = new Set(['node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build', '.trash', '.Trash']);
33
+
34
+ // ---------------------------------------------------------------- files
35
+
36
+ function walk(dir, { exts = null, limit = MAX_FILES } = {}, out = []) {
37
+ let entries;
38
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return out; }
39
+ for (const e of entries) {
40
+ if (out.length >= limit) break;
41
+ const p = path.join(dir, e.name);
42
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(p, { exts, limit }, out); continue; }
43
+ if (!e.isFile()) continue;
44
+ if (exts && !exts.some((x) => e.name.toLowerCase().endsWith(x))) continue;
45
+ out.push(p);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ function isFile(p) {
51
+ try { return fs.statSync(p).isFile(); } catch { return false; }
52
+ }
53
+
54
+ function readText(p) {
55
+ try {
56
+ const st = fs.statSync(p);
57
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) return null;
58
+ const buf = fs.readFileSync(p);
59
+ if (buf.includes(0)) return null; // binary
60
+ return buf.toString('utf8');
61
+ } catch { return null; }
62
+ }
63
+
64
+ const INSTRUCTION_NAMES = ['CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', 'GEMINI.md', '.cursorrules', '.windsurfrules'];
65
+ const TEXT_EXTS = ['.md', '.mdc', '.txt', '.markdown'];
66
+
67
+ // Every file this check reads, each with a role. role decides which rules
68
+ // run on it.
69
+ function collectTargets(root, { home = false, homeDir = os.homedir() } = {}) {
70
+ const t = [];
71
+ const add = (file, role, scope) => { if (isFile(file)) t.push({ file, role, scope }); };
72
+ const addDir = (dir, role, scope, exts) => { for (const f of walk(dir, { exts })) t.push({ file: f, role, scope }); };
73
+ const project = (base, scope) => {
74
+ for (const n of INSTRUCTION_NAMES) add(path.join(base, n), 'instructions', scope);
75
+ add(path.join(base, '.claude', 'CLAUDE.md'), 'instructions', scope);
76
+ add(path.join(base, '.github', 'copilot-instructions.md'), 'instructions', scope);
77
+ add(path.join(base, 'atris', 'CLAUDE.md'), 'instructions', scope);
78
+ add(path.join(base, '.claude', 'settings.json'), 'claude-settings', scope);
79
+ add(path.join(base, '.claude', 'settings.local.json'), 'claude-settings', scope);
80
+ addDir(path.join(base, '.claude', 'hooks'), 'hook-script', scope);
81
+ addDir(path.join(base, '.claude', 'skills'), 'skill', scope, TEXT_EXTS);
82
+ addDir(path.join(base, '.claude', 'commands'), 'skill', scope, TEXT_EXTS);
83
+ addDir(path.join(base, '.claude', 'agents'), 'skill', scope, TEXT_EXTS);
84
+ addDir(path.join(base, 'atris', 'skills'), 'skill', scope, TEXT_EXTS);
85
+ addDir(path.join(base, '.cursor', 'rules'), 'instructions', scope, TEXT_EXTS);
86
+ for (const rel of ['.mcp.json', path.join('.cursor', 'mcp.json'), path.join('.vscode', 'mcp.json'), path.join('.gemini', 'settings.json')]) add(path.join(base, rel), 'mcp-config', scope);
87
+ };
88
+ project(path.resolve(root), 'project');
89
+ if (home) {
90
+ const h = homeDir;
91
+ const claude = path.join(h, '.claude');
92
+ add(path.join(claude, 'CLAUDE.md'), 'instructions', 'home');
93
+ add(path.join(claude, 'settings.json'), 'claude-settings', 'home');
94
+ add(path.join(claude, 'settings.local.json'), 'claude-settings', 'home');
95
+ addDir(path.join(claude, 'hooks'), 'hook-script', 'home');
96
+ addDir(path.join(claude, 'skills'), 'skill', 'home', TEXT_EXTS);
97
+ addDir(path.join(claude, 'commands'), 'skill', 'home', TEXT_EXTS);
98
+ addDir(path.join(claude, 'agents'), 'skill', 'home', TEXT_EXTS);
99
+ add(path.join(h, '.claude.json'), 'claude-user-config', 'home');
100
+ const codex = path.join(h, '.codex');
101
+ add(path.join(codex, 'config.toml'), 'codex-config', 'home');
102
+ add(path.join(codex, 'AGENTS.md'), 'instructions', 'home');
103
+ addDir(path.join(codex, 'skills'), 'skill', 'home', TEXT_EXTS);
104
+ addDir(path.join(codex, 'prompts'), 'skill', 'home', TEXT_EXTS);
105
+ add(path.join(h, '.gemini', 'settings.json'), 'mcp-config', 'home');
106
+ add(path.join(h, '.gemini', 'GEMINI.md'), 'instructions', 'home');
107
+ add(path.join(h, '.cursor', 'mcp.json'), 'mcp-config', 'home');
108
+ }
109
+ const seen = new Set();
110
+ return t.filter((x) => { const k = path.resolve(x.file); if (seen.has(k)) return false; seen.add(k); return true; });
111
+ }
112
+
113
+ function lineOf(text, index) {
114
+ let n = 1;
115
+ for (let i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) n += 1;
116
+ return n;
117
+ }
118
+
119
+ // ---------------------------------------------------------------- rules
120
+
121
+ // The fixed sentences. Each rule id maps to the words a person sees and the
122
+ // words that leave in an event. Never a value from the file.
123
+ const RULES = {
124
+ 'secret-in-file': { area: 'secrets', severity: 'critical', title: 'A secret key is pasted into one of your agent files.', fix: 'Move the key into an environment variable or a secrets manager. Then rotate it since agents and logs may have read it.' },
125
+ 'secret-in-env-block': { area: 'secrets', severity: 'high', title: 'An MCP or agent config holds a secret key as plain text.', fix: 'Reference the key from an environment variable instead of pasting it. Then rotate it.' },
126
+ 'allow-any-shell': { area: 'permissions', severity: 'high', title: 'Your agent may run any shell command without asking.', fix: 'Replace the open shell rule with the few commands the agent needs.' },
127
+ 'bypass-permissions': { area: 'permissions', severity: 'high', title: 'Your agent settings skip every approval prompt.', fix: 'Set the default permission mode back to asking and allow only the commands you trust.' },
128
+ 'skip-danger-prompt': { area: 'permissions', severity: 'medium', title: 'Your agent settings hide the warning for the mode that skips approvals.', fix: 'Remove the setting that silences the skip approvals warning.' },
129
+ 'skip-approval-flag': { area: 'permissions', severity: 'high', title: 'An agent is started with a flag that skips every approval.', fix: 'Drop the skip approvals flag and allow only the commands the agent needs.' },
130
+ 'codex-never-ask': { area: 'permissions', severity: 'high', title: 'Codex is set to run commands without ever asking you.', fix: 'Set the Codex approval policy to on request or untrusted.' },
131
+ 'codex-full-access': { area: 'permissions', severity: 'high', title: 'Codex is set to run with full disk and network access and no sandbox.', fix: 'Use the workspace write sandbox mode unless a task truly needs more.' },
132
+ 'hook-download-run': { area: 'hooks', severity: 'critical', title: 'A hook downloads code from the internet and runs it.', fix: 'Save the script into your project after reading it and point the hook at that copy.' },
133
+ 'hook-eval-fetched': { area: 'hooks', severity: 'critical', title: 'A hook runs code it fetched from the internet through eval.', fix: 'Save the script into your project after reading it and run that copy instead.' },
134
+ 'mcp-unpinned': { area: 'mcp', severity: 'medium', title: 'An MCP server starts from a package with no pinned version.', fix: 'Pin the package to an exact version so a bad release cannot reach your agent.' },
135
+ 'mcp-plain-http': { area: 'mcp', severity: 'high', title: 'An MCP server is reached over plain http.', fix: 'Use an https address for remote MCP servers.' },
136
+ 'hidden-characters': { area: 'hidden', severity: 'high', title: 'Instruction text holds invisible characters that can hide commands.', fix: 'Delete the invisible characters and read the file again to see what it really says.' },
137
+ 'override-phrase': { area: 'hidden', severity: 'high', title: 'Instruction text tells the agent to ignore its other instructions.', fix: 'Remove the override phrase and check who added it.' },
138
+ 'override-phrase-quoted': { area: 'hidden', severity: 'low', title: 'Instruction text quotes a phrase that tells an agent to ignore its instructions.', fix: 'Check that the quote is only an example. Remove it if the file is not about prompt attacks.' },
139
+ 'hidden-comment': { area: 'hidden', severity: 'medium', title: 'Instruction text hides directions for the agent inside an HTML comment.', fix: 'Move the comment into visible text or delete it so people see what the agent reads.' },
140
+ };
141
+
142
+ // Secret check: the review scanner's rules and its placeholder filter.
143
+ function secretHits(text) {
144
+ let scan;
145
+ try { scan = require('./security-scan'); } catch { return []; }
146
+ const out = [];
147
+ const lines = text.split('\n');
148
+ lines.forEach((line, i) => {
149
+ for (const f of scan.scanLine(line) || []) {
150
+ if (f.cat !== 'secret') continue;
151
+ out.push({ line: i + 1, rule: f.rule });
152
+ break;
153
+ }
154
+ });
155
+ return out;
156
+ }
157
+
158
+ // "API_KEY": "abc..." in JSON, and API_KEY = "abc..." in TOML. Only keys
159
+ // whose name says secret, and only values that look real (not $VAR, not a
160
+ // placeholder, not short).
161
+ const SECRET_KEY_NAME = /(?:^|_|-)(?:api[_-]?key|key|token|secret|password|passwd|pat|credential|credentials|auth)(?:$|_|-)/i;
162
+ function envBlockHits(text) {
163
+ let scan;
164
+ try { scan = require('./security-scan'); } catch { return []; }
165
+ const out = [];
166
+ const re = /["']?([A-Za-z_][A-Za-z0-9_.-]{1,80})["']?\s*[:=]\s*"([^"\n]{12,400})"/g;
167
+ let m;
168
+ while ((m = re.exec(text))) {
169
+ const [, key, value] = m;
170
+ if (!SECRET_KEY_NAME.test(key)) continue;
171
+ if (/^\$|\$\{|^<|^\{\{|^env:|^op:\/\/|^\/|^~|^https?:\/\//i.test(value)) continue;
172
+ if (/\s/.test(value)) continue;
173
+ const q = scan.secretQuality(value, {}, m[0]);
174
+ if (!q.ok) continue;
175
+ out.push({ line: lineOf(text, m.index) });
176
+ }
177
+ return out;
178
+ }
179
+
180
+ // Download piped into a shell or interpreter, process substitution of a
181
+ // download, and eval of fetched content.
182
+ const DOWNLOAD = '(?:curl|wget|fetch|iwr|Invoke-WebRequest|irm|Invoke-RestMethod)\\b';
183
+ const DOWNLOAD_RUN = [
184
+ new RegExp(`${DOWNLOAD}[^\\n|;&]*\\|\\s*(?:sudo\\s+)?(?:env\\s+)?(?:ba|z|da|k|fi)?sh\\b`, 'i'),
185
+ new RegExp(`${DOWNLOAD}[^\\n|;&]*\\|\\s*(?:sudo\\s+)?(?:python[0-9.]*|node|perl|ruby|php|iex|Invoke-Expression)\\b`, 'i'),
186
+ new RegExp(`(?:ba|z|k)?sh\\s+<\\(\\s*${DOWNLOAD}`, 'i'),
187
+ new RegExp(`(?:ba|z|k)?sh\\s+-c\\s+["']?\\$\\(\\s*${DOWNLOAD}`, 'i'),
188
+ new RegExp(`source\\s+<\\(\\s*${DOWNLOAD}`, 'i'),
189
+ ];
190
+ const EVAL_FETCHED = [
191
+ new RegExp(`\\beval\\s+["']?\\$\\(\\s*${DOWNLOAD}`, 'i'),
192
+ new RegExp(`\\beval\\s+["']?\`\\s*${DOWNLOAD}`, 'i'),
193
+ /\beval\s*\(\s*(?:await\s+)?\(?\s*(?:await\s+)?fetch\s*\(/i,
194
+ /\bexec\s*\(\s*(?:urllib\.request\.)?urlopen\s*\(/i,
195
+ /\bexec\s*\(\s*requests\.get\s*\(/i,
196
+ ];
197
+
198
+ function downloadRunHits(text) {
199
+ const out = [];
200
+ text.split('\n').forEach((line, i) => {
201
+ if (/^\s*(?:#|\/\/)/.test(line)) return;
202
+ if (DOWNLOAD_RUN.some((re) => re.test(line))) out.push({ line: i + 1, rule: 'hook-download-run' });
203
+ else if (EVAL_FETCHED.some((re) => re.test(line))) out.push({ line: i + 1, rule: 'hook-eval-fetched' });
204
+ });
205
+ return out;
206
+ }
207
+
208
+ const SKIP_FLAGS = /(?:^|\s)(?:--dangerously-skip-permissions|--dangerously-bypass-approvals-and-sandbox|--yolo|--permission-mode[ =]bypassPermissions|--approval-mode[ =]yolo|--allow-dangerously-skip-permissions)(?=\s|$|["'])/;
209
+
210
+ function flagHits(text) {
211
+ const out = [];
212
+ text.split('\n').forEach((line, i) => {
213
+ if (/^\s*(?:#|\/\/)/.test(line)) return;
214
+ if (SKIP_FLAGS.test(line)) out.push({ line: i + 1 });
215
+ });
216
+ return out;
217
+ }
218
+
219
+ // Invisible characters: zero-width, bidirectional controls and Unicode tag
220
+ // characters. A zero-width joiner between two emoji is normal and allowed.
221
+ const INVISIBLE = /[​‌‎‏⁠-⁤‪-‮⁦-⁩]|‍|\uDB40[\uDC00-\uDC7F]/g;
222
+ function invisibleHits(text) {
223
+ const out = [];
224
+ const seen = new Set();
225
+ let m;
226
+ INVISIBLE.lastIndex = 0;
227
+ while ((m = INVISIBLE.exec(text))) {
228
+ const i = m.index;
229
+ if (m[0] === '' && i === 0) continue; // byte order mark
230
+ if (m[0] === '‍') {
231
+ const before = text.charCodeAt(i - 1);
232
+ const after = text.charCodeAt(i + 1);
233
+ const emojiish = (c) => (c >= 0xD800 && c <= 0xDFFF) || (c >= 0x2600 && c <= 0x27BF) || c === 0xFE0F;
234
+ if (emojiish(before) && emojiish(after)) continue;
235
+ }
236
+ const line = lineOf(text, i);
237
+ if (!seen.has(line)) { seen.add(line); out.push({ line }); }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ const OVERRIDE = [
243
+ /\b(?:ignore|disregard|forget|override|bypass)\s+(?:all\s+|any\s+|every\s+)?(?:of\s+)?(?:the\s+|your\s+|my\s+)?(?:previous|prior|above|earlier|preceding|original|system|other)\s+(?:instructions?|prompts?|rules|directions|guidelines|messages)\b/i,
244
+ /\byou\s+are\s+now\s+(?:in\s+)?(?:developer|dan|jailbreak|god)\s+mode\b/i,
245
+ // Secrecy, not ordinary guidance: "do not tell the user about this", but
246
+ // not "do not tell the user to restart".
247
+ /\b(?:do\s+not|don't|never)\s+(?:tell|inform|show)\s+the\s+user\s+(?:about|that|what|anything|this|you\b|we\b)/i,
248
+ /\b(?:do\s+not|don't|never)\s+mention\s+(?:this|it)\s+to\s+the\s+user\b/i,
249
+ /\bwithout\s+(?:telling|informing|alerting)\s+the\s+user\b/i,
250
+ /\bhide\s+this\s+from\s+the\s+user\b/i,
251
+ /\bnew\s+system\s+prompt\s*:/i,
252
+ ];
253
+
254
+ function overrideHits(text) {
255
+ const out = [];
256
+ text.split('\n').forEach((line, i) => {
257
+ for (const re of OVERRIDE) {
258
+ const m = line.match(re);
259
+ if (!m) continue;
260
+ // Quoted or in code: a doc about prompt attacks, most likely.
261
+ const at = m.index;
262
+ const before = line.slice(0, at);
263
+ const quoted = /["'`“‘]\s*$/.test(before) || (before.split('`').length % 2 === 0) || /^\s*>/.test(line);
264
+ out.push({ line: i + 1, rule: quoted ? 'override-phrase-quoted' : 'override-phrase' });
265
+ break;
266
+ }
267
+ });
268
+ return out;
269
+ }
270
+
271
+ const COMMENT_DIRECTIVE = /\b(?:ignore\s+(?:the|all|any|previous|prior|above)|disregard|secretly|silently|do\s+not\s+tell|don't\s+tell|without\s+telling|curl|wget|exfiltrate|send\s+(?:the|all|any|your)\s+\w+\s+to|system\s+prompt|you\s+are\s+(?:now|an?|the)|you\s+must|always\s+run|run\s+the\s+following|execute\s+(?:the|this))\b/i;
272
+ function hiddenCommentHits(text) {
273
+ const out = [];
274
+ // Comments inside fenced code blocks are examples, not hidden text.
275
+ const fences = [];
276
+ const fenceRe = /^(?:```|~~~)/gm;
277
+ let f;
278
+ let open = null;
279
+ while ((f = fenceRe.exec(text))) {
280
+ if (open === null) open = f.index;
281
+ else { fences.push([open, f.index]); open = null; }
282
+ }
283
+ if (open !== null) fences.push([open, text.length]);
284
+ const re = /<!--([\s\S]*?)-->/g;
285
+ let m;
286
+ while ((m = re.exec(text))) {
287
+ if (fences.some(([a, b]) => m.index > a && m.index < b)) continue;
288
+ const body = m[1].trim();
289
+ if (body.split(/\s+/).filter(Boolean).length < 6) continue;
290
+ if (!COMMENT_DIRECTIVE.test(body)) continue;
291
+ out.push({ line: lineOf(text, m.index) });
292
+ }
293
+ return out;
294
+ }
295
+
296
+ // ---------------------------------------------------------------- configs
297
+
298
+ function parseJson(text) {
299
+ try { return JSON.parse(text); } catch { return null; }
300
+ }
301
+
302
+ function settingsChecks(json) {
303
+ const out = [];
304
+ if (!json || typeof json !== 'object') return out;
305
+ const perms = json.permissions || {};
306
+ const allow = Array.isArray(perms.allow) ? perms.allow : [];
307
+ for (const rule of allow) {
308
+ const r = String(rule).replace(/\s+/g, '');
309
+ if (r === 'Bash' || r === 'Bash(*)' || r === 'Bash(:*)' || r === 'Bash(**)' || r === '*') { out.push({ rule: 'allow-any-shell', key: `allow:${r}` }); break; }
310
+ }
311
+ if (perms.defaultMode === 'bypassPermissions') out.push({ rule: 'bypass-permissions', key: 'defaultMode' });
312
+ if (json.skipDangerousModePermissionPrompt === true) out.push({ rule: 'skip-danger-prompt', key: 'skipDangerousModePermissionPrompt' });
313
+ return out;
314
+ }
315
+
316
+ function hookCommands(json) {
317
+ const cmds = [];
318
+ const hooks = json && json.hooks;
319
+ if (!hooks || typeof hooks !== 'object') return cmds;
320
+ for (const groups of Object.values(hooks)) {
321
+ for (const g of Array.isArray(groups) ? groups : []) {
322
+ for (const h of (g && Array.isArray(g.hooks)) ? g.hooks : []) if (h && typeof h.command === 'string') cmds.push(h.command);
323
+ }
324
+ }
325
+ return cmds;
326
+ }
327
+
328
+ function mcpServersOf(json) {
329
+ const out = [];
330
+ if (!json || typeof json !== 'object') return out;
331
+ const pull = (obj, where) => {
332
+ if (!obj || typeof obj !== 'object') return;
333
+ for (const [name, cfg] of Object.entries(obj)) if (cfg && typeof cfg === 'object') out.push({ name, cfg, where });
334
+ };
335
+ pull(json.mcpServers, 'mcpServers');
336
+ pull(json.servers, 'servers'); // .vscode/mcp.json
337
+ if (json.projects && typeof json.projects === 'object') {
338
+ for (const [proj, p] of Object.entries(json.projects)) if (p && p.mcpServers) pull(p.mcpServers, `projects:${proj}`);
339
+ }
340
+ return out;
341
+ }
342
+
343
+ // True when an npm/pypi package spec carries an exact version.
344
+ function pinnedSpec(spec, runner) {
345
+ const s = String(spec || '');
346
+ if (!s || /^[./~]/.test(s) || /^(?:file|git\+|https?):/i.test(s)) return true; // local path or explicit source
347
+ if (runner === 'uvx' || runner === 'pipx') return /==\s*\d/.test(s) || /@\d/.test(s);
348
+ const at = s.lastIndexOf('@');
349
+ if (at <= 0) return false;
350
+ const v = s.slice(at + 1);
351
+ return /^\d+\.\d+\.\d+/.test(v);
352
+ }
353
+
354
+ function packageArg(args, runner) {
355
+ const list = (Array.isArray(args) ? args : []).map(String);
356
+ for (let i = 0; i < list.length; i++) {
357
+ const a = list[i];
358
+ if (a === '-p' || a === '--package' || a === '--from') return list[i + 1] || '';
359
+ if (a.startsWith('--package=') || a.startsWith('--from=')) return a.slice(a.indexOf('=') + 1);
360
+ if (a.startsWith('-')) continue;
361
+ if (runner === 'pnpm' || runner === 'bun' || runner === 'yarn') { if (a === 'dlx' || a === 'x') continue; }
362
+ return a;
363
+ }
364
+ return '';
365
+ }
366
+
367
+ function mcpChecks(server) {
368
+ const out = [];
369
+ const cfg = server.cfg;
370
+ const cmdRaw = String(cfg.command || '');
371
+ const cmd = path.basename(cmdRaw.split(/\s+/)[0] || '');
372
+ let args = Array.isArray(cfg.args) ? cfg.args : [];
373
+ if (!args.length && /\s/.test(cmdRaw)) args = cmdRaw.split(/\s+/).slice(1);
374
+ const runners = { npx: 'npx', bunx: 'bunx', uvx: 'uvx', pipx: 'pipx' };
375
+ let runner = runners[cmd] || null;
376
+ if (!runner && (cmd === 'pnpm' || cmd === 'yarn' || cmd === 'bun') && args.some((a) => a === 'dlx' || a === 'x')) runner = cmd;
377
+ if (runner) {
378
+ const spec = runner === 'pipx' ? packageArg(args.filter((a) => a !== 'run'), runner) : packageArg(args, runner);
379
+ if (spec && !pinnedSpec(spec, runner)) out.push({ rule: 'mcp-unpinned', key: `${server.name}:${spec.replace(/@[^@/]*$/, '')}` });
380
+ }
381
+ const url = String(cfg.url || cfg.serverUrl || cfg.httpUrl || '');
382
+ if (/^http:\/\//i.test(url)) {
383
+ let host = '';
384
+ try { host = new URL(url).hostname; } catch {}
385
+ if (!/^(?:localhost|127\.\d+\.\d+\.\d+|\[?::1\]?|0\.0\.0\.0|host\.docker\.internal)$/i.test(host)) out.push({ rule: 'mcp-plain-http', key: `${server.name}:${host}` });
386
+ }
387
+ // mcp-remote style: npx mcp-remote http://...
388
+ for (const a of args.map(String)) {
389
+ if (/^http:\/\//i.test(a)) {
390
+ let host = '';
391
+ try { host = new URL(a).hostname; } catch {}
392
+ if (host && !/^(?:localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0)$/i.test(host)) out.push({ rule: 'mcp-plain-http', key: `${server.name}:${host}` });
393
+ }
394
+ }
395
+ const all = [cmdRaw, ...args.map(String)].join(' ');
396
+ if (SKIP_FLAGS.test(` ${all} `)) out.push({ rule: 'skip-approval-flag', key: `${server.name}:flag` });
397
+ return out;
398
+ }
399
+
400
+ // A small TOML reader for ~/.codex/config.toml: top-level keys, and
401
+ // [mcp_servers.<name>] tables with command, args and url.
402
+ function codexChecks(text) {
403
+ const out = [];
404
+ let table = '';
405
+ const servers = {};
406
+ text.split('\n').forEach((raw, i) => {
407
+ const line = raw.replace(/\s+#.*$/, '').trim();
408
+ if (!line) return;
409
+ const t = line.match(/^\[([^\]]+)\]$/);
410
+ if (t) { table = t[1].trim(); return; }
411
+ const kv = line.match(/^([A-Za-z0-9_."-]+)\s*=\s*(.+)$/);
412
+ if (!kv) return;
413
+ const key = kv[1].replace(/"/g, '');
414
+ const value = kv[2].trim();
415
+ const str = (value.match(/^"([^"]*)"$/) || value.match(/^'([^']*)'$/) || [])[1];
416
+ const inProfile = table === '' || /^profiles\./.test(table);
417
+ if (inProfile && key === 'approval_policy' && str === 'never') out.push({ rule: 'codex-never-ask', line: i + 1, key: `${table}:approval_policy` });
418
+ if (inProfile && key === 'sandbox_mode' && str === 'danger-full-access') out.push({ rule: 'codex-full-access', line: i + 1, key: `${table}:sandbox_mode` });
419
+ const ms = table.match(/^mcp_servers\.("?)([^"]+)\1$/);
420
+ if (ms) {
421
+ const name = ms[2];
422
+ servers[name] = servers[name] || { name, cfg: {}, line: i + 1 };
423
+ if (key === 'command' && str != null) servers[name].cfg.command = str;
424
+ if (key === 'url' && str != null) servers[name].cfg.url = str;
425
+ if (key === 'args') servers[name].cfg.args = (value.match(/"([^"]*)"|'([^']*)'/g) || []).map((s) => s.slice(1, -1));
426
+ }
427
+ });
428
+ for (const s of Object.values(servers)) for (const f of mcpChecks(s)) out.push({ ...f, line: s.line });
429
+ return out;
430
+ }
431
+
432
+ function jsonKeyLine(text, needle) {
433
+ const i = text.indexOf(needle);
434
+ return i === -1 ? 1 : lineOf(text, i);
435
+ }
436
+
437
+ // ---------------------------------------------------------------- run
438
+
439
+ function checkFile(target) {
440
+ const text = readText(target.file);
441
+ if (text == null) return [];
442
+ const found = [];
443
+ const push = (rule, line, key = '') => found.push({ rule, line, key, file: target.file, scope: target.scope, role: target.role });
444
+ const role = target.role;
445
+ const textual = role === 'instructions' || role === 'skill';
446
+
447
+ // Secrets: every file except the big user config, where only MCP env
448
+ // blocks are read (it holds account state, not agent instructions).
449
+ if (role !== 'claude-user-config') for (const h of secretHits(text)) push('secret-in-file', h.line, `secret:${h.line}`);
450
+ if (role === 'mcp-config' || role === 'claude-settings' || role === 'codex-config' || role === 'claude-user-config') {
451
+ const flagged = new Set(found.filter((f) => f.rule === 'secret-in-file').map((f) => f.line));
452
+ if (role === 'claude-user-config') {
453
+ const json = parseJson(text);
454
+ for (const s of mcpServersOf(json)) {
455
+ const envText = JSON.stringify({ env: s.cfg.env || {}, headers: s.cfg.headers || {} }, null, 1);
456
+ if (envBlockHits(envText).length || secretHits(envText).length) push('secret-in-env-block', jsonKeyLine(text, `"${s.name}"`), `env:${s.where}:${s.name}`);
457
+ }
458
+ } else {
459
+ for (const h of envBlockHits(text)) if (!flagged.has(h.line)) push('secret-in-env-block', h.line, `env:${h.line}`);
460
+ }
461
+ }
462
+
463
+ if (role === 'claude-settings') {
464
+ const json = parseJson(text);
465
+ for (const c of settingsChecks(json)) push(c.rule, jsonKeyLine(text, c.rule === 'allow-any-shell' ? '"allow"' : `"${c.key}"`), c.key);
466
+ for (const cmd of hookCommands(json)) {
467
+ for (const h of downloadRunHits(cmd)) push(h.rule, jsonKeyLine(text, cmd.slice(0, 40)), `hook:${cmd}`);
468
+ if (SKIP_FLAGS.test(` ${cmd} `)) push('skip-approval-flag', jsonKeyLine(text, cmd.slice(0, 40)), `hookflag:${cmd}`);
469
+ }
470
+ for (const s of mcpServersOf(json)) for (const c of mcpChecks(s)) push(c.rule, jsonKeyLine(text, `"${s.name}"`), c.key);
471
+ }
472
+ if (role === 'hook-script') {
473
+ for (const h of downloadRunHits(text)) push(h.rule, h.line, `script:${h.line}`);
474
+ for (const h of flagHits(text)) push('skip-approval-flag', h.line, `scriptflag:${h.line}`);
475
+ }
476
+ if (role === 'mcp-config' || role === 'claude-user-config') {
477
+ const json = parseJson(text);
478
+ for (const s of mcpServersOf(json)) for (const c of mcpChecks(s)) push(c.rule, jsonKeyLine(text, `"${s.name}"`), `${s.where}:${c.key}`);
479
+ }
480
+ if (role === 'codex-config') for (const c of codexChecks(text)) push(c.rule, c.line, c.key);
481
+ if (textual) {
482
+ for (const h of invisibleHits(text)) push('hidden-characters', h.line, `inv:${h.line}`);
483
+ for (const h of overrideHits(text)) push(h.rule, h.line, `ovr:${h.line}`);
484
+ for (const h of hiddenCommentHits(text)) push('hidden-comment', h.line, `cmt:${h.line}`);
485
+ }
486
+ return found;
487
+ }
488
+
489
+ function displayPath(file, root, homeDir) {
490
+ const abs = path.resolve(file);
491
+ const r = path.resolve(root);
492
+ if (abs.startsWith(`${r}${path.sep}`)) return path.relative(r, abs);
493
+ if (homeDir && abs.startsWith(`${homeDir}${path.sep}`)) return `~${abs.slice(homeDir.length)}`;
494
+ return abs;
495
+ }
496
+
497
+ function grade(findings) {
498
+ if (findings.some((f) => f.severity === 'critical' || f.severity === 'high')) return 'fail';
499
+ if (findings.length) return 'warn';
500
+ return 'pass';
501
+ }
502
+
503
+ function runAgentCheck(root, { home = false, homeDir = os.homedir() } = {}) {
504
+ const targets = collectTargets(root, { home, homeDir });
505
+ const findings = [];
506
+ for (const t of targets) {
507
+ for (const f of checkFile(t)) {
508
+ const rule = RULES[f.rule];
509
+ const where = displayPath(f.file, root, homeDir);
510
+ findings.push({
511
+ rule: f.rule,
512
+ area: rule.area,
513
+ severity: rule.severity,
514
+ title: rule.title,
515
+ fix: rule.fix,
516
+ file: where,
517
+ line: f.line,
518
+ scope: f.scope,
519
+ // Stable across runs: rule, file and the finding's own key. A secret
520
+ // key holds only a line number, never the value.
521
+ fingerprint: events.fingerprint('agent_check', f.rule, where, f.key),
522
+ });
523
+ }
524
+ }
525
+ const areas = {};
526
+ for (const a of AREAS) {
527
+ const list = findings.filter((f) => f.area === a);
528
+ areas[a] = { grade: grade(list), findings: list.length };
529
+ }
530
+ const overall = grade(findings);
531
+ return { root: path.resolve(root), files: targets.length, findings, areas, grade: overall, home };
532
+ }
533
+
534
+ function toEvents(result, { repo, now = new Date() } = {}) {
535
+ const name = repo || events.repoName(result.root);
536
+ return result.findings.map((f) => events.makeEvent({
537
+ kind: 'agent_check',
538
+ repo: name,
539
+ severity: f.severity,
540
+ title: f.title,
541
+ detail: f.fix,
542
+ status: 'open',
543
+ fingerprint: f.fingerprint,
544
+ occurredAt: now,
545
+ }));
546
+ }
547
+
548
+ const GRADE_WORD = { pass: 'pass', warn: 'warn', fail: 'fail' };
549
+
550
+ function renderReport(result, { color = false } = {}) {
551
+ const L = [''];
552
+ L.push(` sentinel agents: checked ${result.files} agent file${result.files === 1 ? '' : 's'}${result.home ? ' in this project and your home folder' : ' in this project'}`);
553
+ L.push('');
554
+ for (const a of AREAS) {
555
+ const s = result.areas[a];
556
+ L.push(` ${GRADE_WORD[s.grade].padEnd(5)} ${AREA_LABEL[a]}${s.findings ? ` (${s.findings})` : ''}`);
557
+ }
558
+ const rank = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
559
+ const sorted = [...result.findings].sort((x, y) => rank[x.severity] - rank[y.severity] || x.file.localeCompare(y.file) || x.line - y.line);
560
+ if (sorted.length) {
561
+ L.push('');
562
+ for (const f of sorted.slice(0, 60)) {
563
+ L.push(` ${f.severity.padEnd(8)} ${f.file}:${f.line}`);
564
+ L.push(` ${f.title.replace(/\.$/, '').toLowerCase()}.`);
565
+ L.push(` fix: ${f.fix.toLowerCase()}`);
566
+ }
567
+ if (sorted.length > 60) L.push(` and ${sorted.length - 60} more. run with --json to see all`);
568
+ }
569
+ L.push('');
570
+ const word = result.grade === 'pass' ? 'nothing risky found in this agent setup' : (result.grade === 'warn' ? 'a few things to look at, nothing urgent' : 'at least one finding needs you soon');
571
+ L.push(` overall: ${result.grade}. ${word}`);
572
+ L.push(' no secret values are shown or sent');
573
+ L.push('');
574
+ void color;
575
+ return L.join('\n');
576
+ }
577
+
578
+ module.exports = {
579
+ RULES,
580
+ checkFile,
581
+ runAgentCheck,
582
+ toEvents,
583
+ renderReport,
584
+ };
@@ -408,6 +408,9 @@ function buildUploadBody(dir) {
408
408
  const meta = readJsonFile(path.join(dir, 'run-metadata.json'));
409
409
  if (!meta) throw new Error(`no run-metadata.json in ${dir}`);
410
410
  const records = readJsonFile(path.join(dir, 'findings.json')) || [];
411
+ // Confirmed records a run could not settle wait in carried-pending.json.
412
+ // They are still open and still count.
413
+ const waiting = (readJsonFile(path.join(dir, 'carried-pending.json')) || []).filter((r) => r && r.verdict === 'confirmed' && !records.some((x) => x && x.fingerprint === r.fingerprint));
411
414
  let report = '';
412
415
  try { report = fs.readFileSync(path.join(dir, 'REPORT.md'), 'utf8'); } catch {}
413
416
  let nvText = '';
@@ -417,9 +420,11 @@ function buildUploadBody(dir) {
417
420
  const counts = meta.counts || {};
418
421
  const sev = counts.severity || {};
419
422
  const rank = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
420
- const confirmed = records.filter((r) => r && r.verdict === 'confirmed')
421
- .sort((a, b) => (rank[(a.severity || {}).overall] ?? 9) - (rank[(b.severity || {}).overall] ?? 9));
422
- const findings = confirmed.slice(0, MAX_UPLOAD_FINDINGS).map((r, i) => {
423
+ const bySeverity = (a, b) => (rank[(a.severity || {}).overall] ?? 9) - (rank[(b.severity || {}).overall] ?? 9);
424
+ const confirmed = [...records.filter((r) => r && r.verdict === 'confirmed'), ...waiting].sort(bySeverity);
425
+ // Fixed records carry the verifier's yes and the line that closes them.
426
+ const fixed = records.filter((r) => r && r.verdict === 'fixed' && r.closed_at && r.fixed_by).sort(bySeverity);
427
+ const findings = [...confirmed, ...fixed].slice(0, MAX_UPLOAD_FINDINGS).map((r, i) => {
423
428
  const trace = Array.isArray(r.trace) ? r.trace : [];
424
429
  const sink = trace[trace.length - 1] || {};
425
430
  const severity = SEVERITIES.includes((r.severity || {}).overall) ? r.severity.overall : 'informational';
@@ -430,7 +435,7 @@ function buildUploadBody(dir) {
430
435
  location: location(sink),
431
436
  // Only the plain summary leaves; the code-level strategy stays local.
432
437
  fix: plain((r.remediation || {}).summary, 600) || FALLBACK_SENTENCE,
433
- status: 'open',
438
+ status: r.verdict === 'fixed' ? 'fixed' : 'open',
434
439
  };
435
440
  });
436
441
  let needs = records.filter((r) => r && r.verdict === 'needs_validation').map((r) => ({
@@ -440,7 +445,7 @@ function buildUploadBody(dir) {
440
445
  if (!needs.length && nvText) needs = needsFromMarkdown(nvText);
441
446
  const engines = meta.engines || {};
442
447
  const body = {
443
- repo: String(meta.repo || path.basename(path.dirname(dir))),
448
+ repo: require('./sentinel-events').cleanRepo(String(meta.repo || path.basename(path.dirname(dir)))),
444
449
  run_id: String(meta.run_id || path.basename(dir)),
445
450
  started_at: meta.started_at || null,
446
451
  finished_at: meta.finished_at || null,
@@ -538,6 +543,9 @@ function removeApproval(target, homeDir) {
538
543
 
539
544
  module.exports = {
540
545
  FALLBACK_SENTENCE,
546
+ plain,
547
+ request,
548
+ httpError,
541
549
  writeApproval,
542
550
  readApproval,
543
551
  removeApproval,