@hank-warren/pi-auto-permissions 0.12.1 → 0.13.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 +28 -0
- package/README.md +100 -5
- package/config-writer.ts +28 -0
- package/config.ts +145 -6
- package/default-rules.ts +265 -0
- package/denial-log.ts +111 -0
- package/evaluation-log.ts +27 -1
- package/gates.ts +29 -1
- package/guardian-transport.ts +6 -6
- package/index.ts +478 -14
- package/package.json +6 -2
- package/review.ts +97 -11
- package/session-environment.ts +73 -0
- package/settings-menu.ts +137 -0
- package/setup-scan.ts +262 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @hank-warren/pi-auto-permissions
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 488b4bd: Auto Permissions becomes a review-by-default competitor to Claude Code auto mode.
|
|
8
|
+
|
|
9
|
+
**Migration note:** a fresh install (or a config with no `rules` key) now runs the built-in default ruleset — deny rules for agent-oversight bypasses and critical-path destruction, guardian review for force pushes, infrastructure destroys, credential access, and the other default groups. `"rules": []` restores the old gate-nothing behavior; an authored `rules` array is unchanged; `"$defaults"` inside an authored array splices the built-ins in.
|
|
10
|
+
|
|
11
|
+
New in pi-auto-permissions:
|
|
12
|
+
|
|
13
|
+
- `deny` rule level: a hard boundary that outranks every other match and cannot be cleared by `request_override`, `.pi/trusted-ops`, or user approval
|
|
14
|
+
- Built-in default ruleset (9 groups) active when no `rules` key is authored, with `"$defaults"` splice support
|
|
15
|
+
- `reviewAllShell`: blanket guardian review for commands no rule matches
|
|
16
|
+
- `guardianPolicy`: prose trust configuration (environment / allow / softDeny / hardDeny) with four-tier precedence
|
|
17
|
+
- Session-start trust snapshot: remotes captured once at session start; remotes added mid-session sit outside the trust baseline
|
|
18
|
+
- Unresolvable destructive targets (`rm -rf "$VAR"` with no visible assignment) get `revise` naming the literal-path fix
|
|
19
|
+
- Optional two-stage review: a stateless single-token SAFE/REVIEW prefilter (`reviewer.prefilter`) before full review, fail-closed
|
|
20
|
+
- Delegated (subagent) tasks held to an explicit-intent standard; non-allowlisted custom messages become capped tool-source evidence
|
|
21
|
+
- Denial ledger (`denials.jsonl`), Recent-denials + Allow-on-retry in `/auto-permissions`, an `auto-permissions:denied` event, and permission overrides that persist across session resume
|
|
22
|
+
- `/auto-permissions setup`: a wizard that scans the project and recent session command strings (never user messages) and drafts `guardianPolicy.environment` entries plus trusted-ops suggestions
|
|
23
|
+
|
|
24
|
+
pi-permission-selector: `OptionSelector` title lines now wrap to the render width instead of overflowing — an unwrapped reviewer-failure detail in the title crashed the whole session under pi-tui's width assertion.
|
|
25
|
+
|
|
26
|
+
### Patch Changes
|
|
27
|
+
|
|
28
|
+
- Updated dependencies [488b4bd]
|
|
29
|
+
- @hank-warren/pi-permission-selector@1.4.1
|
|
30
|
+
|
|
3
31
|
## 0.12.1
|
|
4
32
|
|
|
5
33
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -19,6 +19,23 @@ Pi Auto Permissions pauses configured Bash commands before execution. A guardian
|
|
|
19
19
|
|
|
20
20
|
Only user messages can grant permission or impose constraints. The assistant cannot authorize its own command.
|
|
21
21
|
|
|
22
|
+
The posture is review-by-default: a fresh install ships a live ruleset (deny rules for oversight bypasses and critical-path destruction, guardian review for force pushes, infrastructure destroys, credential access, and the rest of the default groups), prose trust configuration in `guardianPolicy`, a session-start trust snapshot, an optional blanket `reviewAllShell` mode with a cheap prefilter stage, a denial ledger with allow-on-retry, and a setup wizard that drafts the trust config from your project.
|
|
23
|
+
|
|
24
|
+
## Compared with Claude Code auto mode
|
|
25
|
+
|
|
26
|
+
The design deliberately mirrors Claude Code's auto mode (default ruleset content, trust tiers, `"$defaults"` splice, session-start remote baseline, two-stage review, denial review) while keeping the pieces pi does better. Honestly stated, both directions:
|
|
27
|
+
|
|
28
|
+
| | pi-auto-permissions | Claude Code auto mode |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| **Loop/retry budgets** | Three orthogonal budgets (per-concern, per-gate, streak); the streak resets only when an approval lands on a gate that was actually blocking; persisted across compaction and resume | 3-consecutive/20-total; *any* allowed action resets the consecutive counter, so interleaved reads defeat it; not configurable |
|
|
31
|
+
| **Verdicts and reasons** | `approve`/`revise`/`ask_user`; always a concrete one-sentence reason; `revise` tells the agent exactly how to clear the objection | Deny only; fixed `Blocked by classifier` text in most sessions |
|
|
32
|
+
| **Evidence caching** | Append-only reviewer lineage: full envelope once, deltas after, fingerprint-invalidated | Stage-1→stage-2 prompt cache; no cross-review evidence cache |
|
|
33
|
+
| **Deterministic surface** | Regex gates are testable; the false-positive surface is bounded by the patterns; the guardian runs only after a mechanical match | Prose rules are checkable only by running the classifier |
|
|
34
|
+
| **Evaluation loop** | Labelled evaluation JSONL (three-way user labels, prefilter FNR records) + per-review cost sidecar | `/feedback`; no per-review cost or label surface |
|
|
35
|
+
| **Coverage** | **Bash only.** Edits, writes, fetches, MCP tools, and subagent spawns are ungated (the beyond-bash plan is the named follow-up) | All tool calls Tier 3-reviewed; in-project edits skip review by design |
|
|
36
|
+
| **Sandbox** | None — pair with OS sandboxing for a hard boundary | Integrated sandbox + network-domain review |
|
|
37
|
+
| **Injection posture** | Keeps assistant text and tool-call summaries as (non-authorizing) evidence — better provenance, larger injection surface | Strips assistant text and tool output entirely; input-layer injection probe |
|
|
38
|
+
|
|
22
39
|
## Example
|
|
23
40
|
|
|
24
41
|
Suppose Git commits are globally guarded.
|
|
@@ -59,7 +76,11 @@ Remove or disable other extensions that gate the same commands to avoid duplicat
|
|
|
59
76
|
|
|
60
77
|
## Define your policy
|
|
61
78
|
|
|
62
|
-
The extension ships with
|
|
79
|
+
The extension ships with a default policy, derived from the mechanically matchable rows of Claude Code auto mode's blocked-by-default list. Out of the box — no config file at all — it denies agent-oversight bypasses and critical-path destruction outright, and sends force pushes, history rewrites, infrastructure destroys, piped remote code execution, credential access, registry overrides, PR merges/self-approvals, and shell writes to protected dotfiles to the guardian. A normal dev session (build, test, commit, push a feature branch) triggers zero gates.
|
|
80
|
+
|
|
81
|
+
The policy is a list of rules. When your config has no `rules` key at all, the built-in ruleset is active. An authored `rules` array fully replaces the built-ins — unless it splices them back in with the literal string `"$defaults"`, which expands to the built-in ruleset at that position so your entries can sit before or after them. An explicit `"rules": []` gates nothing (the pre-0.13 out-of-box behavior, now an explicit opt-out). Commands that do not match a rule run normally.
|
|
82
|
+
|
|
83
|
+
The default groups — usable in `.pi/trusted-ops` (guarded rules only) — are `oversight`, `destructive`, `git`, `iac`, `net-exec`, `secrets`, `supply-chain`, `review`, and `protected-paths`. See [`default-rules.ts`](default-rules.ts) for the exact patterns and levels.
|
|
63
84
|
|
|
64
85
|
Configuration is read before every Bash command from:
|
|
65
86
|
|
|
@@ -69,11 +90,12 @@ $PI_CODING_AGENT_DIR/pi-auto-permissions/config.json
|
|
|
69
90
|
|
|
70
91
|
`PI_CODING_AGENT_DIR` defaults to `~/.pi/agent`.
|
|
71
92
|
|
|
72
|
-
Here is a
|
|
93
|
+
Here is a policy that keeps the defaults and additionally gates commits, pushes, and publishing:
|
|
73
94
|
|
|
74
95
|
```json
|
|
75
96
|
{
|
|
76
97
|
"rules": [
|
|
98
|
+
"$defaults",
|
|
77
99
|
{
|
|
78
100
|
"pattern": "\\bgit\\s+commit\\b",
|
|
79
101
|
"flags": "i",
|
|
@@ -103,19 +125,74 @@ Rule fields are:
|
|
|
103
125
|
|
|
104
126
|
- `pattern`: JavaScript regular expression source
|
|
105
127
|
- `flags`: optional regular expression flags, defaulting to `i`
|
|
106
|
-
- `level`: `guarded` for guardian review
|
|
128
|
+
- `level`: `guarded` for guardian review, `convention` for an overridable policy block, or `deny` for a hard block
|
|
107
129
|
- `group`: policy group used by trusted-project bypasses
|
|
108
130
|
- `label`: short description shown during review
|
|
109
|
-
- `message`: optional feedback; required for convention rules
|
|
131
|
+
- `message`: optional feedback; required for convention and deny rules
|
|
110
132
|
|
|
111
133
|
A convention rule blocks directly with its configured feedback instead of asking the guardian. The agent can call `request_override` for a legitimate one-session exception. Overrides require user confirmation and cannot bypass guarded rules.
|
|
112
134
|
|
|
135
|
+
A deny rule is a hard policy boundary: it blocks immediately with its message, is evaluated before every other level, and nothing lifts it — not `request_override`, not a trusted group, not user approval at a prompt. Reserve it for operations that should never happen in an agent session (disabling agent oversight, deleting critical paths), and use `guarded` where a human judgment call is legitimate.
|
|
136
|
+
|
|
137
|
+
When several rules match one command, the most severe level wins (deny, then convention, then guarded) regardless of their order in the list.
|
|
138
|
+
|
|
113
139
|
Set `enabled` to `false` to disable the extension. Invalid configuration fails closed and blocks Bash calls until corrected.
|
|
114
140
|
|
|
141
|
+
### Review every shell command
|
|
142
|
+
|
|
143
|
+
Set `"reviewAllShell": true` to review every bash command that matches no rule under a generic `shell command` gate (group `all-shell`), the analogue of Claude Code's `classifyAllShell`. The rules then act as a severity layer on top of blanket coverage: deny rules still block outright, convention rules still block with feedback, and everything else — named by a rule or not — goes to the guardian. The trade is one guardian call per command; pair it with a cheap reviewer model. A command whose matching group is listed in `.pi/trusted-ops` was explicitly waved through and is not re-captured by the blanket gate; trusting `all-shell` itself opts a project out of blanket review while keeping the ruleset live. Loop budgets, lineage caching, and the evaluation log apply to `all-shell` reviews unchanged.
|
|
144
|
+
|
|
145
|
+
## Define trusted infrastructure
|
|
146
|
+
|
|
147
|
+
The rules decide *which* commands are reviewed; `guardianPolicy` tells the guardian *how to judge them* against your infrastructure. All four lists are prose — write entries the way you would describe your environment to a new engineer:
|
|
148
|
+
|
|
149
|
+
```json
|
|
150
|
+
{
|
|
151
|
+
"guardianPolicy": {
|
|
152
|
+
"environment": [
|
|
153
|
+
"Our GitHub orgs nuclearn-ai and hank-warren are trusted source control",
|
|
154
|
+
"registry.internal.example is our internal package registry"
|
|
155
|
+
],
|
|
156
|
+
"allow": [
|
|
157
|
+
"Uploading build artifacts to the artifacts.example.com bucket is routine"
|
|
158
|
+
],
|
|
159
|
+
"softDeny": [
|
|
160
|
+
"Do not run database migrations against staging without being asked"
|
|
161
|
+
],
|
|
162
|
+
"hardDeny": [
|
|
163
|
+
"Never push code or data to a repository outside our GitHub orgs"
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The lists extend the built-in decision table (which stays active) and are appended to the reviewer prompt as a labeled section with explicit precedence:
|
|
170
|
+
|
|
171
|
+
1. **hardDeny** blocks unconditionally — user intent, permission overrides, and allow entries never clear it.
|
|
172
|
+
2. **softDeny** blocks unless an allow entry covers the action or the user's own message names the exact action and target ("force-push this branch", not "clean up the repo").
|
|
173
|
+
3. **allow** entries are exceptions to softDeny and to the trust boundary: data flow only, never destructive or credential operations on the same infrastructure.
|
|
174
|
+
4. **environment** entries define what "internal" means — a destination nothing names is treated as a potential exfiltration target.
|
|
175
|
+
|
|
176
|
+
Each list is independent: setting `environment` alone leaves the others intact. Worth filling in, slot by slot: your source control orgs, trusted internal domains, cloud buckets, package registries and mirrors, key internal services — and on the sensitive side, production namespaces, sensitive data locations, and remote targets that should always get a hard look. These sections are appended outside `systemPrompt`, so a customized `systemPromptFile` still receives them, and every entry participates in the reviewer fingerprint, so editing the policy safely invalidates the cached reviewer lineage.
|
|
177
|
+
|
|
178
|
+
Alongside your configured entries, the guardian always receives a `SESSION ENVIRONMENT` section captured once at session start: the working directory, repository root, and the `git remote -v` baseline. Those remotes are within the session's trust boundary; a remote added or repointed mid-session is not — which pairs with the default `git remote add|set-url` gate rule, giving the guardian a baseline to judge those commands against. In a non-repository directory the section says exactly that, so no repository trust is assumed.
|
|
179
|
+
|
|
180
|
+
The precedence tiers are prompt-enforced, like Claude Code's own; the mechanically enforced hard boundary is the `deny` rule level.
|
|
181
|
+
|
|
182
|
+
### `/auto-permissions setup`
|
|
183
|
+
|
|
184
|
+
The wizard drafts this configuration for you. It scans the current project — git remotes, `AGENTS.md`/`README.md`, lockfiles and the registry hosts inside them — and recent session files under `$PI_CODING_AGENT_DIR/sessions` for **command strings only** (hosts, registries, buckets, CLI names — never user messages, matching Claude Code's privacy line for `/auto-mode-setup`). One model call turns the facts into proposed `guardianPolicy.environment` entries plus suggested `.pi/trusted-ops` groups, rendered for accept-or-discard as a whole. Accepting appends the environment entries through the narrow config writer (nothing else in the file is rewritten); the trusted-ops suggestions are display-only — a wizard must not widen a project-level bypass file on its own. And deliberately, `guardianPolicy` is read only from the user-scoped config file — never from a project file — so a checked-in repository cannot inject its own trust entries.
|
|
185
|
+
|
|
115
186
|
## Guardian configuration
|
|
116
187
|
|
|
117
188
|
By default, the guardian uses Pi's active model with low reasoning effort and a 30-second timeout. You can select a separate low-cost model.
|
|
118
189
|
|
|
190
|
+
### Two-stage review
|
|
191
|
+
|
|
192
|
+
Set `"prefilter": true` inside the `reviewer` block for the Claude Code stage-1/stage-2 structure: before the full lineage review, a stateless single-token pass at minimal reasoning answers `SAFE` or `REVIEW` over the same envelope text. `SAFE` approves immediately (the review row shows reason `prefilter`); `REVIEW` — and any parse or infrastructure failure — falls through to the full review unchanged, so the prefilter can only ever short-circuit toward *more* review, never approve by accident. Because the prefilter envelope matches a full-rebuild review's, the escalated call is largely a provider prompt-cache hit of the prefilter call. The lineage conversation is untouched.
|
|
193
|
+
|
|
194
|
+
Prefilter calls are recorded in the usage sidecar under a distinct `prefilter` label, and — when the evaluation log is enabled — every prefilter approval is logged with `"decisionSource": "prefilter"` so its false-negative rate can be measured offline against the same labels as prompted reviews. Off by default until that data justifies flipping it; it pays off most with `reviewAllShell`, where the per-command cost matters.
|
|
195
|
+
|
|
119
196
|
Giving the guardian its own account keeps reviews from competing with your interactive session for a subscription's rate limits. Pi keys OAuth credentials by provider id, so a second login needs a second provider id — which is what [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) exists to create. This package no longer registers one itself.
|
|
120
197
|
|
|
121
198
|
1. `pi install npm:@hank-warren/pi-multi-login`
|
|
@@ -225,8 +302,26 @@ The default is `["loop-objective"]` — the objective anchor written by [`@hank-
|
|
|
225
302
|
|
|
226
303
|
Each text block contributes one `USER (<customType>):` record, in session order, never truncated — user records are the only ones that can authorize or constrain. Images are dropped. The guardian is told these records authorize exactly the operations they name, that their constraints bind, and that their content is data rather than instructions.
|
|
227
304
|
|
|
305
|
+
Injected messages whose `customType` is **not** allowlisted are still visible — as `CUSTOM <customType>:` records with `source: "tool"`, capped like any tool record. They routinely carry the reason a command was proposed (subagent return values, CI outcomes, process notifications), so hiding them left the guardian judging commands with no visible motive; but they are extension output, and only the allowlist can promote a type to user-source, so a subagent's return value can explain a command without ever authorizing it.
|
|
306
|
+
|
|
228
307
|
The allowlist matches what you wrote: a bare name such as `ask_user_question` matches that tool in any namespace (`functions.ask_user_question` included), while a dotted name matches exactly. The default is an empty list.
|
|
229
308
|
|
|
309
|
+
## Denial log and retry
|
|
310
|
+
|
|
311
|
+
Every non-approved outcome — a guardian revise, a user block at the prompt, a loop-budget block, a convention or deny block, a review-infrastructure failure — is appended to a private `denials.jsonl` sidecar next to the config (0600, 16 MB rotation, one previous generation kept):
|
|
312
|
+
|
|
313
|
+
```json
|
|
314
|
+
{"v":1,"ts":"…","sessionId":"…","tool":"bash","gate":{"label":"Force push","group":"git"},"command":"git push --force …","verdict":"block","reason":"…","decisionSource":"user"}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
On by default; `"denialLog": { "enabled": false }` opts out, and `path` relocates it.
|
|
318
|
+
|
|
319
|
+
`/auto-permissions` gains a **Recent denials** view over that ledger. Selecting a denial offers **Allow on retry**: an exact-command session override is added through the existing override machinery (it re-injects as user-source evidence on every later review and survives session resume), and an injected message tells the agent it may run that exact command again. No new authorization pathway exists — it is the same override a live approval prompt would have produced.
|
|
320
|
+
|
|
321
|
+
Every denial also emits a `pi.events` event — `auto-permissions:denied` with `tool`, `command`, `gate`, `group`, `verdict`, `reason`, `decisionSource` — the pi-native equivalent of Claude Code's `PermissionDenied` hook, for other extensions to react to. Like that hook, it cannot reverse a denial.
|
|
322
|
+
|
|
323
|
+
Permission overrides now also persist as custom session entries (the way the loop revise budget does), so a resumed session keeps the user's earlier allow decisions and standing block constraints instead of forgetting them.
|
|
324
|
+
|
|
230
325
|
## Prompted-review evaluation log
|
|
231
326
|
|
|
232
327
|
Auto Permissions can append a private JSONL regression record whenever the guardian asks for confirmation and the user gives explicit feedback:
|
|
@@ -323,7 +418,7 @@ git
|
|
|
323
418
|
gh
|
|
324
419
|
```
|
|
325
420
|
|
|
326
|
-
Group names come from your configured rules. A trusted group bypasses
|
|
421
|
+
Group names come from your configured rules. A trusted group bypasses guarded review and convention blocks for that group, so use it only in projects you control. Deny rules are never bypassed: `.pi/trusted-ops` is a project-scoped file, and a checked-in file must not be able to disarm a hard policy boundary.
|
|
327
422
|
|
|
328
423
|
## Subagent sessions
|
|
329
424
|
|
package/config-writer.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface ReviewerBlock {
|
|
|
26
26
|
export interface ConfigPatch {
|
|
27
27
|
enabled?: boolean;
|
|
28
28
|
reviewer?: ReviewerBlock;
|
|
29
|
+
/**
|
|
30
|
+
* Entries to append to `guardianPolicy.environment` (the setup wizard's
|
|
31
|
+
* accept path). Deduplicated against what is already there; every other
|
|
32
|
+
* `guardianPolicy` list and unrelated key is left exactly as written.
|
|
33
|
+
*/
|
|
34
|
+
appendEnvironment?: string[];
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
const DEFAULT_INDENT = " ";
|
|
@@ -82,14 +88,36 @@ export function patchAutoPermissionsConfig(
|
|
|
82
88
|
else merged.enabled = false;
|
|
83
89
|
}
|
|
84
90
|
if (patch.reviewer !== undefined) {
|
|
91
|
+
// Preserve file-only reviewer keys the menu does not edit (prefilter):
|
|
92
|
+
// this writer must never silently drop what the human wrote by hand.
|
|
93
|
+
const existingReviewer = merged.reviewer && typeof merged.reviewer === "object" && !Array.isArray(merged.reviewer)
|
|
94
|
+
? merged.reviewer as Record<string, unknown>
|
|
95
|
+
: {};
|
|
85
96
|
merged.reviewer = {
|
|
86
97
|
provider: patch.reviewer.provider,
|
|
87
98
|
model: patch.reviewer.model,
|
|
88
99
|
reasoningEffort: patch.reviewer.reasoningEffort,
|
|
89
100
|
timeoutMs: patch.reviewer.timeoutMs,
|
|
101
|
+
...(existingReviewer.prefilter !== undefined ? { prefilter: existingReviewer.prefilter } : {}),
|
|
90
102
|
};
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
if (patch.appendEnvironment !== undefined && patch.appendEnvironment.length > 0) {
|
|
106
|
+
const policy = merged.guardianPolicy && typeof merged.guardianPolicy === "object" && !Array.isArray(merged.guardianPolicy)
|
|
107
|
+
? merged.guardianPolicy as Record<string, unknown>
|
|
108
|
+
: {};
|
|
109
|
+
const existingEnvironment = Array.isArray(policy.environment)
|
|
110
|
+
? policy.environment.filter((entry): entry is string => typeof entry === "string")
|
|
111
|
+
: [];
|
|
112
|
+
policy.environment = [
|
|
113
|
+
...new Set([
|
|
114
|
+
...existingEnvironment,
|
|
115
|
+
...patch.appendEnvironment.map((entry) => entry.trim()).filter(Boolean),
|
|
116
|
+
]),
|
|
117
|
+
];
|
|
118
|
+
merged.guardianPolicy = policy;
|
|
119
|
+
}
|
|
120
|
+
|
|
93
121
|
const payload = `${JSON.stringify(merged, null, detectIndent(existing))}\n`;
|
|
94
122
|
const temporary = `${path}.${pid}.tmp`;
|
|
95
123
|
mkdirSync(dirname(path), { recursive: true });
|
package/config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
5
|
import type { Gate, GateLevel } from "./gates.js";
|
|
6
|
+
import { DEFAULT_RULES } from "./default-rules.js";
|
|
6
7
|
import { AUTO_PERMISSIONS_SYSTEM_PROMPT } from "./review.js";
|
|
7
8
|
|
|
8
9
|
export const CONFIG_FILENAME = "pi-auto-permissions/config.json";
|
|
@@ -33,6 +34,14 @@ export interface AutoPermissionsConfig {
|
|
|
33
34
|
model: string;
|
|
34
35
|
reasoningEffort: ReasoningEffort;
|
|
35
36
|
timeoutMs: number;
|
|
37
|
+
/**
|
|
38
|
+
* Two-stage review: a stateless single-token SAFE/REVIEW pass at minimal
|
|
39
|
+
* reasoning before the full lineage review. SAFE approves; REVIEW and
|
|
40
|
+
* every parse or infrastructure failure fall through to the full review
|
|
41
|
+
* (fail closed). Opt-in until evaluation-log data justifies default-on;
|
|
42
|
+
* recommended together with `reviewAllShell`.
|
|
43
|
+
*/
|
|
44
|
+
prefilter: boolean;
|
|
36
45
|
};
|
|
37
46
|
systemPrompt: string;
|
|
38
47
|
systemPromptSource: SystemPromptSource;
|
|
@@ -77,7 +86,37 @@ export interface AutoPermissionsConfig {
|
|
|
77
86
|
enabled: boolean;
|
|
78
87
|
path: string;
|
|
79
88
|
};
|
|
89
|
+
/** Every non-approved outcome, for the Recent denials view. Default on. */
|
|
90
|
+
denialLog: {
|
|
91
|
+
enabled: boolean;
|
|
92
|
+
path: string;
|
|
93
|
+
};
|
|
80
94
|
rules: Gate[];
|
|
95
|
+
/**
|
|
96
|
+
* Review every bash command that matches no rule under the generic
|
|
97
|
+
* `shell command` gate (`ALL_SHELL_GATE`). Default false: the ruleset is
|
|
98
|
+
* the reviewed surface. The trade is deliberate and mirrors Claude Code's
|
|
99
|
+
* `classifyAllShell` — full coverage for one guardian call per command.
|
|
100
|
+
*/
|
|
101
|
+
reviewAllShell: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Prose trust configuration, appended to the reviewer policy prompt as a
|
|
104
|
+
* labeled section (outside `systemPrompt`, so customized prompt files still
|
|
105
|
+
* receive it). Entries are natural-language rules — "write them the way you
|
|
106
|
+
* would describe your infrastructure to a new engineer" — mirroring Claude
|
|
107
|
+
* Code's `autoMode.environment`/`allow`/`soft_deny`/`hard_deny`.
|
|
108
|
+
*
|
|
109
|
+
* Each list is independent: setting one leaves the others (and the built-in
|
|
110
|
+
* decision table, which stays the default policy) intact. Deliberately
|
|
111
|
+
* user-scoped-config only — no project-file read — closing by construction
|
|
112
|
+
* the checked-in-file injection hole CC patched in v2.1.207.
|
|
113
|
+
*/
|
|
114
|
+
guardianPolicy: {
|
|
115
|
+
environment: string[];
|
|
116
|
+
allow: string[];
|
|
117
|
+
softDeny: string[];
|
|
118
|
+
hardDeny: string[];
|
|
119
|
+
};
|
|
81
120
|
ui: {
|
|
82
121
|
enabled: boolean;
|
|
83
122
|
resultDisplayMs: number;
|
|
@@ -132,11 +171,13 @@ function compileRule(value: unknown, index: number): Gate {
|
|
|
132
171
|
const flags = input.flags === undefined ? "i" : input.flags;
|
|
133
172
|
if (typeof flags !== "string") throw new Error(`rules[${index}].flags must be a string`);
|
|
134
173
|
const level: GateLevel = input.level === undefined ? "guarded" : input.level as GateLevel;
|
|
135
|
-
if (level !== "guarded" && level !== "convention") {
|
|
136
|
-
throw new Error(`rules[${index}].level must be guarded or
|
|
174
|
+
if (level !== "guarded" && level !== "convention" && level !== "deny") {
|
|
175
|
+
throw new Error(`rules[${index}].level must be guarded, convention, or deny`);
|
|
137
176
|
}
|
|
138
177
|
const message = optionalString(input.message, `rules[${index}].message`);
|
|
139
|
-
if (level === "convention" && !message)
|
|
178
|
+
if ((level === "convention" || level === "deny") && !message) {
|
|
179
|
+
throw new Error(`rules[${index}].message is required for ${level} rules`);
|
|
180
|
+
}
|
|
140
181
|
|
|
141
182
|
return {
|
|
142
183
|
pattern: new RegExp(pattern, flags),
|
|
@@ -147,6 +188,36 @@ function compileRule(value: unknown, index: number): Gate {
|
|
|
147
188
|
};
|
|
148
189
|
}
|
|
149
190
|
|
|
191
|
+
/** The literal a `rules` array uses to splice the built-in ruleset in place. */
|
|
192
|
+
export const DEFAULT_RULES_TOKEN = "$defaults";
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Compile an authored `rules` array, splicing `defaults` wherever the literal
|
|
196
|
+
* string `"$defaults"` appears (mirroring Claude Code's splice semantics: the
|
|
197
|
+
* built-ins keep updating across releases, and custom entries can sit before
|
|
198
|
+
* or after them). The token may appear at most once — a second occurrence is
|
|
199
|
+
* far more likely a copy-paste error than a request for double gating.
|
|
200
|
+
*
|
|
201
|
+
* The caller handles the other two shapes: an absent `rules` key means the
|
|
202
|
+
* defaults are active, and an authored array without the token is a full
|
|
203
|
+
* replacement (an explicit `[]` gates nothing).
|
|
204
|
+
*/
|
|
205
|
+
export function expandRules(rawRules: readonly unknown[], defaults: readonly Gate[]): Gate[] {
|
|
206
|
+
const rules: Gate[] = [];
|
|
207
|
+
let spliced = false;
|
|
208
|
+
for (let index = 0; index < rawRules.length; index++) {
|
|
209
|
+
const value = rawRules[index];
|
|
210
|
+
if (value === DEFAULT_RULES_TOKEN) {
|
|
211
|
+
if (spliced) throw new Error(`rules may contain "${DEFAULT_RULES_TOKEN}" at most once`);
|
|
212
|
+
spliced = true;
|
|
213
|
+
rules.push(...defaults);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
rules.push(compileRule(value, index));
|
|
217
|
+
}
|
|
218
|
+
return rules;
|
|
219
|
+
}
|
|
220
|
+
|
|
150
221
|
function resolvePrompt(
|
|
151
222
|
raw: Record<string, unknown>,
|
|
152
223
|
path: string,
|
|
@@ -275,6 +346,56 @@ function resolveUsageLog(
|
|
|
275
346
|
return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
|
|
276
347
|
}
|
|
277
348
|
|
|
349
|
+
const GUARDIAN_POLICY_KEYS = ["environment", "allow", "softDeny", "hardDeny"] as const;
|
|
350
|
+
|
|
351
|
+
function resolveGuardianPolicy(raw: Record<string, unknown>): AutoPermissionsConfig["guardianPolicy"] {
|
|
352
|
+
const empty = { environment: [], allow: [], softDeny: [], hardDeny: [] };
|
|
353
|
+
if (raw.guardianPolicy === undefined) return empty;
|
|
354
|
+
if (!raw.guardianPolicy || typeof raw.guardianPolicy !== "object" || Array.isArray(raw.guardianPolicy)) {
|
|
355
|
+
throw new Error("guardianPolicy must be an object");
|
|
356
|
+
}
|
|
357
|
+
const policy = raw.guardianPolicy as Record<string, unknown>;
|
|
358
|
+
for (const key of Object.keys(policy)) {
|
|
359
|
+
if (!(GUARDIAN_POLICY_KEYS as readonly string[]).includes(key)) {
|
|
360
|
+
throw new Error(`guardianPolicy.${key} is not a recognized list (use environment, allow, softDeny, hardDeny)`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const resolveList = (key: typeof GUARDIAN_POLICY_KEYS[number]): string[] => {
|
|
364
|
+
const value = policy[key];
|
|
365
|
+
if (value === undefined) return [];
|
|
366
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || !entry.trim())) {
|
|
367
|
+
throw new Error(`guardianPolicy.${key} must be an array of non-empty strings`);
|
|
368
|
+
}
|
|
369
|
+
return [...new Set((value as string[]).map((entry) => entry.trim()))];
|
|
370
|
+
};
|
|
371
|
+
return {
|
|
372
|
+
environment: resolveList("environment"),
|
|
373
|
+
allow: resolveList("allow"),
|
|
374
|
+
softDeny: resolveList("softDeny"),
|
|
375
|
+
hardDeny: resolveList("hardDeny"),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function resolveDenialLog(
|
|
380
|
+
raw: Record<string, unknown>,
|
|
381
|
+
configFilePath: string,
|
|
382
|
+
): AutoPermissionsConfig["denialLog"] {
|
|
383
|
+
const defaultPath = resolve(dirname(configFilePath), "denials.jsonl");
|
|
384
|
+
if (raw.denialLog === undefined) return { enabled: true, path: defaultPath };
|
|
385
|
+
if (!raw.denialLog || typeof raw.denialLog !== "object" || Array.isArray(raw.denialLog)) {
|
|
386
|
+
throw new Error("denialLog must be an object");
|
|
387
|
+
}
|
|
388
|
+
const denialLog = raw.denialLog as Record<string, unknown>;
|
|
389
|
+
if (denialLog.enabled !== undefined && typeof denialLog.enabled !== "boolean") {
|
|
390
|
+
throw new Error("denialLog.enabled must be boolean");
|
|
391
|
+
}
|
|
392
|
+
const enabled = denialLog.enabled !== false;
|
|
393
|
+
const configuredPath = optionalString(denialLog.path, "denialLog.path");
|
|
394
|
+
if (!configuredPath) return { enabled, path: defaultPath };
|
|
395
|
+
const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
|
|
396
|
+
return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
|
|
397
|
+
}
|
|
398
|
+
|
|
278
399
|
function resolveUi(raw: Record<string, unknown>): AutoPermissionsConfig["ui"] {
|
|
279
400
|
if (raw.ui === undefined) return { enabled: true, resultDisplayMs: 2500, placement: "widget" };
|
|
280
401
|
if (!raw.ui || typeof raw.ui !== "object" || Array.isArray(raw.ui)) {
|
|
@@ -317,14 +438,29 @@ function resolveReviewer(raw: Record<string, unknown>): AutoPermissionsConfig["r
|
|
|
317
438
|
) {
|
|
318
439
|
throw new Error("reviewer.timeoutMs must be an integer between 1000 and 300000");
|
|
319
440
|
}
|
|
320
|
-
|
|
441
|
+
if (reviewer.prefilter !== undefined && typeof reviewer.prefilter !== "boolean") {
|
|
442
|
+
throw new Error("reviewer.prefilter must be boolean");
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
provider,
|
|
446
|
+
model,
|
|
447
|
+
reasoningEffort,
|
|
448
|
+
timeoutMs: Number(timeoutMs),
|
|
449
|
+
prefilter: reviewer.prefilter === true,
|
|
450
|
+
};
|
|
321
451
|
}
|
|
322
452
|
|
|
323
453
|
export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): AutoPermissionsConfig {
|
|
324
454
|
const raw = readObject(path);
|
|
325
455
|
if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") throw new Error("enabled must be boolean");
|
|
456
|
+
if (raw.reviewAllShell !== undefined && typeof raw.reviewAllShell !== "boolean") {
|
|
457
|
+
throw new Error("reviewAllShell must be boolean");
|
|
458
|
+
}
|
|
326
459
|
if (raw.rules !== undefined && !Array.isArray(raw.rules)) throw new Error("rules must be an array");
|
|
327
|
-
|
|
460
|
+
// Absent means the built-in ruleset is active; an authored array replaces it
|
|
461
|
+
// entirely unless it splices "$defaults" back in; an explicit [] gates
|
|
462
|
+
// nothing. Existing configs with their own rules keep exactly their rules.
|
|
463
|
+
const rules = raw.rules === undefined ? [...DEFAULT_RULES] : expandRules(raw.rules, DEFAULT_RULES);
|
|
328
464
|
const prompt = resolvePrompt(raw, path);
|
|
329
465
|
|
|
330
466
|
return {
|
|
@@ -335,7 +471,10 @@ export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): A
|
|
|
335
471
|
reviewEvidence: resolveReviewEvidence(raw),
|
|
336
472
|
evaluationLog: resolveEvaluationLog(raw, path),
|
|
337
473
|
usageLog: resolveUsageLog(raw, path),
|
|
338
|
-
|
|
474
|
+
denialLog: resolveDenialLog(raw, path),
|
|
475
|
+
rules,
|
|
476
|
+
reviewAllShell: raw.reviewAllShell === true,
|
|
477
|
+
guardianPolicy: resolveGuardianPolicy(raw),
|
|
339
478
|
ui: resolveUi(raw),
|
|
340
479
|
};
|
|
341
480
|
}
|