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.
@@ -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
- // so it keeps working if the CLI moves, and the settings file is rewritten
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
 
@@ -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
- // Destinations a secret may go to without a block: local servers, and the
59
- // services those keys belong to. More hosts can be added in
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 DEFAULT_ALLOW_HOSTS = [
62
- 'localhost', '127.0.0.1', '0.0.0.0', '::1',
63
- 'api.github.com', 'github.com', 'uploads.github.com',
64
- 'api.openai.com', 'api.anthropic.com',
65
- 'registry.npmjs.org', 'pypi.org', 'upload.pypi.org',
66
- 'api.atris.ai', 'atris.ai',
67
- 'api.stripe.com', 'slack.com', 'api.vercel.com', 'api.render.com',
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) => SECRET_PARTS.has(p))) return true;
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; any other
330
- // option, attached or separate (a proxy, a resolve override, a config file),
331
- // blocks. Programs not listed here (nc, socat, scp, ssh, rsync, telnet and
332
- // the rest) are never allowed with a secret.
333
- const SAFE_NET = {
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 = SAFE_NET[c.name];
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) => NET_TOOLS.has(c.name));
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 (SECRET_FILES.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.');
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 (KEY_SHAPES.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.');
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) => DB_CLIENTS.has(path.basename(a)));
527
- if (cmds.some((c) => DB_CLIENTS.has(c.name) || viaRunner(c)) && DROP_TEXT.test(command)) return block('destroy-database', 'critical', 'blocked: this deletes a whole database.', 'An agent tried to drop a database. Nothing was deleted.');
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 (READ_ONLY.has(c.name)) return true;
580
- if (c.name === 'git') return READ_ONLY_GIT.has(gitSub(c.args).sub);
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
- function decide(event, { env = process.env, gitBranch = currentBranch } = {}) {
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 = [...DEFAULT_ALLOW_HOSTS, ...(Array.isArray(cfg.allow_hosts) ? cfg.allow_hosts.map((h) => String(h).toLowerCase()) : [])];
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,