@hank-warren/pi-auto-permissions 0.12.0 → 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.
@@ -0,0 +1,265 @@
1
+ import type { Gate } from "./gates.js";
2
+
3
+ /**
4
+ * The built-in ruleset.
5
+ *
6
+ * Active when the config file has no `rules` key at all, and spliceable into
7
+ * an authored `rules` array with the literal string `"$defaults"` (see
8
+ * `expandRules` in config.ts). An authored array without the token fully
9
+ * replaces this list; an explicit `[]` gates nothing.
10
+ *
11
+ * Content is derived from Claude Code auto mode's blocked-by-default list
12
+ * (the mechanically matchable rows), adapted to this extension's levels:
13
+ *
14
+ * - `deny` — the analogue of CC's deterministic pre-classifier circuit
15
+ * breaker: oversight bypasses and critical-path destruction that no agent
16
+ * session should ever perform. Not overridable, not bypassed by trusted
17
+ * groups, never sent to the guardian.
18
+ * - `guarded` — operations where a human judgment call is legitimate; the
19
+ * guardian reviews them against the conversation evidence.
20
+ *
21
+ * Design constraints, enforced by test/default-rules.test.ts:
22
+ * - A normal dev session (build, test, commit, push a feature branch) must
23
+ * trigger zero gates. Plain `git commit` and `git push` are deliberately
24
+ * not gated; only force pushes, history rewrites, deploy-branch pushes and
25
+ * remote reconfiguration are.
26
+ * - Every rule ships with commands that must match and near-misses that must
27
+ * not. Rules match raw shell text, so quoting and variable indirection can
28
+ * evade them — the guardian and the system prompt are the semantic layer;
29
+ * these rules are the deterministic prefilter.
30
+ */
31
+
32
+ /** Shell-write prefix: redirection or a write verb, then no command separator. */
33
+ const WRITE = String.raw`(?:>>?|\btee\b|\bsed\s+-i\b|\bcp\b|\bmv\b)[^|&;]*`;
34
+
35
+ const OVERSIGHT_RULES: readonly Gate[] = [
36
+ {
37
+ pattern: /--dangerously-skip-permissions\b|--dangerously-bypass-approvals-and-sandbox\b|--yes-always\b|\b(?:pi|claude|codex)\b[^|&;]*--no-sandbox\b/i,
38
+ level: "deny",
39
+ group: "oversight",
40
+ label: "Agent oversight bypass",
41
+ message: "Launching an agent with approvals or sandboxing disabled removes the oversight this session runs under. Run the agent with its normal approval flow instead.",
42
+ },
43
+ {
44
+ pattern: /\btmux\s+send-keys\b(?![^|&;]*\s-t)|\btmux\s+send-keys\b[^|&;]*\s-t[= ]?\s*['"]?\$\{?TMUX_PANE/i,
45
+ level: "deny",
46
+ group: "oversight",
47
+ label: "Self-driving keystrokes",
48
+ message: "Sending keystrokes to this agent's own terminal pane drives its own interface, which amounts to changing its own permissions or oversight. Target an explicit other pane with -t if you are automating a different process.",
49
+ },
50
+ {
51
+ pattern: /(?:>>?|\btee\b|\bsed\s+-i\b|\bcp\b|\bmv\b|\brm\b|\btruncate\b)[^|&;]*(?:\$\{?PI_CODING_AGENT_DIR\}?|\.pi\/agent)\/sessions\b/i,
52
+ level: "deny",
53
+ group: "oversight",
54
+ label: "Session transcript write",
55
+ message: "Session transcripts are the audit record of agent behavior; writing to them from a session is never allowed. Reading them is fine.",
56
+ },
57
+ ];
58
+
59
+ const DESTRUCTIVE_RULES: readonly Gate[] = [
60
+ {
61
+ pattern: /\b(?:rm|rmdir)\b[^|&;]*\s['"]?\/[^/\s'"]*\/?['"]?(?=\s|$|[;|&])/i,
62
+ level: "deny",
63
+ group: "destructive",
64
+ label: "Delete of a critical path",
65
+ message: "Deleting the filesystem root or a top-level directory is irreversible system destruction. Name the specific deeper path you actually mean.",
66
+ },
67
+ {
68
+ pattern: /\b(?:rm|rmdir)\b[^|&;]*\s['"]?(?:~|\$\{?HOME\}?)\/?['"]?(?=\s|$|[;|&])/i,
69
+ level: "deny",
70
+ group: "destructive",
71
+ label: "Delete of the home directory",
72
+ message: "Deleting the home directory is irreversible. Name the specific path under it you actually mean.",
73
+ },
74
+ {
75
+ pattern: /\b(?:rm|rmdir)\b[^|&;]*\s['"]?\.\.?\/?['"]?(?=\s|$|[;|&])/i,
76
+ level: "deny",
77
+ group: "destructive",
78
+ label: "Delete of the working directory or a parent",
79
+ message: "Deleting the current directory or a parent destroys the workspace this session runs in. Name the specific entry inside it you actually mean.",
80
+ },
81
+ {
82
+ pattern: /\brm\b[^|&;]*\s['"]?\$\{?[A-Za-z_]\w*\}?['"]?\/\*/i,
83
+ level: "deny",
84
+ group: "destructive",
85
+ label: "Glob delete under a shell variable",
86
+ message: "A glob rooted at a shell variable becomes a root delete when the variable is empty or unset. Re-run the delete with the resolved literal path written into the command.",
87
+ },
88
+ {
89
+ pattern: /\brm\b[^|&;]*\s(?:['"]?\/tmp\/|['"]?\$\{?TMPDIR\}?[/'"]?)[^|&;]*\*/i,
90
+ level: "guarded",
91
+ group: "destructive",
92
+ label: "Wildcard delete in shared temp",
93
+ },
94
+ {
95
+ pattern: /\bfind\b[^|&;]*\s(?:-delete\b|-exec\s+rm\b)/i,
96
+ level: "guarded",
97
+ group: "destructive",
98
+ label: "Filesystem sweep via find",
99
+ },
100
+ {
101
+ pattern: /\brm\b[^|&;]*\s['"]?\$\{?[A-Za-z_]\w*\}?\/?['"]?(?=\s|$|[;|&])/i,
102
+ level: "guarded",
103
+ group: "destructive",
104
+ label: "Delete of a shell-variable target",
105
+ },
106
+ ];
107
+
108
+ const GIT_RULES: readonly Gate[] = [
109
+ {
110
+ pattern: /\bgit\s+push\b[^|&;]*(?:\s--force\b|\s--force-with-lease\b|\s-f\b)/i,
111
+ level: "guarded",
112
+ group: "git",
113
+ label: "Force push",
114
+ },
115
+ {
116
+ pattern: /\bgit\s+(?:reset\s+--hard\b|checkout\s+--\s+\.(?=\s|$)|restore\s+\.(?=\s|$)|clean\s+-[a-z]*f|stash\s+(?:drop|clear)\b)/i,
117
+ level: "guarded",
118
+ group: "git",
119
+ label: "Uncommitted-work destruction",
120
+ },
121
+ {
122
+ pattern: /\bgit\s+commit\b[^|&;]*--amend\b/i,
123
+ level: "guarded",
124
+ group: "git",
125
+ label: "Commit amend",
126
+ },
127
+ {
128
+ pattern: /\bgit\s+remote\s+(?:add|set-url)\b/i,
129
+ level: "guarded",
130
+ group: "git",
131
+ label: "Remote reconfiguration",
132
+ },
133
+ {
134
+ pattern: /\bgit\s+push\b[^|&;]*[\s:](?:production|release|gh-pages)(?=\s|$|[:;|&])/i,
135
+ level: "guarded",
136
+ group: "git",
137
+ label: "Push to a deploy branch",
138
+ },
139
+ ];
140
+
141
+ const IAC_RULES: readonly Gate[] = [
142
+ {
143
+ pattern: /\b(?:terraform|pulumi|cdk|terragrunt)\b[^|&;]*\bdestroy\b/i,
144
+ level: "guarded",
145
+ group: "iac",
146
+ label: "Infrastructure destroy",
147
+ },
148
+ {
149
+ pattern: /\bkubectl\b[^|&;]*\s(?:drain|delete\s+node|exec|port-forward)\b/i,
150
+ level: "guarded",
151
+ group: "iac",
152
+ label: "Cluster node or pod intrusion",
153
+ },
154
+ {
155
+ pattern: /\bkubectl\b[^|&;]*\s(?:delete|scale|patch|label|annotate|cordon|uncordon|taint)\b[^|&;]*(?:\s--all\b|\s-l[= ]|\s--selector[= ])/i,
156
+ level: "guarded",
157
+ group: "iac",
158
+ label: "Cluster-wide write",
159
+ },
160
+ ];
161
+
162
+ const NET_EXEC_RULES: readonly Gate[] = [
163
+ {
164
+ pattern: /\b(?:curl|wget)\b[^|&;]*\|\s*(?:sudo\s+)?(?:bash|sh|zsh|python3?|perl|node)\b/i,
165
+ level: "guarded",
166
+ group: "net-exec",
167
+ label: "Piped remote code execution",
168
+ },
169
+ {
170
+ pattern: /\bngrok\b|\bcloudflared\s+tunnel\b|\bssh\b[^|&;]*\s-R\b|\/dev\/tcp\//i,
171
+ level: "guarded",
172
+ group: "net-exec",
173
+ label: "Tunnel or reverse shell",
174
+ },
175
+ ];
176
+
177
+ const SECRETS_RULES: readonly Gate[] = [
178
+ {
179
+ pattern: /\baws\s+secretsmanager\s+(?:put|create|update|delete|rotate)|\bvault\s+kv\s+(?:put|patch|destroy|delete)|\bgcloud\s+dns\b|\bcertbot\b|\baz\s+keyvault\b[^|&;]*\s(?:set|import|delete)\b/i,
180
+ level: "guarded",
181
+ group: "secrets",
182
+ label: "Secret store, DNS, or certificate write",
183
+ },
184
+ {
185
+ pattern: /\benv\b\s*\|\s*grep\b[^|&;]*(?:key|token|secret|pass)|\bcat\b[^|&;]*\.env['"]?\s*[|>]|\bprintenv\b[^|&;]*(?:key|token|secret)/i,
186
+ level: "guarded",
187
+ group: "secrets",
188
+ label: "Credential print",
189
+ },
190
+ {
191
+ pattern: /\b(?:cat|cp|scp|rsync|curl|tar|base64|grep|head|tail)\b[^|&;]*(?:~|\$\{?HOME\}?)\/\.(?:ssh|aws|config\/gcloud)\b|\b(?:cat|grep|head|tail)\b[^|&;]*\.(?:bash_history|zsh_history)\b/i,
192
+ level: "guarded",
193
+ group: "secrets",
194
+ label: "Credential store read",
195
+ },
196
+ ];
197
+
198
+ const SUPPLY_CHAIN_RULES: readonly Gate[] = [
199
+ {
200
+ pattern: /\b(?:npm|yarn|pnpm|bun)\b[^|&;]*\s--registry\b|\bpip3?\b[^|&;]*\s(?:-i|--index-url)[= ]/i,
201
+ level: "guarded",
202
+ group: "supply-chain",
203
+ label: "Registry override install",
204
+ },
205
+ {
206
+ pattern: /\bgh\s+repo\s+fork\b|\bgh\s+pr\s+create\b[^|&;]*\s(?:--repo|-R)[= ]/i,
207
+ level: "guarded",
208
+ group: "supply-chain",
209
+ label: "Cross-repo contribution",
210
+ },
211
+ ];
212
+
213
+ const REVIEW_RULES: readonly Gate[] = [
214
+ {
215
+ pattern: /\bgh\s+pr\s+merge\b|\bgh\s+pr\s+review\b[^|&;]*--approve\b/i,
216
+ level: "guarded",
217
+ group: "review",
218
+ label: "Merge or approve a pull request",
219
+ },
220
+ {
221
+ pattern: /\bgit\s+(?:commit|push|merge)\b[^|&;]*--no-verify\b|\b(?:curl|wget)\b[^|&;]*(?:\s-k\b|\s--insecure\b)|--skip-checks\b/i,
222
+ level: "guarded",
223
+ group: "review",
224
+ label: "Safety guard bypass flag",
225
+ },
226
+ ];
227
+
228
+ const PROTECTED_PATH_RULES: readonly Gate[] = [
229
+ {
230
+ pattern: new RegExp(WRITE + String.raw`['"]?(?:\S*\/)?\.(?:git\/|gitconfig\b|gitmodules\b)`, "i"),
231
+ level: "guarded",
232
+ group: "protected-paths",
233
+ label: "Shell write to git internals",
234
+ },
235
+ {
236
+ pattern: new RegExp(WRITE + String.raw`['"]?(?:\S*\/)?(?:\.(?:husky\/|pre-commit-config\.ya?ml\b)|\.?lefthook\.ya?ml\b)`, "i"),
237
+ level: "guarded",
238
+ group: "protected-paths",
239
+ label: "Shell write to hook config",
240
+ },
241
+ {
242
+ pattern: new RegExp(WRITE + String.raw`['"]?(?:\S*\/)?\.(?:bashrc\b|bash_profile\b|bash_aliases\b|bash_login\b|bash_logout\b|zshrc\b|zprofile\b|zshenv\b|zlogin\b|zlogout\b|profile\b|envrc\b|npmrc\b|yarnrc\b|cargo\/)`, "i"),
243
+ level: "guarded",
244
+ group: "protected-paths",
245
+ label: "Shell write to shell or tool dotfiles",
246
+ },
247
+ {
248
+ pattern: new RegExp(WRITE + String.raw`['"]?(?:\S*\/)?\.pi\/(?!agent\/sessions)`, "i"),
249
+ level: "guarded",
250
+ group: "protected-paths",
251
+ label: "Shell write to project agent config",
252
+ },
253
+ ];
254
+
255
+ export const DEFAULT_RULES: readonly Gate[] = [
256
+ ...OVERSIGHT_RULES,
257
+ ...DESTRUCTIVE_RULES,
258
+ ...GIT_RULES,
259
+ ...IAC_RULES,
260
+ ...NET_EXEC_RULES,
261
+ ...SECRETS_RULES,
262
+ ...SUPPLY_CHAIN_RULES,
263
+ ...REVIEW_RULES,
264
+ ...PROTECTED_PATH_RULES,
265
+ ];
package/denial-log.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { appendFileSync, chmodSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname } from "node:path";
4
+
5
+ /** Rotate once the sidecar grows past this size; one previous generation is kept. */
6
+ export const DENIAL_LOG_ROTATE_BYTES = 16 * 1024 * 1024;
7
+
8
+ /** The two ways a review can end without the command running. */
9
+ export type DenialVerdict = "revise" | "block";
10
+
11
+ /**
12
+ * What produced the denial:
13
+ * - `deny` / `convention` — a rule blocked mechanically, no reviewer involved
14
+ * - `guardian` — the reviewer's own verdict (a revise, or an ask_user with no
15
+ * interactive user to ask)
16
+ * - `user` — the user chose Block at the approval prompt
17
+ * - `loop` — a guardian verdict delivered as a bounded block because an
18
+ * unattended loop cannot answer a prompt
19
+ * - `review_failure` — review infrastructure failed and the command was
20
+ * blocked rather than waved through
21
+ */
22
+ export type DenialSource = "deny" | "convention" | "guardian" | "user" | "loop" | "review_failure";
23
+
24
+ export interface DenialRecord {
25
+ v: 1;
26
+ id: string;
27
+ ts: string;
28
+ sessionId: string;
29
+ cwd: string;
30
+ tool: string;
31
+ gate: { label: string; group: string };
32
+ command: string;
33
+ verdict: DenialVerdict;
34
+ reason: string;
35
+ decisionSource: DenialSource;
36
+ }
37
+
38
+ export function buildDenialRecord(input: {
39
+ sessionId: string;
40
+ cwd: string;
41
+ tool: string;
42
+ gate: { label: string; group: string };
43
+ command: string;
44
+ verdict: DenialVerdict;
45
+ reason: string;
46
+ decisionSource: DenialSource;
47
+ }): DenialRecord {
48
+ return {
49
+ v: 1,
50
+ id: randomUUID(),
51
+ ts: new Date().toISOString(),
52
+ sessionId: input.sessionId,
53
+ cwd: input.cwd,
54
+ tool: input.tool,
55
+ gate: { label: input.gate.label, group: input.gate.group },
56
+ command: input.command,
57
+ verdict: input.verdict,
58
+ reason: input.reason,
59
+ decisionSource: input.decisionSource,
60
+ };
61
+ }
62
+
63
+ function rotateIfLarge(path: string): void {
64
+ try {
65
+ if (statSync(path).size < DENIAL_LOG_ROTATE_BYTES) return;
66
+ renameSync(path, `${path}.1`);
67
+ } catch {
68
+ // A missing or unrotatable sidecar simply keeps appending.
69
+ }
70
+ }
71
+
72
+ export function appendDenialRecord(path: string, record: DenialRecord): void {
73
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
74
+ rotateIfLarge(path);
75
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
76
+ chmodSync(path, 0o600);
77
+ }
78
+
79
+ /**
80
+ * The newest `limit` denials, newest first, for the `/auto-permissions`
81
+ * Recent denials view. Malformed lines are skipped, a missing file is an
82
+ * empty history, and only the live generation is read — the rotated `.1`
83
+ * file is archival.
84
+ */
85
+ export function readRecentDenials(path: string, limit: number): DenialRecord[] {
86
+ let content: string;
87
+ try {
88
+ content = readFileSync(path, "utf8");
89
+ } catch {
90
+ return [];
91
+ }
92
+ const records: DenialRecord[] = [];
93
+ for (const line of content.split("\n")) {
94
+ if (!line.trim()) continue;
95
+ try {
96
+ const value = JSON.parse(line) as DenialRecord;
97
+ if (
98
+ value && typeof value === "object" && value.v === 1
99
+ && typeof value.command === "string"
100
+ && typeof value.reason === "string"
101
+ && (value.verdict === "revise" || value.verdict === "block")
102
+ && value.gate && typeof value.gate.label === "string" && typeof value.gate.group === "string"
103
+ ) {
104
+ records.push(value);
105
+ }
106
+ } catch {
107
+ // Skip unparsable lines; the log is append-only best effort.
108
+ }
109
+ }
110
+ return records.slice(-limit).reverse();
111
+ }
package/evaluation-log.ts CHANGED
@@ -45,6 +45,32 @@ export interface PromptEvaluationRecord {
45
45
  expectedDecision: Extract<PermissionDecision, "approve" | "ask_user">;
46
46
  }
47
47
 
48
+ /**
49
+ * A prefilter SAFE approval, recorded so false negatives are measurable: no
50
+ * prompt opened, so there is no user label yet — the record carries the
51
+ * command and compact evidence for offline labelling against the same
52
+ * criteria as prompted records.
53
+ */
54
+ export interface PrefilterEvaluationRecord {
55
+ version: 2;
56
+ timestamp: string;
57
+ sessionId: string;
58
+ cwd: string;
59
+ tool: string;
60
+ gate: {
61
+ label: string;
62
+ group: string;
63
+ };
64
+ userRequest: string;
65
+ command: string;
66
+ relevantContext: ReviewEvidenceRecord[];
67
+ actualDecision: "approve";
68
+ actualReason: "prefilter";
69
+ decisionSource: "prefilter";
70
+ }
71
+
72
+ export type EvaluationRecord = PromptEvaluationRecord | PrefilterEvaluationRecord;
73
+
48
74
  export function classifyPromptChoice(choice: string | undefined): PromptChoiceClassification | undefined {
49
75
  if (choice === "Allow") return { allowsExecution: true };
50
76
  // Plain "Block" is still live: the logging-disabled prompt offers
@@ -69,7 +95,7 @@ export function expectedDecisionForChoice(
69
95
  return choice === "allow_unnecessary" ? "approve" : "ask_user";
70
96
  }
71
97
 
72
- export function appendPromptEvaluation(path: string, record: PromptEvaluationRecord): void {
98
+ export function appendPromptEvaluation(path: string, record: EvaluationRecord): void {
73
99
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
74
100
  appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
75
101
  chmodSync(path, 0o600);
package/gates.ts CHANGED
@@ -1,4 +1,18 @@
1
- export type GateLevel = "guarded" | "convention";
1
+ /**
2
+ * Rule severity levels, in decision order:
3
+ * - `deny`: blocks immediately with the rule's required `message`. A hard
4
+ * policy boundary — not clearable by `request_override`, not bypassed by
5
+ * `.pi/trusted-ops` groups, never shown to the guardian. The mechanical
6
+ * analogue of Claude Code's pre-classifier circuit breaker.
7
+ * - `convention`: blocks directly with the rule's required `message`, but the
8
+ * agent may ask the user for a one-session exception via `request_override`.
9
+ * - `guarded`: sends the command to the guardian reviewer.
10
+ *
11
+ * When several rules match one command, the effective level is the most
12
+ * severe across *all* matches (deny > convention > guarded), never the first
13
+ * match in config order.
14
+ */
15
+ export type GateLevel = "deny" | "guarded" | "convention";
2
16
 
3
17
  export interface Gate {
4
18
  pattern: RegExp;
@@ -9,6 +23,20 @@ export interface Gate {
9
23
  suggest?: (command: string) => string;
10
24
  }
11
25
 
26
+ /**
27
+ * The fallback gate used when `reviewAllShell` is enabled and a bash command
28
+ * matches no configured rule: everything the ruleset does not name is still
29
+ * reviewed under this generic gate (the analogue of Claude Code's
30
+ * `classifyAllShell`). Its group participates in `.pi/trusted-ops` like any
31
+ * other, so a trusted project can opt back out of blanket review.
32
+ */
33
+ export const ALL_SHELL_GATE: Gate = {
34
+ pattern: /(?:)/,
35
+ level: "guarded",
36
+ group: "all-shell",
37
+ label: "shell command",
38
+ };
39
+
12
40
  export function findGates(command: string, rules: readonly Gate[]): Gate[] {
13
41
  return rules.filter((gate) => {
14
42
  gate.pattern.lastIndex = 0;
@@ -22,12 +22,12 @@ import { completeSimple } from "@earendil-works/pi-ai/compat";
22
22
  * future pi release renames the seam — degrading to the historical behavior
23
23
  * instead of crashing.
24
24
  *
25
- * THIS FILE IS DUPLICATED VERBATIM across packages/pi-auto-permissions and
26
- * packages/pi-herdr-auto-title, and `scripts/validate.py` fails if the copies
27
- * differ by a single byte. The duplication is deliberate: sharing it made a
28
- * pane-titling extension depend on an entire permissions engine to reuse a
29
- * dozen lines. Edit one copy, then copy it over the other. Every
30
- * package-specific detail must stay behind a parameter — hence `packageLabel`.
25
+ * This file was duplicated verbatim into packages/pi-herdr-auto-title until
26
+ * that package was removed; `DUPLICATED_SOURCES` in `scripts/validate.py`
27
+ * policed the copies. It has one home again. If a second extension package
28
+ * ever needs it, copy it rather than importing across packages by relative
29
+ * path, and register the pair in `DUPLICATED_SOURCES` — which is why every
30
+ * package-specific detail stays behind a parameter (`packageLabel`).
31
31
  * The "guardian" in the names is historical: pi-auto-permissions' guardian
32
32
  * reviewer was the first caller.
33
33
  */