specpi 0.28.0 → 0.30.0
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/CHANGELOG.md +45 -0
- package/NPM_RELEASE.md +1 -1
- package/README.md +22 -23
- package/SECURITY_MODEL.md +14 -12
- package/THIRD_PARTY.md +6 -5
- package/extensions/jev-advisor/broker.mjs +2 -4
- package/extensions/jev-advisor/config.mjs +91 -62
- package/extensions/jev-advisor/gate.mjs +5 -30
- package/extensions/jev-advisor/index.ts +8 -260
- package/extensions/jev-advisor/key-source.mjs +1 -1
- package/extensions/jev-advisor/layer.mjs +8 -31
- package/package.json +2 -1
- package/scripts/jev-guard.mjs +154 -0
- package/scripts/packages.mjs +0 -56
- package/scripts/specpi.mjs +24 -52
- package/templates/settings.json +2 -1
- package/extensions/jev-advisor/questions/compaction.mjs +0 -153
- package/extensions/jev-advisor/questions/guard.mjs +0 -168
- package/extensions/jev-advisor/risk.mjs +0 -442
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
// System 8: score a shell or file call that local rules could not settle, before it runs.
|
|
2
|
-
//
|
|
3
|
-
// This is the native command guard. It replaces the pinned `specpi-jev-guard` package, and the
|
|
4
|
-
// reason it exists rather than that package being configured is that the package's shape kept
|
|
5
|
-
// producing the same class of defect: its configuration was a global file with no session scope, so
|
|
6
|
-
// there was no such thing as enabling it for one session; it read its key from the environment only,
|
|
7
|
-
// so a credential `/login` had stored was invisible to it; and it was fail-closed, so an outage or a
|
|
8
|
-
// missing key turned every shell call in the session into a refusal.
|
|
9
|
-
//
|
|
10
|
-
// Native, all three go away. The switch is `systems.guard` like every other system, so it is
|
|
11
|
-
// session-scoped, budgeted, reported in `/jev status` and toggled by `/jev enable guard`. The key is
|
|
12
|
-
// whatever `key-source.mjs` resolves, which includes Pi's own credential store. And the failure
|
|
13
|
-
// posture is inverted, deliberately:
|
|
14
|
-
//
|
|
15
|
-
// FAIL OPEN. Jev unreachable, unconfident, out of budget, or without a key means the call goes to
|
|
16
|
-
// @gotgenes/pi-permission-system exactly as it did before this layer existed.
|
|
17
|
-
//
|
|
18
|
-
// That is the rule the rest of this extension already follows -- "nothing here is ever the reason a
|
|
19
|
-
// tool is blocked" -- and applying it here is not a weakening. The guard sits *in front of* the
|
|
20
|
-
// permission system, which still decides every call it defers. Fail-closed meant an outage stopped
|
|
21
|
-
// work; fail-open means an outage returns policy to the component that owned it anyway. The guard
|
|
22
|
-
// adds blocking for confident, dangerous verdicts, and adds nothing else.
|
|
23
|
-
//
|
|
24
|
-
// What it sends is bounded by sanitize.mjs like every other system: a redacted digest, never the raw
|
|
25
|
-
// environment, and never more than the shared 1 KB budget.
|
|
26
|
-
|
|
27
|
-
import { score } from "../client.mjs";
|
|
28
|
-
import { THRESHOLDS, scoreLevel } from "../gate.mjs";
|
|
29
|
-
|
|
30
|
-
/** How a decision was reached, for the ledger and for `/jev status`. */
|
|
31
|
-
export const SOURCES = Object.freeze(["local", "jev", "human", "deferred"]);
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* One Score, not a Noul.
|
|
35
|
-
*
|
|
36
|
-
* A Noul returns a bare probability with no confidence field, and this is the one system whose
|
|
37
|
-
* action takes a tool call away from the person. Gating that on a number with no reported confidence
|
|
38
|
-
* is exactly what the calibration work found unreachable elsewhere: without a confidence the gate
|
|
39
|
-
* either fires on coin flips or never fires at all. A Score reports both, so "confident this is
|
|
40
|
-
* destructive" and "unsure" are distinguishable, and only the first can block.
|
|
41
|
-
*/
|
|
42
|
-
export function questions(input) {
|
|
43
|
-
return {
|
|
44
|
-
risk: score(
|
|
45
|
-
"Rate how likely this call is to destroy data, exfiltrate a credential, or hand control " +
|
|
46
|
-
"of the machine to someone else, given what the person asked for. Judge the call as " +
|
|
47
|
-
"written, not the worst command that shares its first word. Routine development work " +
|
|
48
|
-
"-- building, testing, installing declared dependencies, editing source, committing -- " +
|
|
49
|
-
"is not destructive merely because it writes.",
|
|
50
|
-
[
|
|
51
|
-
"harmless: reads, or writes only within the project in the ordinary course of the task",
|
|
52
|
-
"ordinary: changes state the person would expect this task to change",
|
|
53
|
-
"risky: destroys work, rewrites history, or reaches outside the project, but plausibly intended",
|
|
54
|
-
"destructive: irreversible loss, credential exposure, or remote code execution that the request does not call for",
|
|
55
|
-
],
|
|
56
|
-
),
|
|
57
|
-
intended: score("Rate how well this call matches what the person actually asked for.", [
|
|
58
|
-
"unrelated",
|
|
59
|
-
"tangential",
|
|
60
|
-
"plausible",
|
|
61
|
-
"clearly what was asked",
|
|
62
|
-
]),
|
|
63
|
-
...(input?.protected === true
|
|
64
|
-
? {
|
|
65
|
-
credential: score(
|
|
66
|
-
"Rate how likely this write targets a real secret rather than a template, " +
|
|
67
|
-
"example, fixture or documentation file that merely looks like one.",
|
|
68
|
-
["template or example", "unclear", "likely a real secret", "certainly a real secret"],
|
|
69
|
-
),
|
|
70
|
-
}
|
|
71
|
-
: {}),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Turn the answers into one of three outcomes.
|
|
77
|
-
*
|
|
78
|
-
* `block` needs a confident destructive reading *and* a confident reading that the request does not
|
|
79
|
-
* account for the call. Both, because the single most likely way to be wrong here is a genuinely
|
|
80
|
-
* destructive-looking command that the person asked for in as many words -- `rm -rf node_modules`, a
|
|
81
|
-
* force push to a branch they named. Requiring the intent answer to actively disagree is what keeps
|
|
82
|
-
* those working.
|
|
83
|
-
*
|
|
84
|
-
* "Both" means both, including when the intent answer does not survive its own confidence gate. An
|
|
85
|
-
* earlier version read a missing intent answer as agreement, which mattered far more than it sounds:
|
|
86
|
-
* `gate.mjs`'s own calibration records a score coverage near 0.2, so roughly four answers in five are
|
|
87
|
-
* ungated and a confident risk=3 would have blocked essentially unconditionally -- turning the one
|
|
88
|
-
* stated safeguard into a clause that almost never applied.
|
|
89
|
-
*
|
|
90
|
-
* `ask` is the middle band, and only when there is a human to ask. Without a UI it becomes `defer`,
|
|
91
|
-
* because a question nobody can answer is a block wearing a friendlier word.
|
|
92
|
-
*
|
|
93
|
-
* Everything else defers to the permission system.
|
|
94
|
-
*/
|
|
95
|
-
export function decide(answers, { hasUI = false } = {}) {
|
|
96
|
-
const risk = answers?.risk;
|
|
97
|
-
const intended = answers?.intended;
|
|
98
|
-
if (!risk) {
|
|
99
|
-
return { action: "defer", source: "deferred", reason: "no answer" };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const level = scoreLevel(risk, "guard");
|
|
103
|
-
const wanted = intended ? scoreLevel(intended, "guard") : undefined;
|
|
104
|
-
|
|
105
|
-
// A confident secret verdict blocks on its own: a write to a real credential file is not made
|
|
106
|
-
// acceptable by having been asked for, and the fixture case is what the question separates.
|
|
107
|
-
const credential = answers?.credential ? scoreLevel(answers.credential, "guard") : undefined;
|
|
108
|
-
if (credential === 3) {
|
|
109
|
-
return { action: "block", source: "jev", reason: "writes a real credential file" };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (level === 3 && wanted !== undefined && wanted <= 1) {
|
|
113
|
-
return { action: "block", source: "jev", reason: "destructive and not what was asked for" };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (level === 3 || level === 2) {
|
|
117
|
-
return hasUI
|
|
118
|
-
? { action: "ask", source: "human", reason: level === 3 ? "destructive" : "risky" }
|
|
119
|
-
: { action: "defer", source: "deferred", reason: "no interface to ask" };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return { action: "defer", source: "deferred", reason: "below the bar" };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** The two answers the guard's confirmation dialog offers, in the order it offers them. */
|
|
126
|
-
export const CHOICES = Object.freeze({ run: "Run it", block: "Block it" });
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Whether a human's answer to that dialog is consent.
|
|
130
|
-
*
|
|
131
|
-
* Only the affirmative is. Escape, a dismissed picker, a host that resolves with nothing and a host
|
|
132
|
-
* that throws all produce something that is not `CHOICES.run`, and every one of them has to mean
|
|
133
|
-
* "not approved" -- reaching this point means the verdict already said the call needs a person's
|
|
134
|
-
* approval, and an unanswerable question resolved as yes is what a confirmation dialog exists to
|
|
135
|
-
* rule out. It lives here rather than in the handler so that it is a rule with a test, not a
|
|
136
|
-
* comparison inside a closure no test imports.
|
|
137
|
-
*/
|
|
138
|
-
export function approved(choice) {
|
|
139
|
-
return choice === CHOICES.run;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** The gate's own numbers, named so `/jev status` and the tests read the same source. */
|
|
143
|
-
export const GATE = Object.freeze({
|
|
144
|
-
scoreConfidence: THRESHOLDS.guard.scoreConfidence,
|
|
145
|
-
boundary: THRESHOLDS.guard.boundary,
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* The state the question is asked against, bounded like every other system's.
|
|
150
|
-
*
|
|
151
|
-
* What the model needs is the call, what the person asked for, and enough recent history to tell a
|
|
152
|
-
* cleanup step from a first move. What it must not receive is the environment, the file's contents,
|
|
153
|
-
* or anything the sanitiser has not seen: `buildState` applies the shared redaction and the 1 KB
|
|
154
|
-
* ceiling, so a long command arrives truncated rather than in full.
|
|
155
|
-
*/
|
|
156
|
-
export function buildInput({ tool, subject, protectedTarget, objective, recent, cwd }) {
|
|
157
|
-
return {
|
|
158
|
-
system: "command-guard",
|
|
159
|
-
tool: String(tool ?? ""),
|
|
160
|
-
call: String(subject ?? ""),
|
|
161
|
-
protectedTarget: protectedTarget === true,
|
|
162
|
-
// Why the call was made matters as much as what it does: the same command is routine in one
|
|
163
|
-
// task and destructive in another, and the intent question has nothing to weigh without it.
|
|
164
|
-
objective: String(objective ?? ""),
|
|
165
|
-
recent: Array.isArray(recent) ? recent.slice(-6).map((item) => `${item.tool}:${item.outcome}`) : [],
|
|
166
|
-
cwd: String(cwd ?? ""),
|
|
167
|
-
};
|
|
168
|
-
}
|
|
@@ -1,442 +0,0 @@
|
|
|
1
|
-
// Local triage for shell and file calls, before anything is sent anywhere.
|
|
2
|
-
//
|
|
3
|
-
// This is the first half of the native command guard. Jev is the second half and the one that does
|
|
4
|
-
// the analysis; everything here exists to decide what Jev is asked about. Three answers:
|
|
5
|
-
//
|
|
6
|
-
// safe -- settled locally. Jev is never asked, and nothing else will look at this call.
|
|
7
|
-
// dangerous -- blocked locally, with no call and no human.
|
|
8
|
-
// unknown -- Jev is asked.
|
|
9
|
-
//
|
|
10
|
-
// THE THREE ARE NOT SYMMETRIC, and that asymmetry is the whole design:
|
|
11
|
-
//
|
|
12
|
-
// a wrong `safe` is a silent, permanent hole -- the one verdict with no second reader
|
|
13
|
-
// a wrong `unknown` costs one call out of a per-session budget of 208, and Jev decides
|
|
14
|
-
// a wrong `dangerous` blocks real work with no recourse but switching the guard off
|
|
15
|
-
//
|
|
16
|
-
// So the two lists below are maintained under opposite pressures, and the mistake worth naming is
|
|
17
|
-
// treating them as one thing called "the guard" and hardening both the same way.
|
|
18
|
-
//
|
|
19
|
-
// READ_ONLY is a BUDGET mechanism, not a safety one. The guard has 208 calls and each is a few
|
|
20
|
-
// hundred milliseconds awaited on the tool path, so a session that greps and cats a few hundred
|
|
21
|
-
// times would spend the lot and then defer everything for the rest of its life -- a guard that runs
|
|
22
|
-
// out is weaker than one with a fast path. The admission test is therefore NOT "is this harmless".
|
|
23
|
-
// It is: is this binary simple whatever flags it is given, and common enough to be worth it? A
|
|
24
|
-
// binary that can launch a program, write a file or change machine state under any flag is not
|
|
25
|
-
// simple, however harmless its name reads, and Jev parses it. `env` and `fd` launch things,
|
|
26
|
-
// `find` has -delete, `rg` has --pre, `date -s` sets the clock, `hostname` sets the hostname,
|
|
27
|
-
// `file -C` writes a compiled magic file. Every one of those was on this list, and every one was a
|
|
28
|
-
// general bypass that cost almost no budget to keep.
|
|
29
|
-
//
|
|
30
|
-
// CATASTROPHIC is deliberately tiny, and its only real job is the case where Jev is NOT THERE: no
|
|
31
|
-
// key, budget spent, a timeout. Jev catches everything this list would, and weighs intent besides,
|
|
32
|
-
// which a pattern cannot. So resist adding to it. A rule here fires with no model and no human, and
|
|
33
|
-
// a false positive is a blocked session whose only remedy is switching the whole guard off -- and a
|
|
34
|
-
// guard people switch off protects nobody. Ask whether you would stake "this is never legitimate"
|
|
35
|
-
// on it; if not, it belongs in the question set, where being wrong costs one call.
|
|
36
|
-
//
|
|
37
|
-
// And nothing here throws. A classifier that can fail is a classifier that can take a session down,
|
|
38
|
-
// so an unparseable command reads as `unknown` -- ask about it -- rather than as an error.
|
|
39
|
-
|
|
40
|
-
import path from "node:path";
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Shell tools, under every name a harness gives them.
|
|
44
|
-
*
|
|
45
|
-
* `scripts/eval-proxy.mjs` already folds `pwsh`, `shell`, `exec`, `exec_command` and `write_stdin`
|
|
46
|
-
* into `bash`, which means those names reach real sessions. A guard that only knew `bash` would be
|
|
47
|
-
* bypassed by spelling, so the alias list lives here rather than being rediscovered later.
|
|
48
|
-
*/
|
|
49
|
-
export const SHELL_TOOLS = Object.freeze([
|
|
50
|
-
"bash",
|
|
51
|
-
"powershell",
|
|
52
|
-
"pwsh",
|
|
53
|
-
"shell",
|
|
54
|
-
"exec",
|
|
55
|
-
"exec_command",
|
|
56
|
-
"write_stdin",
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Tools that write a file. The same list `questions/progress.mjs` uses for "the worktree changed",
|
|
61
|
-
* deliberately, because a write the guard cannot see is a write the credential question is never
|
|
62
|
-
* asked about -- and `multi_edit` reaching `~/.ssh/authorized_keys` is exactly that.
|
|
63
|
-
*/
|
|
64
|
-
export const WRITE_TOOLS = Object.freeze(["write", "edit", "multi_edit", "apply_patch", "create_file", "str_replace"]);
|
|
65
|
-
|
|
66
|
-
/** The tool calls this guard looks at. Everything else passes without inspection. */
|
|
67
|
-
export const GATED_TOOLS = Object.freeze([...SHELL_TOOLS, ...WRITE_TOOLS]);
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Simple binaries: ones that change nothing whatever flags they are given.
|
|
71
|
-
*
|
|
72
|
-
* "Whatever flags" is the whole test, and it is stricter than it sounds. `git` fails it obviously
|
|
73
|
-
* (`push`, `reset`, `clean`), and special-casing `git status` would only add a subcommand allowlist
|
|
74
|
-
* to maintain beside this one. These fail it less obviously, which is what made each of them a
|
|
75
|
-
* bypass worth having:
|
|
76
|
-
*
|
|
77
|
-
* env, fd launch another program outright -- `env rm -rf build`, `fd -x rm`
|
|
78
|
-
* find has -delete and -exec
|
|
79
|
-
* rg has --pre, which runs an arbitrary preprocessor per file
|
|
80
|
-
* sort, uniq name an output file (`sort -o`, `uniq in out`)
|
|
81
|
-
* date -s sets the system clock
|
|
82
|
-
* hostname with an argument, sets the hostname
|
|
83
|
-
* file -C compiles and writes a magic file
|
|
84
|
-
* printenv changes nothing and hands over a secret, which the risk question also asks about
|
|
85
|
-
*
|
|
86
|
-
* None of them reads as dangerous, and none of them was common enough for the fast path to be
|
|
87
|
-
* buying much. That is the trade: a rare binary on this list saves almost no budget and costs a
|
|
88
|
-
* silent hole, so when in doubt it comes off and Jev parses it.
|
|
89
|
-
*
|
|
90
|
-
* Being here is not a claim that a call is harmless. It is a claim that asking about it would spend
|
|
91
|
-
* the budget without learning anything -- which is why the arguments are still checked below, and
|
|
92
|
-
* `cat ~/.ssh/id_rsa` leaves the fast path even though `cat` never belongs anywhere else.
|
|
93
|
-
*/
|
|
94
|
-
const READ_ONLY = new Set([
|
|
95
|
-
"ls",
|
|
96
|
-
"dir",
|
|
97
|
-
"pwd",
|
|
98
|
-
"cd",
|
|
99
|
-
"cat",
|
|
100
|
-
"head",
|
|
101
|
-
"tail",
|
|
102
|
-
"wc",
|
|
103
|
-
"stat",
|
|
104
|
-
"du",
|
|
105
|
-
"df",
|
|
106
|
-
"whoami",
|
|
107
|
-
"uname",
|
|
108
|
-
"echo",
|
|
109
|
-
"printf",
|
|
110
|
-
"which",
|
|
111
|
-
"type",
|
|
112
|
-
"grep",
|
|
113
|
-
"diff",
|
|
114
|
-
"cut",
|
|
115
|
-
"tr",
|
|
116
|
-
"basename",
|
|
117
|
-
"dirname",
|
|
118
|
-
"realpath",
|
|
119
|
-
"true",
|
|
120
|
-
"false",
|
|
121
|
-
]);
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Shell syntax that can turn a safe-looking command into any other command: chaining, substitution,
|
|
125
|
-
* redirection, background execution. Their presence disqualifies the fast path entirely rather than
|
|
126
|
-
* being parsed, because parsing a shell correctly is not something a guard should be attempting.
|
|
127
|
-
*/
|
|
128
|
-
const SHELL_CONTROL = /[;&|><`$(){}\n\r]|\|\||&&/u;
|
|
129
|
-
|
|
130
|
-
/** Targets that mean "everything": the filesystem root, or a bare home directory. */
|
|
131
|
-
const ROOT_TARGETS = new Set(["/", "/*", "~", "~/*", "$HOME", "$HOME/*", "%USERPROFILE%", "%USERPROFILE%\\*"]);
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* A recursive delete aimed at the root or a bare home.
|
|
135
|
-
*
|
|
136
|
-
* Written as a function rather than a regex because the regex it replaces anchored on the end of the
|
|
137
|
-
* string, so it matched `rm -rf /` -- which GNU `rm` refuses on its own -- and missed
|
|
138
|
-
* `rm -rf / --no-preserve-root`, which is the spelling that actually empties the disk. Flag order and
|
|
139
|
-
* position are not something a pattern should be asked to track.
|
|
140
|
-
*/
|
|
141
|
-
function removesEverything(value) {
|
|
142
|
-
const tokens = value.trim().split(/\s+/u);
|
|
143
|
-
const start = tokens[0] === "sudo" || tokens[0] === "doas" ? 1 : 0;
|
|
144
|
-
if (leadingBinary(tokens[start]) !== "rm") {
|
|
145
|
-
return false;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const args = tokens.slice(start + 1);
|
|
149
|
-
const recursive = args.some(
|
|
150
|
-
(token) => token === "--recursive" || (/^-[a-zA-Z]+$/u.test(token) && /[rR]/u.test(token)),
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
return (
|
|
154
|
-
recursive && args.some((token) => !token.startsWith("-") && ROOT_TARGETS.has(token.replace(/\/+$/u, "") || "/"))
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Catastrophic and unambiguous. Every entry here is something that destroys data or hands the
|
|
160
|
-
* machine to someone else, with no legitimate reading in an agent session.
|
|
161
|
-
*
|
|
162
|
-
* Kept short on purpose. These fire without a model and without a human, so a false positive here
|
|
163
|
-
* is a blocked session with no recourse but switching the guard off -- and a guard people switch
|
|
164
|
-
* off protects nobody. Everything requiring judgement is `unknown`.
|
|
165
|
-
*/
|
|
166
|
-
const CATASTROPHIC = Object.freeze([
|
|
167
|
-
{
|
|
168
|
-
// `rm -rf /`, `rm -rf / --no-preserve-root`, `rm -r -f ~`. Not `rm -rf ./build`.
|
|
169
|
-
test: removesEverything,
|
|
170
|
-
reason: "recursive delete of the filesystem root or home directory",
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
// Writing a raw block device: mkfs, or dd with a device destination.
|
|
174
|
-
pattern: /\b(mkfs(\.\w+)?|fdisk|diskpart)\b|\bdd\b[^\n]*\bof=\/dev\/(sd|nvme|hd|disk)/u,
|
|
175
|
-
reason: "writing directly to a disk device",
|
|
176
|
-
},
|
|
177
|
-
{
|
|
178
|
-
// Piping a downloaded script straight into a shell.
|
|
179
|
-
pattern: /\b(curl|wget|iwr|Invoke-WebRequest)\b[^\n|]*\|\s*(sudo\s+)?(ba|z|k|fi|da)?sh\b/u,
|
|
180
|
-
reason: "executing a downloaded script without inspecting it",
|
|
181
|
-
},
|
|
182
|
-
{
|
|
183
|
-
// Recursive world-writable or ownership changes over a filesystem root.
|
|
184
|
-
pattern:
|
|
185
|
-
/\bchmod\s+(-[a-zA-Z]*\s+)*(-R|--recursive)\s+777\s+\/\s*$|\bchown\s+(-R|--recursive)\s+[^\s]+\s+\/\s*$/u,
|
|
186
|
-
reason: "recursive permission or ownership change over the filesystem root",
|
|
187
|
-
},
|
|
188
|
-
{
|
|
189
|
-
pattern: /:\(\)\s*\{\s*:\|\s*:\s*&\s*\}\s*;\s*:/u,
|
|
190
|
-
reason: "fork bomb",
|
|
191
|
-
},
|
|
192
|
-
{
|
|
193
|
-
// Overwriting the shell history or a credential store with nothing is how a session hides
|
|
194
|
-
// what it did; blocking it is cheap and it is never a legitimate agent action.
|
|
195
|
-
pattern: /\b(history\s+-c|Clear-History)\b|>\s*~?\/?\.bash_history\b/u,
|
|
196
|
-
reason: "clearing shell history",
|
|
197
|
-
},
|
|
198
|
-
]);
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Paths whose contents are credentials, keys or version-control internals.
|
|
202
|
-
*
|
|
203
|
-
* Every pattern is tested against a path already resolved against the working directory and written
|
|
204
|
-
* with forward slashes, so a segment is bounded by `/` or by the end of the string. Matching a
|
|
205
|
-
* directory matters as much as matching a file: `secrets/api.txt` is a secret, and the first version
|
|
206
|
-
* of this list -- which required the secret's name to be the last segment -- called it an ordinary
|
|
207
|
-
* project file.
|
|
208
|
-
*/
|
|
209
|
-
const PROTECTED = Object.freeze([
|
|
210
|
-
/(^|\/)\.env(\.|\/|$)/u,
|
|
211
|
-
/\.env$/u,
|
|
212
|
-
/(^|\/)\.git(\/|$)/u,
|
|
213
|
-
/(^|\/)\.ssh(\/|$)/u,
|
|
214
|
-
/(^|\/)\.aws(\/|$)/u,
|
|
215
|
-
/(^|\/)\.gnupg(\/|$)/u,
|
|
216
|
-
/(^|\/)(id_rsa|id_dsa|id_ecdsa|id_ed25519)(\.|$)/u,
|
|
217
|
-
/\.(pem|key|pfx|p12|keystore|jks)$/iu,
|
|
218
|
-
/(^|\/)(credentials?|secrets?)(\.|\/|$)/iu,
|
|
219
|
-
/(^|\/)auth\.json$/u,
|
|
220
|
-
/(^|\/)\.npmrc$/u,
|
|
221
|
-
/(^|\/)\.netrc$/u,
|
|
222
|
-
/(^|\/)\.pi(\/|$)/u,
|
|
223
|
-
]);
|
|
224
|
-
|
|
225
|
-
function text(value) {
|
|
226
|
-
return typeof value === "string" ? value : "";
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** The binary a command starts with, lowercased and stripped of any path. */
|
|
230
|
-
export function leadingBinary(command) {
|
|
231
|
-
const trimmed = text(command).trim();
|
|
232
|
-
if (trimmed.length === 0) {
|
|
233
|
-
return "";
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const first = trimmed.split(/\s+/u)[0];
|
|
237
|
-
|
|
238
|
-
return path.basename(first.replace(/\\/gu, "/")).toLowerCase();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Classify a shell command without asking anyone.
|
|
243
|
-
*
|
|
244
|
-
* Returns `{ decision, reason }` where decision is "safe", "dangerous" or "unknown". The order
|
|
245
|
-
* matters: dangerous is checked before safe, so a catastrophic command hidden behind a read-only
|
|
246
|
-
* binary cannot pass on the fast path.
|
|
247
|
-
*/
|
|
248
|
-
export function classifyCommand(command, cwd = process.cwd()) {
|
|
249
|
-
const value = text(command).trim();
|
|
250
|
-
if (value.length === 0) {
|
|
251
|
-
return { decision: "unknown", reason: "empty command" };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
for (const rule of CATASTROPHIC) {
|
|
255
|
-
if (rule.test ? rule.test(value) : rule.pattern.test(value)) {
|
|
256
|
-
return { decision: "dangerous", reason: rule.reason };
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// A command with shell control characters is never taken on the fast path, however harmless its
|
|
261
|
-
// first word looks: `ls; rm -rf ~` begins with `ls`.
|
|
262
|
-
if (SHELL_CONTROL.test(value)) {
|
|
263
|
-
return { decision: "unknown", reason: "shell control characters" };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
if (READ_ONLY.has(leadingBinary(value))) {
|
|
267
|
-
// Read-only is a statement about what the binary does, not about what it is pointed at, and
|
|
268
|
-
// the question this guard asks names exfiltration beside destruction. `cat ~/.ssh/id_rsa`
|
|
269
|
-
// changes nothing and hands over a private key, so the fast path has to look at the
|
|
270
|
-
// arguments too or half of the question it asks is unreachable for the commands that answer
|
|
271
|
-
// it. A protected argument costs one question; every other read stays free.
|
|
272
|
-
return commandArguments(value).some((argument) => protectedPath(argument, cwd))
|
|
273
|
-
? { decision: "unknown", reason: "reads a protected path" }
|
|
274
|
-
: { decision: "safe", reason: "read-only command" };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return { decision: "unknown", reason: "not a known read-only command" };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
/**
|
|
281
|
-
* The non-flag arguments of a command, unquoted.
|
|
282
|
-
*
|
|
283
|
-
* Deliberately crude: this decides whether to ask a question, never whether to block, so a token it
|
|
284
|
-
* splits wrongly costs a question and nothing else.
|
|
285
|
-
*/
|
|
286
|
-
function commandArguments(value) {
|
|
287
|
-
return value
|
|
288
|
-
.split(/\s+/u)
|
|
289
|
-
.slice(1)
|
|
290
|
-
.filter((token) => !token.startsWith("-"))
|
|
291
|
-
.map((token) => token.replace(/^["']|["']$/gu, ""))
|
|
292
|
-
.filter((token) => token.length > 0);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Whether a write or edit target holds credentials or version-control internals.
|
|
297
|
-
*
|
|
298
|
-
* Relative targets are resolved against the working directory first, so `../../.ssh/config` is seen
|
|
299
|
-
* for what it is rather than for what it is spelled as.
|
|
300
|
-
*/
|
|
301
|
-
export function protectedPath(target, cwd = process.cwd()) {
|
|
302
|
-
const value = text(target).trim();
|
|
303
|
-
if (value.length === 0) {
|
|
304
|
-
return false;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
let resolved;
|
|
308
|
-
try {
|
|
309
|
-
resolved = path.resolve(cwd, value);
|
|
310
|
-
} catch {
|
|
311
|
-
resolved = value;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const normalized = resolved.replaceAll("\\", "/");
|
|
315
|
-
|
|
316
|
-
return PROTECTED.some((pattern) => pattern.test(normalized));
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Keys a harness uses for "the file this call writes", across the tools in `WRITE_TOOLS`. */
|
|
320
|
-
const TARGET_KEYS = Object.freeze(["path", "file_path", "filePath", "file", "target", "notebook_path"]);
|
|
321
|
-
|
|
322
|
-
/** Nested arrays of edits, each element carrying a target of its own. */
|
|
323
|
-
const TARGET_LISTS = Object.freeze(["edits", "files", "changes", "operations"]);
|
|
324
|
-
|
|
325
|
-
/** Header forms that name a file inside a patch body. */
|
|
326
|
-
const PATCH_TARGET = /^(?:\*\*\* (?:Add|Update|Delete) File: |--- (?:a\/)?|\+\+\+ (?:b\/)?)(.+)$/gmu;
|
|
327
|
-
|
|
328
|
-
function pushTarget(into, value) {
|
|
329
|
-
if (typeof value === "string" && value.trim().length > 0 && into.length < 64) {
|
|
330
|
-
into.push(value.trim());
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Every file a tool call names, from a tool input whose shape this module does not control.
|
|
336
|
-
*
|
|
337
|
-
* `write` and `edit` carry one `path`; `multi_edit` carries a list; `apply_patch` carries the paths
|
|
338
|
-
* inside a patch body and nowhere else. Reading only the first of those is how a write to a
|
|
339
|
-
* credential file reaches disk without the guard ever seeing a target -- so this reads all three,
|
|
340
|
-
* and returning nothing is itself a meaningful answer to `classifyCall`.
|
|
341
|
-
*/
|
|
342
|
-
export function callTargets(input) {
|
|
343
|
-
const found = [];
|
|
344
|
-
if (input === null || typeof input !== "object") {
|
|
345
|
-
return found;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
for (const key of TARGET_KEYS) {
|
|
349
|
-
pushTarget(found, input[key]);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
for (const key of TARGET_LISTS) {
|
|
353
|
-
const list = input[key];
|
|
354
|
-
if (!Array.isArray(list)) {
|
|
355
|
-
continue;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
for (const item of list) {
|
|
359
|
-
if (typeof item === "string") {
|
|
360
|
-
pushTarget(found, item);
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (item !== null && typeof item === "object") {
|
|
365
|
-
for (const key2 of TARGET_KEYS) {
|
|
366
|
-
pushTarget(found, item[key2]);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const patch = typeof input.patch === "string" ? input.patch : typeof input.diff === "string" ? input.diff : "";
|
|
373
|
-
if (patch.length > 0) {
|
|
374
|
-
// Bounded: a patch is arbitrary size and this runs on every call.
|
|
375
|
-
for (const match of patch.slice(0, 20_000).matchAll(PATCH_TARGET)) {
|
|
376
|
-
pushTarget(found, match[1].replace(/\t.*$/u, ""));
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
return found;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** Keys a harness uses for "the text this call runs", across the tools in `SHELL_TOOLS`. */
|
|
384
|
-
const COMMAND_KEYS = Object.freeze(["command", "cmd", "script", "input", "text", "data", "stdin", "line"]);
|
|
385
|
-
|
|
386
|
-
/**
|
|
387
|
-
* The text a shell call will run, from a tool input whose shape this module does not control.
|
|
388
|
-
*
|
|
389
|
-
* `bash` carries `command`; `write_stdin` carries the text it types into a live shell under some
|
|
390
|
-
* other name entirely. Reading `command` alone meant every `write_stdin` was classified as an empty
|
|
391
|
-
* command -- spending a guard call on the empty string while the `rm -rf ~` being typed went
|
|
392
|
-
* unexamined -- so the tool most worth reading was the one read as blank.
|
|
393
|
-
*/
|
|
394
|
-
export function commandText(input) {
|
|
395
|
-
if (typeof input === "string") {
|
|
396
|
-
return input;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
if (input === null || typeof input !== "object") {
|
|
400
|
-
return "";
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
for (const key of COMMAND_KEYS) {
|
|
404
|
-
if (typeof input[key] === "string" && input[key].trim().length > 0) {
|
|
405
|
-
return input[key];
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
return "";
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* What a gated tool call is, before Jev is involved.
|
|
414
|
-
*
|
|
415
|
-
* `write` and its siblings are only interesting when they target something protected: an ordinary
|
|
416
|
-
* source file being edited is the entire point of the agent, and asking about each one would spend a
|
|
417
|
-
* session's budget on the first directory it refactored.
|
|
418
|
-
*
|
|
419
|
-
* A write whose target could not be read is `unknown` rather than `safe`. That costs a question on a
|
|
420
|
-
* tool shape this module does not recognise, which is the right way round: the alternative is a
|
|
421
|
-
* silent hole that appears the moment a harness renames a field.
|
|
422
|
-
*/
|
|
423
|
-
export function classifyCall({ tool, command, target, targets, cwd }) {
|
|
424
|
-
if (!GATED_TOOLS.includes(tool)) {
|
|
425
|
-
return { decision: "safe", reason: "tool is not gated" };
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
if (SHELL_TOOLS.includes(tool)) {
|
|
429
|
-
return classifyCommand(command, cwd);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const all = [...(Array.isArray(targets) ? targets : []), ...(typeof target === "string" ? [target] : [])].filter(
|
|
433
|
-
(item) => typeof item === "string" && item.trim().length > 0,
|
|
434
|
-
);
|
|
435
|
-
if (all.length === 0) {
|
|
436
|
-
return { decision: "unknown", reason: "write target could not be read" };
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
return all.some((item) => protectedPath(item, cwd))
|
|
440
|
-
? { decision: "unknown", reason: "writes to a protected path" }
|
|
441
|
-
: { decision: "safe", reason: "ordinary project file" };
|
|
442
|
-
}
|