@hank-warren/pi-auto-permissions 0.21.0 → 0.22.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 +6 -0
- package/README.md +14 -0
- package/config.ts +8 -0
- package/index.ts +29 -14
- package/package.json +2 -1
- package/prompt-select.ts +6 -2
- package/remote-answers.ts +115 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @hank-warren/pi-auto-permissions
|
|
2
2
|
|
|
3
|
+
## 0.22.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Opt-in `remoteAnswers`: announce open approval prompts on `auto-permissions:prompt` and accept an answer for one on `auto-permissions:answer`, so another extension can relay a prompt to another device. Off by default; nothing is emitted or accepted until it is set.
|
|
8
|
+
|
|
3
9
|
## 0.21.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -430,6 +430,20 @@ When the environment variable `HERDR_ENV` is set to exactly `1`, the extension a
|
|
|
430
430
|
|
|
431
431
|
This is an optional integration and nothing needs to be configured to use it. Outside Herdr the variable is unset, the emit is skipped entirely, and every other feature behaves identically; the extension has no dependency on Herdr being installed.
|
|
432
432
|
|
|
433
|
+
### Answering a prompt from another device
|
|
434
|
+
|
|
435
|
+
Off by default. Set `"remoteAnswers": true` to let another extension relay an open approval prompt elsewhere, such as to your phone, and answer it from there. Nothing changes until you do: with the setting off, no event is sent and every answer is ignored.
|
|
436
|
+
|
|
437
|
+
With it on:
|
|
438
|
+
|
|
439
|
+
- **When a prompt opens**, the extension emits `auto-permissions:prompt` on `pi.events`: `{ active: true, id, gate, header, command, noteLabel, note, options, cwd, sessionId, openedAt }`.
|
|
440
|
+
- **When it settles**, it emits `{ active: false, id, choice?, remote }`. `choice` is absent when the prompt was cancelled; `remote` says whether the answer came from the event.
|
|
441
|
+
- **To answer**, emit `auto-permissions:answer` with `{ id, choice }`. The answer is honoured only while that prompt is still open, and only with one of the `options` it announced. It settles the prompt exactly as the matching keypress would, including the override evidence and the evaluation log.
|
|
442
|
+
- **The terminal prompt stays live.** Whichever answer comes first wins, and a later one is ignored.
|
|
443
|
+
- **Subagent prompts are not announced.** They are rendered by the parent session (RPC mode), which owns answering them.
|
|
444
|
+
|
|
445
|
+
The events stay inside the pi process; how a relay reaches another device, and who may answer there, is that extension's job. Only enable this with a relay you trust: any extension that can emit the answer event can approve a prompt.
|
|
446
|
+
|
|
433
447
|
## Trusted groups
|
|
434
448
|
|
|
435
449
|
In a trusted project, create `.pi/trusted-ops` to bypass selected rule groups:
|
package/config.ts
CHANGED
|
@@ -80,6 +80,12 @@ export interface AutoPermissionsConfig {
|
|
|
80
80
|
* `classifyAllShell` — full coverage for one guardian call per command.
|
|
81
81
|
*/
|
|
82
82
|
reviewAllShell: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Announce open approval prompts on pi's event bus (`auto-permissions:prompt`) and accept
|
|
85
|
+
* an answer for one from another extension (`auto-permissions:answer`), for relaying a
|
|
86
|
+
* prompt to another device. Default false: nothing is emitted or accepted.
|
|
87
|
+
*/
|
|
88
|
+
remoteAnswers: boolean;
|
|
83
89
|
/**
|
|
84
90
|
* How many guardian reviews may run at once when several guarded commands
|
|
85
91
|
* are pending together (parallel tool calls, codemode scripts). Verdicts are
|
|
@@ -339,6 +345,7 @@ export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): A
|
|
|
339
345
|
const raw = readObject(path);
|
|
340
346
|
optionalBoolean(raw.enabled, "enabled");
|
|
341
347
|
optionalBoolean(raw.reviewAllShell, "reviewAllShell");
|
|
348
|
+
optionalBoolean(raw.remoteAnswers, "remoteAnswers");
|
|
342
349
|
if (raw.rules !== undefined && !Array.isArray(raw.rules)) throw new Error("rules must be an array");
|
|
343
350
|
// Absent means the built-in ruleset is active; an authored array replaces it
|
|
344
351
|
// entirely unless it splices "$defaults" back in; an explicit [] gates
|
|
@@ -357,6 +364,7 @@ export function loadAutoPermissionsConfig(path = autoPermissionsConfigPath()): A
|
|
|
357
364
|
denialLog: resolveSidecar(raw, "denialLog", "denials.jsonl", true, path),
|
|
358
365
|
rules,
|
|
359
366
|
reviewAllShell: raw.reviewAllShell === true,
|
|
367
|
+
remoteAnswers: raw.remoteAnswers === true,
|
|
360
368
|
reviewConcurrency: boundedInteger(
|
|
361
369
|
raw.reviewConcurrency,
|
|
362
370
|
DEFAULT_REVIEW_CONCURRENCY,
|
package/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type { Gate } from "./gates.js";
|
|
|
21
21
|
import { classifyCommand } from "./classify.js";
|
|
22
22
|
import type { PermissionVerdict, ReviewEvidenceRecord } from "./review.js";
|
|
23
23
|
import { promptSelect, setHerdrBlocked } from "./prompt-select.js";
|
|
24
|
+
import { RemotePrompts, type RemotePromptHandle } from "./remote-answers.js";
|
|
24
25
|
import { createReviewDisplay } from "./review-display.js";
|
|
25
26
|
import { createSessionOverrides } from "./session-overrides.js";
|
|
26
27
|
import { createGuardianReviewer } from "./guardian-reviewer.js";
|
|
@@ -193,6 +194,9 @@ function assistantToolCalls(message: unknown): AssistantToolCall[] {
|
|
|
193
194
|
|
|
194
195
|
export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
195
196
|
const overrides = createSessionOverrides(pi);
|
|
197
|
+
// Opt-in (`remoteAnswers`); with it off no prompt registers, so answers are ignored.
|
|
198
|
+
const remotePrompts = new RemotePrompts(pi);
|
|
199
|
+
remotePrompts.listen();
|
|
196
200
|
let trustedGroups = new Set<string>();
|
|
197
201
|
// Set per session: a subagent child (PI_SUBAGENT_CHILD=1) revises before it
|
|
198
202
|
// asks. `reviseFirst` maps each command it was told to revise to the turn
|
|
@@ -457,22 +461,32 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
457
461
|
|
|
458
462
|
const evaluationContext = config.evaluationLog.enabled ? reviewer.collectEvidence(scope) : [];
|
|
459
463
|
setHerdrBlocked(pi, true, gate.label);
|
|
464
|
+
const content = {
|
|
465
|
+
header: `${gate.label} — Auto Permissions needs approval`,
|
|
466
|
+
command,
|
|
467
|
+
noteLabel: decisionSource === "guardian" ? "Guardian" : "Review failed",
|
|
468
|
+
note: detail,
|
|
469
|
+
inTranscript: scope.target.scriptToolCallId === undefined,
|
|
470
|
+
};
|
|
471
|
+
const options = permissionPromptOptions(config.evaluationLog.enabled);
|
|
472
|
+
// RPC prompts render in another process (a subagent's parent), which owns answering them.
|
|
473
|
+
let remote: RemotePromptHandle | undefined;
|
|
474
|
+
if (config.remoteAnswers && ctx.mode !== "rpc") {
|
|
475
|
+
remote = remotePrompts.begin({
|
|
476
|
+
gate: gate.label,
|
|
477
|
+
header: content.header,
|
|
478
|
+
command,
|
|
479
|
+
noteLabel: content.noteLabel,
|
|
480
|
+
note: detail,
|
|
481
|
+
options,
|
|
482
|
+
cwd: ctx.cwd,
|
|
483
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
let choice: string | undefined;
|
|
460
487
|
try {
|
|
461
|
-
let choice: string | undefined;
|
|
462
488
|
try {
|
|
463
|
-
choice = await promptSelect(
|
|
464
|
-
pi,
|
|
465
|
-
ctx,
|
|
466
|
-
{
|
|
467
|
-
header: `${gate.label} — Auto Permissions needs approval`,
|
|
468
|
-
command,
|
|
469
|
-
noteLabel: decisionSource === "guardian" ? "Guardian" : "Review failed",
|
|
470
|
-
note: detail,
|
|
471
|
-
inTranscript: scope.target.scriptToolCallId === undefined,
|
|
472
|
-
},
|
|
473
|
-
permissionPromptOptions(config.evaluationLog.enabled),
|
|
474
|
-
promptSignal,
|
|
475
|
-
);
|
|
489
|
+
choice = await promptSelect(pi, ctx, content, options, promptSignal, remote?.attach);
|
|
476
490
|
} catch (error) {
|
|
477
491
|
if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
|
|
478
492
|
if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
|
|
@@ -502,6 +516,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
502
516
|
block: "Blocked by user",
|
|
503
517
|
});
|
|
504
518
|
} finally {
|
|
519
|
+
remote?.close(choice);
|
|
505
520
|
if (!lifecycleStale()) setHerdrBlocked(pi, false);
|
|
506
521
|
}
|
|
507
522
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-auto-permissions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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": [
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"widget-status.ts",
|
|
63
63
|
"classify.ts",
|
|
64
64
|
"prompt-select.ts",
|
|
65
|
+
"remote-answers.ts",
|
|
65
66
|
"approval-prompt.ts",
|
|
66
67
|
"review-display.ts",
|
|
67
68
|
"review-scope.ts",
|
package/prompt-select.ts
CHANGED
|
@@ -37,17 +37,21 @@ export function promptSelect(
|
|
|
37
37
|
content: ApprovalPromptContent,
|
|
38
38
|
values: string[],
|
|
39
39
|
signal: AbortSignal,
|
|
40
|
+
/** Receives a function that settles this prompt with a choice, as a keypress would (remote answers). */
|
|
41
|
+
onAnswerable?: (answer: (choice: string) => boolean) => void,
|
|
40
42
|
): Promise<string | undefined> {
|
|
41
43
|
if (ctx.mode === "rpc") return ctx.ui.select(approvalPromptText(content), values, { signal });
|
|
42
44
|
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
43
45
|
let finished = false;
|
|
44
46
|
const onAbort = () => finish(undefined);
|
|
45
|
-
const finish = (result: string | undefined) => {
|
|
46
|
-
if (finished) return;
|
|
47
|
+
const finish = (result: string | undefined): boolean => {
|
|
48
|
+
if (finished) return false;
|
|
47
49
|
finished = true;
|
|
48
50
|
signal.removeEventListener("abort", onAbort);
|
|
49
51
|
done(result);
|
|
52
|
+
return true;
|
|
50
53
|
};
|
|
54
|
+
onAnswerable?.((choice) => finish(choice));
|
|
51
55
|
const selector = new OptionSelector({
|
|
52
56
|
options: values.map((value) => ({ value, label: value })),
|
|
53
57
|
allowComment: true,
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Remote answers: opt-in (`"remoteAnswers": true`), off by default.
|
|
6
|
+
*
|
|
7
|
+
* While an approval prompt is open, it is announced on pi's event bus, and another
|
|
8
|
+
* extension in the same pi process may answer it — for example one that relays the
|
|
9
|
+
* prompt to the user's phone. The terminal prompt is unchanged and stays live: whichever
|
|
10
|
+
* answer comes first settles it, through the same path as a keypress.
|
|
11
|
+
*
|
|
12
|
+
* Off, nothing is emitted and every answer is ignored, because no prompt is ever
|
|
13
|
+
* registered. An answer is honoured only for a prompt that is open right now, by its
|
|
14
|
+
* id, and only with one of the options that prompt offers; everything else is dropped.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Emitted when a prompt opens (`active: true`) and when it settles (`active: false`). */
|
|
18
|
+
export const PROMPT_EVENT = "auto-permissions:prompt";
|
|
19
|
+
/** Listened on: `{ id, choice }` answers the open prompt `id` with `choice`. */
|
|
20
|
+
export const ANSWER_EVENT = "auto-permissions:answer";
|
|
21
|
+
|
|
22
|
+
export interface RemotePromptOpened {
|
|
23
|
+
active: true;
|
|
24
|
+
/** Unique per prompt; an answer must name it. */
|
|
25
|
+
id: string;
|
|
26
|
+
gate: string;
|
|
27
|
+
header: string;
|
|
28
|
+
command: string;
|
|
29
|
+
noteLabel: string;
|
|
30
|
+
note: string;
|
|
31
|
+
/** The options exactly as the prompt offers them, in order. */
|
|
32
|
+
options: string[];
|
|
33
|
+
cwd: string;
|
|
34
|
+
sessionId?: string;
|
|
35
|
+
openedAt: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RemotePromptClosed {
|
|
39
|
+
active: false;
|
|
40
|
+
id: string;
|
|
41
|
+
/** The option picked, or undefined when the prompt was cancelled (Esc, abort, shutdown). */
|
|
42
|
+
choice?: string;
|
|
43
|
+
/** True when the choice came through ANSWER_EVENT rather than the terminal. */
|
|
44
|
+
remote: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RemoteAnswer {
|
|
48
|
+
id: string;
|
|
49
|
+
choice: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface OpenPrompt {
|
|
53
|
+
options: readonly string[];
|
|
54
|
+
answer?: (choice: string) => boolean;
|
|
55
|
+
remote: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A registered prompt: `attach` receives the prompt's answer function, `close` announces the outcome. */
|
|
59
|
+
export interface RemotePromptHandle {
|
|
60
|
+
id: string;
|
|
61
|
+
/** `answer` settles the prompt with a choice and returns false when it had already settled. */
|
|
62
|
+
attach(answer: (choice: string) => boolean): void;
|
|
63
|
+
close(choice: string | undefined): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class RemotePrompts {
|
|
67
|
+
private readonly open = new Map<string, OpenPrompt>();
|
|
68
|
+
|
|
69
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
70
|
+
|
|
71
|
+
/** Subscribe to ANSWER_EVENT. Inert while no prompt is registered. */
|
|
72
|
+
listen(): () => void {
|
|
73
|
+
return this.pi.events.on(ANSWER_EVENT, (data) => {
|
|
74
|
+
this.answer(data);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Register an open prompt and announce it. */
|
|
79
|
+
begin(prompt: Omit<RemotePromptOpened, "active" | "id" | "openedAt">): RemotePromptHandle {
|
|
80
|
+
const id = randomUUID();
|
|
81
|
+
const entry: OpenPrompt = { options: [...prompt.options], remote: false };
|
|
82
|
+
this.open.set(id, entry);
|
|
83
|
+
this.emit({ active: true, id, ...prompt, options: [...prompt.options], openedAt: Date.now() } satisfies RemotePromptOpened);
|
|
84
|
+
return {
|
|
85
|
+
id,
|
|
86
|
+
attach: (answer) => {
|
|
87
|
+
entry.answer = answer;
|
|
88
|
+
},
|
|
89
|
+
close: (choice) => {
|
|
90
|
+
if (!this.open.delete(id)) return;
|
|
91
|
+
this.emit({ active: false, id, ...(choice === undefined ? {} : { choice }), remote: entry.remote } satisfies RemotePromptClosed);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Apply an answer; true when it settled an open prompt. */
|
|
97
|
+
answer(data: unknown): boolean {
|
|
98
|
+
if (!data || typeof data !== "object") return false;
|
|
99
|
+
const { id, choice } = data as Partial<RemoteAnswer>;
|
|
100
|
+
if (typeof id !== "string" || typeof choice !== "string") return false;
|
|
101
|
+
const entry = this.open.get(id);
|
|
102
|
+
if (!entry?.answer || !entry.options.includes(choice)) return false;
|
|
103
|
+
if (!entry.answer(choice)) return false;
|
|
104
|
+
entry.remote = true;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private emit(payload: RemotePromptOpened | RemotePromptClosed): void {
|
|
109
|
+
try {
|
|
110
|
+
this.pi.events.emit(PROMPT_EVENT, payload);
|
|
111
|
+
} catch {
|
|
112
|
+
// A listener's failure must never break the approval flow.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|