@gotgenes/pi-permission-system 39.0.1 → 39.0.3

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,29 @@ 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.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.2...pi-permission-system-v39.0.3) (2026-10-03)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **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)
14
+
15
+ ### Documentation
16
+
17
+ * **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)
18
+ * **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)
19
+
20
+ ## [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)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * **pi-permission-system:** stop logging config- and default-allowed bash external paths as session approvals ([1e597c0](https://github.com/gotgenes/pi-packages/commit/1e597c033c58843f298d9fd0f01cf227dc649e60)), closes [#1011](https://github.com/gotgenes/pi-packages/issues/1011)
26
+
27
+ ### Documentation
28
+
29
+ * **pi-permission-system:** say what external_directory does when the key is absent ([7319277](https://github.com/gotgenes/pi-packages/commit/7319277d338665225d59af21c43b463e94bda6e6)), closes [#1011](https://github.com/gotgenes/pi-packages/issues/1011)
30
+
8
31
  ## [39.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.0...pi-permission-system-v39.0.1) (2026-10-03)
9
32
 
10
33
 
@@ -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.
@@ -789,6 +791,9 @@ Use a pattern map to allow specific directories without opening all external acc
789
791
  For example, `read: "allow"` can permit ordinary reads while `external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`.
790
792
  Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided.
791
793
 
794
+ When no `external_directory` key is present, the universal fallback (`permission["*"]`) applies.
795
+ So `"*": "allow"` lets every outside-CWD access through with no prompt and, like any policy allow, no review-log entry; the examples on this page set `"*": "ask"` inside `external_directory` so the boundary asks regardless of the fallback.
796
+
792
797
  #### Allow an outside-CWD cache directory
793
798
 
794
799
  When an agent keeps reading a local cache outside the working tree — `~/.cargo/registry`, `~/.npm`, `~/go/pkg/mod` — and you want to stop confirming it every time, allow that directory on the `external_directory` surface:
@@ -1117,6 +1122,21 @@ Path **values** supplied by bash commands and extension tools are expanded the s
1117
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.
1118
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.
1119
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
+
1120
1140
  ---
1121
1141
 
1122
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.1",
3
+ "version": "39.0.3",
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
+ }
@@ -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
  }
@@ -14,7 +14,10 @@ import type { ToolCallContext } from "./types";
14
14
  * Reads the external paths from the injected `BashProgram` and checks whether
15
15
  * any reference directories outside the working directory. Returns `null` when the gate
16
16
  * does not apply (not a shell invocation, no command, or no external paths found).
17
- * Returns a `GateBypass` when all paths are allowed (by config or session rule).
17
+ * Returns `null` when every path is allowed by a config rule or the universal
18
+ * fallback — like any policy allow, it writes no review entry. Returns a
19
+ * `GateBypass` recording a session approval when every path is allowed and at
20
+ * least one was covered by a session grant.
18
21
  * Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
19
22
  *
20
23
  * Each path is resolved on the narrowest `external_directory`-family surface
