@gotgenes/pi-permission-system 39.0.2 → 39.0.4

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
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [39.0.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.3...pi-permission-system-v39.0.4) (2026-10-05)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** show a broken config file's error once, not twice, at session start ([67ddf4f](https://github.com/gotgenes/pi-packages/commit/67ddf4f637bd6f4e15246093dc3d1c459552c7f2)), closes [#953](https://github.com/gotgenes/pi-packages/issues/953)
14
+ * **pi-permission-system:** report a policy file that fails closed mid-session ([100a0d3](https://github.com/gotgenes/pi-packages/commit/100a0d3e1b49c92454163b270df52df7cc78ca1f)), closes [#953](https://github.com/gotgenes/pi-packages/issues/953)
15
+
16
+ ## [39.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.2...pi-permission-system-v39.0.3) (2026-10-03)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **pi-permission-system:** match a home-prefixed bash rule against a command typed with ~ or $HOME ([af2d24a](https://github.com/gotgenes/pi-packages/commit/af2d24af90e77156cc7a58b5a75d70d1154cce3c)), closes [#981](https://github.com/gotgenes/pi-packages/issues/981)
22
+
23
+ ### Documentation
24
+
25
+ * **pi-permission-system:** document bash command spellings ([e6cd84f](https://github.com/gotgenes/pi-packages/commit/e6cd84fe1ff5fa3ab7938f576ace1e574cc46409)), closes [#981](https://github.com/gotgenes/pi-packages/issues/981)
26
+ * **pi-permission-system:** accept the HOME-rebinding residual for bash command spellings ([3c787e4](https://github.com/gotgenes/pi-packages/commit/3c787e43e79400fd132cbb6597c51c0448e2b39a)), closes [#981](https://github.com/gotgenes/pi-packages/issues/981)
27
+
8
28
  ## [39.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.1...pi-permission-system-v39.0.2) (2026-10-03)
9
29
 
10
30
 
@@ -451,6 +451,8 @@ A quoted heredoc delimiter (`<<'EOF'` or `<<"EOF"`) does not interpolate, so its
451
451
  The enclosing command is still matched without its redirect, so a rule like `npm install` keeps matching `npm install > out.txt`.
452
452
  Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are not descended into; their contents are matched as part of the enclosing statement's text.
453
453
 
454
+ A command that starts with `~`, `$HOME`, or `${HOME}` also matches a home-anchored pattern spelled any of those ways, or with the absolute home directory (see [Home Directory Expansion in Patterns](#home-directory-expansion-in-patterns)).
455
+
454
456
  A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
455
457
  So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`.
456
458
  Prefixes like `PGPASSWORD=` and `KUBECONFIG=` are handled the same way.
@@ -1120,6 +1122,21 @@ Path **values** supplied by bash commands and extension tools are expanded the s
1120
1122
  This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: `cat ~/.ssh/config`, `cat $HOME/.ssh/config`, or `cat /Users/me/.ssh/config` is caught by a `"~/.ssh/*": "deny"` rule.
1121
1123
  Pi's built-in file tools expand only `~`, as Pi itself does: a `read` of `~/.ssh/config` or `/Users/me/.ssh/config` is caught, while a `read` of `$HOME/.ssh/config` opens `<cwd>/$HOME/.ssh/config` and is matched as that file.
1122
1124
 
1125
+ A `bash` command that **starts** with a home prefix matches a home-anchored command pattern the same way.
1126
+ With `"~/bin/tool *": "deny"`, the commands `~/bin/tool --now`, `$HOME/bin/tool --now`, `${HOME}/bin/tool --now`, and `/Users/me/bin/tool --now` are all denied, and an allow written with `~` covers each of those spellings too.
1127
+ The command is still shown, logged, and offered for session approval as typed.
1128
+ Three limits apply:
1129
+
1130
+ - On Windows, a home-anchored `bash` pattern is compiled with backslash separators, so it matches only a command typed with the backslashed absolute path ([issue #1020](https://github.com/gotgenes/pi-packages/issues/1020)).
1131
+ - A command that reassigns `HOME` earlier in the same invocation (`HOME=/tmp/x; ~/bin/tool`, or a `HOME=/tmp/x` prefix) is matched only as typed, because its `~` no longer names your home directory.
1132
+ A reassignment the parser cannot see, such as one through a variable holding the name (`n=HOME; read $n`), is not detected, so the command still matches your home-anchored rules.
1133
+ Writing a command that way takes deliberate effort to evade a rule; run an agent you would not trust with that inside a sandbox rather than relying on command patterns.
1134
+ - A home prefix later in the command, in argument position, is matched as typed: a pattern `cat ~/notes` matches the command `cat ~/notes` but not `cat /Users/me/notes`.
1135
+ Gate file access by any spelling on the `path` and `external_directory` surfaces instead.
1136
+
1137
+ The `bash` surface matches text, so a `*` in a home-anchored pattern also matches `..`: `~/bin/*` allows `~/bin/../../tmp/x`, exactly as it allows `/Users/me/bin/../../tmp/x`.
1138
+ Anchor an allow on the full command (`~/bin/tool *`) rather than a directory wildcard when that matters.
1139
+
1123
1140
  ---
1124
1141
 
1125
1142
  ## Per-Agent Overrides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "39.0.2",
3
+ "version": "39.0.4",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -52,16 +52,48 @@ export interface AccessPathAccessIntent {
52
52
  agentName?: string;
53
53
  }
54
54
 
55
- /** What a gate emits — a raw tool input or an `AccessPath`. */
56
- export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
55
+ /** What a gate emits: a raw tool input, an `AccessPath`, or a bash command unit. */
56
+ export type AccessIntent =
57
+ | ToolAccessIntent
58
+ | AccessPathAccessIntent
59
+ | BashCommandAccessIntent;
60
+
61
+ /**
62
+ * One bash command unit, with the other spellings the shell runs identically.
63
+ *
64
+ * `command` is the unit as typed: the prompt, the decision value, and the
65
+ * session-approval suggestion read it. `spellings` come from the program
66
+ * analysis, the only party that knows what a spelling means in this program
67
+ * (a `~` names the startup home only while the program leaves `HOME` alone),
68
+ * and the manager evaluates them with `command` as aliases of one invocation:
69
+ * the last rule matching any of them decides.
70
+ *
71
+ * String-only, so it stays on the manager's side of the ADR-0002 boundary.
72
+ */
73
+ export interface BashCommandAccessIntent {
74
+ kind: "bash-command";
75
+ /**
76
+ * Always `"bash"`. Typed `string` because the resolver re-surfaces every
77
+ * intent across a surface family (`{ ...intent, surface }`); `bash` is never
78
+ * a family, so that never happens to this one.
79
+ */
80
+ surface: string;
81
+ command: string;
82
+ spellings: readonly string[];
83
+ agentName?: string;
84
+ }
57
85
 
58
86
  /**
59
87
  * What the manager consumes — the `access-path` variant has already been
60
- * unwrapped to `path-values` by the resolver via `path.matchValues()`.
88
+ * unwrapped to `path-values` by the resolver via `path.matchValues()`; a
89
+ * `bash-command` intent passes through as emitted.
61
90
  *
62
91
  * The manager stays string-based and never imports `AccessPath`: this is the
63
92
  * deliberate boundary formalized in ADR-0002
64
93
  * (`docs/decisions/0002-path-values-string-boundary.md`), guarded by a
65
94
  * `no-restricted-imports` lint rule on `permission-manager.ts`.
66
95
  */
67
- export type ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent;
96
+ export type ResolvedAccessIntent =
97
+ | ToolAccessIntent
98
+ | PathValuesAccessIntent
99
+ | BashCommandAccessIntent;
@@ -73,6 +73,15 @@ export interface BashCommand {
73
73
  * unreachable.
74
74
  */
75
75
  readonly salvaged?: true;
76
+ /**
77
+ * Other spellings of {@link text} the shell runs identically, matched with
78
+ * it as aliases of one invocation. Absent when there are none.
79
+ *
80
+ * Today the only spelling is the home-expanded one, for a unit whose text
81
+ * opens with `~`, `$HOME`, or `${HOME}` in a program that leaves `HOME`
82
+ * alone ({@link WordReader.spellHomeAtStart}).
83
+ */
84
+ readonly spellings?: readonly string[];
76
85
  }
77
86
 
78
87
  /**
@@ -409,19 +418,23 @@ function unresolvedScope(node: TSNode, scope: UnitScope): UnitScope {
409
418
  : scope;
410
419
  }
411
420
 
412
- /** The wrapper facts a `command` node's words establish about its unit. */
413
- interface WrapperFacts {
421
+ /**
422
+ * The facts a `command` node's words establish about its unit: the three
423
+ * wrapper answers, and the other spellings its text has.
424
+ */
425
+ interface UnitFacts {
414
426
  readonly wrapperKind?: WrapperKind;
415
427
  readonly executedUnit?: string;
416
428
  readonly floorExemption?: FloorExemption;
429
+ readonly spellings?: readonly string[];
417
430
  }
418
431
 
419
432
  function makeUnit(
420
433
  text: string,
421
434
  scope: UnitScope,
422
- wrapper: WrapperFacts = {},
435
+ facts: UnitFacts = {},
423
436
  ): BashCommand {
424
- const { wrapperKind, executedUnit, floorExemption } = wrapper;
437
+ const { wrapperKind, executedUnit, floorExemption, spellings } = facts;
425
438
  const scoped: BashCommand = scope.context
426
439
  ? { text, context: scope.context }
427
440
  : { text };
@@ -433,7 +446,10 @@ function makeUnit(
433
446
  const marked: BashCommand = scope.parseUnresolved
434
447
  ? { ...exempted, parseUnresolved: true }
435
448
  : exempted;
436
- return scope.salvaged ? { ...marked, salvaged: true } : marked;
449
+ const salvaged: BashCommand = scope.salvaged
450
+ ? { ...marked, salvaged: true }
451
+ : marked;
452
+ return spellings === undefined ? salvaged : { ...salvaged, spellings };
437
453
  }
438
454
 
439
455
  /**
@@ -447,7 +463,9 @@ function makeUnit(
447
463
  */
448
464
  function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
449
465
  const { text, words } = readCommandUnit(node, scope.words);
466
+ const homeSpelling = scope.words.spellHomeAtStart(text);
450
467
  return makeUnit(text, scope, {
468
+ spellings: homeSpelling === undefined ? undefined : [homeSpelling],
451
469
  wrapperKind: classifyWrapperWords(words),
452
470
  executedUnit: executedUnitOf(text, words) ?? undefined,
453
471
  floorExemption: isTransparentWrapper(words, redirectedScope(node, scope))
@@ -102,6 +102,11 @@ export class WordReader {
102
102
  spellsReboundHome(token: string): boolean {
103
103
  return this.variables.spellsReboundHome(token);
104
104
  }
105
+
106
+ /** A command unit's text with its leading home prefix spelled out ({@link ShellVariables.spellHomeAtStart}). */
107
+ spellHomeAtStart(text: string): string | undefined {
108
+ return this.variables.spellHomeAtStart(text);
109
+ }
105
110
  }
106
111
 
107
112
  /**
@@ -17,7 +17,7 @@
17
17
  * See `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
18
18
  */
19
19
  import { homedir } from "node:os";
20
- import { hasHomePrefix } from "#src/path/expand-home";
20
+ import { hasHomePrefix, splitHomePrefix } from "#src/path/expand-home";
21
21
 
22
22
  import type { TSNode } from "./parser";
23
23
 
@@ -87,6 +87,24 @@ export class ShellVariables {
87
87
  return this.rebound.has("HOME") && hasHomePrefix(token);
88
88
  }
89
89
 
90
+ /**
91
+ * `text` with a leading `~` / `$HOME` / `${HOME}` spelled as the startup
92
+ * home, or `undefined` when it opens with none or the program rebinds `HOME`.
93
+ *
94
+ * The complement of {@link spellsReboundHome}: the spelling a command-pattern
95
+ * rule written with a home prefix is compiled to, so the rule matches the
96
+ * command as the shell runs it. The rest is kept verbatim, never
97
+ * path-normalized, because `..` in an argument is not a path step of the
98
+ * command's name. A backslash after the prefix gets none, because bash does
99
+ * not expand a tilde prefix holding a quoted character.
100
+ */
101
+ spellHomeAtStart(text: string): string | undefined {
102
+ if (this.rebound.has("HOME")) return undefined;
103
+ const rest = splitHomePrefix(text);
104
+ if (rest === undefined || rest.startsWith("\\")) return undefined;
105
+ return homedir() + rest;
106
+ }
107
+
90
108
  /**
91
109
  * How a word's leading unquoted text expands when it opens with a tilde
92
110
  * prefix that reads `HOME` (`~` or `~/…`; `~user` reads the password
@@ -125,10 +125,10 @@ export interface NormalizedInput {
125
125
  surface: string;
126
126
  /**
127
127
  * Alternative lookup names for this one access, most-specific first.
128
- * MCP is the only surface producing more than one; every other surface
129
- * produces a single-element array. Order does not decide which rule wins
130
- * (rule position does) — it decides which name the decision is reported
131
- * under when the winning rule matches several.
128
+ * MCP targets and a bash command's spellings are the only sources of more
129
+ * than one; every other surface produces a single-element array. Order does
130
+ * not decide which rule wins (rule position does); it decides which name the
131
+ * decision is reported under when the winning rule matches several.
132
132
  */
133
133
  values: string[];
134
134
  /**
@@ -175,16 +175,7 @@ export function normalizeInput(
175
175
  case "bash": {
176
176
  const record = toRecord(input);
177
177
  const command = typeof record.command === "string" ? record.command : "";
178
- // Strip leading shell comment lines so pattern matching operates on the
179
- // actual command, not a `# description` prefix agents often prepend.
180
- // Fall back to the raw command when stripping leaves nothing, so an
181
- // all-comment command still evaluates against its literal text.
182
- const matchValue = stripBashCommentLines(command) || command;
183
- return {
184
- surface: "bash",
185
- values: [matchValue],
186
- resultExtras: { command },
187
- };
178
+ return normalizeBashCommand(command, []);
188
179
  }
189
180
 
190
181
  // --- MCP ---
@@ -226,3 +217,26 @@ function normalizeMcpTargets(targets: readonly string[]): NormalizedInput {
226
217
  resultExtras: { target: values[0] },
227
218
  };
228
219
  }
220
+
221
+ /**
222
+ * The `bash` surface's normalized form: the command as typed, then the other
223
+ * spellings the shell runs identically, matched as aliases of one invocation.
224
+ *
225
+ * Leading shell comment lines are stripped from the typed command so pattern
226
+ * matching operates on the actual command, not a `# description` prefix agents
227
+ * often prepend; when stripping leaves nothing, an all-comment command still
228
+ * evaluates against its literal text. A spelling comes from the program
229
+ * analysis and is matched as given. `command` stays the typed text, because the
230
+ * prompt, the decision value, and the session-approval suggestion read it.
231
+ */
232
+ export function normalizeBashCommand(
233
+ command: string,
234
+ spellings: readonly string[],
235
+ ): NormalizedInput {
236
+ const matchValue = stripBashCommentLines(command) || command;
237
+ return {
238
+ surface: "bash",
239
+ values: [...new Set([matchValue, ...spellings])],
240
+ resultExtras: { command },
241
+ };
242
+ }
@@ -101,7 +101,7 @@ export class ConfigStore
101
101
  *
102
102
  * This store answers; `ConfigIssueReporter` decides whether the operator has
103
103
  * heard it yet (#933). Not to be confused with
104
- * `PermissionResolver.getConfigIssues(agentName?)`, which answers for the
104
+ * `PermissionResolver.getPolicyIssues(agentName?)`, which answers for the
105
105
  * *policy* files rather than the extension config.
106
106
  */
107
107
  getConfigIssues(): readonly string[] {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * policy-issue-reporter.ts — Tell the operator what composing their policy
3
+ * revealed, once per notice, for as long as it holds.
4
+ *
5
+ * The policy side answers only what it alone knows: the fail-closed notice for
6
+ * a rejected non-global scope (its `allow` rules are clamped to `ask`) and the
7
+ * port notice for relocated MCP tool keys. A config file's own schema errors
8
+ * are `ConfigIssueReporter`'s; both reporters reading them showed the operator
9
+ * each one twice (#953).
10
+ *
11
+ * Policy is re-read from file mtimes whenever it is consulted, so a file broken
12
+ * mid-session is clamped mid-session. Reporting only at `session_start` left
13
+ * that clamp unexplained; this is driven there and on every
14
+ * `before_agent_start`, so the operator hears about it on the next turn.
15
+ *
16
+ * The notices are agent-scoped, so the agent name is an argument rather than
17
+ * something this reads for itself: `AgentPrepHandler` resolves it from the
18
+ * `<active_agent>` prompt tag — the only name a pi-subagents child has — after
19
+ * turn prep runs, and hands it here.
20
+ *
21
+ * The latch is `ConfigIssueReporter`'s: per notice, delivered as one joined
22
+ * message per report, and *replaced* each report rather than added to — so a
23
+ * file fixed and broken again, or an agent switched away from and back into
24
+ * with a broken scope, is announced again.
25
+ */
26
+
27
+ import type { ConfigIssueWarner } from "./config-issue-reporter";
28
+
29
+ /** The policy seam this reads (ISP): the notices composing an agent's policy produced. */
30
+ export interface PolicyIssueSource {
31
+ getPolicyIssues(agentName?: string): readonly string[];
32
+ }
33
+
34
+ /** The seam the session-start and agent-prep handlers drive. */
35
+ export interface PolicyIssueReporting {
36
+ report(agentName: string | undefined): void;
37
+ }
38
+
39
+ export class PolicyIssueReporter implements PolicyIssueReporting {
40
+ private reported: ReadonlySet<string> = new Set();
41
+
42
+ constructor(
43
+ private readonly source: PolicyIssueSource,
44
+ private readonly log: ConfigIssueWarner,
45
+ ) {}
46
+
47
+ report(agentName: string | undefined): void {
48
+ const current = this.source.getPolicyIssues(agentName);
49
+ const unreported = current.filter((issue) => !this.reported.has(issue));
50
+ if (unreported.length > 0) {
51
+ this.log.warn(unreported.join("\n"));
52
+ }
53
+ this.reported = new Set(current);
54
+ }
55
+ }
@@ -81,8 +81,8 @@ export interface ResolvedPolicyPaths {
81
81
 
82
82
  /**
83
83
  * Abstraction over file I/O for loading permission policy from disk.
84
- * Implementations handle caching, path resolution, and config-issue
85
- * accumulation. `PermissionManager` depends on this interface so that
84
+ * Implementations handle caching, path resolution, and marking a rejected
85
+ * scope `invalid`. `PermissionManager` depends on this interface so that
86
86
  * merge + evaluation logic can be tested with an in-memory stub.
87
87
  */
88
88
  export interface PolicyLoader {
@@ -93,8 +93,6 @@ export interface PolicyLoader {
93
93
  getConfiguredMcpServerNames(): readonly string[];
94
94
  /** Combined mtime stamp for cache invalidation. */
95
95
  getCacheStamp(agentName?: string): string;
96
- /** Accumulated config-parse issues across all loads. */
97
- getConfigIssues(): string[];
98
96
  /** Resolved paths for the /permission-system show command. */
99
97
  getResolvedPolicyPaths(): ResolvedPolicyPaths;
100
98
  }
@@ -172,7 +170,6 @@ export class FilePolicyLoader implements PolicyLoader {
172
170
  private configuredMcpServerNamesCache: FileCacheEntry<
173
171
  readonly string[]
174
172
  > | null = null;
175
- private accumulatedConfigIssues: string[] = [];
176
173
 
177
174
  constructor(options: PolicyLoaderOptions = {}) {
178
175
  this.globalConfigPath =
@@ -194,21 +191,11 @@ export class FilePolicyLoader implements PolicyLoader {
194
191
  : null;
195
192
  }
196
193
 
197
- // ── Config issue accumulation ────────────────────────────────────────
198
-
199
- private accumulateConfigIssues(issues: string[]): void {
200
- for (const issue of issues) {
201
- if (!this.accumulatedConfigIssues.includes(issue)) {
202
- this.accumulatedConfigIssues.push(issue);
203
- }
204
- }
205
- }
206
-
207
- getConfigIssues(): string[] {
208
- return [...this.accumulatedConfigIssues];
209
- }
210
-
211
194
  // ── Scope loaders ────────────────────────────────────────────────────
195
+ //
196
+ // A config file's schema errors are not kept here: `ConfigStore` loads the
197
+ // same files through the same `loadUnifiedConfig` and reports them, so this
198
+ // loader reads them only to decide whether a scope fails closed (#953).
212
199
 
213
200
  loadGlobalConfig(): ScopeConfig {
214
201
  const stamp = getFileStamp(this.globalConfigPath);
@@ -216,8 +203,7 @@ export class FilePolicyLoader implements PolicyLoader {
216
203
  return this.globalConfigCache.value;
217
204
  }
218
205
 
219
- const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
220
- this.accumulateConfigIssues(issues);
206
+ const { config } = loadUnifiedConfig(this.globalConfigPath);
221
207
 
222
208
  const value: ScopeConfig = {
223
209
  permission: config.permission,
@@ -238,7 +224,6 @@ export class FilePolicyLoader implements PolicyLoader {
238
224
  }
239
225
 
240
226
  const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
241
- this.accumulateConfigIssues(issues);
242
227
 
243
228
  // A present-but-rejected file yields issues (parse error or schema
244
229
  // rejection); an absent file yields none. Fail closed on the former.
@@ -4,6 +4,7 @@ import type {
4
4
  NormalizedBuildSystemPromptOptions,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import type { SubagentDetector } from "#src/authority/subagent-detection";
7
+ import type { PolicyIssueReporting } from "#src/config/policy-issue-reporter";
7
8
  import {
8
9
  visibleSkillPromptEntries,
9
10
  withoutDeniedSkills,
@@ -78,6 +79,10 @@ export function shouldExposeTool(
78
79
  * - `logger` — records each change to the effective tool surface
79
80
  * - `detector` — tells a subagent child from a root, which decides whether a
80
81
  * custom prompt gets this node's tool surface
82
+ * - `policyIssues` — reports what composing policy revealed for the agent the
83
+ * prompt names; driven here rather than in turn prep because the
84
+ * `<active_agent>` tag, the only name a pi-subagents child has, is read here
85
+ * (#953)
81
86
  *
82
87
  * The active set is recomputed from the session's pre-filter tool surface
83
88
  * every turn, so relaxing a rule restores the tool it had withheld (#873).
@@ -90,6 +95,7 @@ export class AgentPrepHandler {
90
95
  private readonly toolRegistry: ToolRegistry,
91
96
  private readonly logger: DebugLogger,
92
97
  private readonly detector: SubagentDetector,
98
+ private readonly policyIssues: PolicyIssueReporting,
93
99
  ) {}
94
100
 
95
101
  // eslint-disable-next-line @typescript-eslint/require-await
@@ -105,6 +111,9 @@ export class AgentPrepHandler {
105
111
  ? event.systemPrompt
106
112
  : event.systemPrompt.join("\n");
107
113
  const agentName = this.session.resolveAgentName(ctx, systemPrompt);
114
+ // Policy is re-read by mtime, so this turn may compose a clamp the file
115
+ // just caused; say so for the agent this turn runs as.
116
+ this.policyIssues.report(agentName ?? undefined);
108
117
  const registered = readRegisteredTools(this.toolRegistry.getAll());
109
118
  const surface = this.session.resolveExposedTools(
110
119
  this.observeToolSurface(registered),
@@ -75,7 +75,7 @@ export function resolveBashCommandCheck(
75
75
  resolver: ScopedPermissionResolver,
76
76
  ): PermissionCheckResult {
77
77
  if (isTriviallyEmptyCommand(command)) {
78
- return resolveOnBashSurface(command, agentName, resolver);
78
+ return resolveOnBashSurface(command, [], agentName, resolver);
79
79
  }
80
80
 
81
81
  if (!commands.some((cmd) => cmd.salvaged !== true)) {
@@ -86,7 +86,7 @@ export function resolveBashCommandCheck(
86
86
  // from the wreckage: `> f <<'M' 2>&1 | rm -rf /tmp/x` has zero primary
87
87
  // units and one salvaged one, and keying the check on the combined list
88
88
  // would silently drop a `deny` the pre-salvage gate reached (#875).
89
- const whole = resolveOnBashSurface(command, agentName, resolver);
89
+ const whole = resolveOnBashSurface(command, [], agentName, resolver);
90
90
  if (whole.state === "deny") {
91
91
  return whole;
92
92
  }
@@ -107,7 +107,7 @@ export function resolveBashCommandCheck(
107
107
  );
108
108
  return (
109
109
  pickMostRestrictive(results) ??
110
- resolveOnBashSurface(command, agentName, resolver)
110
+ resolveOnBashSurface(command, [], agentName, resolver)
111
111
  );
112
112
  }
113
113
 
@@ -122,7 +122,12 @@ function resolveCommandUnit(
122
122
  agentName: string | undefined,
123
123
  resolver: ScopedPermissionResolver,
124
124
  ): PermissionCheckResult {
125
- const base = resolveOnBashSurface(cmd.text, agentName, resolver);
125
+ const base = resolveOnBashSurface(
126
+ cmd.text,
127
+ cmd.spellings ?? [],
128
+ agentName,
129
+ resolver,
130
+ );
126
131
  const floored =
127
132
  cmd.wrapperKind && base.state === "allow"
128
133
  ? resolveWrapperUnit(cmd, cmd.wrapperKind, base, agentName, resolver)
@@ -204,7 +209,7 @@ function resolveWrapperUnit(
204
209
  // `command`, and naming a fragment of the command line there would offer a
205
210
  // grant that does not cover what the user is looking at.
206
211
  return {
207
- ...resolveOnBashSurface(inner, agentName, resolver),
212
+ ...resolveOnBashSurface(inner, [], agentName, resolver),
208
213
  command: base.command,
209
214
  floorExemption: cmd.floorExemption,
210
215
  };
@@ -225,21 +230,26 @@ function isTriviallyEmptyCommand(command: string): boolean {
225
230
  }
226
231
 
227
232
  /**
228
- * Resolve one command string against the `bash` surface's rules.
233
+ * Resolve one command string, and the other spellings the shell runs it by,
234
+ * against the `bash` surface's rules.
229
235
  *
230
236
  * Three callers share it: each command unit of the chain, the whole command
231
237
  * when the chain yields no units, and the inner command of a wrapper the floor
232
- * no longer covers.
238
+ * no longer covers. Only a unit has spellings: they come from the program
239
+ * analysis that produced it, and the whole command or a wrapper's inner text
240
+ * has no unit of its own to carry them.
233
241
  */
234
242
  function resolveOnBashSurface(
235
243
  command: string,
244
+ spellings: readonly string[],
236
245
  agentName: string | undefined,
237
246
  resolver: ScopedPermissionResolver,
238
247
  ): PermissionCheckResult {
239
248
  return resolver.resolve({
240
- kind: "tool",
249
+ kind: "bash-command",
241
250
  surface: "bash",
242
- input: { command },
251
+ command,
252
+ spellings,
243
253
  agentName,
244
254
  });
245
255
  }
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AskDialogRelease } from "#src/authority/ask-dialog-queue";
3
3
  import type { ConfigIssueReporting } from "#src/config/config-issue-reporter";
4
+ import type { PolicyIssueReporting } from "#src/config/policy-issue-reporter";
4
5
  import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/config/status";
5
6
  import type { DecisionSummaryWriter } from "#src/logging/decision-audit";
6
7
  import type { SessionLogger } from "#src/logging/session-logger";
7
- import type { PermissionResolver } from "#src/policy/permission-resolver";
8
8
  import type { ServiceLifecycle } from "#src/service/service-lifecycle";
9
9
  import type { PermissionSession } from "#src/session/permission-session";
10
10
 
@@ -41,7 +41,9 @@ export const UNTRUSTED_PROJECT_MESSAGE =
41
41
  *
42
42
  * Constructor deps:
43
43
  * - `session` — encapsulates all mutable session state and lifecycle operations
44
- * - `resolver` — owns permission-query surface: `getConfigIssues`
44
+ * - `policyIssues` — reports what composing policy revealed (a fail-closed clamp,
45
+ * a port notice) for the agent the session names, latched per notice; driven
46
+ * here and on every turn, so a clamp caused mid-session is explained (#953)
45
47
  * - `serviceLifecycle` — owns the process-global service publication;
46
48
  * `activate` publishes (skipped for registered subagent children) and emits
47
49
  * the ready event; `teardown` unsubscribes all session listeners and unpublishes
@@ -56,7 +58,7 @@ export const UNTRUSTED_PROJECT_MESSAGE =
56
58
  export class SessionLifecycleHandler {
57
59
  constructor(
58
60
  private readonly session: PermissionSession,
59
- private readonly resolver: PermissionResolver,
61
+ private readonly policyIssues: PolicyIssueReporting,
60
62
  private readonly serviceLifecycle: ServiceLifecycle,
61
63
  private readonly logger: SessionLogger,
62
64
  private readonly audit: DecisionSummaryWriter,
@@ -84,10 +86,7 @@ export class SessionLifecycleHandler {
84
86
  }
85
87
 
86
88
  const agentName = this.session.resolveAgentName(ctx);
87
- const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
88
- for (const issue of policyIssues) {
89
- this.logger.warn(issue);
90
- }
89
+ this.policyIssues.report(agentName ?? undefined);
91
90
 
92
91
  if (event.reason === "reload") {
93
92
  this.logger.debug("lifecycle.reload", {
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import { ConfigStore } from "#src/config/config-store";
41
41
  import { resolveDialogKeys } from "#src/config/dialog-keys";
42
42
  import { isYoloModeEnabled } from "#src/config/extension-config";
43
43
  import { computeExtensionPaths } from "#src/config/extension-paths";
44
+ import { PolicyIssueReporter } from "#src/config/policy-issue-reporter";
44
45
  import { GateRunner } from "#src/handlers/gates/runner";
45
46
  import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
46
47
  import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
@@ -322,9 +323,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
322
323
  // was recorded as delivered and never shown (#933). Driven at session_start
323
324
  // and on every turn; the latch keeps an unchanged issue quiet.
324
325
  const configIssueReporter = new ConfigIssueReporter(configStore, logger);
326
+ // What composing policy revealed (a fail-closed clamp, a port notice) is
327
+ // agent-scoped, so each driver hands over the agent name it resolved (#953).
328
+ const policyIssueReporter = new PolicyIssueReporter(resolver, logger);
325
329
  const lifecycle = new SessionLifecycleHandler(
326
330
  session,
327
- resolver,
331
+ policyIssueReporter,
328
332
  serviceLifecycle,
329
333
  logger,
330
334
  audit,
@@ -346,6 +350,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
346
350
  toolRegistry,
347
351
  logger,
348
352
  subagentDetection,
353
+ policyIssueReporter,
349
354
  );
350
355
 
351
356
  const gateRunner = new GateRunner(
@@ -29,7 +29,7 @@ const HOME_PREFIXES = ["~", "$HOME", "${HOME}"] as const;
29
29
  * All other patterns are returned unchanged.
30
30
  */
31
31
  export function expandHomePath(pattern: string): string {
32
- const rest = afterHomePrefix(pattern);
32
+ const rest = splitHomePrefix(pattern);
33
33
  if (rest === undefined) return pattern;
34
34
  return rest === "" ? homedir() : join(homedir(), rest.slice(1));
35
35
  }
@@ -39,14 +39,18 @@ export function expandHomePath(pattern: string): string {
39
39
  * directory.
40
40
  */
41
41
  export function hasHomePrefix(value: string): boolean {
42
- return afterHomePrefix(value) !== undefined;
42
+ return splitHomePrefix(value) !== undefined;
43
43
  }
44
44
 
45
45
  /**
46
46
  * What follows a recognized home prefix (`""`, or a separator and the rest),
47
47
  * or `undefined` when `value` has none.
48
+ *
49
+ * Exported so a caller that must not path-normalize the rest (a bash command
50
+ * string, which {@link expandHomePath}'s `join` would rewrite across its
51
+ * arguments) reads the same prefix table.
48
52
  */
49
- function afterHomePrefix(value: string): string | undefined {
53
+ export function splitHomePrefix(value: string): string | undefined {
50
54
  for (const prefix of HOME_PREFIXES) {
51
55
  if (!value.startsWith(prefix)) continue;
52
56
  const rest = value.slice(prefix.length);
@@ -1,6 +1,9 @@
1
1
  import { join } from "node:path";
2
2
  import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
3
- import { normalizeInput } from "#src/access-intent/input-normalizer";
3
+ import {
4
+ normalizeBashCommand,
5
+ normalizeInput,
6
+ } from "#src/access-intent/input-normalizer";
4
7
  import { surfaceFamilyOf } from "#src/access-intent/path-surfaces";
5
8
  import { classifyToolKind } from "#src/access-intent/tool-kind";
6
9
  import {
@@ -59,12 +62,12 @@ type ResolvedPermissions = {
59
62
  /**
60
63
  * Non-global scopes whose config file failed to load or validate. When
61
64
  * non-empty the composed ruleset has been floored allow→ask (#646); the
62
- * names also drive the fail-closed notice in {@link getConfigIssues}.
65
+ * names also drive the fail-closed notice in {@link getPolicyIssues}.
63
66
  */
64
67
  failClosedScopes: RuleOrigin[];
65
68
  /**
66
69
  * Top-level permission keys naming Pi MCP tools, relocated onto the `mcp`
67
- * surface; they drive the port notice in {@link getConfigIssues}.
70
+ * surface; they drive the port notice in {@link getPolicyIssues}.
68
71
  */
69
72
  legacyMcpToolKeys: string[];
70
73
  };
@@ -89,7 +92,7 @@ export interface ScopedPermissionManager {
89
92
  ): PermissionCheckResult;
90
93
  getToolPermission(toolName: string, agentName?: string): PermissionState;
91
94
  isToolFullyDenied(toolName: string, agentName?: string): boolean;
92
- getConfigIssues(agentName?: string): string[];
95
+ getPolicyIssues(agentName?: string): string[];
93
96
  }
94
97
 
95
98
  export interface PermissionManagerOptions extends PolicyLoaderOptions {
@@ -155,11 +158,20 @@ export class PermissionManager implements ScopedPermissionManager {
155
158
  this.resolvedPermissionsCache.clear();
156
159
  }
157
160
 
158
- getConfigIssues(agentName?: string): string[] {
159
- // Trigger a load/resolve to ensure issues are collected.
161
+ /**
162
+ * What composing `agentName`'s policy revealed: the fail-closed notice for
163
+ * a rejected non-global scope and the port notice for relocated MCP tool
164
+ * keys. Recomputed on every resolve, so a notice disappears once its cause is
165
+ * fixed.
166
+ *
167
+ * A config file's own schema errors are not here: `ConfigStore` loads the
168
+ * same files through the same `loadUnifiedConfig` and owns reporting them,
169
+ * so listing them here too showed the operator each one twice (#953).
170
+ */
171
+ getPolicyIssues(agentName?: string): string[] {
160
172
  const { failClosedScopes, legacyMcpToolKeys } =
161
173
  this.resolvePermissions(agentName);
162
- const issues = [...this.loader.getConfigIssues()];
174
+ const issues: string[] = [];
163
175
  if (failClosedScopes.length > 0) {
164
176
  issues.push(
165
177
  `Invalid ${failClosedScopes.join(", ")} configuration detected — ` +
@@ -321,9 +333,11 @@ export class PermissionManager implements ScopedPermissionManager {
321
333
  * extension surfaces). Path-bearing surfaces arrive as `"path-values"` via
322
334
  * the access-path gate (#502) or service/RPC builder (#503).
323
335
  * `"path-values"` → evaluates the precomputed values directly.
336
+ * `"bash-command"` → evaluates a bash command unit and its spellings as
337
+ * aliases through `normalizeBashCommand`.
324
338
  *
325
339
  * The manager stays string-based by design: it consumes `ResolvedAccessIntent`
326
- * (`tool | path-values`) and never imports `AccessPath`. This deliberate
340
+ * (`tool | path-values | bash-command`) and never imports `AccessPath`. This deliberate
327
341
  * boundary is formalized in ADR-0002
328
342
  * (`docs/decisions/0002-path-values-string-boundary.md`) and guarded by a
329
343
  * `no-restricted-imports` lint rule on this file.
@@ -357,6 +371,22 @@ export class PermissionManager implements ScopedPermissionManager {
357
371
  );
358
372
  }
359
373
 
374
+ if (intent.kind === "bash-command") {
375
+ const { surface, values, resultExtras } = normalizeBashCommand(
376
+ intent.command,
377
+ intent.spellings,
378
+ );
379
+ return buildCheckResult(
380
+ surface,
381
+ values,
382
+ resultExtras,
383
+ surface,
384
+ surface,
385
+ fullRules,
386
+ this.flavor,
387
+ );
388
+ }
389
+
360
390
  // kind === "tool"
361
391
  const toolName = intent.surface.trim();
362
392
  const { surface, values, resultExtras } = normalizeInput(
@@ -4,6 +4,7 @@ import type {
4
4
  ResolvedAccessIntent,
5
5
  } from "#src/access-intent/access-intent";
6
6
  import { surfaceFamilyMembers } from "#src/access-intent/path-surfaces";
7
+ import type { PolicyIssueSource } from "#src/config/policy-issue-reporter";
7
8
  import type { SkillPermissionChecker } from "#src/exposure/skill-prompt-sanitizer";
8
9
  import type { SessionRules } from "#src/session/session-rules";
9
10
  import type { PermissionCheckResult, PermissionState } from "#src/types";
@@ -64,7 +65,7 @@ function toResolvedIntent(
64
65
  * - `sessionRules` — narrowed to `getRuleset` (ISP: the resolver only reads, never records)
65
66
  */
66
67
  export class PermissionResolver
67
- implements ScopedPermissionResolver, SkillPermissionChecker
68
+ implements ScopedPermissionResolver, SkillPermissionChecker, PolicyIssueSource
68
69
  {
69
70
  constructor(
70
71
  private readonly permissionManager: ScopedPermissionManager,
@@ -139,7 +140,7 @@ export class PermissionResolver
139
140
  return this.permissionManager.isToolFullyDenied(toolName, agentName);
140
141
  }
141
142
 
142
- getConfigIssues(agentName?: string): string[] {
143
- return this.permissionManager.getConfigIssues(agentName);
143
+ getPolicyIssues(agentName?: string): string[] {
144
+ return this.permissionManager.getPolicyIssues(agentName);
144
145
  }
145
146
  }
@@ -42,6 +42,16 @@ function escapeRegExp(value: string): string {
42
42
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
43
43
  }
44
44
 
45
+ /**
46
+ * Compile `pattern` once for repeated matching.
47
+ *
48
+ * A leading `~` / `$HOME` / `${HOME}` is expanded on the pattern only. The value
49
+ * side is not expanded here, because what a home prefix means depends on where
50
+ * the value came from: a bash program can rebind `HOME`. A value arrives
51
+ * already spelled by the producer that knows, as an alias matched alongside the
52
+ * typed text (`AccessPath.matchValues()` for a path, a bash command unit's
53
+ * `spellings`).
54
+ */
45
55
  export function compileWildcardPattern<TState>(
46
56
  pattern: string,
47
57
  state: TState,