@gotgenes/pi-permission-system 25.2.2 → 25.3.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/config/config.example.json +3 -0
  4. package/dist/public.d.ts +156 -41
  5. package/docs/configuration.md +17 -2
  6. package/package.json +1 -1
  7. package/schemas/permissions.schema.json +16 -0
  8. package/src/access-intent/bash/command-enumeration.ts +45 -117
  9. package/src/access-intent/bash/wrapper-analysis.ts +335 -0
  10. package/src/authority/forwarded-request-server.ts +5 -14
  11. package/src/authority/local-user-authorizer.ts +2 -3
  12. package/src/authority/permission-prompt-component.ts +87 -47
  13. package/src/authority/permission-prompter.ts +9 -0
  14. package/src/config-loader.ts +2 -0
  15. package/src/config-schema.ts +14 -0
  16. package/src/extension-config.ts +10 -0
  17. package/src/handlers/gates/bash-command.ts +7 -2
  18. package/src/handlers/gates/bash-external-directory.ts +11 -6
  19. package/src/handlers/gates/bash-path.ts +10 -6
  20. package/src/handlers/gates/external-directory.ts +13 -8
  21. package/src/handlers/gates/path.ts +11 -14
  22. package/src/handlers/gates/skill-input.ts +5 -2
  23. package/src/handlers/gates/skill-read.ts +5 -6
  24. package/src/handlers/gates/tool.ts +10 -5
  25. package/src/index.ts +2 -0
  26. package/src/permission-prompts.ts +4 -72
  27. package/src/presentation/dialog-renderer.ts +404 -0
  28. package/src/presentation/forwarded-ask-payload.ts +45 -0
  29. package/src/presentation/legacy-message.ts +117 -0
  30. package/src/presentation/line-fitting.ts +27 -0
  31. package/src/presentation/path-ask-payload.ts +128 -0
  32. package/src/presentation/prompt-payload.ts +137 -0
  33. package/src/presentation/skill-ask-payload.ts +50 -0
  34. package/src/presentation/tool-ask-payload.ts +104 -0
  35. package/src/tool-preview-formatter.ts +1 -1
  36. package/src/types.ts +6 -0
  37. package/src/handlers/gates/external-directory-messages.ts +0 -28
