pi-warden 0.23.0 → 0.26.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 +34 -0
- package/README.md +3 -1
- package/dist/arming.d.ts +60 -0
- package/dist/arming.js +190 -0
- package/dist/backend.d.ts +23 -0
- package/dist/backend.js +36 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +154 -2
- package/dist/extension.js +190 -17
- package/dist/extension.js.bak +1262 -0
- package/dist/guard.d.ts +31 -4
- package/dist/guard.js +274 -9
- package/dist/holds.d.ts +5 -1
- package/dist/holds.js +3 -1
- package/dist/learning.d.ts +68 -0
- package/dist/learning.js +190 -0
- package/dist/rules.js +1 -1
- package/dist/shape.js +14 -1
- package/dist/widget.js +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,40 @@ Notable changes to pi-warden, newest first. Versions follow semver. The publishe
|
|
|
4
4
|
|
|
5
5
|
How to keep this current: add the entry in the same pull request as the change, under `Unreleased`. The release commit renames `Unreleased` to the version it ships and adds its own notes. Entries before 0.10.0 are one-line summaries taken from the release commit headers; the detail for those is in `git log`.
|
|
6
6
|
|
|
7
|
+
## Unreleased
|
|
8
|
+
|
|
9
|
+
<!-- Empty. Next release starts here. -->
|
|
10
|
+
|
|
11
|
+
## 0.26.0
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- OpenRouter as a configurable judgment backend (`typesafeBackend` in user config). Set to `"openrouter"` to route Jev decisions through OpenRouter's decisions API instead of api.typesafe.ai. New `src/backend.ts` module; `OPENROUTER_API_KEY` env var; project overrides cannot redirect judgments.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Bumped pi-typesafe to ^0.6.0 (native `backend` option on `TypeSafeOptions`).
|
|
20
|
+
- Deduplicated `resolveBackend` call in `shape.ts`.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Host-TUI capability guard: the extension no longer crashes when the host's bundled `pi-tui` lacks the `MouseRegion` component (e.g. omp 18.2.5). A missing named import was a link-time error that silently killed every guard, the `/warden` command, and the shortcut. The import is now a namespace property read (`tuiModule.MouseRegion`), which degrades to `undefined` instead of failing. The status widget renders the same guard text without the clickable wrapper.
|
|
25
|
+
|
|
26
|
+
## 0.25.0
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Smart hold learning system (src/learning.ts). Records full context (task, plan, conversation, agent reason) with each hold decision and predicts outcomes using historical patterns. SQLite database at ~/.pi/agent/pi-warden/holds.db. Query functions weight exact matches 5x, similar matches 2x, and same-reason matches 1x, with time decay. Never skips destructive patterns.
|
|
31
|
+
|
|
32
|
+
## 0.24.0
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- User-defined command rules (`action.commandRules`, `action.commandDenyRules`, `action.exemptRules`, user config only): your own patterns on the same data-text-stripped command the built-ins read, with `warn`/`confirm`/`deny` severity. `confirm` defaults to a user dialog in every mode; `deny` blocks with no dialog and no TypeSafe request; `exemptRules` silences built-ins, the `rm` classifier ids, `sensitive-path`, and your own rules by id. The `deny` verdict renders as a chip in the status line and the trace sidebar.
|
|
37
|
+
- Overnight eval stability proof: 109 cycles, 13,952 cases, 100% pass rate, zero score drift ([docs/overnight-eval.md](docs/overnight-eval.md), [eval/reports/2026-09-18-overnight-stability/](eval/reports/2026-09-18-overnight-stability/)). Confirms that guard thresholds are deterministic against the TypeSafe API and the case set is a reliable regression gate.
|
|
38
|
+
- User-defined path rules (`action.pathRules`, user config only): a path dimension for the pattern floor — `{ id, paths, access, tools, action, message?, onlyIfExists?, regex? }`. `access` names the side that flows (`"none"` holds any touch, `"read"` holds writes, `"write"` holds reads); file tools match the structured `path` field, the bash surface matches whole-text mentions and redirect/`tee` write sinks only — never arbitrary argv tokens. `action` maps to `note`/`warn`/`confirm` (dialog)/`block` (deny); `exemptRules` silences a path rule by id. A `read`-scoped rule needs `read` added to `action.tools` (read tools are not inspected by default).
|
|
39
|
+
- User-defined arming rules (`action.armingRules`, user config only): session-scoped rules that correlate a preparation edit with a later command — `{ id, when: { edited, regex?, tools? }, arms: { command, for?, caseSensitive? }, action, message? }`. Editing a file matching a `when.edited` glob arms `arms.command` for `arms.for` (default 10 minutes); while armed, matching commands fire the rule's `action` (`confirm` dialog, `hold` steer, `block` deny). State lives for the rule's window within a session, is visible in `/warden status`, and never depends on Jev.
|
|
40
|
+
|
|
7
41
|
## 0.23.0
|
|
8
42
|
|
|
9
43
|
### Added
|
package/README.md
CHANGED
|
@@ -59,6 +59,8 @@ The clipping task shows the shape of the effect. Without the warden, 6 of 10 glm
|
|
|
59
59
|
|
|
60
60
|
Run it on your own model: `npm run eval:ab -- --repeats 3 --model <model>` (it asks you to pick a model on purpose, because a full batch spends real tokens). Per-batch reports, including the runs where nothing changed, live in [eval/reports/](eval/reports/README.md).
|
|
61
61
|
|
|
62
|
+
The guards are also stable over repeated invocations. A continuous overnight run (2026-09-18) evaluated every guard across **109 cycles, 13,952 cases, 100% pass rate, zero score drift**. The same synthetic input produces the same judgment within rounding noise across all runs. Full numbers: [docs/overnight-eval.md](docs/overnight-eval.md) and [eval/reports/2026-09-18-overnight-stability/](eval/reports/2026-09-18-overnight-stability/).
|
|
63
|
+
|
|
62
64
|
### Why a strong model does not make this pointless
|
|
63
65
|
|
|
64
66
|
A guard like this is a smoke alarm. Most days it says nothing, and that is the design: it only pays off on the run where the model is tired, compacted, four hours in, or confident about a shortcut you would have caught in review. Four of its ten guards do not depend on the model being weak at all:
|
|
@@ -140,7 +142,7 @@ Every guarded call (`bash`, `write`, `edit`, and shell-like tools such as contex
|
|
|
140
142
|
1. **Read-only? Skip.** `git status && ls` or a `read`: no request, no line in the trace.
|
|
141
143
|
2. **Pattern list, offline.** Force push, `git reset --hard`, recursive `rm` outside the project, SQL `DROP`/`TRUNCATE`/`DELETE FROM`, `curl | sh`, publishing, infrastructure destroys: the call is held. Risky but recoverable things (`rm -rf` on a project path, `sudo`, `--no-verify`, `gh pr merge`, touching `.env`) warn. Text that is data, such as a commit message that mentions `git push --force`, is not a command.
|
|
142
144
|
3. **Jev, with your consent.** One request carries your request, up to eight earlier messages, the agent's own words right before the call (its plan), and the call itself. Jev answers fixed questions with probabilities: irreversible, off task, does it change anything, how it relates to the task, does it contradict the plan, is the effect visible outside the working tree. On a write the slop and security questions ride along, and the rules check is a second request in parallel. About 0.3 s before the tool runs.
|
|
143
|
-
4. **Act.**
|
|
145
|
+
4. **Act.** Three things hold a call: a destructive pattern, a `deny` command rule (blocked outright, no dialog), or `irreversible` at 0.7 or above. Everything else (off task, plan mismatch, slop, a rule violation, a security smell) tells the agent and lets the call run.
|
|
144
146
|
|
|
145
147
|
In the default `steer` mode a hold goes back to the agent as its tool result with two acceptable next moves: find a recoverable way, or explain the action to you in a sentence and wait. If your reply approves it, the retry goes through. `confirm` mode shows you a dialog instead; `advise` never holds.
|
|
146
148
|
|
package/dist/arming.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped arming rules: a preparation (editing files matching globs) arms a
|
|
3
|
+
* command pattern for a window; while armed, matching commands fire the rule's action.
|
|
4
|
+
*
|
|
5
|
+
* The tracker is in-memory per session. State lives for the rule's window within a session;
|
|
6
|
+
* it is cleared when the session starts anew (session_start) and expires per `for`. It
|
|
7
|
+
* survives across agent runs within the same session (the Scenario B case: edit in one
|
|
8
|
+
* turn, reconcile in the next). No cross-session persistence.
|
|
9
|
+
*
|
|
10
|
+
* The armed check is deterministic: it fires whether or not Jev is available. If Jev
|
|
11
|
+
* is available, armed-rule names ride as context so the judge can weigh them, but the
|
|
12
|
+
* action never depends on Jev.
|
|
13
|
+
*/
|
|
14
|
+
import type { ArmingRule } from "./config.js";
|
|
15
|
+
/** Ids of rules whose regex or duration could not be compiled; surfaced via the one-time notice channel. */
|
|
16
|
+
export declare function unparseableArmingRules(rules: readonly ArmingRule[]): string[];
|
|
17
|
+
export type NowFn = () => number;
|
|
18
|
+
/**
|
|
19
|
+
* In-memory arming state for one session.
|
|
20
|
+
*
|
|
21
|
+
* `arm` is called on write/edit calls whose `input.path` matches a rule's `when.edited` globs.
|
|
22
|
+
* `checkArmed` is called on command calls to see if any armed rule's `arms.command` regex matches.
|
|
23
|
+
* `reset` clears all state (called on `agent_end`).
|
|
24
|
+
*/
|
|
25
|
+
export declare class ArmingTracker {
|
|
26
|
+
private readonly armed;
|
|
27
|
+
private compiled;
|
|
28
|
+
private now;
|
|
29
|
+
constructor(rules: readonly ArmingRule[], now?: NowFn);
|
|
30
|
+
/** Set the clock function (for testing with injectable time). */
|
|
31
|
+
setNow(now: NowFn): void;
|
|
32
|
+
/** Update the compiled rules from config; recompiles only when the reference changes. */
|
|
33
|
+
private lastRulesRef;
|
|
34
|
+
updateRules(rules: readonly ArmingRule[]): void;
|
|
35
|
+
/**
|
|
36
|
+
* Record a write/edit call. If the tool is in a rule's `when.tools` (default ["write","edit"])
|
|
37
|
+
* and the path matches a `when.edited` glob, arm (or refresh) that rule.
|
|
38
|
+
* Returns the ids of rules that were armed or refreshed by this call.
|
|
39
|
+
*/
|
|
40
|
+
arm(tool: string, path: string | undefined, cwd: string, sinkTargets?: readonly string[]): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Check whether any armed rule's command regex matches the given command.
|
|
43
|
+
* Returns the matching rules (with their compiled config) for the extension to act on.
|
|
44
|
+
* Expired rules are pruned first.
|
|
45
|
+
*/
|
|
46
|
+
checkArmed(command: string): {
|
|
47
|
+
id: string;
|
|
48
|
+
action: ArmingRule["action"];
|
|
49
|
+
message?: string;
|
|
50
|
+
armedByPaths: string[];
|
|
51
|
+
}[];
|
|
52
|
+
/** Names of currently armed rules (for Jev context). */
|
|
53
|
+
armedRuleIds(): string[];
|
|
54
|
+
/** Human-readable status line for `/warden status`. */
|
|
55
|
+
statusLine(): string;
|
|
56
|
+
/** Clear all arming state (called on session_start). */
|
|
57
|
+
reset(): void;
|
|
58
|
+
/** Remove expired entries. */
|
|
59
|
+
private prune;
|
|
60
|
+
}
|
package/dist/arming.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped arming rules: a preparation (editing files matching globs) arms a
|
|
3
|
+
* command pattern for a window; while armed, matching commands fire the rule's action.
|
|
4
|
+
*
|
|
5
|
+
* The tracker is in-memory per session. State lives for the rule's window within a session;
|
|
6
|
+
* it is cleared when the session starts anew (session_start) and expires per `for`. It
|
|
7
|
+
* survives across agent runs within the same session (the Scenario B case: edit in one
|
|
8
|
+
* turn, reconcile in the next). No cross-session persistence.
|
|
9
|
+
*
|
|
10
|
+
* The armed check is deterministic: it fires whether or not Jev is available. If Jev
|
|
11
|
+
* is available, armed-rule names ride as context so the judge can weigh them, but the
|
|
12
|
+
* action never depends on Jev.
|
|
13
|
+
*/
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { matchPathGlobs, stripDataText } from "./guard.js";
|
|
17
|
+
/** Compile a rule, returning undefined if the regex is invalid. compileArmingRules returns the ids of rules that failed. */
|
|
18
|
+
function compileArmingRule(rule) {
|
|
19
|
+
try {
|
|
20
|
+
const flags = rule.arms.caseSensitive ? "" : "i";
|
|
21
|
+
const duration = parseDuration(rule.arms.for);
|
|
22
|
+
return { rule, commandRegex: new RegExp(rule.arms.command, flags), durationMs: duration };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
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
|
+
/** Ids of rules whose regex or duration could not be compiled; surfaced via the one-time notice channel. */
|
|
49
|
+
export function unparseableArmingRules(rules) {
|
|
50
|
+
const ids = [];
|
|
51
|
+
for (const rule of rules) {
|
|
52
|
+
try {
|
|
53
|
+
new RegExp(rule.arms.command, rule.arms.caseSensitive ? "" : "i");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
ids.push(rule.id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return ids;
|
|
60
|
+
}
|
|
61
|
+
/** Match a candidate path against a rule's edited globs/regexes (reuses the shared matcher from guard.ts). */
|
|
62
|
+
function editedMatches(rule, candidate) {
|
|
63
|
+
return matchPathGlobs(rule.when.edited, rule.when.regex ?? false, candidate);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* In-memory arming state for one session.
|
|
67
|
+
*
|
|
68
|
+
* `arm` is called on write/edit calls whose `input.path` matches a rule's `when.edited` globs.
|
|
69
|
+
* `checkArmed` is called on command calls to see if any armed rule's `arms.command` regex matches.
|
|
70
|
+
* `reset` clears all state (called on `agent_end`).
|
|
71
|
+
*/
|
|
72
|
+
export class ArmingTracker {
|
|
73
|
+
armed = new Map();
|
|
74
|
+
compiled;
|
|
75
|
+
now;
|
|
76
|
+
constructor(rules, now = Date.now) {
|
|
77
|
+
this.now = now;
|
|
78
|
+
this.compiled = rules.map(compileArmingRule).filter((r) => r !== undefined);
|
|
79
|
+
}
|
|
80
|
+
/** Set the clock function (for testing with injectable time). */
|
|
81
|
+
setNow(now) {
|
|
82
|
+
this.now = now;
|
|
83
|
+
}
|
|
84
|
+
/** Update the compiled rules from config; recompiles only when the reference changes. */
|
|
85
|
+
lastRulesRef;
|
|
86
|
+
updateRules(rules) {
|
|
87
|
+
if (rules === this.lastRulesRef)
|
|
88
|
+
return;
|
|
89
|
+
this.lastRulesRef = rules;
|
|
90
|
+
this.compiled = rules.map(compileArmingRule).filter((r) => r !== undefined);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Record a write/edit call. If the tool is in a rule's `when.tools` (default ["write","edit"])
|
|
94
|
+
* and the path matches a `when.edited` glob, arm (or refresh) that rule.
|
|
95
|
+
* Returns the ids of rules that were armed or refreshed by this call.
|
|
96
|
+
*/
|
|
97
|
+
arm(tool, path, cwd, sinkTargets) {
|
|
98
|
+
if (!path && (!sinkTargets || sinkTargets.length === 0))
|
|
99
|
+
return [];
|
|
100
|
+
const now = this.now();
|
|
101
|
+
const armed = new Set();
|
|
102
|
+
const allCandidates = [path, ...(sinkTargets ?? [])].filter((p) => typeof p === "string" && p.length > 0);
|
|
103
|
+
for (const { rule, durationMs } of this.compiled) {
|
|
104
|
+
const tools = rule.when.tools ?? ["write", "edit"];
|
|
105
|
+
// The structured path is checked only when the tool matches when.tools (write/edit by default).
|
|
106
|
+
// Sink targets (redirect/tee) are checked regardless of tool: a bash redirect IS a write to that path,
|
|
107
|
+
// and the spec says bash edits that create the same condition via redirect arm when the glob matches.
|
|
108
|
+
const structuredMatch = tools.includes(tool) && allCandidates.some(candidate => {
|
|
109
|
+
const home = homedir();
|
|
110
|
+
const expanded = candidate === "~" || candidate.startsWith("~/") ? home + candidate.slice(1) : candidate;
|
|
111
|
+
const abs = resolve(cwd, expanded);
|
|
112
|
+
return editedMatches(rule, candidate) || editedMatches(rule, abs);
|
|
113
|
+
});
|
|
114
|
+
// When the tool is not in when.tools (e.g. bash), only sink targets are checked.
|
|
115
|
+
const sinkCandidates = tools.includes(tool) ? allCandidates : (sinkTargets ?? []).filter((p) => typeof p === "string" && p.length > 0);
|
|
116
|
+
const sinkMatch = sinkCandidates.some(candidate => {
|
|
117
|
+
const home = homedir();
|
|
118
|
+
const expanded = candidate === "~" || candidate.startsWith("~/") ? home + candidate.slice(1) : candidate;
|
|
119
|
+
const abs = resolve(cwd, expanded);
|
|
120
|
+
return editedMatches(rule, candidate) || editedMatches(rule, abs);
|
|
121
|
+
});
|
|
122
|
+
if (!structuredMatch && !sinkMatch)
|
|
123
|
+
continue;
|
|
124
|
+
const existing = this.armed.get(rule.id);
|
|
125
|
+
const paths = existing?.armedByPaths ?? [];
|
|
126
|
+
// Dedup the arming path.
|
|
127
|
+
const displayPath = path ?? allCandidates[0];
|
|
128
|
+
if (!paths.includes(displayPath))
|
|
129
|
+
paths.push(displayPath);
|
|
130
|
+
this.armed.set(rule.id, { expiresAt: now + durationMs, armedByPaths: paths });
|
|
131
|
+
armed.add(rule.id);
|
|
132
|
+
}
|
|
133
|
+
this.prune(now);
|
|
134
|
+
return [...armed];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Check whether any armed rule's command regex matches the given command.
|
|
138
|
+
* Returns the matching rules (with their compiled config) for the extension to act on.
|
|
139
|
+
* Expired rules are pruned first.
|
|
140
|
+
*/
|
|
141
|
+
checkArmed(command) {
|
|
142
|
+
// Fix 5: avoid stripDataText (a non-trivial parse) when nothing is armed — the common case.
|
|
143
|
+
if (this.armed.size === 0)
|
|
144
|
+
return [];
|
|
145
|
+
const now = this.now();
|
|
146
|
+
this.prune(now);
|
|
147
|
+
if (this.armed.size === 0)
|
|
148
|
+
return []; // prune may have expired everything
|
|
149
|
+
const text = stripDataText(command).text;
|
|
150
|
+
const hits = [];
|
|
151
|
+
for (const { rule, commandRegex } of this.compiled) {
|
|
152
|
+
const entry = this.armed.get(rule.id);
|
|
153
|
+
if (!entry)
|
|
154
|
+
continue;
|
|
155
|
+
if (commandRegex.test(text)) {
|
|
156
|
+
hits.push({ id: rule.id, action: rule.action, ...(rule.message ? { message: rule.message } : {}), armedByPaths: entry.armedByPaths });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return hits;
|
|
160
|
+
}
|
|
161
|
+
/** Names of currently armed rules (for Jev context). */
|
|
162
|
+
armedRuleIds() {
|
|
163
|
+
this.prune(this.now());
|
|
164
|
+
return [...this.armed.keys()];
|
|
165
|
+
}
|
|
166
|
+
/** Human-readable status line for `/warden status`. */
|
|
167
|
+
statusLine() {
|
|
168
|
+
this.prune(this.now());
|
|
169
|
+
if (this.armed.size === 0)
|
|
170
|
+
return "";
|
|
171
|
+
const parts = [];
|
|
172
|
+
for (const [id, entry] of this.armed) {
|
|
173
|
+
const remaining = Math.max(0, entry.expiresAt - this.now());
|
|
174
|
+
const mins = Math.ceil(remaining / 60_000);
|
|
175
|
+
parts.push(`${id} (${mins}m left)`);
|
|
176
|
+
}
|
|
177
|
+
return `${this.armed.size} armed: ${parts.join(", ")}`;
|
|
178
|
+
}
|
|
179
|
+
/** Clear all arming state (called on session_start). */
|
|
180
|
+
reset() {
|
|
181
|
+
this.armed.clear();
|
|
182
|
+
}
|
|
183
|
+
/** Remove expired entries. */
|
|
184
|
+
prune(now) {
|
|
185
|
+
for (const [id, entry] of this.armed) {
|
|
186
|
+
if (entry.expiresAt <= now)
|
|
187
|
+
this.armed.delete(id);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TypeSafeOptions } from "pi-typesafe";
|
|
2
|
+
/** The judgment backend that receives pi-warden's Jev requests. */
|
|
3
|
+
export type JudgmentBackend = "typesafe" | "openrouter";
|
|
4
|
+
/** The host the consent disclosure names as the destination. */
|
|
5
|
+
export declare function backendHost(backend: JudgmentBackend): string;
|
|
6
|
+
/** Adapt the consent text to the active backend by substituting the destination host. */
|
|
7
|
+
export declare function disclosureFor(backend: JudgmentBackend, disclosure: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Whether the right credential exists for the chosen backend.
|
|
10
|
+
* TypeSafe uses the pi-typesafe keystore; other backends use an environment variable.
|
|
11
|
+
*/
|
|
12
|
+
export declare function keyAvailable(backend: JudgmentBackend, env: Record<string, string | undefined>, keystoreUsable: () => boolean): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Build the options forwarded to `createTypeSafe`.
|
|
15
|
+
* The caller must gate on `keyAvailable` before calling `createTypeSafe`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function judgeOptions(config: {
|
|
18
|
+
maxRequests: number;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
typesafeBackend: JudgmentBackend;
|
|
21
|
+
}): TypeSafeOptions;
|
|
22
|
+
/** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */
|
|
23
|
+
export declare function resolveBackend(raw: unknown): JudgmentBackend;
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { authState } from "pi-typesafe";
|
|
2
|
+
const BACKENDS = {
|
|
3
|
+
typesafe: { host: "api.typesafe.ai" },
|
|
4
|
+
openrouter: { host: "openrouter.ai", keyEnv: "OPENROUTER_API_KEY" },
|
|
5
|
+
};
|
|
6
|
+
/** The host the consent disclosure names as the destination. */
|
|
7
|
+
export function backendHost(backend) {
|
|
8
|
+
return BACKENDS[backend].host;
|
|
9
|
+
}
|
|
10
|
+
/** Adapt the consent text to the active backend by substituting the destination host. */
|
|
11
|
+
export function disclosureFor(backend, disclosure) {
|
|
12
|
+
return backend === "typesafe" ? disclosure : disclosure.replace(BACKENDS.typesafe.host, BACKENDS[backend].host);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether the right credential exists for the chosen backend.
|
|
16
|
+
* TypeSafe uses the pi-typesafe keystore; other backends use an environment variable.
|
|
17
|
+
*/
|
|
18
|
+
export function keyAvailable(backend, env, keystoreUsable) {
|
|
19
|
+
const keyEnv = BACKENDS[backend].keyEnv;
|
|
20
|
+
return keyEnv ? (env[keyEnv]?.trim() ?? "") !== "" : keystoreUsable();
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Build the options forwarded to `createTypeSafe`.
|
|
24
|
+
* The caller must gate on `keyAvailable` before calling `createTypeSafe`.
|
|
25
|
+
*/
|
|
26
|
+
export function judgeOptions(config) {
|
|
27
|
+
return {
|
|
28
|
+
maxRequests: config.maxRequests,
|
|
29
|
+
timeoutMs: config.timeoutMs,
|
|
30
|
+
...(config.typesafeBackend === "typesafe" ? {} : { backend: config.typesafeBackend }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Resolve the effective backend from a raw config value; invalid values fall back to "typesafe". */
|
|
34
|
+
export function resolveBackend(raw) {
|
|
35
|
+
return raw === "typesafe" || raw === "openrouter" ? raw : "typesafe";
|
|
36
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { JudgmentBackend } from "./backend.js";
|
|
1
2
|
import type { WidgetConfig } from "./widget.js";
|
|
2
3
|
export interface Threshold {
|
|
3
4
|
/** P(yes) at or above this shows a warning and continues. */
|
|
@@ -11,6 +12,21 @@ export interface OffTaskThreshold {
|
|
|
11
12
|
/** P(off-task) at or above this, with scope unrelated on a call that can change something, also steers the agent back to the task. Never holds: on 17k recorded calls off-task holds caught nothing the user regretted. */
|
|
12
13
|
steer: number;
|
|
13
14
|
}
|
|
15
|
+
/** A user-defined command rule, matched against the stripDataText-processed command. */
|
|
16
|
+
export interface CommandRule {
|
|
17
|
+
/** Stable id; same namespace as built-in rule ids, so exemptRules can reference either. */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Regex source string; compiled case-insensitively unless caseSensitive is true. */
|
|
20
|
+
pattern: string;
|
|
21
|
+
/** warn: notice to the agent; confirm: hold (action dialog steers); deny: block with no dialog. */
|
|
22
|
+
severity: "warn" | "confirm" | "deny";
|
|
23
|
+
/** When severity is confirm, dialog (default) prompts the user; hold uses steer semantics. */
|
|
24
|
+
action?: "dialog" | "hold";
|
|
25
|
+
/** Optional human label shown instead of the derived one. */
|
|
26
|
+
message?: string;
|
|
27
|
+
/** Match case-sensitively. */
|
|
28
|
+
caseSensitive?: boolean;
|
|
29
|
+
}
|
|
14
30
|
export interface ActionGuardConfig {
|
|
15
31
|
enabled: boolean;
|
|
16
32
|
/** Tool names inspected before execution. Read-only tools are skipped to keep latency low. */
|
|
@@ -27,6 +43,65 @@ export interface ActionGuardConfig {
|
|
|
27
43
|
visibleMismatch: number;
|
|
28
44
|
/** 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. */
|
|
29
45
|
feedbackLog: boolean;
|
|
46
|
+
/** User-defined command rules (user file only; project files cannot set severity above warn). */
|
|
47
|
+
commandRules: CommandRule[];
|
|
48
|
+
/** User-defined deny rules, shorthand for commandRules with severity deny (user file only). */
|
|
49
|
+
commandDenyRules: CommandRule[];
|
|
50
|
+
/** Built-in or user rule ids to exempt (user file only). */
|
|
51
|
+
exemptRules: string[];
|
|
52
|
+
/** User-defined path rules with an access dimension (user file only; project files cannot act on them). */
|
|
53
|
+
pathRules: PathRule[];
|
|
54
|
+
/** User-defined arming rules: editing files matching globs arms a command pattern for a window (user file only). */
|
|
55
|
+
armingRules: ArmingRule[];
|
|
56
|
+
}
|
|
57
|
+
/** A user-defined path rule: which paths, which side of the access is held, which tools, what happens on a hit. */
|
|
58
|
+
export interface PathRule {
|
|
59
|
+
/** Stable id; same namespace as rule ids, so exemptRules can silence a user path rule too. */
|
|
60
|
+
id: string;
|
|
61
|
+
/** Path globs (`**` any depth, `*` one segment, `?` one character, `~` expands) or regex sources with regex: true. */
|
|
62
|
+
paths: string[];
|
|
63
|
+
/** Regex instead of glob for shapes globs cannot express. */
|
|
64
|
+
regex?: boolean;
|
|
65
|
+
/** Which touches match: "none" any touch; "read" writes only (reads flow); "write" reads only (writes flow). */
|
|
66
|
+
access: "none" | "read" | "write";
|
|
67
|
+
/** Which surfaces check the rule: file tools by name ("write", "edit", "read"), or "*" to also match bash commands. */
|
|
68
|
+
tools: string[];
|
|
69
|
+
/** note: tell the agent after the fact (the default, today's sensitive-path behavior); warn: notice; confirm: dialog; block: deny. */
|
|
70
|
+
action: "note" | "warn" | "confirm" | "block";
|
|
71
|
+
/** Optional human label shown instead of the derived one. */
|
|
72
|
+
message?: string;
|
|
73
|
+
/** Skip when the path does not exist (default true): phantom paths do not fire. */
|
|
74
|
+
onlyIfExists?: boolean;
|
|
75
|
+
}
|
|
76
|
+
/** A user-defined arming rule: a preparation (editing files matching globs) arms a command pattern for a window.
|
|
77
|
+
* While armed, matching commands fire the rule's action. This is the fix for the class of incident where each
|
|
78
|
+
* individual call was harmless (edit a config, then run the reconciler that applies it) but the composition was
|
|
79
|
+
* destructive — no single-call rule can catch it. */
|
|
80
|
+
export interface ArmingRule {
|
|
81
|
+
/** Stable id; same namespace as rule ids, so exemptRules can silence an arming rule too. */
|
|
82
|
+
id: string;
|
|
83
|
+
/** The preparation: editing files matching these globs arms the rule. */
|
|
84
|
+
when: {
|
|
85
|
+
/** Path globs (`**` any depth, `*` one segment, `?` one character, `~` expands) or regex sources with regex: true. */
|
|
86
|
+
edited: string[];
|
|
87
|
+
/** Regex instead of glob for shapes globs cannot express. */
|
|
88
|
+
regex?: boolean;
|
|
89
|
+
/** Which file tools arm: default ["write", "edit"]. */
|
|
90
|
+
tools?: string[];
|
|
91
|
+
};
|
|
92
|
+
/** The armed command: while armed, commands matching this regex fire the rule's action. */
|
|
93
|
+
arms: {
|
|
94
|
+
/** Regex source string; compiled case-insensitively unless caseSensitive is true. */
|
|
95
|
+
command: string;
|
|
96
|
+
/** How long the rule stays armed after the last matching edit; default "10m". */
|
|
97
|
+
for?: string | number;
|
|
98
|
+
/** Match the command case-sensitively. */
|
|
99
|
+
caseSensitive?: boolean;
|
|
100
|
+
};
|
|
101
|
+
/** confirm: dialog (user-invoked prompt); hold: steer hold; block: deny. */
|
|
102
|
+
action: "confirm" | "hold" | "block";
|
|
103
|
+
/** Optional human label shown in the dialog/status. */
|
|
104
|
+
message?: string;
|
|
30
105
|
}
|
|
31
106
|
export interface StuckGuardConfig {
|
|
32
107
|
enabled: boolean;
|
|
@@ -143,6 +218,8 @@ export interface WardenConfig {
|
|
|
143
218
|
enabled: boolean;
|
|
144
219
|
/** Consent to send task and action summaries to api.typesafe.ai. Set by /warden enable; never by a project file. */
|
|
145
220
|
typesafe: boolean;
|
|
221
|
+
/** The decisions service the judgments go to. User file only: a project must not redirect judgments to another vendor. */
|
|
222
|
+
typesafeBackend: JudgmentBackend;
|
|
146
223
|
/**
|
|
147
224
|
* steer (default): a confirm-level call is held and the agent receives the judgment as its tool result, so it re-plans or asks
|
|
148
225
|
* the user in chat. confirm: open a dialog and let the user decide (falls back to steer without a UI). advise: never hold; report only.
|