@hank-warren/pi-auto-permissions 0.23.0 → 0.24.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # @hank-warren/pi-auto-permissions
2
2
 
3
+ ## 0.24.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Approval mode in the `/auto-permissions` menu, and multi-line deny matches go to review.
8
+
9
+ - **Approval mode row.** The `/auto-permissions` menu has an **Approval mode** row that sets `approvalMode` (auto, careful, afk) like the other rows, with `auto` removing the key. The session it is changed from drops its own `/auto-permissions <mode>` switch, and every session refreshes its mode status at its next prompt. Unattended sessions stay afk.
10
+ - **A deny match in a multi-line command is reviewed.** Rules match raw text, so a deny pattern could match across lines the shell runs separately: a delete on one line plus a quoted `/^##` sed address on the next matched the hard-deny **Delete of a critical path**, which nothing could lift. A deny match on a command containing a newline now goes to the guardian under that rule's label instead; single-line matches stay hard denies.
11
+
3
12
  ## 0.23.0
4
13
 
5
14
  ### Minor Changes
package/README.md CHANGED
@@ -45,7 +45,7 @@ What happens when the guardian cannot approve a command (an `ask_user` verdict,
45
45
  | `careful` | You are prompted as soon as the guardian cannot approve a command, and the agent waits for your answer. |
46
46
  | `afk` | Nothing prompts. Blocked commands are refused, the agent gets a short system prompt section saying you are away, and it lists what it needs approved in its final message. |
47
47
 
48
- Set the default with `"approvalMode": "auto" | "careful" | "afk"` in the config, and switch one session with `/auto-permissions auto`, `/auto-permissions careful` or `/auto-permissions afk`. A session switch lasts until the session ends or switches again, and a new or resumed session starts in the configured default. A blocked command lands in the denial log, so **Recent denials → Allow on retry** clears it too.
48
+ Set the default with the **Approval mode** row in `/auto-permissions` or `"approvalMode": "auto" | "careful" | "afk"` in the config, and switch one session with `/auto-permissions auto`, `/auto-permissions careful` or `/auto-permissions afk`. A session switch lasts until the session ends, switches again, or the default changes in the menu, and a new or resumed session starts in the configured default. A blocked command lands in the denial log, so **Recent denials → Allow on retry** clears it too.
49
49
 
50
50
  To keep the agent working while being careful about specific things, say so in your message ("don't push", "treat vistra as read-only"): the guardian blocks matching actions until a later message lifts the boundary. Boundaries you state live in the transcript, so a compaction can drop them; for anything that must always hold, use `guardianPolicy.softDeny` / `hardDeny` or a deny rule.
51
51
 
@@ -145,7 +145,7 @@ Rule fields are:
145
145
  - `label`: short description shown during review
146
146
  - `message`: optional feedback; required for deny rules
147
147
 
148
- 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 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.
148
+ 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 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. One exception: rules match raw text, so in a multi-line command a deny pattern can match across lines the shell runs as separate commands. A deny match on a command containing a newline is therefore sent to the guardian under that rule's label instead of blocked outright; it is still reviewed, never passed. Single-line matches stay hard denies.
149
149
 
150
150
  When several rules match one command, the most severe level wins (deny, then guarded) regardless of their order in the list.
151
151
 
@@ -234,6 +234,7 @@ The reviewer settings that change most often are editable from a settings menu i
234
234
  | Row | What it edits |
235
235
  | --- | --- |
236
236
  | Enabled | `enabled` — off disables all gating, including deny rules; every command runs unreviewed |
237
+ | Approval mode | `approvalMode`, cycling through `auto`, `careful` and `afk` (see [Approval modes](#approval-modes)); the session you change it from drops its own `/auto-permissions <mode>` switch |
237
238
  | Reviewer model | `reviewer.provider` / `reviewer.model`, picked from the models you are signed in to |
238
239
  | Thinking level | `reviewer.reasoningEffort` |
239
240
  | Review timeout | `reviewer.timeoutMs`, entered as `30s` or `45000ms` |
package/classify.ts CHANGED
@@ -20,6 +20,13 @@ export type CommandClassification =
20
20
  * Deny rules are hard policy boundaries: a project-scoped trusted-ops file
21
21
  * must not be able to lift one, or a checked-in file could disarm the
22
22
  * circuit breaker. Only guarded matches honor the bypass.
23
+ *
24
+ * Rules match raw text, so in a multi-line command a deny can match across
25
+ * lines that the shell runs as separate commands (an `rm` on one line, a
26
+ * quoted `/^##` on the next). Telling those apart takes a shell parser, so a
27
+ * deny match on a multi-line command goes to the guardian instead, which
28
+ * reads the real commands; it is reviewed, never waved through. A
29
+ * single-line match stays a hard deny.
23
30
  */
24
31
  export function classifyCommand(
25
32
  command: string,
@@ -32,7 +39,7 @@ export function classifyCommand(
32
39
  );
33
40
 
34
41
  const deny = matches.find((gate) => gate.level === "deny");
35
- if (deny) return { kind: "deny", gate: deny };
42
+ if (deny) return { kind: /[\r\n]/.test(command) ? "review" : "deny", gate: deny };
36
43
 
37
44
  let gate = matches.find((candidate) => candidate.level === "guarded");
38
45
  // With reviewAllShell on, a command no rule names at all still gets a
package/config-writer.ts CHANGED
@@ -11,14 +11,17 @@
11
11
  import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
12
12
  import { dirname } from "node:path";
13
13
  import { pid } from "node:process";
14
- import { DEFAULT_REVIEW_CONCURRENCY, type ReviewerConfig } from "./config.js";
14
+ import { type ApprovalMode, DEFAULT_REVIEW_CONCURRENCY, type ReviewerConfig } from "./config.js";
15
15
 
16
16
  interface ConfigPatch {
17
17
  enabled?: boolean;
18
+ approvalMode?: ApprovalMode;
18
19
  reviewer?: ReviewerConfig;
19
20
  reviewConcurrency?: number;
20
21
  }
21
22
 
23
+ const DEFAULT_APPROVAL_MODE: ApprovalMode = "auto";
24
+
22
25
  const DEFAULT_INDENT = " ";
23
26
 
24
27
  /**
@@ -56,8 +59,8 @@ function parseObject(text: string): Record<string, unknown> {
56
59
  * Sparse `enabled`: the loader reads a missing key as enabled, so turning the
57
60
  * guardian back on deletes the key rather than writing `true`. That keeps a
58
61
  * default flip reaching hosts that never touched the setting, matching how
59
- * pi-statusline serializes its settings. `reviewConcurrency` is sparse the
60
- * same way: setting it back to the default deletes the key.
62
+ * pi-statusline serializes its settings. `approvalMode` and `reviewConcurrency`
63
+ * are sparse the same way: setting either back to its default deletes the key.
61
64
  *
62
65
  * Throws on an unparsable or non-object file rather than clobbering it; the
63
66
  * caller surfaces that as a notification.
@@ -70,6 +73,10 @@ export function patchAutoPermissionsConfig(path: string, patch: ConfigPatch): vo
70
73
  if (patch.enabled) delete merged.enabled;
71
74
  else merged.enabled = false;
72
75
  }
76
+ if (patch.approvalMode !== undefined) {
77
+ if (patch.approvalMode === DEFAULT_APPROVAL_MODE) delete merged.approvalMode;
78
+ else merged.approvalMode = patch.approvalMode;
79
+ }
73
80
  if (patch.reviewConcurrency !== undefined) {
74
81
  if (patch.reviewConcurrency === DEFAULT_REVIEW_CONCURRENCY) delete merged.reviewConcurrency;
75
82
  else merged.reviewConcurrency = patch.reviewConcurrency;
package/index.ts CHANGED
@@ -919,10 +919,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
919
919
  return decide(scope, input, lifecycleSignal, early ?? runReview(scope, input, lifecycleSignal));
920
920
  });
921
921
 
922
- pi.on("before_agent_start", (event) => {
922
+ pi.on("before_agent_start", (event, ctx) => {
923
923
  let config: AutoPermissionsConfig | undefined;
924
924
  try {
925
925
  config = loadAutoPermissionsConfig();
926
+ // Another session's menu may have changed the configured mode since this one started.
927
+ showApprovalMode(ctx, approvalModeOf(config));
926
928
  if (!config.enabled) return;
927
929
  } catch {
928
930
  // An invalid config fails closed on the first bash call; the guidance still applies.
@@ -962,6 +964,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
962
964
  showApprovalMode(ctx, mode);
963
965
  return undefined;
964
966
  },
967
+ followConfig(mode, ctx) {
968
+ if (unattendedSession) return;
969
+ sessionApprovalMode = undefined;
970
+ blocks.reset();
971
+ showApprovalMode(ctx, mode);
972
+ },
965
973
  },
966
974
  });
967
975
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-auto-permissions",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Fork of @ogulcancelik/pi-auto-permissions with dialog-answer user evidence: context-aware Bash permissions for Pi with automated guardian review.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -48,6 +48,8 @@ const MODE_NOTICES: Record<ApprovalMode, string> = {
48
48
  export interface ApprovalModeControl {
49
49
  /** Switch this session's mode; returns why it cannot, or undefined. */
50
50
  set(mode: ApprovalMode, ctx: ExtensionContext): string | undefined;
51
+ /** The configured mode changed in the menu: drop this session's override and follow it. */
52
+ followConfig(mode: ApprovalMode, ctx: ExtensionContext): void;
51
53
  }
52
54
 
53
55
  export function registerSettingsCommand(
@@ -105,6 +107,7 @@ export function registerSettingsCommand(
105
107
  const config = loadAutoPermissionsConfig(path);
106
108
  settings = {
107
109
  enabled: config.enabled,
110
+ approvalMode: config.approvalMode,
108
111
  reviewer: config.reviewer,
109
112
  reviewConcurrency: config.reviewConcurrency,
110
113
  systemPromptSource: config.systemPromptSource,
@@ -131,7 +134,8 @@ export function registerSettingsCommand(
131
134
  patchAutoPermissionsConfig(path, {
132
135
  enabled: settings.enabled,
133
136
  reviewer: settings.reviewer,
134
- // Only when this edit changed it, so a hand-written value is never rewritten by an unrelated save.
137
+ // Only when this edit changed them, so a hand-written value is never rewritten by an unrelated save.
138
+ ...(settings.approvalMode !== previous.approvalMode ? { approvalMode: settings.approvalMode } : {}),
135
139
  ...(settings.reviewConcurrency !== previous.reviewConcurrency
136
140
  ? { reviewConcurrency: settings.reviewConcurrency }
137
141
  : {}),
@@ -143,6 +147,7 @@ export function registerSettingsCommand(
143
147
  return;
144
148
  }
145
149
  if (change.kind === "warn") ctx.ui.notify(change.message, "warning");
150
+ if (settings.approvalMode !== previous.approvalMode) approvalMode.followConfig(settings.approvalMode, ctx);
146
151
  // A breakage elsewhere in the file would otherwise surface at the next
147
152
  // bash call, looking like this edit caused it.
148
153
  try {
package/settings-menu.ts CHANGED
@@ -18,6 +18,8 @@ import {
18
18
  truncateToWidth,
19
19
  } from "@earendil-works/pi-tui";
20
20
  import {
21
+ APPROVAL_MODES,
22
+ type ApprovalMode,
21
23
  DEFAULT_REVIEWER_REASONING_EFFORT,
22
24
  DEFAULT_REVIEWER_TIMEOUT_MS,
23
25
  MAX_REVIEW_CONCURRENCY,
@@ -30,6 +32,7 @@ import {
30
32
  } from "./config.js";
31
33
 
32
34
  const ENABLED_ID = "enabled";
35
+ const APPROVAL_MODE_ID = "approvalMode";
33
36
  const REVIEWER_MODEL_ID = "reviewerModel";
34
37
  const THINKING_LEVEL_ID = "reasoningEffort";
35
38
  const TIMEOUT_ID = "timeoutMs";
@@ -48,6 +51,7 @@ const NO_REVIEWER_MESSAGE = "Select a reviewer model first";
48
51
  /** The slice of the config the menu edits, plus the read-only prompt source. */
49
52
  export interface ReviewerSettings {
50
53
  enabled: boolean;
54
+ approvalMode: ApprovalMode;
51
55
  reviewer?: ReviewerConfig;
52
56
  reviewConcurrency: number;
53
57
  systemPromptSource: SystemPromptSource;
@@ -169,6 +173,14 @@ export function buildSettingItems(
169
173
  currentValue: toggleValue(settings.enabled),
170
174
  values: TOGGLE_VALUES,
171
175
  },
176
+ {
177
+ id: APPROVAL_MODE_ID,
178
+ label: "Approval mode",
179
+ description:
180
+ "When the guardian can't approve a command: auto blocks it and prompts only after repeated blocks, careful prompts at once, afk never prompts. Applies to every session; /auto-permissions <mode> overrides one session. Unattended sessions are always afk.",
181
+ currentValue: settings.approvalMode,
182
+ values: [...APPROVAL_MODES],
183
+ },
172
184
  {
173
185
  id: REVIEWER_MODEL_ID,
174
186
  label: "Reviewer model",
@@ -241,6 +253,14 @@ export function applySettingChange(
241
253
  return { kind: "settings", settings: { ...settings, enabled } };
242
254
  }
243
255
 
256
+ if (id === APPROVAL_MODE_ID) {
257
+ if (!(APPROVAL_MODES as readonly string[]).includes(value)) {
258
+ return { kind: "error", message: `Unknown approval mode: ${value}` };
259
+ }
260
+ if (value === settings.approvalMode) return { kind: "ignored" };
261
+ return { kind: "settings", settings: { ...settings, approvalMode: value as ApprovalMode } };
262
+ }
263
+
244
264
  if (id === THINKING_LEVEL_ID) {
245
265
  if (!REASONING_EFFORTS.includes(value as ReasoningEffort)) {
246
266
  return { kind: "error", message: `Unknown thinking level: ${value}` };