@@ -0,0 +1,128 @@
1
+ import type { ExternalPathDisclosure } from "#src/denial-messages";
2
+ import type {
3
+ PromptEvidence,
4
+ PromptPayload,
5
+ } from "#src/presentation/prompt-payload";
6
+ import { localRequester } from "#src/presentation/prompt-payload";
7
+
8
+ /** The facts a path-shaped gate holds when it raises an ask. */
9
+ interface PathAskFacts {
10
+ toolName: string;
11
+ /** The path as the caller typed it — what the user recognizes. */
12
+ pathValue: string;
13
+ agentName: string | null;
14
+ matchedPattern?: string;
15
+ }
16
+
17
+ /** A tool ask gated by an explicit `path` rule. */
18
+ export function buildPathAskPayload(facts: PathAskFacts): PromptPayload {
19
+ return pathPayload("path", "path", facts, []);
20
+ }
21
+
22
+ /** The facts the external-directory gate adds: the boundary and the alias. */
23
+ interface ExternalDirectoryAskFacts extends PathAskFacts {
24
+ /** The canonical location, when it names somewhere other than the typed path. */
25
+ resolvedPath?: string;
26
+ /** The working directory the path escapes. */
27
+ cwd: string;
28
+ }
29
+
30
+ /** A tool ask for a path outside the working directory. */
31
+ export function buildExternalDirectoryAskPayload(
32
+ facts: ExternalDirectoryAskFacts,
33
+ ): PromptPayload {
34
+ return pathPayload("external_directory", "external_directory", facts, [
35
+ ...resolvedAliasEvidence(facts.resolvedPath),
36
+ workingDirectoryEvidence(facts.cwd),
37
+ ]);
38
+ }
39
+
40
+ /** The facts the bash external-directory gate holds: one command, many paths. */
41
+ interface BashExternalDirectoryAskFacts {
42
+ command: string;
43
+ /** Every uncovered path the command references, with its canonical alias. */
44
+ externalPaths: readonly ExternalPathDisclosure[];
45
+ cwd: string;
46
+ agentName: string | null;
47
+ toolName: string;
48
+ matchedPattern?: string;
49
+ }
50
+
51
+ /** A bash ask whose command references paths outside the working directory. */
52
+ export function buildBashExternalDirectoryAskPayload(
53
+ facts: BashExternalDirectoryAskFacts,
54
+ ): PromptPayload {
55
+ return {
56
+ kind: "bash_external_directory",
57
+ request: {
58
+ requester: localRequester(facts.agentName),
59
+ surface: "external_directory",
60
+ toolName: facts.toolName,
61
+ invokedToolName: null,
62
+ value: facts.command,
63
+ matchedPattern: facts.matchedPattern ?? null,
64
+ commandContext: null,
65
+ executedUnit: null,
66
+ },
67
+ evidence: [
68
+ workingDirectoryEvidence(facts.cwd),
69
+ ...facts.externalPaths.map(externalPathEvidence),
70
+ ],
71
+ annotations: [],
72
+ };
73
+ }
74
+
75
+ // ── Shared shape ────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * The payload common to the single-path asks: the typed path is the
79
+ * decision-relevant value, and the gate surface distinguishes them.
80
+ */
81
+ function pathPayload(
82
+ kind: "path" | "external_directory",
83
+ surface: string,
84
+ facts: PathAskFacts,
85
+ evidence: PromptEvidence[],
86
+ ): PromptPayload {
87
+ return {
88
+ kind,
89
+ request: {
90
+ requester: localRequester(facts.agentName),
91
+ surface,
92
+ toolName: facts.toolName,
93
+ invokedToolName: null,
94
+ value: facts.pathValue,
95
+ matchedPattern: facts.matchedPattern ?? null,
96
+ commandContext: null,
97
+ executedUnit: null,
98
+ },
99
+ evidence,
100
+ annotations: [],
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The canonical location, as its own entry rather than folded into the value:
106
+ * the user decides on the path they typed, and the alias is what that path
107
+ * turns out to name.
108
+ */
109
+ function resolvedAliasEvidence(resolvedPath?: string): PromptEvidence[] {
110
+ return resolvedPath === undefined
111
+ ? []
112
+ : [{ label: "resolves to", text: resolvedPath, detail: null }];
113
+ }
114
+
115
+ function workingDirectoryEvidence(cwd: string): PromptEvidence {
116
+ return { label: "working directory", text: cwd, detail: null };
117
+ }
118
+
119
+ /**
120
+ * One escaping path. The canonical alias rides as the entry's `detail` so a
121
+ * render cannot separate a path from what it resolves to.
122
+ */
123
+ function externalPathEvidence({
124
+ path,
125
+ resolvedPath,
126
+ }: ExternalPathDisclosure): PromptEvidence {
127
+ return { label: "external path", text: path, detail: resolvedPath ?? null };
128
+ }
@@ -0,0 +1,137 @@
1
+ import type { BashCommandContext } from "#src/types";
2
+
3
+ /**
4
+ * The complete, structured description of a permission ask (ADR 0011 §2).
5
+ *
6
+ * A gate emits one of these instead of a sentence. It is complete by contract:
7
+ * it never truncates and never decides what a human will see. Every consumer is
8
+ * a renderer over it, eliding under its own budget — so elision is a property
9
+ * of a render, never of the payload.
10
+ */
11
+ export interface PromptPayload {
12
+ readonly kind: PromptPayloadKind;
13
+ readonly request: PromptRequestFacts;
14
+ /** Complete; each renderer elides to fit its own budget. */
15
+ readonly evidence: readonly PromptEvidence[];
16
+ /** Supplied by registered annotators; always marked as model-generated. */
17
+ readonly annotations: readonly PromptAnnotation[];
18
+ }
19
+
20
+ /**
21
+ * Which ask this payload describes — the renderers' dispatch discriminant.
22
+ *
23
+ * Present because the ask shapes are not separable by surface alone: a tool
24
+ * external-directory ask and a bash one share the `external_directory` surface,
25
+ * and the `path` gate and the per-tool gate differ only in wording. It mirrors
26
+ * `DenialContext`'s discriminated union, the shape ADR 0011 §7 names as already
27
+ * correct, and gives every renderer an exhaustive switch rather than a set of
28
+ * string comparisons a new variant sails past.
29
+ */
30
+ export type PromptPayloadKind =
31
+ | "bash"
32
+ | "mcp"
33
+ | "tool"
34
+ | "path"
35
+ | "external_directory"
36
+ | "bash_external_directory"
37
+ | "skill"
38
+ | "skill_read"
39
+ | "forwarded";
40
+
41
+ /**
42
+ * The invariant core (ADR 0011 §3): the facts visible in every render, that no
43
+ * renderer's budget may elide.
44
+ *
45
+ * Named for what it holds — the permission request's own facts, matching the
46
+ * package's `PermissionRequest` / `ForwardedPermissionRequest` vocabulary —
47
+ * rather than for its contract, which this comment states instead.
48
+ */
49
+ export interface PromptRequestFacts {
50
+ /** Who is asking, and whether the ask arrived from a subagent. */
51
+ readonly requester: PromptRequester;
52
+ /** The gate surface the rule fired on. */
53
+ readonly surface: string;
54
+ /** The gated tool name; `null` when the ask is not tool-shaped. */
55
+ readonly toolName: string | null;
56
+ /**
57
+ * The invoked tool name when a shell alias re-exposes bash under another
58
+ * name (#574) — "gated as bash, invoked as exec_command" is two facts.
59
+ * `null` when it adds nothing.
60
+ */
61
+ readonly invokedToolName: string | null;
62
+ /** The decision-relevant value: the command, path, MCP target, or skill name. */
63
+ readonly value: string;
64
+ /** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
65
+ readonly matchedPattern: string | null;
66
+ /**
67
+ * Where the offending bash unit runs, when it came from a substitution or a
68
+ * subshell. A fact rather than a rendered clause: it is what makes the
69
+ * matched rule intelligible, and how it reads is the renderer's choice.
70
+ */
71
+ readonly commandContext: BashCommandContext | null;
72
+ /**
73
+ * For bash, the unit that will actually run — including inside an unstrippable
74
+ * wrapper (#713). `null` when it adds nothing over {@link value}.
75
+ */
76
+ readonly executedUnit: string | null;
77
+ }
78
+
79
+ /** Who is asking, one hop below when the ask was forwarded. */
80
+ export interface PromptRequester {
81
+ readonly agentName: string | null;
82
+ readonly forwarded: boolean;
83
+ /** The requesting session, for a forwarded ask; `null` for a local one. */
84
+ readonly sessionId: string | null;
85
+ }
86
+
87
+ /**
88
+ * One piece of decision evidence.
89
+ *
90
+ * Complete on the payload; each renderer elides entries and orders them under
91
+ * its own budget (ADR 0011 §4).
92
+ */
93
+ export interface PromptEvidence {
94
+ readonly label: string;
95
+ readonly text: string;
96
+ /**
97
+ * A secondary fact bound to this entry that a renderer may show alongside
98
+ * {@link text} or elide independently — a path's symlink-resolved alias, for
99
+ * instance. Bound to the entry rather than listed as a second one so an
100
+ * elision cannot separate the two.
101
+ */
102
+ readonly detail: string | null;
103
+ }
104
+
105
+ /**
106
+ * A model-generated advisory (ADR 0011 §8).
107
+ *
108
+ * The slot owns the attribution and the model-generated marking, so marking is
109
+ * a property of the payload rather than a discipline each annotator must
110
+ * remember. Structurally separate from any verdict: an annotation cannot allow,
111
+ * deny, defer, or suppress.
112
+ */
113
+ export interface PromptAnnotation {
114
+ readonly source: string;
115
+ readonly text: string;
116
+ }
117
+
118
+ /** The `requester` facts for an ask raised by this session. */
119
+ export function localRequester(agentName: string | null): PromptRequester {
120
+ return { agentName, forwarded: false, sessionId: null };
121
+ }
122
+
123
+ /** Find the evidence entry a renderer knows by label. */
124
+ export function findEvidence(
125
+ payload: PromptPayload,
126
+ label: string,
127
+ ): PromptEvidence | undefined {
128
+ return payload.evidence.find((entry) => entry.label === label);
129
+ }
130
+
131
+ /** Every evidence entry carrying the given label, in payload order. */
132
+ export function allEvidence(
133
+ payload: PromptPayload,
134
+ label: string,
135
+ ): readonly PromptEvidence[] {
136
+ return payload.evidence.filter((entry) => entry.label === label);
137
+ }
@@ -0,0 +1,50 @@
1
+ import type { PromptPayload } from "#src/presentation/prompt-payload";
2
+ import { localRequester } from "#src/presentation/prompt-payload";
3
+ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
4
+
5
+ /** A request to load a skill. */
6
+ export function buildSkillAskPayload(
7
+ skillName: string,
8
+ agentName: string | null,
9
+ ): PromptPayload {
10
+ return skillPayload("skill", skillName, agentName, []);
11
+ }
12
+
13
+ /**
14
+ * A read that reaches a skill through one of its files.
15
+ *
16
+ * The skill is the decision-relevant value — it is what the policy names — and
17
+ * the path is the evidence for why this read counts as reaching it.
18
+ */
19
+ export function buildSkillPathAskPayload(
20
+ skill: SkillPromptEntry,
21
+ readPath: string,
22
+ agentName: string | null,
23
+ ): PromptPayload {
24
+ return skillPayload("skill_read", skill.name, agentName, [
25
+ { label: "read path", text: readPath, detail: null },
26
+ ]);
27
+ }
28
+
29
+ function skillPayload(
30
+ kind: "skill" | "skill_read",
31
+ skillName: string,
32
+ agentName: string | null,
33
+ evidence: PromptPayload["evidence"],
34
+ ): PromptPayload {
35
+ return {
36
+ kind,
37
+ request: {
38
+ requester: localRequester(agentName),
39
+ surface: "skill",
40
+ toolName: null,
41
+ invokedToolName: null,
42
+ value: skillName,
43
+ matchedPattern: null,
44
+ commandContext: null,
45
+ executedUnit: null,
46
+ },
47
+ evidence,
48
+ annotations: [],
49
+ };
50
+ }
@@ -0,0 +1,104 @@
1
+ import { classifyToolKind, isMcpCheck } from "#src/access-intent/tool-kind";
2
+ import type {
3
+ PromptEvidence,
4
+ PromptPayload,
5
+ } from "#src/presentation/prompt-payload";
6
+ import { localRequester } from "#src/presentation/prompt-payload";
7
+ import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
8
+ import type { PermissionCheckResult } from "#src/types";
9
+ import { getNonEmptyString, toRecord } from "#src/value-guards";
10
+
11
+ /** The facts the per-tool gate holds when it raises an ask. */
12
+ export interface ToolAskFacts {
13
+ /** The resolved check: the gated tool, the matched rule, the offending unit. */
14
+ check: PermissionCheckResult;
15
+ agentName: string | null;
16
+ /** The gate surface the rule fired on — `bash` for a shell alias (#574). */
17
+ surface: string;
18
+ /** The tool the agent actually called, when a shell alias re-exposes bash. */
19
+ invokedToolName?: string | null;
20
+ /** The raw tool input, the source of the input-preview evidence. */
21
+ input?: unknown;
22
+ /** Renders the per-tool input preview; absent means no preview evidence. */
23
+ formatter?: ToolPreviewFormatter;
24
+ }
25
+
26
+ /**
27
+ * Build the payload for the per-tool gate: a bash, MCP, or generic-tool ask.
28
+ *
29
+ * The branch decides only the payload's `kind` and which fact is the
30
+ * decision-relevant `value`; how any of it reads is a renderer's decision.
31
+ */
32
+ export function buildToolAskPayload(facts: ToolAskFacts): PromptPayload {
33
+ const { check } = facts;
34
+ const bash = classifyToolKind(check.toolName) === "bash";
35
+ const mcp = isMcpCheck(check) && check.target !== undefined;
36
+
37
+ return {
38
+ kind: bash ? "bash" : mcp ? "mcp" : "tool",
39
+ request: {
40
+ requester: localRequester(facts.agentName),
41
+ surface: facts.surface,
42
+ toolName: check.toolName,
43
+ invokedToolName: distinctInvokedName(facts),
44
+ value: askValue(check, bash, mcp),
45
+ matchedPattern: check.matchedPattern ?? null,
46
+ commandContext: check.commandContext ?? null,
47
+ executedUnit: check.executedUnit ?? null,
48
+ },
49
+ evidence: bash
50
+ ? fullCommandEvidence(facts)
51
+ : inputPreviewEvidence(facts, mcp),
52
+ annotations: [],
53
+ };
54
+ }
55
+
56
+ /**
57
+ * The decision-relevant value: the offending command for bash, the qualified
58
+ * target for MCP, the tool name otherwise.
59
+ *
60
+ * A bash check with no command yields the empty string rather than the tool
61
+ * name — the ask is about a command, and naming the surface instead would
62
+ * assert a command that was never resolved.
63
+ */
64
+ function askValue(
65
+ check: PermissionCheckResult,
66
+ bash: boolean,
67
+ mcp: boolean,
68
+ ): string {
69
+ if (bash) return check.command ?? "";
70
+ if (mcp) return check.target ?? "";
71
+ return check.toolName;
72
+ }
73
+
74
+ /** The invoked tool name, but only when it is a fact the gated name does not carry. */
75
+ function distinctInvokedName(facts: ToolAskFacts): string | null {
76
+ const invoked = facts.invokedToolName ?? null;
77
+ return invoked === null || invoked === facts.check.toolName ? null : invoked;
78
+ }
79
+
80
+ /** The enclosing command, when the gated unit is only part of what will run. */
81
+ function fullCommandEvidence(facts: ToolAskFacts): PromptEvidence[] {
82
+ const fullCommand = getNonEmptyString(toRecord(facts.input).command);
83
+ if (fullCommand === null || fullCommand === facts.check.command) {
84
+ return [];
85
+ }
86
+ return [{ label: "full command", text: fullCommand, detail: null }];
87
+ }
88
+
89
+ /**
90
+ * The per-tool input preview, when a formatter is registered and produces one.
91
+ *
92
+ * An MCP ask previews under the `mcp` key rather than the qualified target, so
93
+ * a registered MCP formatter is consulted for every server.
94
+ */
95
+ function inputPreviewEvidence(
96
+ facts: ToolAskFacts,
97
+ mcp: boolean,
98
+ ): PromptEvidence[] {
99
+ const preview = facts.formatter?.formatToolInputForPrompt(
100
+ mcp ? "mcp" : facts.check.toolName,
101
+ facts.input,
102
+ );
103
+ return preview ? [{ label: "input", text: preview, detail: null }] : [];
104
+ }
@@ -135,7 +135,7 @@ export class ToolPreviewFormatter {
135
135
  case "ls":
136
136
  return this.formatSearchInputForPrompt(toolName, inputRecord);
137
137
  case "mcp":
138
- // The MCP target is already surfaced in formatAskPrompt's MCP branch.
138
+ // The MCP target is already a request fact on the prompt payload.
139
139
  // When no custom formatter is registered (or it declines), produce no
140
140
  // additional preview rather than leaking the raw event JSON.
141
141
  return "";
package/src/types.ts CHANGED
@@ -58,6 +58,12 @@ export interface PermissionCheckResult {
58
58
  * (top-level) commands.
59
59
  */
60
60
  commandContext?: BashCommandContext;
61
+ /**
62
+ * The command the winning bash unit actually runs, when it is a wrapper whose
63
+ * inner command differs from the unit text (#713). Display-only: the gate
64
+ * still decides on `command`, so this never widens or narrows a decision.
65
+ */
66
+ executedUnit?: string;
61
67
  }
62
68
 
63
69
  export function isPermissionState(value: unknown): value is PermissionState {
@@ -1,28 +0,0 @@
1
- import {
2
- type ExternalPathDisclosure,
3
- resolvesToSuffix,
4
- } from "#src/denial-messages";
5
-
6
- export function formatExternalDirectoryAskPrompt(
7
- toolName: string,
8
- pathValue: string,
9
- resolvedPath: string | undefined,
10
- cwd: string,
11
- agentName?: string,
12
- ): string {
13
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
14
- return `${subject} requested tool '${toolName}' for path '${pathValue}'${resolvesToSuffix(resolvedPath)} outside working directory '${cwd}'. Allow this external directory access?`;
15
- }
16
-
17
- export function formatBashExternalDirectoryAskPrompt(
18
- command: string,
19
- externalPaths: ExternalPathDisclosure[],
20
- cwd: string,
21
- agentName?: string,
22
- ): string {
23
- const subject = agentName ? `Agent '${agentName}'` : "Current agent";
24
- const pathList = externalPaths
25
- .map(({ path, resolvedPath }) => `${path}${resolvesToSuffix(resolvedPath)}`)
26
- .join(", ");
27
- return `${subject} requested bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. Allow this external directory access?`;
28
- }