@gotgenes/pi-permission-system 39.0.2 → 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 +12 -0
- package/docs/configuration.md +17 -0
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +36 -4
- package/src/access-intent/bash/command-enumeration.ts +23 -5
- package/src/access-intent/bash/node-text.ts +5 -0
- package/src/access-intent/bash/shell-variable-expansion.ts +19 -1
- package/src/access-intent/input-normalizer.ts +28 -14
- package/src/handlers/gates/bash-command.ts +19 -9
- package/src/path/expand-home.ts +7 -3
- package/src/policy/permission-manager.ts +23 -2
- package/src/policy/wildcard-matcher.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ 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
|
+
|
|
8
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)
|
|
9
21
|
|
|
10
22
|
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
@@ -52,16 +52,48 @@ export interface AccessPathAccessIntent {
|
|
|
52
52
|
agentName?: string;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/** What a gate emits
|
|
56
|
-
export type AccessIntent =
|
|
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 =
|
|
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
|
-
/**
|
|
413
|
-
|
|
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
|
-
|
|
435
|
+
facts: UnitFacts = {},
|
|
423
436
|
): BashCommand {
|
|
424
|
-
const { wrapperKind, executedUnit, floorExemption } =
|
|
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
|
-
|
|
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
|
|
129
|
-
* produces a single-element array. Order does
|
|
130
|
-
* (rule position does)
|
|
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
|
-
|
|
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(
|
|
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
|
|
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: "
|
|
249
|
+
kind: "bash-command",
|
|
241
250
|
surface: "bash",
|
|
242
|
-
|
|
251
|
+
command,
|
|
252
|
+
spellings,
|
|
243
253
|
agentName,
|
|
244
254
|
});
|
|
245
255
|
}
|
package/src/path/expand-home.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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 {
|
|
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,
|