atris 3.82.5 → 3.82.8
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/bin/atris.js +7 -2
- package/commands/security-review.js +18 -0
- package/commands/sentinel-bench.js +172 -0
- package/commands/sentinel-fix.js +243 -0
- package/commands/sentinel-powers.js +97 -11
- package/lib/security-audit.js +3 -0
- package/lib/sentinel-agents.js +75 -41
- package/lib/sentinel-bench.js +556 -0
- package/lib/sentinel-cloud.js +2 -1
- package/lib/sentinel-events.js +32 -10
- package/lib/sentinel-fix.js +777 -0
- package/lib/sentinel-guard-install.js +10 -4
- package/lib/sentinel-guard.js +142 -83
- package/lib/sentinel-improve.js +827 -0
- package/lib/sentinel-rules.js +108 -0
- package/lib/sentinel-rules.json +237 -0
- package/lib/sentinel-watch.js +940 -98
- package/package.json +1 -1
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// Installs the sentinel guard (lib/sentinel-guard.js) as a Claude Code
|
|
4
4
|
// PreToolUse hook, the same way `atris spend guard install` installs its
|
|
5
|
-
// hook (lib/claude-spend-guard.js): the hook is a copy of the guard file
|
|
6
|
-
//
|
|
5
|
+
// hook (lib/claude-spend-guard.js): the hook is a copy of the guard file.
|
|
6
|
+
// Its rules file (lib/sentinel-rules.json) sits beside it. It keeps
|
|
7
|
+
// working if the CLI moves. The settings file is rewritten
|
|
7
8
|
// atomically and never clobbered when it does not parse.
|
|
8
9
|
//
|
|
9
10
|
// project <project>/.claude/hooks/sentinel-guard.js, wired in
|
|
@@ -19,6 +20,9 @@ const guard = require('./sentinel-guard');
|
|
|
19
20
|
|
|
20
21
|
const SCRIPT_NAME = `${guard.GUARD_MARK}.js`;
|
|
21
22
|
const GUARD_SOURCE = path.join(__dirname, 'sentinel-guard.js');
|
|
23
|
+
// The guard reads its tunable rules from this file beside it.
|
|
24
|
+
const RULES_SOURCE = path.join(__dirname, 'sentinel-rules.json');
|
|
25
|
+
const RULES_NAME = 'sentinel-rules.json';
|
|
22
26
|
|
|
23
27
|
function homeDir(env = process.env) {
|
|
24
28
|
return env.HOME || os.homedir();
|
|
@@ -26,7 +30,7 @@ function homeDir(env = process.env) {
|
|
|
26
30
|
|
|
27
31
|
function locations({ global = false, root = process.cwd(), env = process.env } = {}) {
|
|
28
32
|
const base = global ? path.join(homeDir(env), '.claude') : path.join(path.resolve(root), '.claude');
|
|
29
|
-
return { scope: global ? 'global' : 'project', settings: path.join(base, 'settings.json'), script: path.join(base, 'hooks', SCRIPT_NAME) };
|
|
33
|
+
return { scope: global ? 'global' : 'project', settings: path.join(base, 'settings.json'), script: path.join(base, 'hooks', SCRIPT_NAME), rules: path.join(base, 'hooks', RULES_NAME) };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
function nodeBinary() {
|
|
@@ -93,7 +97,7 @@ function status({ global = false, root, env = process.env } = {}) {
|
|
|
93
97
|
const groups = (settings.hooks && settings.hooks.PreToolUse) || [];
|
|
94
98
|
const wired = readable && Array.isArray(groups) && groups.some(isGuardGroup);
|
|
95
99
|
const present = fs.existsSync(loc.script);
|
|
96
|
-
const current = present && fileHash(loc.script) === fileHash(GUARD_SOURCE);
|
|
100
|
+
const current = present && fileHash(loc.script) === fileHash(GUARD_SOURCE) && fileHash(loc.rules) === fileHash(RULES_SOURCE);
|
|
97
101
|
const hooksOff = settings.disableAllHooks === true;
|
|
98
102
|
return { ...loc, installed: wired && present, wired, present, current, readable, hooks_off: hooksOff };
|
|
99
103
|
}
|
|
@@ -116,6 +120,7 @@ function install({ global = false, root, env = process.env, atrisBin } = {}) {
|
|
|
116
120
|
const loc = locations({ global, root, env });
|
|
117
121
|
const settings = readSettings(loc.settings);
|
|
118
122
|
fs.mkdirSync(path.dirname(loc.script), { recursive: true });
|
|
123
|
+
fs.copyFileSync(RULES_SOURCE, loc.rules);
|
|
119
124
|
fs.copyFileSync(GUARD_SOURCE, loc.script);
|
|
120
125
|
fs.chmodSync(loc.script, 0o755);
|
|
121
126
|
settings.hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {};
|
|
@@ -142,6 +147,7 @@ function uninstall({ global = false, root, env = process.env } = {}) {
|
|
|
142
147
|
writeSettings(loc.settings, settings);
|
|
143
148
|
}
|
|
144
149
|
try { fs.unlinkSync(loc.script); } catch {}
|
|
150
|
+
try { fs.unlinkSync(loc.rules); } catch {}
|
|
145
151
|
return status({ global, root, env });
|
|
146
152
|
}
|
|
147
153
|
|
package/lib/sentinel-guard.js
CHANGED
|
@@ -55,17 +55,100 @@ const crypto = require('crypto');
|
|
|
55
55
|
const GUARD_MARK = 'sentinel-guard';
|
|
56
56
|
const MATCHER = 'Bash|Edit|Write|MultiEdit|NotebookEdit';
|
|
57
57
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
58
|
+
// The tunable rules live in sentinel-rules.json beside this file. They are
|
|
59
|
+
// literal data, never code: trusted hosts, network programs, secret name
|
|
60
|
+
// parts, the safe option lists, read-only programs, database clients and
|
|
61
|
+
// literal command blocks. `atris sentinel improve` may change only that
|
|
62
|
+
// file. The guard matches every entry with a plain string operation. A
|
|
63
|
+
// proposed entry can never be a regular expression or stall on long input.
|
|
64
|
+
// The hand-written regular expressions below stay in code, out of the
|
|
65
|
+
// proposer's reach. More hosts can be added in
|
|
60
66
|
// ~/.atris/sentinel/guard.json { "allow_hosts": ["api.example.com"] }.
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
const RULES_FILE = path.join(__dirname, 'sentinel-rules.json');
|
|
68
|
+
const RULE_CATEGORIES = ['exfil', 'destroy', 'disarm'];
|
|
69
|
+
|
|
70
|
+
// Fixed shapes, not proposer data: the review scanner's known-token rules
|
|
71
|
+
// and the secrets files. These are the only regular expressions the guard
|
|
72
|
+
// uses. None of them come from the rules file.
|
|
73
|
+
const KEY_SHAPES = [
|
|
74
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
75
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
|
|
76
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
|
|
77
|
+
/\bgithub_pat_[A-Za-z0-9_]{40,}\b/,
|
|
78
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
|
|
79
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
|
|
80
|
+
/\bsk-proj-[A-Za-z0-9_-]{32,}\b/,
|
|
81
|
+
/\bsk-[A-Za-z0-9]{32,}\b/,
|
|
82
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/,
|
|
83
|
+
/\b[rs]k_live_[A-Za-z0-9]{20,}\b/,
|
|
84
|
+
/\bnpm_[A-Za-z0-9]{36}\b/,
|
|
85
|
+
/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/,
|
|
68
86
|
];
|
|
87
|
+
const DROP_TEXT = /\bdrop\s+(?:database|schema)\b|\.dropDatabase\s*\(|\bflushall\b|\bflushdb\b/i;
|
|
88
|
+
// SECRET_FILES needs HOME_REF, defined further down; it is built lazily.
|
|
89
|
+
let SECRET_FILES = null;
|
|
90
|
+
function secretFiles() {
|
|
91
|
+
if (SECRET_FILES) return SECRET_FILES;
|
|
92
|
+
SECRET_FILES = [
|
|
93
|
+
new RegExp(`${HOME_REF}/\\.ssh(?:/(?![^\\s'"]*\\.pub(?=$|[\\s'"|;&)]))[^\\s'"|;&)]*|(?=$|[\\s'"|;&)]))`),
|
|
94
|
+
/(?:^|[\s'"=@<:/])id_(?:rsa|dsa|ecdsa|ed25519)(?!\.pub)(?=$|[\s'"|;&)])/,
|
|
95
|
+
/\.aws\/(?:credentials|config)\b/,
|
|
96
|
+
/(?:^|[\s'"=@<:/])\.env(?:\.(?!example\b|sample\b|template\b|dist\b|defaults?\b)[\w-]+)?(?=$|[\s'"|;&)<>])/,
|
|
97
|
+
/\.netrc\b/, /\.git-credentials\b/, /\.npmrc\b/, /\.pypirc\b/,
|
|
98
|
+
/\.docker\/config\.json\b/, /\.kube\/config\b/, /\/etc\/shadow\b/,
|
|
99
|
+
/\.config\/gh\/hosts\.yml\b/, /\.atris\/credentials/, /\.gnupg\//,
|
|
100
|
+
];
|
|
101
|
+
return SECRET_FILES;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Turns the literal rules data into sets and lists. Throws on a bad file:
|
|
105
|
+
// the hook then fails open and logs it, like any other guard error.
|
|
106
|
+
function compileRules(raw) {
|
|
107
|
+
const g = (raw && raw.guard) || {};
|
|
108
|
+
const list = (v, name) => { if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) throw new Error(`rules: guard.${name} must be a list of strings`); return v; };
|
|
109
|
+
const safeNet = {};
|
|
110
|
+
for (const [prog, spec] of Object.entries(g.safe_net || {})) {
|
|
111
|
+
safeNet[prog] = {
|
|
112
|
+
flags: new Set(list(spec.flags, `safe_net.${prog}.flags`)),
|
|
113
|
+
values: new Set(list(spec.values, `safe_net.${prog}.values`)),
|
|
114
|
+
localValue: new Set(list(spec.local_values, `safe_net.${prog}.local_values`)),
|
|
115
|
+
shortFlags: String(spec.short_flags || ''),
|
|
116
|
+
shortValues: String(spec.short_values || ''),
|
|
117
|
+
items: spec.items === true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
for (const [alias, target] of Object.entries(g.safe_net_aliases || {})) if (safeNet[target]) safeNet[alias] = safeNet[target];
|
|
121
|
+
return {
|
|
122
|
+
allowHosts: list(g.allow_hosts, 'allow_hosts').map((h) => h.toLowerCase()),
|
|
123
|
+
netTools: new Set(list(g.net_tools, 'net_tools')),
|
|
124
|
+
secretParts: new Set(list(g.secret_parts, 'secret_parts')),
|
|
125
|
+
keyShapes: KEY_SHAPES,
|
|
126
|
+
secretFiles: secretFiles(),
|
|
127
|
+
safeNet,
|
|
128
|
+
readOnly: new Set(list(g.read_only, 'read_only')),
|
|
129
|
+
readOnlyGit: new Set(list(g.read_only_git, 'read_only_git')),
|
|
130
|
+
dbClients: new Set(list(g.db_clients, 'db_clients')),
|
|
131
|
+
dropText: DROP_TEXT,
|
|
132
|
+
// Literal command blocks. Each blocks when the command runs one of its
|
|
133
|
+
// programs (if any) and contains every one of its literal substrings.
|
|
134
|
+
// No regular expression, no proposer pattern.
|
|
135
|
+
commandBlocks: (Array.isArray(g.command_blocks) ? g.command_blocks : []).map((b, i) => {
|
|
136
|
+
if (!b || !RULE_CATEGORIES.includes(b.category)) throw new Error(`rules: guard.command_blocks[${i}].category must be exfil, destroy or disarm`);
|
|
137
|
+
const all = list(b.all, `command_blocks[${i}].all`);
|
|
138
|
+
if (!all.length) throw new Error(`rules: guard.command_blocks[${i}].all needs at least one substring`);
|
|
139
|
+
return { id: String(b.id || i), category: b.category, programs: Array.isArray(b.programs) ? new Set(b.programs.map(String)) : null, all };
|
|
140
|
+
}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let DEFAULT_RULES = null;
|
|
145
|
+
let ACTIVE_RULES = null;
|
|
146
|
+
// The rules in force: the ones passed to decide(), else the file beside this one.
|
|
147
|
+
function R() {
|
|
148
|
+
if (ACTIVE_RULES) return ACTIVE_RULES;
|
|
149
|
+
if (!DEFAULT_RULES) DEFAULT_RULES = compileRules(JSON.parse(fs.readFileSync(RULES_FILE, 'utf8')));
|
|
150
|
+
return DEFAULT_RULES;
|
|
151
|
+
}
|
|
69
152
|
|
|
70
153
|
function homeDir(env = process.env) {
|
|
71
154
|
return env.HOME || os.homedir();
|
|
@@ -279,16 +362,14 @@ function gitSub(args) {
|
|
|
279
362
|
|
|
280
363
|
// ---------------------------------------------------------------- rules
|
|
281
364
|
|
|
282
|
-
const NET_TOOLS = new Set(['curl', 'wget', 'nc', 'ncat', 'netcat', 'socat', 'telnet', 'scp', 'sftp', 'ftp', 'rsync', 'http', 'https', 'xh', 'httpie', 'aria2c', 'lftp', 'ssh']);
|
|
283
365
|
const DEV_TCP = /\/dev\/(?:tcp|udp)\//;
|
|
284
366
|
|
|
285
|
-
const SECRET_PARTS = new Set(['KEY', 'KEYS', 'TOKEN', 'TOKENS', 'SECRET', 'SECRETS', 'PASSWORD', 'PASSWD', 'PASS', 'PWD', 'CREDENTIAL', 'CREDENTIALS', 'CREDS', 'AUTH', 'PRIVATE', 'SESSION', 'COOKIE', 'APIKEY', 'PAT', 'DSN']);
|
|
286
367
|
// A variable whose name says it holds a secret: one of its underscore parts
|
|
287
368
|
// is a secret word (GITHUB_TOKEN, DB_PASSWORD), or it ends in KEY or TOKEN.
|
|
288
369
|
function secretVarName(name) {
|
|
289
370
|
const up = String(name || '').toUpperCase();
|
|
290
371
|
if (!up || up === 'PWD' || up === 'OLDPWD') return false;
|
|
291
|
-
if (up.split('_').some((p) =>
|
|
372
|
+
if (up.split('_').some((p) => R().secretParts.has(p))) return true;
|
|
292
373
|
return /(?:KEY|TOKEN|SECRET)$/.test(up);
|
|
293
374
|
}
|
|
294
375
|
function hasSecretVar(text) {
|
|
@@ -298,71 +379,21 @@ function hasSecretVar(text) {
|
|
|
298
379
|
return false;
|
|
299
380
|
}
|
|
300
381
|
|
|
301
|
-
// Key shapes, a copy of the review scanner's known-token rules.
|
|
302
|
-
const KEY_SHAPES = [
|
|
303
|
-
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
304
|
-
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
|
|
305
|
-
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/,
|
|
306
|
-
/\bgithub_pat_[A-Za-z0-9_]{40,}\b/,
|
|
307
|
-
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
|
|
308
|
-
/\bsk-ant-[A-Za-z0-9_-]{20,}\b/,
|
|
309
|
-
/\bsk-proj-[A-Za-z0-9_-]{32,}\b/,
|
|
310
|
-
/\bsk-[A-Za-z0-9]{32,}\b/,
|
|
311
|
-
/\bAIza[0-9A-Za-z_-]{35}\b/,
|
|
312
|
-
/\b[rs]k_live_[A-Za-z0-9]{20,}\b/,
|
|
313
|
-
/\bnpm_[A-Za-z0-9]{36}\b/,
|
|
314
|
-
/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/,
|
|
315
|
-
];
|
|
316
382
|
|
|
317
383
|
const HOME_REF = '(?:~|\\$HOME|\\$\\{HOME\\}|\\/Users\\/[^\\/\\s]+|\\/home\\/[^\\/\\s]+|\\/root)';
|
|
318
|
-
const SECRET_FILES = [
|
|
319
|
-
new RegExp(`${HOME_REF}/\\.ssh(?:/(?![^\\s'"]*\\.pub(?=$|[\\s'"|;&)]))[^\\s'"|;&)]*|(?=$|[\\s'"|;&)]))`),
|
|
320
|
-
/(?:^|[\s'"=@<:/])id_(?:rsa|dsa|ecdsa|ed25519)(?!\.pub)(?=$|[\s'"|;&)])/,
|
|
321
|
-
/\.aws\/(?:credentials|config)\b/,
|
|
322
|
-
/(?:^|[\s'"=@<:/])\.env(?:\.(?!example\b|sample\b|template\b|dist\b|defaults?\b)[\w-]+)?(?=$|[\s'"|;&)<>])/,
|
|
323
|
-
/\.netrc\b/, /\.git-credentials\b/, /\.npmrc\b/, /\.pypirc\b/,
|
|
324
|
-
/\.docker\/config\.json\b/, /\.kube\/config\b/, /\/etc\/shadow\b/,
|
|
325
|
-
/\.config\/gh\/hosts\.yml\b/, /\.atris\/credentials/, /\.gnupg\//,
|
|
326
|
-
];
|
|
327
384
|
|
|
328
385
|
// Secret-carrying network calls are judged by allow-list, not by pattern.
|
|
329
|
-
// Each program has a short list of options known to be safe
|
|
330
|
-
// option, attached or separate (a proxy, a
|
|
331
|
-
// blocks. Programs not listed
|
|
332
|
-
// the rest) are never allowed with a
|
|
333
|
-
|
|
334
|
-
curl: {
|
|
335
|
-
flags: new Set(['-s', '--silent', '-S', '--show-error', '-f', '--fail', '--fail-with-body', '--fail-early', '-L', '--location', '-O', '--remote-name', '-i', '--include', '-v', '--verbose', '--compressed', '-G', '--get']),
|
|
336
|
-
values: new Set(['-H', '--header', '-d', '--data', '--data-raw', '--data-binary', '--data-urlencode', '--data-ascii', '--json', '-X', '--request', '-o', '--output', '-w', '--write-out', '-m', '--max-time']),
|
|
337
|
-
localValue: new Set(['-o', '--output']),
|
|
338
|
-
shortFlags: 'sSfLOivG',
|
|
339
|
-
shortValues: 'HdXowm',
|
|
340
|
-
},
|
|
341
|
-
wget: {
|
|
342
|
-
flags: new Set(['-q', '--quiet', '-S', '--server-response', '-nv', '--no-verbose']),
|
|
343
|
-
values: new Set(['-O', '--output-document', '--header', '--post-data', '--method', '--body-data', '-T', '--timeout', '-t', '--tries']),
|
|
344
|
-
localValue: new Set(['-O', '--output-document']),
|
|
345
|
-
shortFlags: 'qS',
|
|
346
|
-
shortValues: 'OTt',
|
|
347
|
-
},
|
|
348
|
-
http: {
|
|
349
|
-
flags: new Set(['-j', '--json', '-f', '--form', '-h', '--headers', '-b', '--body', '-v', '--verbose', '--check-status', '--ignore-stdin']),
|
|
350
|
-
values: new Set(['-p', '--print', '--timeout', '-o', '--output']),
|
|
351
|
-
localValue: new Set(['-o', '--output']),
|
|
352
|
-
shortFlags: 'jfhbv',
|
|
353
|
-
shortValues: 'po',
|
|
354
|
-
items: true,
|
|
355
|
-
},
|
|
356
|
-
};
|
|
357
|
-
SAFE_NET.https = SAFE_NET.http;
|
|
358
|
-
SAFE_NET.xh = SAFE_NET.http;
|
|
359
|
-
SAFE_NET.httpie = SAFE_NET.http;
|
|
386
|
+
// Each program has a short list of options known to be safe (safe_net in
|
|
387
|
+
// the rules file); any other option, attached or separate (a proxy, a
|
|
388
|
+
// resolve override, a config file), blocks. Programs not listed there (nc,
|
|
389
|
+
// socat, scp, ssh, rsync, telnet and the rest) are never allowed with a
|
|
390
|
+
// secret.
|
|
360
391
|
|
|
361
392
|
const HTTP_METHODS = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/;
|
|
362
393
|
|
|
363
394
|
// The destinations of one call on the safe list, or ok: false.
|
|
364
395
|
function safeNetCall(c) {
|
|
365
|
-
const spec =
|
|
396
|
+
const spec = R().safeNet[c.name];
|
|
366
397
|
if (!spec) return { ok: false };
|
|
367
398
|
const dests = [];
|
|
368
399
|
const localOk = (v) => typeof v === 'string' && v !== '' && !/^[a-z][a-z0-9+.-]*:/i.test(v) && !v.startsWith('-');
|
|
@@ -455,14 +486,14 @@ function isEnvDump(c) {
|
|
|
455
486
|
}
|
|
456
487
|
|
|
457
488
|
function checkExfil(command, cmds, { allowHosts }) {
|
|
458
|
-
const netCmds = cmds.filter((c) =>
|
|
489
|
+
const netCmds = cmds.filter((c) => R().netTools.has(c.name));
|
|
459
490
|
const devTcp = DEV_TCP.test(command);
|
|
460
491
|
if (!netCmds.length && !devTcp) return null;
|
|
461
|
-
if (
|
|
492
|
+
if (R().secretFiles.some((re) => re.test(command))) return block('exfil-secret-file', 'critical', 'blocked: this command reads a private key or secrets file and makes a network call. it could send your secrets out.', 'An agent tried to read a secrets file and make a network call in the same command. Nothing was sent.');
|
|
462
493
|
if (/\/proc\/(?:self|\d+)\/environ/.test(command) || /\$\(\s*(?:env|printenv)\s*\)|`\s*(?:env|printenv)\s*`/.test(command) || cmds.some((c) => isEnvDump(c) && c.pipe.length > 1)) {
|
|
463
494
|
return block('exfil-env-dump', 'critical', 'blocked: this command sends your whole environment over the network. it holds your keys.', 'An agent tried to send all environment variables over the network. Nothing was sent.');
|
|
464
495
|
}
|
|
465
|
-
if (
|
|
496
|
+
if (R().keyShapes.some((re) => re.test(command))) return block('exfil-key-literal', 'critical', 'blocked: this command puts a secret key into a network call.', 'An agent tried to send a string that looks like a secret key over the network. Nothing was sent.');
|
|
466
497
|
if (carriesSecret(command, netCmds)) {
|
|
467
498
|
let trusted = !devTcp && netCmds.length > 0;
|
|
468
499
|
for (const c of netCmds) {
|
|
@@ -477,8 +508,6 @@ function checkExfil(command, cmds, { allowHosts }) {
|
|
|
477
508
|
}
|
|
478
509
|
|
|
479
510
|
const RM_TARGET_DANGER = new RegExp(`^(?:/|/\\*|${HOME_REF}/?|${HOME_REF}/\\*|/(?:Users|home|etc|usr|var|bin|sbin|lib|opt|System|Library|Applications|private)/?|\\.\\./?\\.\\./?.*)$`);
|
|
480
|
-
const DB_CLIENTS = new Set(['psql', 'pg_restore', 'mysql', 'mariadb', 'sqlite3', 'sqlite', 'sqlcmd', 'mongosh', 'mongo', 'redis-cli', 'clickhouse-client', 'clickhouse', 'cockroach', 'duckdb', 'supabase', 'prisma', 'turso', 'wrangler', 'neonctl', 'cqlsh']);
|
|
481
|
-
const DROP_TEXT = /\bdrop\s+(?:database|schema)\b|\.dropDatabase\s*\(|\bflushall\b|\bflushdb\b/i;
|
|
482
511
|
|
|
483
512
|
function checkDestroy(command, cmds, { cwd, homeDirPath, gitBranch }) {
|
|
484
513
|
for (const c of cmds) {
|
|
@@ -523,8 +552,8 @@ function checkDestroy(command, cmds, { cwd, homeDirPath, gitBranch }) {
|
|
|
523
552
|
// Database deletion text counts only when a database client is in the
|
|
524
553
|
// command to run it. Searching for it (rg, grep, cat) is fine.
|
|
525
554
|
// A client run inside a container or over ssh counts too.
|
|
526
|
-
const viaRunner = (c) => ['docker', 'podman', 'kubectl', 'ssh', 'heroku', 'fly', 'flyctl', 'railway'].includes(c.name) && c.args.some((a) =>
|
|
527
|
-
if (cmds.some((c) =>
|
|
555
|
+
const viaRunner = (c) => ['docker', 'podman', 'kubectl', 'ssh', 'heroku', 'fly', 'flyctl', 'railway'].includes(c.name) && c.args.some((a) => R().dbClients.has(path.basename(a)));
|
|
556
|
+
if (cmds.some((c) => R().dbClients.has(c.name) || viaRunner(c)) && R().dropText.test(command)) return block('destroy-database', 'critical', 'blocked: this deletes a whole database.', 'An agent tried to drop a database. Nothing was deleted.');
|
|
528
557
|
return null;
|
|
529
558
|
}
|
|
530
559
|
|
|
@@ -547,7 +576,7 @@ function protectedTarget(word, ctx = {}) {
|
|
|
547
576
|
const home = homeDir(ctx.env || process.env);
|
|
548
577
|
const sentinel = path.join(home, '.atris', 'sentinel');
|
|
549
578
|
if (/\/\.claude\/settings(?:\.local)?\.json$/.test(abs)) return 'settings';
|
|
550
|
-
if (/\/\.claude\/hooks\/sentinel-guard\.js$/.test(abs)) return 'guard';
|
|
579
|
+
if (/\/\.claude\/hooks\/sentinel-(?:guard\.js|rules\.json)$/.test(abs)) return 'guard';
|
|
551
580
|
if (abs.startsWith(`${sentinel}/`) && /^(?:guard\.json|guard\.log|guard-errors\.log|queue\.jsonl.*|flush\.stamp)$/.test(path.basename(abs))) return 'guard';
|
|
552
581
|
if (abs === sentinel || abs === path.join(home, '.atris') || abs === path.join(home, '.claude') || /\/\.claude(?:\/hooks)?$/.test(abs)) return 'folder';
|
|
553
582
|
return null;
|
|
@@ -555,14 +584,12 @@ function protectedTarget(word, ctx = {}) {
|
|
|
555
584
|
|
|
556
585
|
// Names that are protected wherever they sit, for commands that change
|
|
557
586
|
// folder first (cd .claude && echo {} > settings.json).
|
|
558
|
-
const PROTECTED_NAMES = /^(?:settings\.json|settings\.local\.json|sentinel-guard\.js|queue\.jsonl.*|guard\.json|guard\.log)$/;
|
|
587
|
+
const PROTECTED_NAMES = /^(?:settings\.json|settings\.local\.json|sentinel-guard\.js|sentinel-rules\.json|queue\.jsonl.*|guard\.json|guard\.log)$/;
|
|
559
588
|
|
|
560
589
|
// Programs that only read. A command that names a protected path anywhere
|
|
561
590
|
// in its arguments (key=value forms like of= and attached options like
|
|
562
591
|
// -ofile included) runs only when it is one of these. Everything else
|
|
563
592
|
// blocks: dd, tee, cp, mv, sed, truncate, ln, install, rsync, interpreters.
|
|
564
|
-
const READ_ONLY = new Set(['cat', 'less', 'more', 'head', 'tail', 'grep', 'egrep', 'fgrep', 'rg', 'jq', 'ls', 'stat', 'wc', 'file', 'diff', 'echo', 'test', '[', 'realpath', 'readlink']);
|
|
565
|
-
const READ_ONLY_GIT = new Set(['diff', 'log', 'show', 'status']);
|
|
566
593
|
|
|
567
594
|
function pathCandidates(arg) {
|
|
568
595
|
const a = String(arg);
|
|
@@ -576,8 +603,8 @@ function pathCandidates(arg) {
|
|
|
576
603
|
}
|
|
577
604
|
|
|
578
605
|
function readOnlyCommand(c) {
|
|
579
|
-
if (
|
|
580
|
-
if (c.name === 'git') return
|
|
606
|
+
if (R().readOnly.has(c.name)) return true;
|
|
607
|
+
if (c.name === 'git') return R().readOnlyGit.has(gitSub(c.args).sub);
|
|
581
608
|
return false;
|
|
582
609
|
}
|
|
583
610
|
|
|
@@ -671,12 +698,38 @@ function currentBranch(cwd) {
|
|
|
671
698
|
|
|
672
699
|
// The decision for one tool call. Pure apart from reading guard.json, the
|
|
673
700
|
// settings file an edit targets, and the git branch for a bare force push.
|
|
674
|
-
|
|
701
|
+
const BLOCK_REASON = {
|
|
702
|
+
exfil: ['blocked: this command matches a known way to send secrets out.', 'An agent ran a command that matches a known way to send secrets out. Nothing was sent.'],
|
|
703
|
+
destroy: ['blocked: this command matches a known destructive pattern.', 'An agent ran a command that matches a known destructive pattern. Nothing was run.'],
|
|
704
|
+
disarm: ['blocked: this command matches a known way to turn off a protection.', 'An agent ran a command that matches a known way to turn off a protection. Nothing changed.'],
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
// Literal command blocks from the rules file. Each block reads the whole
|
|
708
|
+
// command with plain substring checks, limited to commands that run one of
|
|
709
|
+
// its programs when it lists any. Linear time, no regular expression.
|
|
710
|
+
function checkBlocks(command, cmds) {
|
|
711
|
+
for (const b of R().commandBlocks) {
|
|
712
|
+
if (b.programs && !cmds.some((c) => b.programs.has(c.name))) continue;
|
|
713
|
+
if (!b.all.every((sub) => command.includes(sub))) continue;
|
|
714
|
+
const [reason, detail] = BLOCK_REASON[b.category];
|
|
715
|
+
return block(`${b.category}-block`, b.category === 'disarm' ? 'high' : 'critical', reason, detail);
|
|
716
|
+
}
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function decide(event, { env = process.env, gitBranch = currentBranch, rules = null } = {}) {
|
|
721
|
+
if (!rules) return decideWith(event, { env, gitBranch });
|
|
722
|
+
const prev = ACTIVE_RULES;
|
|
723
|
+
ACTIVE_RULES = compileRules(rules);
|
|
724
|
+
try { return decideWith(event, { env, gitBranch }); } finally { ACTIVE_RULES = prev; }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function decideWith(event, { env, gitBranch }) {
|
|
675
728
|
const tool = String((event && event.tool_name) || '');
|
|
676
729
|
const input = (event && event.tool_input && typeof event.tool_input === 'object') ? event.tool_input : {};
|
|
677
730
|
const cwd = (event && event.cwd) || process.cwd();
|
|
678
731
|
const cfg = loadConfig(env);
|
|
679
|
-
const allowHosts = [...
|
|
732
|
+
const allowHosts = [...R().allowHosts, ...(Array.isArray(cfg.allow_hosts) ? cfg.allow_hosts.map((h) => String(h).toLowerCase()) : [])];
|
|
680
733
|
if (tool === 'Bash') {
|
|
681
734
|
const command = String(input.command || '');
|
|
682
735
|
if (!command.trim()) return { allow: true };
|
|
@@ -684,6 +737,7 @@ function decide(event, { env = process.env, gitBranch = currentBranch } = {}) {
|
|
|
684
737
|
return checkExfil(command, cmds, { allowHosts })
|
|
685
738
|
|| checkDestroy(command, cmds, { cwd, homeDirPath: homeDir(env), gitBranch })
|
|
686
739
|
|| checkDisarm(command, cmds, { env, cwd })
|
|
740
|
+
|| checkBlocks(command, cmds)
|
|
687
741
|
|| { allow: true };
|
|
688
742
|
}
|
|
689
743
|
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return checkEdit(tool, input, { env, cwd }) || { allow: true };
|
|
@@ -762,6 +816,9 @@ function recordBlock(event, decision, { env = process.env, now = new Date() } =
|
|
|
762
816
|
'disarm-os': 'Sentinel stopped an agent from turning off a security protection.',
|
|
763
817
|
'disarm-guard': 'Sentinel stopped an agent from turning off the guard.',
|
|
764
818
|
'disarm-hooks': 'Sentinel stopped an agent from turning off hooks.',
|
|
819
|
+
'exfil-block': 'Sentinel stopped an agent from sending secrets out.',
|
|
820
|
+
'destroy-block': 'Sentinel stopped an agent from running a destructive command.',
|
|
821
|
+
'disarm-block': 'Sentinel stopped an agent from turning off a protection.',
|
|
765
822
|
};
|
|
766
823
|
appendLine(path.join(dir, 'queue.jsonl'), {
|
|
767
824
|
kind: 'blocked', repo, severity: decision.severity || 'high',
|
|
@@ -861,6 +918,8 @@ function runSamples({ env = process.env, samples = SAMPLES } = {}) {
|
|
|
861
918
|
|
|
862
919
|
module.exports = {
|
|
863
920
|
GUARD_MARK,
|
|
921
|
+
RULES_FILE,
|
|
922
|
+
compileRules,
|
|
864
923
|
MATCHER,
|
|
865
924
|
decide,
|
|
866
925
|
hookMain,
|