atris 3.82.3 → 3.82.4
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/README.md +1 -1
- package/atris/atris.md +6 -0
- package/bin/atris.js +1 -0
- package/commands/aeo.js +15 -2
- package/commands/next.js +16 -6
- package/commands/now.js +6 -1
- package/commands/security-review.js +24 -0
- package/commands/sentinel-powers.js +284 -0
- package/commands/task.js +9 -2
- package/lib/agent-next.js +255 -0
- package/lib/first-minute.js +8 -4
- package/lib/member-context.js +11 -2
- package/lib/sentinel-agents.js +584 -0
- package/lib/sentinel-cloud.js +3 -0
- package/lib/sentinel-events.js +320 -0
- package/lib/sentinel-guard-install.js +163 -0
- package/lib/sentinel-guard.js +856 -0
- package/lib/sentinel-watch.js +529 -0
- package/lib/task-core.js +3 -3
- package/lib/task-db.js +12 -3
- package/package.json +1 -1
|
@@ -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
|
+
};
|