@@ -42,23 +45,29 @@ export function describeBashExternalDirectoryGate(
42
45
 
43
46
  // Resolve every external path on the external_directory surface and keep the
44
47
  // ones not already allowed (config-level allows suppress the prompt just as
45
- // session-level allows do); the shared helper single-sources the #418 alias
46
- // matching and the worst-uncovered selection.
47
- const { uncovered: uncoveredEntries, worstCheck } =
48
- selectUncoveredExternalPaths(
49
- externalAccesses,
50
- resolver,
51
- tcc.agentName ?? undefined,
52
- );
48
+ // session-level allows do), noting which allowed ones a session grant
49
+ // covered; the shared helper single-sources the #418 alias matching and the
50
+ // worst-uncovered selection.
51
+ const {
52
+ uncovered: uncoveredEntries,
53
+ sessionCovered,
54
+ worstCheck,
55
+ } = selectUncoveredExternalPaths(
56
+ externalAccesses,
57
+ resolver,
58
+ tcc.agentName ?? undefined,
59
+ );
53
60
  const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
54
61
 
55
62
  if (uncoveredPaths.length === 0) {
63
+ // No session grant decided any path: policy alone allowed the command.
64
+ if (sessionCovered.length === 0) return null;
56
65
  return {
57
66
  action: "allow",
58
- // A whole-command bypass covers every external path at once, and each
59
- // may have matched a different session pattern -- so the surface is one
60
- // value and the pattern is not. The entry's `externalPaths` lists what
61
- // was covered.
67
+ // A whole-command bypass covers every session-granted path at once, and
68
+ // each may have matched a different session pattern -- so the surface is
69
+ // one value and the pattern is not. The entry's `externalPaths` lists the
70
+ // paths a grant covered, never the ones policy allowed.
62
71
  decidedBy: {
63
72
  kind: "session_approval",
64
73
  surface: "external_directory",
@@ -72,7 +81,7 @@ export function describeBashExternalDirectoryGate(
72
81
  toolName: tcc.toolName,
73
82
  agentName: tcc.agentName,
74
83
  command,
75
- externalPaths: externalAccesses.map(({ path }) => path.value()),
84
+ externalPaths: sessionCovered.map((path) => path.value()),
76
85
  resolution: "session_approved",
77
86
  },
78
87
  },
@@ -16,9 +16,18 @@ export interface UncoveredExternalPath {
16
16
  check: PermissionCheckResult;
17
17
  }
18
18
 
19
- /** The uncovered external paths plus the most restrictive check among them. */
20
- export interface UncoveredExternalPaths {
19
+ /**
20
+ * How a set of external paths resolved: the uncovered ones, the worst among
21
+ * them, and which allowed paths a session grant covered.
22
+ */
23
+ export interface ExternalPathCoverage {
21
24
  uncovered: UncoveredExternalPath[];
25
+ /**
26
+ * Allowed paths whose winning rule was a session grant. A path allowed by a
27
+ * config rule or the universal fallback is in neither list, so a caller can
28
+ * stamp `session_approval` only where a grant actually decided.
29
+ */
30
+ sessionCovered: AccessPath[];
22
31
  /** Worst check among uncovered paths; `undefined` only when none are uncovered. */
23
32
  worstCheck: PermissionCheckResult | undefined;
24
33
  }
@@ -60,14 +69,16 @@ export function resolveExternalDirectoryPolicy(
60
69
  * not "allow" are collected (filtering on state, not source, so config-level
61
70
  * allow rules suppress the prompt just as session-level allow rules do), and
62
71
  * the most restrictive uncovered check is returned so a config "deny" is not
63
- * downgraded to the catch-all "ask".
72
+ * downgraded to the catch-all "ask". Allowed entries a session rule decided
73
+ * are reported apart, as the provenance a whole-command bypass records.
64
74
  */
65
75
  export function selectUncoveredExternalPaths(
66
76
  accesses: readonly BashExternalPath[],
67
77
  resolver: ScopedPermissionResolver,
68
78
  agentName: string | undefined,
69
- ): UncoveredExternalPaths {
79
+ ): ExternalPathCoverage {
70
80
  const uncovered: UncoveredExternalPath[] = [];
81
+ const sessionCovered: AccessPath[] = [];
71
82
  for (const { path, effect } of accesses) {
72
83
  const surface = capabilitySurfaceForEffect(
73
84
  "external_directory",
@@ -81,10 +92,13 @@ export function selectUncoveredExternalPaths(
81
92
  );
82
93
  if (check.state !== "allow") {
83
94
  uncovered.push({ path, surface, effect, check });
95
+ } else if (check.source === "session") {
96
+ sessionCovered.push(path);
84
97
  }
85
98
  }
86
99
  return {
87
100
  uncovered,
101
+ sessionCovered,
88
102
  worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
89
103
  };
90
104
  }
@@ -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 {
@@ -321,9 +324,11 @@ export class PermissionManager implements ScopedPermissionManager {
321
324
  * extension surfaces). Path-bearing surfaces arrive as `"path-values"` via
322
325
  * the access-path gate (#502) or service/RPC builder (#503).
323
326
  * `"path-values"` → evaluates the precomputed values directly.
327
+ * `"bash-command"` → evaluates a bash command unit and its spellings as
328
+ * aliases through `normalizeBashCommand`.
324
329
  *
325
330
  * The manager stays string-based by design: it consumes `ResolvedAccessIntent`
326
- * (`tool | path-values`) and never imports `AccessPath`. This deliberate
331
+ * (`tool | path-values | bash-command`) and never imports `AccessPath`. This deliberate
327
332
  * boundary is formalized in ADR-0002
328
333
  * (`docs/decisions/0002-path-values-string-boundary.md`) and guarded by a
329
334
  * `no-restricted-imports` lint rule on this file.
@@ -357,6 +362,22 @@ export class PermissionManager implements ScopedPermissionManager {
357
362
  );
358
363
  }
359
364
 
365
+ if (intent.kind === "bash-command") {
366
+ const { surface, values, resultExtras } = normalizeBashCommand(
367
+ intent.command,
368
+ intent.spellings,
369
+ );
370
+ return buildCheckResult(
371
+ surface,
372
+ values,
373
+ resultExtras,
374
+ surface,
375
+ surface,
376
+ fullRules,
377
+ this.flavor,
378
+ );
379
+ }
380
+
360
381
  // kind === "tool"
361
382
  const toolName = intent.surface.trim();
362
383
  const { surface, values, resultExtras } = normalizeInput(
@@ -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,