atris 3.82.9 → 3.82.10
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/lib/sentinel-guard.js +185 -10
- package/lib/sentinel-improve.js +8 -2
- package/lib/sentinel-night.js +20 -3
- package/lib/sentinel-rules.js +15 -3
- package/lib/sentinel-rules.json +10 -1
- package/package.json +1 -1
package/lib/sentinel-guard.js
CHANGED
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
//
|
|
37
37
|
// Accepted limits: a determined program that hides its intent can still get
|
|
38
38
|
// past fixed rules. Encoded payloads, a script written to disk and then run,
|
|
39
|
-
// and
|
|
40
|
-
//
|
|
39
|
+
// and code piped into an interpreter are not read. Inline code like python -c
|
|
40
|
+
// or node -e is read only by command blocks in the rules file that ask for
|
|
41
|
+
// it (inline, needs_secret, needs_remote), and by the protected path check. The guard is a seatbelt, not a sandbox. Pair it with
|
|
41
42
|
// approval prompts for anything risky.
|
|
42
43
|
//
|
|
43
44
|
// The guard fails open: if it crashes or reads bad input it allows the call
|
|
@@ -135,8 +136,11 @@ function compileRules(raw) {
|
|
|
135
136
|
commandBlocks: (Array.isArray(g.command_blocks) ? g.command_blocks : []).map((b, i) => {
|
|
136
137
|
if (!b || !RULE_CATEGORIES.includes(b.category)) throw new Error(`rules: guard.command_blocks[${i}].category must be exfil, destroy or disarm`);
|
|
137
138
|
const all = list(b.all, `command_blocks[${i}].all`);
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
const needsSecret = b.needs_secret === true;
|
|
140
|
+
const needsRemote = b.needs_remote === true;
|
|
141
|
+
if (!all.length && !needsSecret && !needsRemote) throw new Error(`rules: guard.command_blocks[${i}].all needs at least one substring`);
|
|
142
|
+
const unless = b.unless === undefined ? [] : list(b.unless, `command_blocks[${i}].unless`);
|
|
143
|
+
return { id: String(b.id || i), category: b.category, programs: Array.isArray(b.programs) ? new Set(b.programs.map(String)) : null, all, inline: b.inline === true, needsSecret, needsRemote, unless };
|
|
140
144
|
}),
|
|
141
145
|
};
|
|
142
146
|
}
|
|
@@ -715,15 +719,186 @@ const BLOCK_REASON = {
|
|
|
715
719
|
// plain chains that pass nothing between pieces.
|
|
716
720
|
// Accepted limit: the guard judges one command at a time, so a script written by
|
|
717
721
|
// one command and run by a later, separate command is not connected by any rule.
|
|
722
|
+
//
|
|
723
|
+
// Four optional conditions narrow a block. All are fixed code here, plain
|
|
724
|
+
// string scans and URL parsing in linear time. Nothing from the rules file is
|
|
725
|
+
// ever compiled.
|
|
726
|
+
// inline the piece must run a program with inline code (python -c,
|
|
727
|
+
// node -e, --eval or -p, perl -e, ruby -e, php -r) and the
|
|
728
|
+
// block reads only that code. When the code itself expands a
|
|
729
|
+
// shell value ($ or a command substitution), the code could be
|
|
730
|
+
// anything, so the block reads the whole command instead.
|
|
731
|
+
// needs_secret the text must name a secret: a secret-looking variable
|
|
732
|
+
// ($GITHUB_TOKEN, os.environ['OPENAI_API_KEY'], os.getenv,
|
|
733
|
+
// process.env.X, ENV['X'], $ENV{X}), the whole environment
|
|
734
|
+
// (dict(os.environ), JSON.stringify(process.env)), a secrets
|
|
735
|
+
// file or a key-shaped string. For inline code, what is piped
|
|
736
|
+
// or redirected into that piece counts too.
|
|
737
|
+
// needs_remote the text must hold an http(s) address or a host:port whose
|
|
738
|
+
// host is not this machine and not on the trusted host list.
|
|
739
|
+
// For inline code it must also make a network call (urlopen,
|
|
740
|
+
// requests, fetch, Net::HTTP, LWP, socket and the like).
|
|
741
|
+
// unless literal substrings. If any is in the text, the block does
|
|
742
|
+
// not fire on it.
|
|
743
|
+
// A block with none of them behaves exactly as before.
|
|
744
|
+
// Accepted limit: PHP $_ENV['X'] access is not read as a secret name.
|
|
745
|
+
// Accepted limit: a connection to a bare host with no URL or host:port, like
|
|
746
|
+
// socket.connect(('badhost.net', 443)), is not read as a remote address.
|
|
718
747
|
const FEEDS_CODE = ['|', '<', '>', '`', '$', 'tee ', 'eval', 'source ', 'sh -c', 'bash -c', 'zsh -c'];
|
|
719
|
-
|
|
748
|
+
|
|
749
|
+
// Interpreter families and the options that carry inline code.
|
|
750
|
+
const INLINE_SPEC = {
|
|
751
|
+
python: { long: [], short: 'c', takesValue: ['-W', '-X', '-Q'] },
|
|
752
|
+
node: { long: ['--eval', '--print'], short: 'ep', takesValue: ['-r', '--require', '--import', '--loader', '--experimental-loader', '--input-type', '--title', '--env-file'] },
|
|
753
|
+
perl: { long: [], short: 'eE', takesValue: ['-I', '-M', '-m'] },
|
|
754
|
+
ruby: { long: [], short: 'e', takesValue: ['-I', '-r'] },
|
|
755
|
+
php: { long: [], short: 'r', takesValue: ['-d', '-c'] },
|
|
756
|
+
other: { long: ['--eval'], short: 'ce', takesValue: [] },
|
|
757
|
+
};
|
|
758
|
+
function inlineFamily(name) {
|
|
759
|
+
const n = String(name || '').toLowerCase();
|
|
760
|
+
if (/^(?:python|pypy)(?:\d+(?:\.\d+)?)?$/.test(n)) return 'python';
|
|
761
|
+
if (n === 'node' || n === 'nodejs' || n === 'bun') return 'node';
|
|
762
|
+
if (n === 'perl' || n === 'ruby' || n === 'php') return n;
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// The inline code arguments of one piece, or [] when it carries none. Stops
|
|
767
|
+
// at the first plain argument: that is a script file, and what follows it
|
|
768
|
+
// belongs to the script.
|
|
769
|
+
function inlineCode(c) {
|
|
770
|
+
const spec = INLINE_SPEC[inlineFamily(c.name) || 'other'];
|
|
771
|
+
const args = (c.args || []).map(String);
|
|
772
|
+
const out = [];
|
|
773
|
+
for (let k = 0; k < args.length; k++) {
|
|
774
|
+
const a = args[k];
|
|
775
|
+
if (a === '--' || a === '-' || !a.startsWith('-')) break;
|
|
776
|
+
if (a.startsWith('--')) {
|
|
777
|
+
const eq = a.indexOf('=');
|
|
778
|
+
const name = eq === -1 ? a : a.slice(0, eq);
|
|
779
|
+
if (spec.long.includes(name)) { if (eq !== -1) out.push(a.slice(eq + 1)); else if (k + 1 < args.length) out.push(args[++k]); continue; }
|
|
780
|
+
if (spec.takesValue.includes(name) && eq === -1) k += 1;
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
if (spec.takesValue.includes(a)) { k += 1; continue; }
|
|
784
|
+
const letters = a.slice(1);
|
|
785
|
+
// node -pe CODE: print and eval together, the code is the next word.
|
|
786
|
+
if (inlineFamily(c.name) === 'node' && (letters === 'pe' || letters === 'ep')) { if (k + 1 < args.length) out.push(args[++k]); continue; }
|
|
787
|
+
// Read like getopt: the first code letter takes the rest of the word as
|
|
788
|
+
// its code (python3 -c"CODE", perl -e'CODE', perl -ne'CODE'), or the next
|
|
789
|
+
// word when nothing follows it (-c CODE, perl -ne CODE).
|
|
790
|
+
const at = [...letters].findIndex((ch) => spec.short.includes(ch));
|
|
791
|
+
if (at === -1) continue;
|
|
792
|
+
if (at + 1 < letters.length) out.push(letters.slice(at + 1));
|
|
793
|
+
else if (k + 1 < args.length) out.push(args[++k]);
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Environment lookups by name in inline code. Hand-written, linear: one
|
|
799
|
+
// character class per run, no nesting.
|
|
800
|
+
const ENV_NAME_SHAPES = [
|
|
801
|
+
/(?:\benviron|\bENV|\$ENV|\benv)\s*[[{]\s*['"`]?([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
802
|
+
/\b(?:getenv|environ\.get|environ\.pop|environ\.setdefault|ENV\.fetch|env\.get)\s*\(\s*['"`]([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
803
|
+
/\b(?:process|Bun|meta)\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
804
|
+
];
|
|
805
|
+
// The whole environment at once.
|
|
806
|
+
const WHOLE_ENV = [
|
|
807
|
+
/\bos\.environ\b(?!\s*(?:\[|\.get\b|\.pop\b|\.setdefault\b))/,
|
|
808
|
+
/\bprocess\.env\b(?!\s*[.[])/,
|
|
809
|
+
/%ENV\b/,
|
|
810
|
+
/\bENV\.(?:to_h|to_a|each|map|inspect|keys|values)\b/,
|
|
811
|
+
/\bgetenv\(\s*\)/,
|
|
812
|
+
];
|
|
813
|
+
function namesSecret(text) {
|
|
814
|
+
const t = String(text || '');
|
|
815
|
+
if (hasSecretVar(t)) return true;
|
|
816
|
+
for (const re of ENV_NAME_SHAPES) {
|
|
817
|
+
re.lastIndex = 0;
|
|
818
|
+
let m;
|
|
819
|
+
while ((m = re.exec(t))) if (secretVarName(m[1])) return true;
|
|
820
|
+
}
|
|
821
|
+
if (WHOLE_ENV.some((re) => re.test(t))) return true;
|
|
822
|
+
if (R().secretFiles.some((re) => re.test(t))) return true;
|
|
823
|
+
return R().keyShapes.some((re) => { re.lastIndex = 0; return re.test(t); });
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// What flows into a piece from elsewhere: files redirected in and the
|
|
827
|
+
// earlier stages of its pipeline.
|
|
828
|
+
// earlier stages of its pipeline. Each pipeline is scanned once per command:
|
|
829
|
+
// `seen` keeps, per pipeline, whether a secret enters before each stage.
|
|
830
|
+
function inputNamesSecret(c, seen) {
|
|
831
|
+
if ((c.reads || []).some((f) => namesSecret(f))) return true;
|
|
832
|
+
const pipe = c.pipe || [];
|
|
833
|
+
let before = seen.get(pipe);
|
|
834
|
+
if (!before) {
|
|
835
|
+
before = [false];
|
|
836
|
+
for (const st of pipe) before.push(before[before.length - 1] || isEnvDump(st) || namesSecret((st.words || [st.name, ...(st.args || [])]).join(' ')));
|
|
837
|
+
seen.set(pipe, before);
|
|
838
|
+
}
|
|
839
|
+
return before[c.index || 0];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// A network call in inline code. needs_remote on inline code wants one of
|
|
843
|
+
// these next to the address, so printing a docs link is not a send.
|
|
844
|
+
const NET_CALLS = ['urlopen', 'requests', 'http.client', 'urllib', 'fetch', 'https.request', 'http.request', 'https.get', 'http.get', 'axios', 'net.connect', 'curl_exec', 'file_get_contents', 'Net::HTTP', 'LWP', 'socket'];
|
|
845
|
+
const callsNet = (text) => NET_CALLS.some((w) => text.includes(w));
|
|
846
|
+
|
|
847
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
|
|
848
|
+
const URL_SHAPE = /\bhttps?:\/\/[^\s'"`<>(){}\\,;|]+/gi;
|
|
849
|
+
const HOST_PORT = /^([A-Za-z0-9][A-Za-z0-9.-]*|\[[0-9A-Fa-f:.]+\]):(\d{1,5})(?:\/\S*)?$/;
|
|
850
|
+
function isRemote(host, allowHosts) {
|
|
851
|
+
const h = String(host || '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
852
|
+
if (!h || LOCAL_HOSTS.has(h)) return false;
|
|
853
|
+
return !hostAllowed(h, allowHosts);
|
|
854
|
+
}
|
|
855
|
+
function namesRemote(text, allowHosts) {
|
|
856
|
+
const t = String(text || '');
|
|
857
|
+
URL_SHAPE.lastIndex = 0;
|
|
858
|
+
let m;
|
|
859
|
+
while ((m = URL_SHAPE.exec(t))) {
|
|
860
|
+
let u;
|
|
861
|
+
try { u = new URL(m[0]); } catch { continue; }
|
|
862
|
+
if (isRemote(u.hostname, allowHosts)) return true;
|
|
863
|
+
}
|
|
864
|
+
for (const tok of t.split(/[\s'"`(),;{}<>=+\\|&]+/)) {
|
|
865
|
+
if (!tok || tok.length > 300 || tok.includes('://')) continue;
|
|
866
|
+
const hp = HOST_PORT.exec(tok);
|
|
867
|
+
if (!hp) continue;
|
|
868
|
+
const host = hp[1];
|
|
869
|
+
if (!host.startsWith('[') && !host.includes('.')) continue;
|
|
870
|
+
if (Number(hp[2]) > 65535) continue;
|
|
871
|
+
if (isRemote(host, allowHosts)) return true;
|
|
872
|
+
}
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function checkBlocks(command, cmds, { allowHosts = R().allowHosts } = {}) {
|
|
720
877
|
const whole = FEEDS_CODE.some((t) => command.includes(t));
|
|
878
|
+
const seen = new Map();
|
|
721
879
|
for (const b of R().commandBlocks) {
|
|
722
|
-
const texts = b.programs && !whole
|
|
723
|
-
? cmds.filter((c) => b.programs.has(c.name)).map((c) => (c.words || [c.name, ...(c.args || [])]).join(' '))
|
|
724
|
-
: [command];
|
|
725
880
|
if (b.programs && !cmds.some((c) => b.programs.has(c.name))) continue;
|
|
726
|
-
|
|
881
|
+
// Each entry: the text the block reads, and the piece it came from.
|
|
882
|
+
let texts;
|
|
883
|
+
if (b.inline) {
|
|
884
|
+
texts = [];
|
|
885
|
+
let wholeAdded = false;
|
|
886
|
+
for (const c of cmds) {
|
|
887
|
+
if (b.programs ? !b.programs.has(c.name) : !inlineFamily(c.name)) continue;
|
|
888
|
+
const code = inlineCode(c);
|
|
889
|
+
if (!code.length) continue;
|
|
890
|
+
const expands = code.some((x) => x.includes('$') || x.includes('`'));
|
|
891
|
+
// The whole command is read at most once, however many pieces expand.
|
|
892
|
+
if (expands) { if (!wholeAdded) texts.push({ text: command, piece: c }); wholeAdded = true; } else texts.push({ text: code.join('\n'), piece: c });
|
|
893
|
+
}
|
|
894
|
+
} else if (b.programs && !whole) {
|
|
895
|
+
texts = cmds.filter((c) => b.programs.has(c.name)).map((c) => ({ text: (c.words || [c.name, ...(c.args || [])]).join(' '), piece: null }));
|
|
896
|
+
} else texts = [{ text: command, piece: null }];
|
|
897
|
+
const fires = texts.some(({ text, piece }) => b.all.every((sub) => text.includes(sub))
|
|
898
|
+
&& !b.unless.some((sub) => text.includes(sub))
|
|
899
|
+
&& (!b.needsSecret || namesSecret(text) || (piece && inputNamesSecret(piece, seen)))
|
|
900
|
+
&& (!b.needsRemote || ((!b.inline || callsNet(text)) && namesRemote(text, allowHosts))));
|
|
901
|
+
if (!fires) continue;
|
|
727
902
|
const [reason, detail] = BLOCK_REASON[b.category];
|
|
728
903
|
return block(`${b.category}-block`, b.category === 'disarm' ? 'high' : 'critical', reason, detail);
|
|
729
904
|
}
|
|
@@ -750,7 +925,7 @@ function decideWith(event, { env, gitBranch }) {
|
|
|
750
925
|
return checkExfil(command, cmds, { allowHosts })
|
|
751
926
|
|| checkDestroy(command, cmds, { cwd, homeDirPath: homeDir(env), gitBranch })
|
|
752
927
|
|| checkDisarm(command, cmds, { env, cwd })
|
|
753
|
-
|| checkBlocks(command, cmds)
|
|
928
|
+
|| checkBlocks(command, cmds, { allowHosts })
|
|
754
929
|
|| { allow: true };
|
|
755
930
|
}
|
|
756
931
|
if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return checkEdit(tool, input, { env, cwd }) || { allow: true };
|
package/lib/sentinel-improve.js
CHANGED
|
@@ -327,8 +327,14 @@ function buildPrompt({ report, signals, seenDirRel, focus = null, tonight = [] }
|
|
|
327
327
|
'You may change exactly one file: lib/sentinel-rules.json, the literal data the guard and the agent check read.',
|
|
328
328
|
'Read lib/sentinel-guard.js and lib/sentinel-agents.js to see how each key is used. You cannot change code. You cannot add a regular expression.',
|
|
329
329
|
' Add or remove literal entries only: a host in guard.allow_hosts, a program in guard.net_tools or guard.db_clients, a secret word in guard.secret_parts, an option name in guard.safe_net, a phrase in agents.override_substrings, an env name in agents.secret_env_names.',
|
|
330
|
-
' guard.command_blocks is a list of {"id", "category": "exfil|destroy|disarm", "all": ["literal substring", ...], "programs"?: ["python3", ...]}.',
|
|
330
|
+
' guard.command_blocks is a list of {"id", "category": "exfil|destroy|disarm", "all": ["literal substring", ...], "programs"?: ["python3", ...], "inline"?: true, "needs_secret"?: true, "needs_remote"?: true, "unless"?: ["literal substring", ...]}.',
|
|
331
331
|
' a block fires when the command runs one of its programs (if listed) and contains every literal substring in all. The guard checks each with a plain includes. Write exact text, never a regular expression. For an either-or, add more blocks.',
|
|
332
|
+
' four optional conditions narrow a block, each checked by fixed code in the guard:',
|
|
333
|
+
' inline: true reads only the inline code of a program piece (python -c, node -e, --eval or -p, perl -e, ruby -e, php -r). A piece without inline code never matches. With no programs listed, any of those interpreters counts.',
|
|
334
|
+
' needs_secret: true also requires a secret in that text: a secret-looking variable ($GITHUB_TOKEN, os.environ[\'OPENAI_API_KEY\'], os.getenv, process.env.X, ENV[\'X\']), the whole environment, a secrets file or a key-shaped string. Names like HOME, PATH, NODE_ENV or NO_PROXY are not secrets. For inline code, what is piped or redirected into it counts too.',
|
|
335
|
+
' needs_remote: true also requires an http(s) address or host:port whose host is not this machine and not in guard.allow_hosts. With inline, the code must also make a network call (urlopen, requests, fetch, Net::HTTP, LWP, socket and similar).',
|
|
336
|
+
' unless: literal substrings. If any appears in the text, the block does not fire.',
|
|
337
|
+
' all may be empty only when needs_secret or needs_remote is true.',
|
|
332
338
|
`The seen exam items are in ${seenDirRel}/guard.jsonl and ${seenDirRel}/agents.jsonl. Values like {{fake:github}} become fake keys at load time.`,
|
|
333
339
|
'',
|
|
334
340
|
'A deterministic exam judges your change, not you. It keeps the change only if:',
|
|
@@ -339,7 +345,7 @@ function buildPrompt({ report, signals, seenDirRel, focus = null, tonight = [] }
|
|
|
339
345
|
'The new items you add are scored and reported but never count toward keeping this change.',
|
|
340
346
|
'So fix a general weakness, not one string. A rule that only matches the seen item will not move the hidden half.',
|
|
341
347
|
'',
|
|
342
|
-
'
|
|
348
|
+
'For one-liners run through an interpreter (python -c, node -e and similar), plain words cannot tell reading a secret and sending it apart from printing a variable next to a web address. Reviewers rejected such rules for blocking everyday commands. Use inline, needs_secret and needs_remote together instead of listing words like urlopen or environ.',
|
|
343
349
|
'Before proposing a command_block, picture five everyday developer commands that contain the same literal substrings. If any of them would be blocked, the rule is too broad.',
|
|
344
350
|
'',
|
|
345
351
|
...(focus ? [`Focus: ${focus}, ${FOCUS_WORDS[focus]}. Other proposers cover the other areas tonight. Propose a change for this area only.`, ''] : []),
|
package/lib/sentinel-night.js
CHANGED
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
// clean agent item. Each is tagged source "review", checked
|
|
21
21
|
// for anything that looks like a real secret and deduplicated.
|
|
22
22
|
// They go on their own branch, sentinel-exam/<date>
|
|
23
|
+
// (sentinel-exam/<date>-<short id> on the remote when an earlier
|
|
24
|
+
// run that day already pushed that name)
|
|
23
25
|
// land the union of the shipped changes is judged again on the grown
|
|
24
26
|
// exam (and reviewed when it holds more than one change). If it
|
|
25
27
|
// fails, the best single shipped change by held-out gain, then
|
|
@@ -301,7 +303,7 @@ function reviewPrompt({ title, why, diff, literal, seen }) {
|
|
|
301
303
|
'The guard blocks a command when it matches the rules. A false alarm on an everyday developer command is the worst outcome: people turn the guard off.',
|
|
302
304
|
'',
|
|
303
305
|
'How the rules are used:',
|
|
304
|
-
' guard.command_blocks: {"id", "category", "all": [literal substrings], "programs"?: [program names]}. A block fires when the command runs one of its programs (if listed) and contains every substring in all, by a plain includes.',
|
|
306
|
+
' guard.command_blocks: {"id", "category", "all": [literal substrings], "programs"?: [program names], "inline"?, "needs_secret"?, "needs_remote"?, "unless"?: [literal substrings]}. A block fires when the command runs one of its programs (if listed) and contains every substring in all, by a plain includes. inline: true reads only the inline code of python -c, node -e, perl -e, ruby -e or php -r. needs_secret: true also needs a secret-looking variable, the whole environment, a secrets file or a key-shaped string in that text. needs_remote: true also needs an http(s) or host:port address that is not this machine and not in allow_hosts. unless: any of these substrings stops the block.',
|
|
305
307
|
' guard.allow_hosts: hosts a network tool may reach. guard.net_tools and guard.db_clients: programs treated as network or database clients. guard.secret_parts: words that mark a variable as a secret. guard.safe_net: options of curl, wget and http that never send data.',
|
|
306
308
|
' agents.override_substrings: phrases in agent instruction files flagged as an instruction override. agents.secret_env_names: env names flagged when an agent config sets them.',
|
|
307
309
|
'',
|
|
@@ -497,6 +499,17 @@ function branchExists(root, name, t) {
|
|
|
497
499
|
return imp.git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { check: false, timeoutMs: t }).status === 0;
|
|
498
500
|
}
|
|
499
501
|
|
|
502
|
+
// The name a night branch takes on the remote. Branch names carry only the
|
|
503
|
+
// date, so an earlier run that day may already have pushed the same name.
|
|
504
|
+
// Then this run pushes under name-<short id> instead of failing the push.
|
|
505
|
+
// When the remote cannot be asked, the plain name is kept and the push
|
|
506
|
+
// reports its own error.
|
|
507
|
+
function remoteBranchName(root, name, shortId, { env, timeoutMs } = {}) {
|
|
508
|
+
let r;
|
|
509
|
+
try { r = imp.git(root, ['ls-remote', '--exit-code', '--heads', 'origin', `refs/heads/${name}`], { check: false, timeoutMs, env }); } catch { return name; }
|
|
510
|
+
return r.status === 0 ? `${name}-${shortId}` : name;
|
|
511
|
+
}
|
|
512
|
+
|
|
500
513
|
function freeBranch(root, name, t) {
|
|
501
514
|
if (!branchExists(root, name, t)) return name;
|
|
502
515
|
for (let n = 2; n < 100; n++) if (!branchExists(root, `${name}-${n}`, t)) return `${name}-${n}`;
|
|
@@ -858,8 +871,11 @@ async function night(opts = {}) {
|
|
|
858
871
|
if (opts.push) {
|
|
859
872
|
for (const b of [run.night_branch, run.exam_branch].filter(Boolean)) {
|
|
860
873
|
let r;
|
|
861
|
-
|
|
862
|
-
|
|
874
|
+
const gitEnv = { ...env, GIT_TERMINAL_PROMPT: '0' };
|
|
875
|
+
const dest = remoteBranchName(root, b, id.slice(-4), { env: gitEnv, timeoutMs: PUSH_MS });
|
|
876
|
+
if (dest !== b) log(`${b} is already on the remote, pushing as ${dest}`);
|
|
877
|
+
try { r = imp.git(root, ['push', 'origin', `refs/heads/${b}:refs/heads/${dest}`], { check: false, timeoutMs: PUSH_MS, env: gitEnv }); } catch (e) { r = { status: 1, stderr: String(e.message || e) }; }
|
|
878
|
+
if (r.status === 0) run.pushed.push(dest);
|
|
863
879
|
else log(`could not push ${b}: ${String(r.stderr || '').trim().split('\n').slice(-1)[0]}`);
|
|
864
880
|
}
|
|
865
881
|
}
|
|
@@ -884,6 +900,7 @@ module.exports = {
|
|
|
884
900
|
lockPath,
|
|
885
901
|
night,
|
|
886
902
|
parseReview,
|
|
903
|
+
remoteBranchName,
|
|
887
904
|
reviewItems,
|
|
888
905
|
reviewPrompt,
|
|
889
906
|
scrubReceipt,
|
package/lib/sentinel-rules.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// word, an option name or a command substring. The guard matches them
|
|
13
13
|
// with plain string operations. There is no pattern to compile and
|
|
14
14
|
// nothing that can stall on long input
|
|
15
|
+
// a command block may add four plain conditions, each read by fixed code
|
|
16
|
+
// in the guard: inline (true or false), needs_secret (true or false),
|
|
17
|
+
// needs_remote (true or false) and unless (a list of literal substrings)
|
|
15
18
|
//
|
|
16
19
|
// validateRules() returns a list of plain problems. Empty means valid.
|
|
17
20
|
|
|
@@ -21,7 +24,9 @@ const MAX_ITEM = 200;
|
|
|
21
24
|
const GUARD_KEYS = ['allow_hosts', 'net_tools', 'secret_parts', 'safe_net', 'safe_net_aliases', 'read_only', 'read_only_git', 'db_clients', 'command_blocks'];
|
|
22
25
|
const AGENT_KEYS = ['override_substrings', 'secret_env_names'];
|
|
23
26
|
const SAFE_NET_KEYS = ['flags', 'values', 'local_values', 'short_flags', 'short_values', 'items'];
|
|
24
|
-
const BLOCK_KEYS = ['id', 'category', 'all', 'programs'];
|
|
27
|
+
const BLOCK_KEYS = ['id', 'category', 'all', 'programs', 'inline', 'needs_secret', 'needs_remote', 'unless'];
|
|
28
|
+
const BLOCK_OPTIONAL = ['programs', 'inline', 'needs_secret', 'needs_remote', 'unless'];
|
|
29
|
+
const BLOCK_FLAGS = ['inline', 'needs_secret', 'needs_remote'];
|
|
25
30
|
|
|
26
31
|
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
27
32
|
|
|
@@ -85,12 +90,19 @@ function validateRules(rules) {
|
|
|
85
90
|
const where = `guard.command_blocks[${i}]`;
|
|
86
91
|
if (!isObj(b)) { problems.push(`${where} must be an object`); return; }
|
|
87
92
|
noPatternField(b, where, problems);
|
|
88
|
-
exactKeys(b, BLOCK_KEYS, where, problems,
|
|
93
|
+
exactKeys(b, BLOCK_KEYS, where, problems, BLOCK_OPTIONAL);
|
|
89
94
|
if (typeof b.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(b.id)) problems.push(`${where}.id must be short lowercase words`);
|
|
90
95
|
if (!['exfil', 'destroy', 'disarm'].includes(b.category)) problems.push(`${where}.category must be exfil, destroy or disarm`);
|
|
96
|
+
for (const k of BLOCK_FLAGS) if (k in b && typeof b[k] !== 'boolean') problems.push(`${where}.${k} must be true or false`);
|
|
91
97
|
strList(b.all, `${where}.all`, problems);
|
|
92
|
-
|
|
98
|
+
// An empty all is allowed only when a secret or an outside address
|
|
99
|
+
// must also be present, so a block can never fire on a program alone.
|
|
100
|
+
if (Array.isArray(b.all) && !b.all.length && b.needs_secret !== true && b.needs_remote !== true) problems.push(`${where}.all needs at least one substring, unless needs_secret or needs_remote is true`);
|
|
93
101
|
if ('programs' in b) strList(b.programs, `${where}.programs`, problems, /^[A-Za-z0-9._+-]{1,40}$/);
|
|
102
|
+
if ('unless' in b) {
|
|
103
|
+
strList(b.unless, `${where}.unless`, problems);
|
|
104
|
+
if (Array.isArray(b.unless) && b.unless.length > 30) problems.push(`${where}.unless has more than 30 items`);
|
|
105
|
+
}
|
|
94
106
|
});
|
|
95
107
|
}
|
|
96
108
|
}
|
package/lib/sentinel-rules.json
CHANGED
|
@@ -228,7 +228,16 @@
|
|
|
228
228
|
"neonctl",
|
|
229
229
|
"cqlsh"
|
|
230
230
|
],
|
|
231
|
-
"command_blocks": [
|
|
231
|
+
"command_blocks": [
|
|
232
|
+
{
|
|
233
|
+
"id": "inline-secret-out",
|
|
234
|
+
"category": "exfil",
|
|
235
|
+
"all": [],
|
|
236
|
+
"inline": true,
|
|
237
|
+
"needs_secret": true,
|
|
238
|
+
"needs_remote": true
|
|
239
|
+
}
|
|
240
|
+
]
|
|
232
241
|
},
|
|
233
242
|
"agents": {
|
|
234
243
|
"override_substrings": [],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atris",
|
|
3
|
-
"version": "3.82.
|
|
3
|
+
"version": "3.82.10",
|
|
4
4
|
"description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
|
|
5
5
|
"mcpName": "io.github.atrislabs/atris",
|
|
6
6
|
"main": "bin/atris.js",
|