pi-warden 0.74.0 → 1.0.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 +280 -1
- package/README.md +93 -11
- package/dist/action-guard.d.ts +14 -6
- package/dist/action-guard.js +15 -17
- package/dist/arming.js +3 -21
- package/dist/ask-gate.d.ts +13 -0
- package/dist/ask-gate.js +308 -0
- package/dist/config.d.ts +69 -16
- package/dist/config.js +205 -49
- package/dist/conscience.d.ts +65 -2
- package/dist/conscience.js +185 -34
- package/dist/curator.d.ts +89 -0
- package/dist/curator.js +154 -0
- package/dist/done.d.ts +2 -2
- package/dist/done.js +12 -3
- package/dist/extension.d.ts +9 -1
- package/dist/extension.js +613 -224
- package/dist/filter.d.ts +82 -0
- package/dist/filter.js +176 -0
- package/dist/guard.d.ts +140 -29
- package/dist/guard.js +765 -55
- package/dist/holds.d.ts +39 -10
- package/dist/holds.js +58 -20
- package/dist/index-cmd.js +7 -6
- package/dist/index.d.ts +12 -5
- package/dist/index.js +6 -3
- package/dist/learning.d.ts +33 -6
- package/dist/learning.js +304 -57
- package/dist/output.d.ts +3 -0
- package/dist/output.js +6 -1
- package/dist/prefs.d.ts +3 -13
- package/dist/prefs.js +3 -15
- package/dist/redact.d.ts +3 -3
- package/dist/redact.js +29 -7
- package/dist/relevance.d.ts +165 -0
- package/dist/relevance.js +558 -0
- package/dist/saver.d.ts +27 -0
- package/dist/saver.js +32 -2
- package/dist/shape.d.ts +1 -1
- package/dist/shape.js +37 -9
- package/dist/sqlite-adapter.d.ts +48 -0
- package/dist/sqlite-adapter.js +68 -0
- package/dist/tools.d.ts +6 -0
- package/dist/tools.js +9 -0
- package/dist/trace.js +5 -1
- package/dist/turn-rules.d.ts +17 -1
- package/dist/turn-rules.js +94 -12
- package/dist/widget.js +1 -1
- package/package.json +5 -2
package/dist/arming.js
CHANGED
|
@@ -14,37 +14,19 @@
|
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { resolve } from "node:path";
|
|
16
16
|
import { matchPathGlobs, stripDataText } from "./guard.js";
|
|
17
|
+
import { DEFAULT_ARMING_DURATION, parseDuration } from "./config.js";
|
|
17
18
|
/** Compile a rule, returning undefined if the regex is invalid. compileArmingRules returns the ids of rules that failed. */
|
|
18
19
|
function compileArmingRule(rule) {
|
|
19
20
|
try {
|
|
20
21
|
const flags = rule.arms.caseSensitive ? "" : "i";
|
|
21
|
-
|
|
22
|
+
// The config parser already turned `for` into milliseconds; a rule built in code may still carry a string.
|
|
23
|
+
const duration = parseDuration(rule.arms.for, DEFAULT_ARMING_DURATION);
|
|
22
24
|
return { rule, commandRegex: new RegExp(rule.arms.command, flags), durationMs: duration };
|
|
23
25
|
}
|
|
24
26
|
catch {
|
|
25
27
|
return undefined;
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
|
-
/** Parse a duration string like "10m", "30s", "2h" into milliseconds. Returns 600000 (10m) for undefined/invalid. */
|
|
29
|
-
function parseDuration(forValue) {
|
|
30
|
-
if (forValue === undefined)
|
|
31
|
-
return 600_000;
|
|
32
|
-
if (typeof forValue === "number")
|
|
33
|
-
return forValue > 0 ? forValue : 600_000;
|
|
34
|
-
const match = /^(\d+)(ms|s|m|h)$/.exec(forValue);
|
|
35
|
-
if (!match)
|
|
36
|
-
return 600_000;
|
|
37
|
-
const value = parseInt(match[1], 10);
|
|
38
|
-
if (value <= 0)
|
|
39
|
-
return 600_000;
|
|
40
|
-
switch (match[2]) {
|
|
41
|
-
case "ms": return value;
|
|
42
|
-
case "s": return value * 1_000;
|
|
43
|
-
case "m": return value * 60_000;
|
|
44
|
-
case "h": return value * 3_600_000;
|
|
45
|
-
default: return 600_000;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
30
|
/** Ids of rules whose regex or duration could not be compiled; surfaced via the one-time notice channel. */
|
|
49
31
|
export function unparseableArmingRules(rules) {
|
|
50
32
|
const ids = [];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AskDecision {
|
|
2
|
+
/** True when the call goes to Jev; false when the offline pass decides it alone. */
|
|
3
|
+
ask: boolean;
|
|
4
|
+
/** Short, secret-free reason for the trace. Never the command text. */
|
|
5
|
+
why: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether this call needs a Jev request, and the short reason when it does not.
|
|
9
|
+
* `text` is the command with data text blanked (`stripDataText`), so a commit message that mentions a push is not a push.
|
|
10
|
+
*/
|
|
11
|
+
export declare function actionAskGate(tool: string, input: Record<string, unknown>, text: string | undefined, cwd?: string): AskDecision;
|
|
12
|
+
/** The decision for one shell command. Exported for the replay and for tests. */
|
|
13
|
+
export declare function gateCommand(scanned: string, raw?: string): AskDecision;
|
package/dist/ask-gate.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ask gate: whether a judged call needs a Jev request at all.
|
|
3
|
+
*
|
|
4
|
+
* The action request is the largest single stream of TypeSafe requests, and most of it is spent on calls whose answer
|
|
5
|
+
* cannot change what the agent sees: a read, a search, a build, a test run. The offline pattern pass and the floor
|
|
6
|
+
* already decide those, and the judge only ever adds an irreversible warning, a hold, an intent steer, or a trace-only
|
|
7
|
+
* note. This module answers one question before the request leaves the machine: could Jev change anything the agent
|
|
8
|
+
* sees for this call?
|
|
9
|
+
*
|
|
10
|
+
* It asks when the call can be irreversible or visible, and it answers `false` for everything else. Recall matters more
|
|
11
|
+
* than precision: a missed ask loses a real warning, an extra ask only costs money. Nothing here changes a level; the
|
|
12
|
+
* caller still runs the pattern pass and the floor for a call the gate leaves unasked.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately narrow and independent of `matchPatterns`: the pattern rules stay the offline floor, this only decides
|
|
15
|
+
* whether a judge is worth a request.
|
|
16
|
+
*/
|
|
17
|
+
import { commandOf } from "./tools.js";
|
|
18
|
+
/** Tools that always reach Jev: what a write or an edit leaves on disk is what every question is about. */
|
|
19
|
+
const ALWAYS_ASK = new Set(["write", "edit"]);
|
|
20
|
+
/** Heads that change version control or remote state, publish, deploy, or drive infrastructure. */
|
|
21
|
+
const EXTERNAL_HEADS = new Set([
|
|
22
|
+
"ssh", "scp", "sftp", "rsync", "rclone", "nc", "ncat", "socat", "telnet", "ftp", "lftp", "curl", "wget", "http", "https", "xh", "aws", "gcloud", "az", "doctl",
|
|
23
|
+
"heroku", "fly", "flyctl", "vercel", "netlify", "wrangler", "firebase", "kubectl", "helm", "terraform", "tofu", "pulumi", "ansible-playbook", "ansible", "nomad",
|
|
24
|
+
"packer", "vagrant", "docker", "podman", "nerdctl", "docker-compose", "compose", "gh", "glab", "hub", "npm", "pnpm", "yarn", "bun", "pip", "pip3", "uv", "poetry",
|
|
25
|
+
"conda", "gem", "go", "cargo", "brew", "apt", "apt-get", "dnf", "yum", "apk", "pacman", "snap", "port", "nix", "nix-env", "systemctl", "launchctl", "service",
|
|
26
|
+
"supervisorctl", "pm2", "crontab", "at", "kill", "killall", "pkill", "sudo", "doas", "mount", "umount", "diskutil", "hdiutil", "defaults", "launchd", "osascript",
|
|
27
|
+
// Build and packaging heads: read-only for their test and build subcommands, a write for deploy, publish, or install.
|
|
28
|
+
"make", "gradle", "./gradlew", "flutter", "dotnet",
|
|
29
|
+
]);
|
|
30
|
+
/** A `curl -o` target that lands in a scratch path: a download there reads nothing the user depends on. */
|
|
31
|
+
const TEMP_TARGET = /^(?:\/tmp\/|\/var\/tmp\/|\/private\/tmp\/|\/dev\/null$|\/dev\/stdout$|\$TMPDIR\b|\$\{TMPDIR\}|~\/scratch\/)/;
|
|
32
|
+
/** Shell wrappers whose own command line names what runs. */
|
|
33
|
+
const WRAPPERS = new Set(["sudo", "doas", "nohup", "time", "env", "command", "builtin", "exec", "nice", "timeout", "stdbuf", "gtimeout"]);
|
|
34
|
+
/** Git subcommands that only read. Everything else changes the index, the working tree, the history, or a remote. */
|
|
35
|
+
const GIT_READ_ONLY = new Set([
|
|
36
|
+
"status", "log", "diff", "show", "rev-parse", "rev-list", "ls-files", "ls-remote", "ls-tree", "grep", "blame", "describe", "shortlog", "cat-file", "for-each-ref",
|
|
37
|
+
"show-ref", "reflog", "whatchanged", "check-ignore", "check-attr", "verify-commit", "name-rev", "merge-base", "diff-tree", "diff-index", "diff-files", "count-objects",
|
|
38
|
+
"fsck", "help", "version", "var", "get-toplevel", "symbolic-ref", "hash-object", "archive", "format-patch", "range-diff", "cherry",
|
|
39
|
+
// These read by default and write only through the flags the checks below look for.
|
|
40
|
+
"branch", "tag", "remote", "worktree", "submodule", "stash", "config", "reflog", "notes",
|
|
41
|
+
]);
|
|
42
|
+
/** Subcommands whose effect others see on the heads that have harmless neighbours. */
|
|
43
|
+
const READ_ONLY_SUBCOMMANDS = {
|
|
44
|
+
npm: new Set(["ls", "list", "view", "show", "outdated", "why", "doctor", "config", "help", "explain", "run", "test", "audit"]),
|
|
45
|
+
pnpm: new Set(["ls", "list", "view", "why", "outdated", "run", "test", "audit"]),
|
|
46
|
+
yarn: new Set(["list", "why", "info", "run", "test", "audit"]),
|
|
47
|
+
bun: new Set(["run", "test", "pm"]),
|
|
48
|
+
pip: new Set(["list", "show", "freeze", "check", "download", "index"]),
|
|
49
|
+
pip3: new Set(["list", "show", "freeze", "check", "download", "index"]),
|
|
50
|
+
uv: new Set(["pip", "tree", "run"]),
|
|
51
|
+
poetry: new Set(["show", "env", "run", "check"]),
|
|
52
|
+
conda: new Set(["list", "info", "env"]),
|
|
53
|
+
brew: new Set(["list", "info", "search", "outdated", "config", "doctor", "deps", "uses"]),
|
|
54
|
+
cargo: new Set(["test", "check", "clippy", "fmt", "build", "run", "doc", "metadata", "tree"]),
|
|
55
|
+
go: new Set(["test", "build", "vet", "env", "version", "list", "doc"]),
|
|
56
|
+
docker: new Set(["ps", "images", "logs", "inspect", "version", "info", "stats", "top", "diff", "port", "history", "events"]),
|
|
57
|
+
podman: new Set(["ps", "images", "logs", "inspect", "version", "info", "stats"]),
|
|
58
|
+
kubectl: new Set(["get", "describe", "logs", "version", "config", "explain", "api-resources", "top", "diff", "auth", "cluster-info"]),
|
|
59
|
+
helm: new Set(["list", "get", "show", "search", "version", "template", "lint", "status", "history", "repo"]),
|
|
60
|
+
terraform: new Set(["plan", "validate", "show", "output", "fmt", "version", "providers", "graph", "state"]),
|
|
61
|
+
tofu: new Set(["plan", "validate", "show", "output", "fmt", "version", "providers", "graph", "state"]),
|
|
62
|
+
pulumi: new Set(["preview", "stack", "config", "version"]),
|
|
63
|
+
gh: new Set(["pr", "issue", "run", "release", "repo", "api", "workflow", "gist", "search", "auth", "status", "version", "help"]),
|
|
64
|
+
glab: new Set(["mr", "issue", "ci", "repo", "api", "auth", "version", "help", "config"]),
|
|
65
|
+
aws: new Set(["s3api", "sts", "configure", "help"]),
|
|
66
|
+
gcloud: new Set(["auth", "config", "version", "help", "info"]),
|
|
67
|
+
az: new Set(["account", "config", "version", "help"]),
|
|
68
|
+
systemctl: new Set(["status", "show", "is-active", "is-enabled", "list-units", "list-unit-files", "cat", "help"]),
|
|
69
|
+
launchctl: new Set(["list", "print", "help"]),
|
|
70
|
+
service: new Set(["status"]),
|
|
71
|
+
curl: new Set([]),
|
|
72
|
+
wget: new Set([]),
|
|
73
|
+
kill: new Set([]),
|
|
74
|
+
git: new Set([]),
|
|
75
|
+
dotnet: new Set(["build", "test", "run", "restore", "list"]),
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* A `gh` or `glab` subcommand that writes. Its read-only siblings (`gh pr view`, `gh run list`) are read-only; every
|
|
79
|
+
* other second word (`gh pr create`, `gh release create`, `gh api -X POST`) changes something a user sees.
|
|
80
|
+
*/
|
|
81
|
+
const GH_READ_ONLY_SECONDS = new Set(["view", "list", "status", "diff", "checks", "watch", "download", "clone", "comment"]);
|
|
82
|
+
const GH_WRITE_SECONDS = new Set(["create", "merge", "close", "edit", "delete", "reopen", "review", "approve", "rerun", "cancel", "dispatch", "upload", "comment", "ready", "revert"]);
|
|
83
|
+
/** Interpreters that run a script given on the command line or on stdin; their text is scanned as a command. */
|
|
84
|
+
const SCRIPT_SINK = /(?:^|[\s;&|(])(?:python[\d.]*|node|deno|bun|tsx|ruby|perl|php|lua[\d.]*|rscript|osascript)\s+(?:-e|--eval|-(?=[\s;&|)]|$))|(?:^|[\s;&|(])(?:ba|z|k|da)?sh\s+-[a-zA-Z]*c\b|(?:^|[\s;&|(])eval\s/;
|
|
85
|
+
/**
|
|
86
|
+
* Shapes that ask wherever they sit in the command, so a call nested in `for`, `do`, `if`, a substitution, or an
|
|
87
|
+
* interpreter script is read the same as one at the head of a segment. Matched against the command with data text
|
|
88
|
+
* blanked; when the command runs a script on the command line or on stdin, also against the raw command.
|
|
89
|
+
*/
|
|
90
|
+
const ANYWHERE = [
|
|
91
|
+
{ why: "deletes, moves, or overwrites", test: /(?:^|[\s;&|(`])(?:rm|rmdir|unlink|shred|truncate|dd|mv|cp|install|patch|chmod|chown|chgrp|mkfifo|mknod)\s/ },
|
|
92
|
+
{ why: "build, deploy, or install target", test: /(?:^|[\s;&|(])(?:make|gradle|\.\/gradlew|flutter|npm|pnpm|yarn|bun|poetry|uv|gem|cargo|go|dotnet)\s[^;|&\n]*\b(?:deploy|publish|release|install|uninstall|upload|push|ship|prod|production|provision|dist|archive|bundle|package)\b/ },
|
|
93
|
+
{ why: "writes through a shell redirect", test: /(?:^|[^0-9&\->=<])(?:>>|>)\s*(?!&)(?!\/dev\/null)(?!\/tmp\/)(?!\/var\/tmp\/)(?!\/private\/tmp\/)(?!\$TMPDIR)(?!\$\{TMPDIR\})(?!~\/scratch\/)[^\s;&|)]/ },
|
|
94
|
+
{ why: "gh write", test: /(?:^|[\s;&|(])(?:gh|glab)\s+(?:repo|release|issue|pr|run|workflow|cache|gist|mr|api)\s+([^\s;&|]+)/ },
|
|
95
|
+
{ why: "database client", test: /(?:^|[\s;&|(])(?:psql|pgcli|pg_restore|pg_dump|pg_dumpall|createdb|dropdb|mysql|mycli|mysqldump|mariadb|sqlite3|mongosh|mongo|redis-cli|clickhouse-client|cqlsh|sqlcmd|influx|supabase|firebase|cockroach)\s/ },
|
|
96
|
+
{ why: "network, publish, or infrastructure write", test: /(?:^|[\s;&|(])(?:ssh|scp|sftp|rsync|rclone|nc|ncat|socat|telnet|lftp|aws|gcloud|az|doctl|heroku|fly|flyctl|vercel|netlify|wrangler|kubectl|helm|terraform|tofu|pulumi|ansible-playbook|ansible|packer|vagrant|docker|podman|nerdctl|docker-compose|npm|pnpm|yarn|bun|pip|pip3|uv|poetry|conda|gem|go|cargo|brew|apt|apt-get|dnf|yum|apk|pacman|snap|port|nix|nix-env|systemctl|launchctl|service|supervisorctl|pm2|crontab|mount|umount|diskutil|hdiutil|defaults|osascript|make|gradle|\.\/gradlew|flutter)\s/ },
|
|
97
|
+
{ why: "sends an HTTP write", test: /(?:^|[\s;&|(])(?:curl|wget|http|xh)\s[^;&|]*(?:-X\s*(?!GET\b)|--request\s+(?!GET\b)|-d\s|--data|-F\s|--form|-T\s|--upload-file|--post-data|--post-file|-O\s|--output-document)/ },
|
|
98
|
+
{ why: "interpreter script with a write", test: /(?:open\s*\([^)\n]{0,120}['"][wa]|writeFile\w*|appendFile\w*|createWriteStream|rmSync|rmdirSync|unlinkSync|shutil\.rmtree|shutil\.move|os\.remove|os\.unlink|os\.rename|os\.replace|subprocess|child_process|os\.system|execSync|spawnSync|execFileSync|Deno\.write|Bun\.write|requests\.(?:post|put|patch|delete)|axios\.(?:post|put|patch|delete)|fetch\s*\(|\.exec\s*\(|DROP\s+TABLE|DELETE\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|TRUNCATE\s+TABLE)/i },
|
|
99
|
+
{ why: "in-place edit", test: /(?:^|[\s;&|(])(?:sed|gsed|perl|ruby)\s+(?:-[^\s]*\s+)*-i(?![a-zA-Z])/ },
|
|
100
|
+
{ why: "find -delete", test: /\bfind\b[^\n;&|]*(?:-delete\b|-exec\w*\s+rm\b)/ },
|
|
101
|
+
{ why: "kills a process", test: /(?:^|[\s;&|(])(?:kill|killall|pkill|killall5)\s/ },
|
|
102
|
+
];
|
|
103
|
+
/** Quote a head word for a regex. */
|
|
104
|
+
const escapeWord = (word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
105
|
+
/**
|
|
106
|
+
* Runs `decide` on every segment that names `head` anywhere, so a call nested in `for`, `do`, `if`, a substitution, or
|
|
107
|
+
* a pipeline is read the same as one at the head of a segment. Segments split on newlines, `;`, `&&`, and `||`, never on
|
|
108
|
+
* a pipe, so a head's own words stay together.
|
|
109
|
+
*/
|
|
110
|
+
function anySegmentWith(text, heads, decide) {
|
|
111
|
+
for (const part of text.split(/[\n;]|&&|\|\|/)) {
|
|
112
|
+
for (const head of heads) {
|
|
113
|
+
const match = new RegExp(`(?:^|[\\s;&|(\"\'\`])${escapeWord(head)}\\s`).exec(part);
|
|
114
|
+
if (!match)
|
|
115
|
+
continue;
|
|
116
|
+
const segment = part.slice(match.index).trimStart();
|
|
117
|
+
if (decide(segment, head))
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
function splitSegments(command) {
|
|
124
|
+
return command.split(/\n|;|&&|\|\||\||&/).map(part => part.trim()).filter(Boolean);
|
|
125
|
+
}
|
|
126
|
+
/** The head word of a segment, past `VAR=value` prefixes and wrappers such as `sudo` and `timeout 5`. */
|
|
127
|
+
function headOf(segment) {
|
|
128
|
+
const tokens = segment.trim().split(/\s+/);
|
|
129
|
+
let index = 0;
|
|
130
|
+
while (index < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index]) || WRAPPERS.has(tokens[index])))
|
|
131
|
+
index++;
|
|
132
|
+
const head = tokens[index];
|
|
133
|
+
return head ? head.replace(/^.*\//, "") : undefined;
|
|
134
|
+
}
|
|
135
|
+
/** The first non-flag word after `head` in a segment; option values are skipped for the flags that take one. */
|
|
136
|
+
function subcommandOf(segment, head) {
|
|
137
|
+
const tokens = segment.trim().split(/\s+/);
|
|
138
|
+
let index = tokens.findIndex(token => token.replace(/^.*\//, "") === head) + 1;
|
|
139
|
+
for (; index < tokens.length; index++) {
|
|
140
|
+
const token = tokens[index];
|
|
141
|
+
if (token === "-C" || token === "-c" || token === "-R" || token === "--repo" || token === "-m" || token === "--message" || token === "-n" || token === "--namespace") {
|
|
142
|
+
index++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (token.startsWith("-"))
|
|
146
|
+
continue;
|
|
147
|
+
return token.replace(/[)`]+$/, "");
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
function gitAsks(segment) {
|
|
152
|
+
const sub = subcommandOf(segment, "git");
|
|
153
|
+
if (sub === undefined)
|
|
154
|
+
return false;
|
|
155
|
+
if (GIT_READ_ONLY.has(sub)) {
|
|
156
|
+
// `git branch -D`, `git tag -d`, `git remote add`: the read-only subcommand has writing flags.
|
|
157
|
+
if (sub === "branch" && /\s-[dDmMcC]\b|--delete\b|--move\b|--copy\b|--force\b/.test(segment))
|
|
158
|
+
return true;
|
|
159
|
+
if (sub === "tag" && /\s-[dDafm]\b|--delete\b|--force\b|--annotate\b/.test(segment))
|
|
160
|
+
return true;
|
|
161
|
+
if (sub === "remote" && /\s(?:add|remove|rm|set-url|rename|prune)\b/.test(segment.replace(/\bremote\b/, "")))
|
|
162
|
+
return true;
|
|
163
|
+
if (sub === "worktree" && /\s(?:add|remove|move|prune)\b/.test(segment))
|
|
164
|
+
return true;
|
|
165
|
+
if (sub === "submodule" && !/\sstatus\b/.test(segment))
|
|
166
|
+
return true;
|
|
167
|
+
// A bare `git stash` saves state; only `list` and `show` read.
|
|
168
|
+
if (sub === "stash")
|
|
169
|
+
return !/\s(?:list|show)\b/.test(segment);
|
|
170
|
+
if (sub === "notes")
|
|
171
|
+
return !/\s(?:list|show)\b/.test(segment);
|
|
172
|
+
if (sub === "config" && /\s(?:set|add|unset|remove-section|rename-section)\b/.test(segment))
|
|
173
|
+
return true;
|
|
174
|
+
if (sub === "reflog" && /\s(?:delete|expire)\b/.test(segment))
|
|
175
|
+
return true;
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
function externalAsks(head, segment) {
|
|
181
|
+
const sub = subcommandOf(segment, head);
|
|
182
|
+
if (head === "make" || head === "gradle" || head === "./gradlew")
|
|
183
|
+
return /\b(?:deploy|publish|release|install|uninstall|upload|push|ship|prod|production|provision|apply|destroy)\b/.test(segment);
|
|
184
|
+
if (head === "flutter")
|
|
185
|
+
return /\s(?:build|pub|run|install|clean|drive|emulators?)\b/.test(segment);
|
|
186
|
+
if (head === "curl")
|
|
187
|
+
return curlAsks(segment);
|
|
188
|
+
if (head === "wget")
|
|
189
|
+
return /\s--post-data\b|\s--post-file\b|\s--method\b|\s-O\b|\s--output-document\b|\s--delete-after\b/.test(segment);
|
|
190
|
+
if (head === "kill")
|
|
191
|
+
return true;
|
|
192
|
+
if (head === "gh" || head === "glab")
|
|
193
|
+
return ghAsks(sub, segment);
|
|
194
|
+
if (head === "aws")
|
|
195
|
+
return !/^(?:s3api\s+(?:list|get-object|head-object|describe)|sts\s+get-caller-identity)/.test(restAfter(segment, head));
|
|
196
|
+
if (head === "gcloud" || head === "az")
|
|
197
|
+
return !/^(?:auth\b|config\b|version\b|info\b)/.test(restAfter(segment, head));
|
|
198
|
+
const readOnly = READ_ONLY_SUBCOMMANDS[head];
|
|
199
|
+
if (sub === undefined) {
|
|
200
|
+
// `terraform state list` and `pulumi stack` are read only; every other bare call of these heads can write.
|
|
201
|
+
return readOnly === undefined || head === "sudo" || head === "doas" || head === "mount" || head === "umount" || head === "defaults";
|
|
202
|
+
}
|
|
203
|
+
if (readOnly === undefined)
|
|
204
|
+
return true;
|
|
205
|
+
return !readOnly.has(sub);
|
|
206
|
+
}
|
|
207
|
+
/** The segment after its head word, for heads whose second word carries the read-only nature. */
|
|
208
|
+
function restAfter(segment, head) {
|
|
209
|
+
const index = segment.indexOf(head);
|
|
210
|
+
return index < 0 ? segment : segment.slice(index + head.length).trim();
|
|
211
|
+
}
|
|
212
|
+
/** curl writes when it sends a body or a non-GET method, or names an output file other than stdout. */
|
|
213
|
+
function curlAsks(segment) {
|
|
214
|
+
if (/\s-X\s*(?!GET\b)\w+|\s--request\s+(?!GET\b)\w+|\s-d\b|\s--data\w*|\s-F\b|\s--form\b|\s-T\b|\s--upload-file\b|\s--post\w*/.test(segment))
|
|
215
|
+
return true;
|
|
216
|
+
const output = /\s-o\s*(\S+)|\s--output\s+(\S+)/.exec(segment);
|
|
217
|
+
if (output) {
|
|
218
|
+
const target = output[1] ?? output[2];
|
|
219
|
+
return target !== "-" && !TEMP_TARGET.test(target);
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
function ghAsks(sub, segment) {
|
|
224
|
+
if (sub === undefined)
|
|
225
|
+
return false;
|
|
226
|
+
// `gh api` with a write method or a field writes; a bare `gh api .../repos` reads.
|
|
227
|
+
if (sub === "api")
|
|
228
|
+
return /\s-X\s*(?!GET\b)|\s--method\s+(?!GET\b)|\s-f\b|\s--field\b|\s-F\b|\s--raw-field\b/.test(segment);
|
|
229
|
+
const second = segment.trim().split(/\s+/).find((token, index, all) => index > all.findIndex(item => item === sub) && !token.startsWith("-"));
|
|
230
|
+
if (sub === "pr" || sub === "issue" || sub === "release" || sub === "run" || sub === "workflow" || sub === "gist" || sub === "cache") {
|
|
231
|
+
if (second === undefined)
|
|
232
|
+
return false;
|
|
233
|
+
if (GH_WRITE_SECONDS.has(second))
|
|
234
|
+
return true;
|
|
235
|
+
return !GH_READ_ONLY_SECONDS.has(second);
|
|
236
|
+
}
|
|
237
|
+
if (sub === "repo")
|
|
238
|
+
return second !== undefined && GH_WRITE_SECONDS.has(second);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Whether this call needs a Jev request, and the short reason when it does not.
|
|
243
|
+
* `text` is the command with data text blanked (`stripDataText`), so a commit message that mentions a push is not a push.
|
|
244
|
+
*/
|
|
245
|
+
export function actionAskGate(tool, input, text, cwd) {
|
|
246
|
+
if (ALWAYS_ASK.has(tool))
|
|
247
|
+
return { ask: true, why: tool };
|
|
248
|
+
const view = commandOf(tool, input);
|
|
249
|
+
const command = view?.command ?? text;
|
|
250
|
+
if (command === undefined) {
|
|
251
|
+
// A command-shaped tool with no readable command is judged from its JSON input only, so nothing can be left out.
|
|
252
|
+
return { ask: true, why: `${tool} input` };
|
|
253
|
+
}
|
|
254
|
+
const scanned = text ?? command;
|
|
255
|
+
const httpTool = /^(?:ctx_execute|ctx_batch_execute|ctx_execute_file)$/.test(tool);
|
|
256
|
+
if (!httpTool && !view?.shell)
|
|
257
|
+
return { ask: true, why: `${tool} code` };
|
|
258
|
+
return gateCommand(scanned, command);
|
|
259
|
+
}
|
|
260
|
+
/** The decision for one shell command. Exported for the replay and for tests. */
|
|
261
|
+
export function gateCommand(scanned, raw = scanned) {
|
|
262
|
+
// A script given on the command line or on stdin is scanned as a command: `python3 -c "shutil.rmtree(...)"` asks.
|
|
263
|
+
const sink = SCRIPT_SINK.test(raw);
|
|
264
|
+
const texts = sink && scanned !== raw ? [scanned, raw] : [scanned];
|
|
265
|
+
for (const text of texts) {
|
|
266
|
+
if (anySegmentWith(text, ["git"], segment => gitAsks(segment)))
|
|
267
|
+
return { ask: true, why: "git history or remote write" };
|
|
268
|
+
for (const rule of ANYWHERE) {
|
|
269
|
+
if (!rule.test.test(text))
|
|
270
|
+
continue;
|
|
271
|
+
// The pattern only proves a `gh`-shaped word; the subcommand decides, as it does at the head of a segment.
|
|
272
|
+
if (rule.why === "gh write" && !anySegmentWith(text, ["gh", "glab"], (segment, head) => ghAsks(subcommandOf(segment, head), segment)))
|
|
273
|
+
continue;
|
|
274
|
+
// A read-only sibling of a risky head does not ask: `helm list`, `docker ps`, `npm view`.
|
|
275
|
+
if (rule.why === "network, publish, or infrastructure write" && readOnlyHead(text))
|
|
276
|
+
continue;
|
|
277
|
+
return { ask: true, why: rule.why };
|
|
278
|
+
}
|
|
279
|
+
for (const segment of splitSegments(text)) {
|
|
280
|
+
const head = headOf(segment);
|
|
281
|
+
if (head === undefined)
|
|
282
|
+
continue;
|
|
283
|
+
if (head === "git") {
|
|
284
|
+
if (gitAsks(segment))
|
|
285
|
+
return { ask: true, why: "git history or remote write" };
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (head === "tee")
|
|
289
|
+
return { ask: true, why: "tee" };
|
|
290
|
+
if (EXTERNAL_HEADS.has(head) && externalAsks(head, segment))
|
|
291
|
+
return { ask: true, why: `external or infrastructure command (${head})` };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { ask: false, why: "no reversible-or-visible shape" };
|
|
295
|
+
}
|
|
296
|
+
/** True when every risky head word in the command is used with a read-only subcommand (`docker ps`, `helm list`, `npm view`). */
|
|
297
|
+
function readOnlyHead(text) {
|
|
298
|
+
let sawRisky = false;
|
|
299
|
+
for (const segment of splitSegments(text)) {
|
|
300
|
+
const head = headOf(segment);
|
|
301
|
+
if (head === undefined || !EXTERNAL_HEADS.has(head))
|
|
302
|
+
continue;
|
|
303
|
+
sawRisky = true;
|
|
304
|
+
if (externalAsks(head, segment))
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
return sawRisky;
|
|
308
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface CommandRule {
|
|
|
19
19
|
id: string;
|
|
20
20
|
/** Regex source string; compiled case-insensitively unless caseSensitive is true. */
|
|
21
21
|
pattern: string;
|
|
22
|
-
/** warn: notice to the agent; confirm: hold (action dialog steers); deny: block with no dialog. */
|
|
22
|
+
/** warn: notice to the agent; confirm: hold (action dialog steers); deny (or its alias block): block with no dialog. */
|
|
23
23
|
severity: "warn" | "confirm" | "deny";
|
|
24
24
|
/** When severity is confirm, dialog (default) prompts the user; hold uses steer semantics. */
|
|
25
25
|
action?: "dialog" | "hold";
|
|
@@ -34,6 +34,12 @@ export interface ActionGuardConfig {
|
|
|
34
34
|
tools: string[];
|
|
35
35
|
/** When TypeSafe cannot answer (timeout, outage, budget), allow the call with a warning instead of asking. */
|
|
36
36
|
failOpen: boolean;
|
|
37
|
+
/** Whether a call whose answer cannot change what the agent sees is decided offline. On by default; the pattern pass and the floor always run. */
|
|
38
|
+
ask: {
|
|
39
|
+
enabled: boolean;
|
|
40
|
+
};
|
|
41
|
+
/** Fraction of judged calls that also asks the trace-only questions (off-task, scope, should-proceed) in a second request, so the recorded signal keeps coming. 0 disables it. */
|
|
42
|
+
traceSample: number;
|
|
37
43
|
/** Per-request TypeSafe timeout. The call is judged as an error after this. */
|
|
38
44
|
timeoutMs: number;
|
|
39
45
|
irreversible: Threshold;
|
|
@@ -42,11 +48,11 @@ export interface ActionGuardConfig {
|
|
|
42
48
|
intentMismatch: number;
|
|
43
49
|
/** The same, for a command whose effect is visible outside the working tree (commit, push, merge, publish, launch): less mismatch is enough. */
|
|
44
50
|
visibleMismatch: number;
|
|
45
|
-
/** Which intent mismatches stay in the trace without a steer: "
|
|
51
|
+
/** Which intent mismatches stay in the trace without a steer: "all" (default) every one, "invisible" only a call with no visible effect (neither a commit, push, merge, tag, reset, pull request, release, or publish by `isVisibleCommand`, nor judged `visible` at 0.8 or more), "none" none. The steer arrives after the call ran: 275 of 275 recorded steers did. On blind labels of 140 sampled calls the score separates a differing call well (AUROC 0.815), but of 37 steers that would reach the agent, 36 were calls the plan or the user's latest request had asked for. */
|
|
46
52
|
intentTraceOnly: "invisible" | "all" | "none";
|
|
47
|
-
/** Low P(should_proceed) is trace-only unless steer is enabled;
|
|
53
|
+
/** Low P(should_proceed) is trace-only unless steer is enabled; threshold is inclusive and never holds a call. `hold` is its deprecated 1.x name. Calibration: AUC 0.26 against regret, 44% flagged at 0.6 (100 targeted sessions, 2026-09-20). */
|
|
48
54
|
shouldProceed: {
|
|
49
|
-
|
|
55
|
+
threshold: number;
|
|
50
56
|
steer: boolean;
|
|
51
57
|
};
|
|
52
58
|
/** Write each judged call and what the user did next (approved, declined, re-planned, regretted) to an owner-only per-session file under the agent directory; redacted, never the command. */
|
|
@@ -79,7 +85,7 @@ export interface PathRule {
|
|
|
79
85
|
access: "none" | "read" | "write";
|
|
80
86
|
/** Which surfaces check the rule: file tools by name ("write", "edit", "read"), or "*" to also match bash commands. */
|
|
81
87
|
tools: string[];
|
|
82
|
-
/** note: tell the agent after the fact (the default, today's sensitive-path behavior); warn: notice; confirm: dialog; block: deny. */
|
|
88
|
+
/** note: tell the agent after the fact (the default, today's sensitive-path behavior); warn: notice; confirm: dialog; block (or its alias deny): deny. */
|
|
83
89
|
action: "note" | "warn" | "confirm" | "block";
|
|
84
90
|
/** Optional human label shown instead of the derived one. */
|
|
85
91
|
message?: string;
|
|
@@ -111,7 +117,7 @@ export interface ArmingRule {
|
|
|
111
117
|
/** Match the command case-sensitively. */
|
|
112
118
|
caseSensitive?: boolean;
|
|
113
119
|
};
|
|
114
|
-
/** confirm: dialog (user-invoked prompt); hold: steer hold; block: deny. */
|
|
120
|
+
/** confirm: dialog (user-invoked prompt); hold: steer hold; block (or its alias deny): deny. */
|
|
115
121
|
action: "confirm" | "hold" | "block";
|
|
116
122
|
/** Optional human label shown in the dialog/status. */
|
|
117
123
|
message?: string;
|
|
@@ -208,6 +214,12 @@ export interface RulesConfig {
|
|
|
208
214
|
/** Glob → note. A write or edit under a matching path steers the agent with the note once per path; code only. */
|
|
209
215
|
sensitivePaths: Record<string, string>;
|
|
210
216
|
}
|
|
217
|
+
export interface RulesAtTurnStartConfig {
|
|
218
|
+
/** Ask which rules apply before each new user message, and append the ones that do as one short message. */
|
|
219
|
+
enabled: boolean;
|
|
220
|
+
/** P(this rule applies) at or above which the rule is named. Measured against the owner's own requests; see docs/guards.md. */
|
|
221
|
+
threshold: number;
|
|
222
|
+
}
|
|
211
223
|
export interface ContextConfig {
|
|
212
224
|
enabled: boolean;
|
|
213
225
|
/** Only new tool output is compressed; warm history and system prompts are never changed. */
|
|
@@ -228,6 +240,34 @@ export interface ContextConfig {
|
|
|
228
240
|
dedupeMessages: boolean;
|
|
229
241
|
/** Prevention before the call: a bash action request asks whether the command will print far more than the agent needs. Never holds. */
|
|
230
242
|
largeOutput: LargeOutputConfig;
|
|
243
|
+
/** Beta, off by default: Jev picks the passages of a large output that matter for the current task instead of the head/diagnostic/tail excerpt. */
|
|
244
|
+
filter: FilterConfig;
|
|
245
|
+
}
|
|
246
|
+
export interface FilterConfig {
|
|
247
|
+
enabled: boolean;
|
|
248
|
+
/** Target chunk size; chunks end at line boundaries, and only a line longer than this is split. */
|
|
249
|
+
chunkChars: number;
|
|
250
|
+
/** Minimum usefulness score (0 to 3) a chunk needs to be kept; 1.5 means it at least partly answers. */
|
|
251
|
+
minScore: number;
|
|
252
|
+
/** Most characters kept, including the final 1000; above it the highest-scoring chunks win. */
|
|
253
|
+
maxKeptChars: number;
|
|
254
|
+
/** Deadline for the filter's requests; on expiry the excerpt is used. */
|
|
255
|
+
timeoutMs: number;
|
|
256
|
+
}
|
|
257
|
+
/** Relevance compaction (relevance.ts): Jev picks what of the discarded span is kept word for word instead of Pi's summary. */
|
|
258
|
+
export interface CompactionConfig {
|
|
259
|
+
/** Replace Pi's compaction summary with a relevance compaction. Off by default. User file only: it sends data and spends requests. */
|
|
260
|
+
enabled: boolean;
|
|
261
|
+
/** P(needed again) at or above which a unit is kept word for word. */
|
|
262
|
+
keepThreshold: number;
|
|
263
|
+
/** Size budget for the summary in tokens (characters / 4); over it the threshold is raised, then Pi's summary runs. */
|
|
264
|
+
maxSummaryTokens: number;
|
|
265
|
+
/** Deadline for one whole compaction; past it Pi's summary runs. Each request is also bounded by the global `timeoutMs`. */
|
|
266
|
+
timeoutMs: number;
|
|
267
|
+
/** Requests one compaction may send; a span that needs more keeps Pi's summary and sends nothing. */
|
|
268
|
+
maxRequests: number;
|
|
269
|
+
/** Providers of the active model for which Pi's summary always runs (a provider that compacts on its own). */
|
|
270
|
+
skipProviders: string[];
|
|
231
271
|
}
|
|
232
272
|
export interface LargeOutputConfig {
|
|
233
273
|
enabled: boolean;
|
|
@@ -313,16 +353,12 @@ export interface SteersConfig {
|
|
|
313
353
|
probeEvery: number;
|
|
314
354
|
}
|
|
315
355
|
export interface LearningConfig {
|
|
316
|
-
/** Enable adaptive thresholds based on learning data. */
|
|
317
|
-
adaptiveThresholds: boolean;
|
|
318
356
|
/** Enable pattern analysis and recommendations. */
|
|
319
357
|
patternAnalysis: boolean;
|
|
320
|
-
/** Minimum number of holds before adaptive thresholds kick in. */
|
|
321
|
-
minHoldsForAdaptive: number;
|
|
322
|
-
/** How aggressively to adjust thresholds (0-1). Higher values mean faster adaptation. */
|
|
323
|
-
adaptationRate: number;
|
|
324
358
|
/** Days to keep hold records in SQLite before pruning. 0 disables pruning. */
|
|
325
359
|
retentionDays: number;
|
|
360
|
+
/** Days to keep records of calls that were not held; they repeat their task text across a turn and are the bulk of the file. 0 disables this pruning. */
|
|
361
|
+
allowedRetentionDays: number;
|
|
326
362
|
}
|
|
327
363
|
export type ConscienceSkillMode = "off" | "recommend" | "load";
|
|
328
364
|
export interface ConscienceSkillConfig {
|
|
@@ -360,8 +396,10 @@ export interface ConscienceConfig {
|
|
|
360
396
|
recommendThreshold: number;
|
|
361
397
|
/** P(advance) from the disposition question must reach this before a candidate is considered. Default 0.70. */
|
|
362
398
|
advanceThreshold: number;
|
|
363
|
-
/**
|
|
364
|
-
|
|
399
|
+
/** Candidates the local ranker keeps for Jev (1..256); the rest never reach a request. Default 31, the most one request holds; the labelled replay keeps 8 of 9 good picks at 31 and fewer at 12 or 20. */
|
|
400
|
+
localTopK: number;
|
|
401
|
+
/** Local BM25 floor against the request and the task spine; below it the conscience sends no request. Default 0.5. */
|
|
402
|
+
localFloor: number;
|
|
365
403
|
}
|
|
366
404
|
export interface WardenConfig {
|
|
367
405
|
/** Master switch. false disables every guard, including offline pattern checks. */
|
|
@@ -388,6 +426,8 @@ export interface WardenConfig {
|
|
|
388
426
|
slop: SlopGuardConfig;
|
|
389
427
|
security: SecurityConfig;
|
|
390
428
|
rules: RulesConfig;
|
|
429
|
+
/** A rules reminder at the start of each user turn (curator.ts). */
|
|
430
|
+
rulesAtTurnStart: RulesAtTurnStartConfig;
|
|
391
431
|
context: ContextConfig;
|
|
392
432
|
runaway: RunawayConfig;
|
|
393
433
|
notify: NotifyConfig;
|
|
@@ -414,10 +454,14 @@ export interface WardenConfig {
|
|
|
414
454
|
prefs: PrefsConfig;
|
|
415
455
|
/** Call-waste notes: advisory sentences attached to the tool result that triggers them. */
|
|
416
456
|
waste: WasteConfig;
|
|
457
|
+
/** Relevance compaction in place of Pi's compaction summary. Off by default. */
|
|
458
|
+
compaction: CompactionConfig;
|
|
459
|
+
/** Config values that were not applied as written, one message each; shown once per session and in `/warden status`. */
|
|
460
|
+
warnings: string[];
|
|
417
461
|
}
|
|
418
462
|
export declare const PACKAGE_NAME = "pi-warden";
|
|
419
463
|
/** Bumped when WardenConfig gains a section; extension.ts checks it so a half-updated module graph is reported, not crashed on. */
|
|
420
|
-
export declare const CONFIG_SCHEMA =
|
|
464
|
+
export declare const CONFIG_SCHEMA = 12;
|
|
421
465
|
export declare const PROJECT_CONFIG_FILE = "pi-warden.json";
|
|
422
466
|
export declare function defaultConfig(): WardenConfig;
|
|
423
467
|
/** The user file is in the host's agent directory: `~/.pi/agent` on Pi, `~/.omp/agent` on oh-my-pi. `PI_CODING_AGENT_DIR` overrides it. */
|
|
@@ -425,9 +469,18 @@ export declare function userConfigPath(dirs?: HostDirs): string;
|
|
|
425
469
|
export declare function projectConfigPath(cwd: string, dirs?: HostDirs): string;
|
|
426
470
|
type Json = Record<string, unknown>;
|
|
427
471
|
export declare function isMode(value: unknown): value is WardenMode;
|
|
472
|
+
/**
|
|
473
|
+
* An arming duration in milliseconds: a number is milliseconds; a string takes `ms`, `s`, `m`, or `h`, and a string
|
|
474
|
+
* without a unit means minutes. Zero, negative, and unreadable values give the fallback. The one parser for `arms.for`.
|
|
475
|
+
*/
|
|
476
|
+
export declare function parseDuration(raw: unknown, fallback: number): number;
|
|
477
|
+
export declare const DEFAULT_ARMING_DURATION = 600000;
|
|
428
478
|
/** Unknown keys and invalid values fall back to the base; nothing throws on a malformed file. */
|
|
429
479
|
export declare function applyUserOverrides(base: WardenConfig, raw: unknown): WardenConfig;
|
|
430
|
-
/**
|
|
480
|
+
/**
|
|
481
|
+
* Project files may tune the guards but cannot grant TypeSafe consent, change the mode, or raise budgets. The action
|
|
482
|
+
* guard and security only get stricter: a value that would weaken them is ignored with one config warning.
|
|
483
|
+
*/
|
|
431
484
|
export declare function applyProjectOverrides(base: WardenConfig, raw: unknown): WardenConfig;
|
|
432
485
|
export interface LoadOptions {
|
|
433
486
|
cwd?: string;
|