@gotgenes/pi-permission-system 20.4.2 → 20.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -0
- package/config/config.example.json +4 -0
- package/docs/configuration.md +36 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +36 -0
- package/src/access-intent/bash/bash-path-resolver.ts +63 -4
- package/src/access-intent/bash/program.ts +22 -1
- package/src/access-intent/bash/token-classification.ts +6 -7
- package/src/{input-normalizer.ts → access-intent/input-normalizer.ts} +5 -5
- package/src/{mcp-targets.ts → access-intent/mcp-targets.ts} +1 -1
- package/src/{tool-input-path.ts → access-intent/tool-input-path.ts} +3 -3
- package/src/access-intent/tool-kind.ts +56 -1
- package/src/config-loader.ts +15 -1
- package/src/config-schema.ts +39 -0
- package/src/extension-config.ts +9 -1
- package/src/handlers/gates/bash-external-directory.ts +6 -7
- package/src/handlers/gates/bash-path.ts +7 -8
- package/src/handlers/gates/external-directory.ts +1 -1
- package/src/handlers/gates/path.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +52 -25
- package/src/handlers/gates/tool.ts +20 -10
- package/src/index.ts +1 -1
- package/src/path/pi-infrastructure-read.ts +1 -2
- package/src/pattern-suggest.ts +1 -1
- package/src/permission-manager.ts +2 -2
- package/src/permission-session.ts +11 -0
- package/src/permissions-service.ts +1 -1
- package/src/rule.ts +1 -1
- /package/src/{path-surfaces.ts → access-intent/path-surfaces.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,38 @@ 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
|
+
## [20.6.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.5.0...pi-permission-system-v20.6.0) (2026-07-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** add resolveShellInvocation dispatch point ([5c5edc8](https://github.com/gotgenes/pi-packages/commit/5c5edc84cd46dafb5dade9fd51b2946632b72776))
|
|
14
|
+
* **pi-permission-system:** gate aliased shell tools through the bash stack ([2c17f2c](https://github.com/gotgenes/pi-packages/commit/2c17f2ce4b6207684f999d0bc30fe630c79934bb)), closes [#574](https://github.com/gotgenes/pi-packages/issues/574)
|
|
15
|
+
* **pi-permission-system:** resolve and gate aliased shell workdir ([d9b2af8](https://github.com/gotgenes/pi-packages/commit/d9b2af86107671366935242e704d1f17821db73f)), closes [#574](https://github.com/gotgenes/pi-packages/issues/574)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Documentation
|
|
19
|
+
|
|
20
|
+
* **pi-permission-system:** document live shellTools enforcement ([b8048ed](https://github.com/gotgenes/pi-packages/commit/b8048eddba5f8c0e9b82cf0c3e31055e7813b745))
|
|
21
|
+
|
|
22
|
+
## [20.5.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.4.2...pi-permission-system-v20.5.0) (2026-07-13)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** add shellTools config schema ([cd4f851](https://github.com/gotgenes/pi-packages/commit/cd4f851ab4ddccbc2409f664432e3cf256997817))
|
|
28
|
+
* **pi-permission-system:** carry shellTools through config merge ([635d66b](https://github.com/gotgenes/pi-packages/commit/635d66b56d753f00192269f04cd7dceba1264f8d))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Bug Fixes
|
|
32
|
+
|
|
33
|
+
* **pi-permission-system:** treat bare / as a filesystem-root path candidate ([#583](https://github.com/gotgenes/pi-packages/issues/583)) ([dfa4080](https://github.com/gotgenes/pi-packages/commit/dfa408026a6f74f63c4537f9e6904acabc4f260a))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
|
|
38
|
+
* **pi-permission-system:** document shellTools config ([1b8e3c3](https://github.com/gotgenes/pi-packages/commit/1b8e3c3a5779ac6cae9686c10da1b8b3a0f36309))
|
|
39
|
+
|
|
8
40
|
## [20.4.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.4.1...pi-permission-system-v20.4.2) (2026-07-13)
|
|
9
41
|
|
|
10
42
|
|
package/README.md
CHANGED
|
@@ -109,6 +109,8 @@ Project overrides global; per-agent YAML frontmatter overrides both.
|
|
|
109
109
|
|
|
110
110
|
Within a surface map like `bash` or `mcp`, **last matching rule wins** — put broad catch-alls first and specific overrides after.
|
|
111
111
|
|
|
112
|
+
The optional `shellTools` field records which non-`bash` tools carry shell semantics (e.g. an `exec_command` tool that replaces native `bash`), so they are gated at full parity with native `bash` — see [docs/configuration.md](docs/configuration.md#shelltools--gating-aliased-shell-tools).
|
|
113
|
+
|
|
112
114
|
For the full reference — all surfaces, runtime knobs, per-agent overrides, merge semantics, and common recipes — see [docs/configuration.md](docs/configuration.md).
|
|
113
115
|
|
|
114
116
|
## Upgrading
|
package/docs/configuration.md
CHANGED
|
@@ -46,6 +46,11 @@ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`) use simple replace
|
|
|
46
46
|
"toolTextSummaryMaxLength": 120,
|
|
47
47
|
"piInfrastructureReadPaths": [],
|
|
48
48
|
|
|
49
|
+
// Non-bash tools that carry shell semantics
|
|
50
|
+
"shellTools": {
|
|
51
|
+
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
|
|
52
|
+
},
|
|
53
|
+
|
|
49
54
|
// Flat permission policy
|
|
50
55
|
"permission": {
|
|
51
56
|
"*": "ask", // universal fallback
|
|
@@ -104,6 +109,37 @@ Example — allow reads from a Homebrew-managed Pi install at any version:
|
|
|
104
109
|
}
|
|
105
110
|
```
|
|
106
111
|
|
|
112
|
+
### `shellTools` — gating aliased shell tools
|
|
113
|
+
|
|
114
|
+
The native `bash` tool goes through the full bash enforcement stack: command decomposition, wrapper flooring, path and external-directory token gates, and `bash:` rules.
|
|
115
|
+
Some extensions replace `bash` with a differently-named tool — for example [`@howaboua/pi-codex-conversion`](https://github.com/IgorWarzocha/howaboua-pi-stuff) registers `exec_command`, which carries the shell command in a `cmd` argument and an optional working directory in `workdir`.
|
|
116
|
+
Without a hint, the permission system cannot tell that such a tool is really a shell, so it gates it as a generic extension tool and the bash rules never apply.
|
|
117
|
+
|
|
118
|
+
`shellTools` records that hint, and an aliased tool is then gated at full parity with native `bash` — command decomposition, wrapper flooring, path and external-directory token gates, and `bash:` rules — with the invoked tool name preserved in the review log.
|
|
119
|
+
Each key is a tool name; its value maps the tool's input arguments (the keys of the tool call's `arguments` object):
|
|
120
|
+
|
|
121
|
+
```jsonc
|
|
122
|
+
{
|
|
123
|
+
"shellTools": {
|
|
124
|
+
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
| Field | Required | Description |
|
|
130
|
+
| ----------------- | -------- | ------------------------------------------------------------------------- |
|
|
131
|
+
| `commandArgument` | yes | The tool's input argument holding the shell command string (e.g. `cmd`). |
|
|
132
|
+
| `workdirArgument` | no | The tool's input argument holding the working directory (e.g. `workdir`). |
|
|
133
|
+
|
|
134
|
+
When `workdirArgument` is set, the tool's working directory is the base the command's relative paths resolve against, and the working directory itself is gated by `external_directory` when it falls outside the session's working directory.
|
|
135
|
+
|
|
136
|
+
Merge semantics: `shellTools` **shallow-merges by tool name** across global → project.
|
|
137
|
+
A project entry overrides a specific tool's mapping on a key collision but never drops a global entry — so adding a project-scoped alias cannot silently remove enforcement for a tool the global config already covers.
|
|
138
|
+
To change a specific tool's mapping, set that tool's key at the project scope (the alias object is replaced wholesale, not deep-merged).
|
|
139
|
+
|
|
140
|
+
`shellTools` only ever *tightens* enforcement and is inert when the named tool is not registered in the current session.
|
|
141
|
+
Opting a project out of a shell-aliasing extension is a package-disable concern, not a `shellTools` edit.
|
|
142
|
+
|
|
107
143
|
---
|
|
108
144
|
|
|
109
145
|
## Policy Reference
|
package/package.json
CHANGED
|
@@ -101,6 +101,42 @@
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
]
|
|
104
|
+
},
|
|
105
|
+
"shellTools": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"propertyNames": {
|
|
108
|
+
"type": "string",
|
|
109
|
+
"minLength": 1,
|
|
110
|
+
"description": "A non-bash tool name that carries shell semantics."
|
|
111
|
+
},
|
|
112
|
+
"additionalProperties": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"properties": {
|
|
115
|
+
"commandArgument": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"minLength": 1,
|
|
118
|
+
"description": "The name of the tool's input argument holding the shell command string (e.g. 'cmd')."
|
|
119
|
+
},
|
|
120
|
+
"workdirArgument": {
|
|
121
|
+
"description": "Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
|
|
122
|
+
"type": "string",
|
|
123
|
+
"minLength": 1
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"required": ["commandArgument"],
|
|
127
|
+
"additionalProperties": false,
|
|
128
|
+
"description": "Maps one shell-aliased tool to the input arguments holding its command and (optionally) its working directory."
|
|
129
|
+
},
|
|
130
|
+
"description": "Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
|
|
131
|
+
"markdownDescription": "Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nExample:\n\n```json\n\"shellTools\": {\n \"exec_command\": { \"commandArgument\": \"cmd\", \"workdirArgument\": \"workdir\" }\n}\n```\n\n**Merge order:** shallow-merge by tool name across global → project. A project entry overrides a specific tool's mapping on key collision but never drops a global entry.",
|
|
132
|
+
"examples": [
|
|
133
|
+
{
|
|
134
|
+
"exec_command": {
|
|
135
|
+
"commandArgument": "cmd",
|
|
136
|
+
"workdirArgument": "workdir"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
]
|
|
104
140
|
}
|
|
105
141
|
},
|
|
106
142
|
"additionalProperties": false,
|
|
@@ -101,20 +101,57 @@ export class BashPathResolver {
|
|
|
101
101
|
constructor(
|
|
102
102
|
private readonly normalizer: PathNormalizer,
|
|
103
103
|
private readonly isPromotablePathToken: PathRuleTokenMatcher = NO_PROMOTION,
|
|
104
|
+
private readonly workdir?: string,
|
|
104
105
|
) {}
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
108
|
* Resolve a parsed bash program's path references into its external-path and
|
|
108
109
|
* rule-candidate slices, walking the AST exactly once.
|
|
110
|
+
*
|
|
111
|
+
* When a `workdir` is set (an aliased shell tool's working directory, #574),
|
|
112
|
+
* it seeds the initial effective base — as if the program were prefixed with
|
|
113
|
+
* `cd <workdir>` — so relative tokens resolve against it, and the `workdir`
|
|
114
|
+
* itself is added to the external paths when it resolves outside the cwd.
|
|
115
|
+
* Containment is always measured against the session cwd baked into the
|
|
116
|
+
* normalizer, so a `workdir` outside the cwd does not widen the sandbox.
|
|
109
117
|
*/
|
|
110
118
|
resolve(rootNode: TSNode): ResolvedBashPaths {
|
|
111
|
-
const
|
|
119
|
+
const initialBase =
|
|
120
|
+
this.workdir === undefined
|
|
121
|
+
? CWD_BASE
|
|
122
|
+
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
|
|
123
|
+
const candidates = this.collectPathCandidates(rootNode, initialBase);
|
|
112
124
|
return {
|
|
113
|
-
externalPaths: this.
|
|
125
|
+
externalPaths: this.withWorkdirExternal(
|
|
126
|
+
this.projectExternalPaths(candidates),
|
|
127
|
+
),
|
|
114
128
|
ruleCandidates: this.projectRuleCandidates(candidates),
|
|
115
129
|
};
|
|
116
130
|
}
|
|
117
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Prepend the `workdir`'s own {@link AccessPath} to the external paths when it
|
|
134
|
+
* resolves outside the cwd. A real `cd /etc` flags `/etc` via its argument
|
|
135
|
+
* token; the seeded base carries no such token, so it is added explicitly and
|
|
136
|
+
* deduplicated against the command's own external tokens (#574).
|
|
137
|
+
*/
|
|
138
|
+
private withWorkdirExternal(
|
|
139
|
+
tokenExternals: readonly AccessPath[],
|
|
140
|
+
): AccessPath[] {
|
|
141
|
+
if (this.workdir === undefined) return [...tokenExternals];
|
|
142
|
+
const wdPath = this.normalizer.forBashToken(this.workdir);
|
|
143
|
+
const canonical = wdPath.boundaryValue();
|
|
144
|
+
const isExternal = canonical
|
|
145
|
+
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
|
|
146
|
+
: true;
|
|
147
|
+
if (!isExternal) return [...tokenExternals];
|
|
148
|
+
const key = canonical || wdPath.value();
|
|
149
|
+
const alreadyPresent = tokenExternals.some(
|
|
150
|
+
(p) => (p.boundaryValue() || p.value()) === key,
|
|
151
|
+
);
|
|
152
|
+
return alreadyPresent ? [...tokenExternals] : [wdPath, ...tokenExternals];
|
|
153
|
+
}
|
|
154
|
+
|
|
118
155
|
// ── AST walk — collect PathCandidates ──────────────────────────────────
|
|
119
156
|
|
|
120
157
|
/**
|
|
@@ -129,9 +166,12 @@ export class BashPathResolver {
|
|
|
129
166
|
* inherit the enclosing base without folding their own `cd`s (a conservative
|
|
130
167
|
* first tier).
|
|
131
168
|
*/
|
|
132
|
-
private collectPathCandidates(
|
|
169
|
+
private collectPathCandidates(
|
|
170
|
+
rootNode: TSNode,
|
|
171
|
+
initialBase: EffectiveBase,
|
|
172
|
+
): PathCandidate[] {
|
|
133
173
|
const out: PathCandidate[] = [];
|
|
134
|
-
this.walkForCandidates(rootNode,
|
|
174
|
+
this.walkForCandidates(rootNode, initialBase, out);
|
|
135
175
|
return out;
|
|
136
176
|
}
|
|
137
177
|
|
|
@@ -330,6 +370,25 @@ export class BashPathResolver {
|
|
|
330
370
|
if (extractCommandName(commandNode) !== "cd") return base;
|
|
331
371
|
const target = cdLiteralTarget(commandNode);
|
|
332
372
|
if (target === null) return UNKNOWN_BASE;
|
|
373
|
+
return this.deriveBaseFromCdTarget(base, target);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Fold a literal `cd`/working-directory target string into the effective
|
|
378
|
+
* base, delegating the platform/MSYS interpretation to the
|
|
379
|
+
* {@link PathNormalizer}. Owns only the base-folding state:
|
|
380
|
+
*
|
|
381
|
+
* - `absolute` → a fresh known base (recovers from an earlier unknown base).
|
|
382
|
+
* - `unknown` → the base becomes conservatively unknown.
|
|
383
|
+
* - `relative` → join into a known base, or stay unknown if already unknown.
|
|
384
|
+
*
|
|
385
|
+
* Shared by {@link foldCd} (inline `cd` commands) and the initial-base seed
|
|
386
|
+
* (an aliased shell tool's `workdir`, an implicit leading `cd <workdir>`).
|
|
387
|
+
*/
|
|
388
|
+
private deriveBaseFromCdTarget(
|
|
389
|
+
base: EffectiveBase,
|
|
390
|
+
target: string,
|
|
391
|
+
): EffectiveBase {
|
|
333
392
|
const interpreted = this.normalizer.interpretBashCdTarget(target);
|
|
334
393
|
switch (interpreted.kind) {
|
|
335
394
|
case "absolute":
|
|
@@ -25,6 +25,7 @@ export type { BashCommand, BashPathRuleCandidate };
|
|
|
25
25
|
*/
|
|
26
26
|
export class BashProgram {
|
|
27
27
|
private constructor(
|
|
28
|
+
private readonly sourceCommand: string,
|
|
28
29
|
private readonly commandUnits: readonly BashCommand[],
|
|
29
30
|
private readonly resolvedExternalPaths: readonly AccessPath[],
|
|
30
31
|
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
|
|
@@ -44,22 +45,30 @@ export class BashProgram {
|
|
|
44
45
|
* specific `path` deny/ask rule (#509). Defaults to promoting nothing, so
|
|
45
46
|
* callers that only read `externalPaths()` (e.g. `bash-path-extractor.ts`)
|
|
46
47
|
* are unaffected.
|
|
48
|
+
*
|
|
49
|
+
* `options.workdir`, when supplied (an aliased shell tool's working directory,
|
|
50
|
+
* #574), seeds the initial effective base — as if the command were prefixed
|
|
51
|
+
* with `cd <workdir>` — so relative tokens resolve against it, and the workdir
|
|
52
|
+
* itself is flagged as external when it resolves outside the cwd.
|
|
47
53
|
*/
|
|
48
54
|
static async parse(
|
|
49
55
|
command: string,
|
|
50
56
|
normalizer: PathNormalizer,
|
|
51
57
|
isPromotablePathToken?: PathRuleTokenMatcher,
|
|
58
|
+
options?: { workdir?: string },
|
|
52
59
|
): Promise<BashProgram> {
|
|
53
60
|
const parser = await getParser();
|
|
54
61
|
const tree = parser.parse(command);
|
|
55
|
-
if (!tree) return new BashProgram([], [], []);
|
|
62
|
+
if (!tree) return new BashProgram(command, [], [], []);
|
|
56
63
|
|
|
57
64
|
try {
|
|
58
65
|
const { externalPaths, ruleCandidates } = new BashPathResolver(
|
|
59
66
|
normalizer,
|
|
60
67
|
isPromotablePathToken,
|
|
68
|
+
options?.workdir,
|
|
61
69
|
).resolve(tree.rootNode);
|
|
62
70
|
return new BashProgram(
|
|
71
|
+
command,
|
|
63
72
|
collectCommands(tree.rootNode),
|
|
64
73
|
externalPaths,
|
|
65
74
|
ruleCandidates,
|
|
@@ -69,6 +78,18 @@ export class BashProgram {
|
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The source command string this program was parsed from.
|
|
83
|
+
*
|
|
84
|
+
* The bash gates read this for prompts, logs, and decision display instead of
|
|
85
|
+
* receiving the command as a separate parameter — the program is the parsed
|
|
86
|
+
* command, so it owns its source text (#574). Native `bash` and an aliased
|
|
87
|
+
* shell tool alike reach the gates through this single collaborator.
|
|
88
|
+
*/
|
|
89
|
+
commandText(): string {
|
|
90
|
+
return this.sourceCommand;
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
/**
|
|
73
94
|
* The top-level command-pattern units of the chain, in source order.
|
|
74
95
|
*
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* which matches an active, specific (non-`*`) `path` deny/ask rule (#509).
|
|
10
10
|
*
|
|
11
11
|
* All three classifiers share the private `rejectNonPathToken` predicate that
|
|
12
|
-
* captures the
|
|
12
|
+
* captures the six rejection cases common to them (the production clone this
|
|
13
13
|
* module was extracted to eliminate).
|
|
14
14
|
*
|
|
15
15
|
* Both `classifyTokenAsPathCandidate` and `classifyTokenAsRuleCandidate` recognize
|
|
@@ -143,8 +143,11 @@ const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
|
|
|
143
143
|
* filesystem path, regardless of which classifier is asking.
|
|
144
144
|
*
|
|
145
145
|
* Rejects: empty tokens, flags (leading `-`), env assignments (`FOO=/bar`),
|
|
146
|
-
* URLs, `@scope/package` patterns,
|
|
147
|
-
*
|
|
146
|
+
* URLs, `@scope/package` patterns, and regex metacharacter sequences.
|
|
147
|
+
*
|
|
148
|
+
* A bare `/` (or `//`, `///`) is NOT rejected: it denotes the filesystem root,
|
|
149
|
+
* a deliberate external-directory access (`find /`, `ls /`), so it must reach
|
|
150
|
+
* the path surfaces like any other absolute token (#583).
|
|
148
151
|
*/
|
|
149
152
|
function rejectNonPathToken(token: string): boolean {
|
|
150
153
|
if (!token) return true;
|
|
@@ -163,10 +166,6 @@ function rejectNonPathToken(token: string): boolean {
|
|
|
163
166
|
// since it looks like an absolute-rooted path, not an npm scope.
|
|
164
167
|
if (token.startsWith("@") && !token.startsWith("@/")) return true;
|
|
165
168
|
|
|
166
|
-
// Bare-slash tokens (/, //, ///) resolve to filesystem root and are never
|
|
167
|
-
// meaningful path arguments in practice.
|
|
168
|
-
if (/^\/+$/.test(token)) return true;
|
|
169
|
-
|
|
170
169
|
if (REGEX_METACHAR_PATTERN.test(token)) return true;
|
|
171
170
|
|
|
172
171
|
return false;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { stripBashCommentLines } from "#src/bash-arity";
|
|
2
|
+
import type { PathNormalizer } from "#src/path-normalizer";
|
|
3
|
+
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
4
|
+
import type { AccessIntent } from "./access-intent";
|
|
4
5
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
5
|
-
import type { PathNormalizer } from "./path-normalizer";
|
|
6
6
|
import { PATH_SURFACES } from "./path-surfaces";
|
|
7
|
-
import {
|
|
7
|
+
import { classifyToolKind } from "./tool-kind";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Build the {@link AccessIntent} an external policy query (the `Symbol.for()`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
2
|
+
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
3
|
+
import { classifyToolKind } from "./tool-kind";
|
|
4
4
|
|
|
5
5
|
export function getPathBearingToolPath(
|
|
6
6
|
toolName: string,
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ShellToolsConfig } from "#src/config-schema";
|
|
2
|
+
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
3
|
+
import { PATH_BEARING_TOOLS } from "./path-surfaces";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* What a tool invocation accesses — decided once from the tool name at the
|
|
@@ -39,6 +41,59 @@ export function classifyToolKind(toolName: string): ToolKind {
|
|
|
39
41
|
return "extension";
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/** A shell invocation's effective command and optional working directory. */
|
|
45
|
+
export interface ShellInvocation {
|
|
46
|
+
/** The shell command string to decompose and gate. */
|
|
47
|
+
command: string;
|
|
48
|
+
/** The working directory the command runs in, if the tool projects one. */
|
|
49
|
+
workdir: string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decide whether a tool invocation carries shell semantics, and if so extract
|
|
54
|
+
* its command and working directory.
|
|
55
|
+
*
|
|
56
|
+
* Native `bash` and any tool recorded in `shellTools` both yield a
|
|
57
|
+
* {@link ShellInvocation}; every other tool yields `null`. This is the single
|
|
58
|
+
* dispatch point the bash gate pipeline consults instead of re-deriving
|
|
59
|
+
* `toolName === "bash"` and reading `input.command`, so an aliased shell tool
|
|
60
|
+
* (e.g. `@howaboua/pi-codex-conversion`'s `exec_command`) is routed through the
|
|
61
|
+
* same bash enforcement stack as native `bash` (#574).
|
|
62
|
+
*
|
|
63
|
+
* The command and workdir are read through {@link getNonEmptyString} (trimmed,
|
|
64
|
+
* empty → `""`/`undefined`), matching the pipeline's existing native-bash
|
|
65
|
+
* extraction. Kept separate from {@link classifyToolKind} because it needs
|
|
66
|
+
* config (the alias map) and returns a richer product than a {@link ToolKind}
|
|
67
|
+
* string — `classifyToolKind` stays AccessPath-free and config-free.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveShellInvocation(
|
|
70
|
+
toolName: string,
|
|
71
|
+
input: unknown,
|
|
72
|
+
aliases: ShellToolsConfig | undefined,
|
|
73
|
+
): ShellInvocation | null {
|
|
74
|
+
const name = toolName.trim();
|
|
75
|
+
const record = toRecord(input);
|
|
76
|
+
|
|
77
|
+
if (name === "bash") {
|
|
78
|
+
return {
|
|
79
|
+
command: getNonEmptyString(record.command) ?? "",
|
|
80
|
+
workdir: undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const alias = aliases?.[name];
|
|
85
|
+
if (alias) {
|
|
86
|
+
return {
|
|
87
|
+
command: getNonEmptyString(record[alias.commandArgument]) ?? "",
|
|
88
|
+
workdir: alias.workdirArgument
|
|
89
|
+
? (getNonEmptyString(record[alias.workdirArgument]) ?? undefined)
|
|
90
|
+
: undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
42
97
|
/** The resolved-check fields that decide MCP-ness. */
|
|
43
98
|
interface McpKindFields {
|
|
44
99
|
toolName: string;
|
package/src/config-loader.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getProjectConfigPath,
|
|
10
10
|
} from "./config-paths";
|
|
11
11
|
import {
|
|
12
|
+
type ShellToolsConfig,
|
|
12
13
|
type UnifiedPermissionConfig,
|
|
13
14
|
unifiedConfigSchema,
|
|
14
15
|
} from "./config-schema";
|
|
@@ -20,7 +21,7 @@ import { isDenyWithReason, isPermissionState } from "./types";
|
|
|
20
21
|
// the single source of truth) and re-exported so existing importers keep their
|
|
21
22
|
// import path. All fields are optional so partial configs merge before
|
|
22
23
|
// defaults are applied downstream.
|
|
23
|
-
export type { UnifiedPermissionConfig };
|
|
24
|
+
export type { ShellToolsConfig, UnifiedPermissionConfig };
|
|
24
25
|
|
|
25
26
|
export interface UnifiedConfigLoadResult {
|
|
26
27
|
config: UnifiedPermissionConfig;
|
|
@@ -228,6 +229,19 @@ export function mergeUnifiedConfigs(
|
|
|
228
229
|
merged.piInfrastructureReadPaths = piInfrastructureReadPaths;
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
// shellTools: shallow-merge by tool name so a project entry overrides a
|
|
233
|
+
// colliding tool's alias but never drops a global entry (a dropped alias is
|
|
234
|
+
// a silent enforcement regression).
|
|
235
|
+
const baseShell = base.shellTools;
|
|
236
|
+
const overrideShell = override.shellTools;
|
|
237
|
+
if (baseShell && overrideShell) {
|
|
238
|
+
merged.shellTools = { ...baseShell, ...overrideShell };
|
|
239
|
+
} else if (baseShell) {
|
|
240
|
+
merged.shellTools = baseShell;
|
|
241
|
+
} else if (overrideShell) {
|
|
242
|
+
merged.shellTools = overrideShell;
|
|
243
|
+
}
|
|
244
|
+
|
|
231
245
|
// Permission: deep-shallow merge
|
|
232
246
|
const basePerm = base.permission;
|
|
233
247
|
const overridePerm = override.permission;
|
package/src/config-schema.ts
CHANGED
|
@@ -110,6 +110,41 @@ const permissionSchema = z
|
|
|
110
110
|
],
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
const shellToolAliasSchema = z
|
|
114
|
+
.strictObject({
|
|
115
|
+
commandArgument: z.string().min(1).meta({
|
|
116
|
+
description:
|
|
117
|
+
"The name of the tool's input argument holding the shell command string (e.g. 'cmd').",
|
|
118
|
+
}),
|
|
119
|
+
workdirArgument: z.string().min(1).optional().meta({
|
|
120
|
+
description:
|
|
121
|
+
"Optional name of the tool's input argument holding the working directory (e.g. 'workdir').",
|
|
122
|
+
}),
|
|
123
|
+
})
|
|
124
|
+
.meta({
|
|
125
|
+
description:
|
|
126
|
+
"Maps one shell-aliased tool to the input arguments holding its command and (optionally) its working directory.",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const shellToolsSchema = z
|
|
130
|
+
.record(
|
|
131
|
+
z.string().min(1).meta({
|
|
132
|
+
description: "A non-bash tool name that carries shell semantics.",
|
|
133
|
+
}),
|
|
134
|
+
shellToolAliasSchema,
|
|
135
|
+
)
|
|
136
|
+
.meta({
|
|
137
|
+
description:
|
|
138
|
+
"Maps non-bash tool names that carry shell semantics to the input arguments holding their command and working directory.",
|
|
139
|
+
markdownDescription:
|
|
140
|
+
'Records which non-`bash` tools carry shell semantics, mapping each tool name to the input argument holding its command (and optionally its working directory).\n\nUse this when an extension replaces the native `bash` tool under a different name — e.g. `@howaboua/pi-codex-conversion` registers `exec_command` with a `cmd` argument and an optional `workdir`. Recording the alias lets the permission system gate that tool through the same bash enforcement stack as native `bash` (command decomposition, wrapper flooring, path/external-directory token gates, and `bash:` rules).\n\nExample:\n\n```json\n"shellTools": {\n "exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }\n}\n```\n\n**Merge order:** shallow-merge by tool name across global → project. A project entry overrides a specific tool\'s mapping on key collision but never drops a global entry.',
|
|
141
|
+
examples: [
|
|
142
|
+
{
|
|
143
|
+
exec_command: { commandArgument: "cmd", workdirArgument: "workdir" },
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
|
|
113
148
|
/**
|
|
114
149
|
* The on-disk config file shape.
|
|
115
150
|
*
|
|
@@ -165,6 +200,7 @@ export const unifiedConfigSchema = z
|
|
|
165
200
|
default: [],
|
|
166
201
|
}),
|
|
167
202
|
permission: permissionSchema.optional(),
|
|
203
|
+
shellTools: shellToolsSchema.optional(),
|
|
168
204
|
})
|
|
169
205
|
.meta({
|
|
170
206
|
title: "PI Permission System Configuration",
|
|
@@ -186,6 +222,9 @@ export type PatternValue = z.infer<typeof patternValueSchema>;
|
|
|
186
222
|
/** The on-disk permission shape inside the `"permission"` key. */
|
|
187
223
|
export type FlatPermissionConfig = z.infer<typeof permissionSchema>;
|
|
188
224
|
|
|
225
|
+
/** The `shellTools` map: tool name → shell-alias argument mapping. */
|
|
226
|
+
export type ShellToolsConfig = z.infer<typeof shellToolsSchema>;
|
|
227
|
+
|
|
189
228
|
/** The raw config file shape after validation (all fields optional). */
|
|
190
229
|
export type UnifiedPermissionConfig = z.infer<typeof unifiedConfigSchema>;
|
|
191
230
|
|
package/src/extension-config.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { mkdirSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ShellToolsConfig,
|
|
7
|
+
UnifiedPermissionConfig,
|
|
8
|
+
} from "./config-loader";
|
|
6
9
|
|
|
7
10
|
export const EXTENSION_ID = "pi-permission-system";
|
|
8
11
|
|
|
@@ -16,6 +19,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
16
19
|
toolInputPreviewMaxLength?: number;
|
|
17
20
|
/** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
|
|
18
21
|
toolTextSummaryMaxLength?: number;
|
|
22
|
+
/** Non-bash tools that carry shell semantics, keyed by tool name. */
|
|
23
|
+
shellTools?: ShellToolsConfig;
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
|
|
@@ -63,6 +68,9 @@ export function normalizePermissionSystemConfig(
|
|
|
63
68
|
if (raw.toolTextSummaryMaxLength !== undefined) {
|
|
64
69
|
result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
|
|
65
70
|
}
|
|
71
|
+
if (raw.shellTools !== undefined) {
|
|
72
|
+
result.shellTools = raw.shellTools;
|
|
73
|
+
}
|
|
66
74
|
return result;
|
|
67
75
|
}
|
|
68
76
|
|
|
@@ -2,7 +2,6 @@ import type { BashProgram } from "#src/access-intent/bash/program";
|
|
|
2
2
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
3
|
import { SessionApproval } from "#src/session-approval";
|
|
4
4
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
5
|
-
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
6
5
|
import type { GateResult } from "./descriptor";
|
|
7
6
|
import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
8
7
|
import { selectUncoveredExternalPaths } from "./external-directory-policy";
|
|
@@ -13,21 +12,21 @@ import type { ToolCallContext } from "./types";
|
|
|
13
12
|
*
|
|
14
13
|
* Reads the external paths from the injected `BashProgram` and checks whether
|
|
15
14
|
* any reference directories outside the working directory. Returns `null` when the gate
|
|
16
|
-
* does not apply (
|
|
15
|
+
* does not apply (not a shell invocation, no command, or no external paths found).
|
|
17
16
|
* Returns a `GateBypass` when all paths are allowed (by config or session rule).
|
|
18
17
|
* Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
|
|
18
|
+
*
|
|
19
|
+
* The shell command (native `bash` or an aliased shell tool) is read from the
|
|
20
|
+
* injected `BashProgram`, which owns the source text it was parsed from, so
|
|
21
|
+
* this gate does not re-derive the input field name (#574).
|
|
19
22
|
*/
|
|
20
23
|
export function describeBashExternalDirectoryGate(
|
|
21
24
|
tcc: ToolCallContext,
|
|
22
25
|
bashProgram: BashProgram | null,
|
|
23
26
|
resolver: ScopedPermissionResolver,
|
|
24
27
|
): GateResult {
|
|
25
|
-
if (tcc.toolName !== "bash") return null;
|
|
26
|
-
|
|
27
|
-
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
28
|
-
if (!command) return null;
|
|
29
|
-
|
|
30
28
|
if (!bashProgram) return null;
|
|
29
|
+
const command = bashProgram.commandText();
|
|
31
30
|
|
|
32
31
|
const externalPaths = bashProgram.externalPaths();
|
|
33
32
|
if (externalPaths.length === 0) return null;
|
|
@@ -4,7 +4,6 @@ import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
|
4
4
|
import { SessionApproval } from "#src/session-approval";
|
|
5
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
6
6
|
import type { PermissionCheckResult } from "#src/types";
|
|
7
|
-
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
8
7
|
import { pickMostRestrictive } from "./candidate-check";
|
|
9
8
|
import type { GateResult } from "./descriptor";
|
|
10
9
|
import { formatPathAskPrompt } from "./path";
|
|
@@ -20,22 +19,22 @@ import type { ToolCallContext } from "./types";
|
|
|
20
19
|
* restrictive result, while prompts, logs, and session approvals use the raw
|
|
21
20
|
* token.
|
|
22
21
|
*
|
|
23
|
-
* Returns `null` when the gate does not apply (
|
|
24
|
-
* no tokens extracted, or all tokens evaluate to `allow`).
|
|
22
|
+
* Returns `null` when the gate does not apply (not a shell invocation, no
|
|
23
|
+
* command, no tokens extracted, or all tokens evaluate to `allow`).
|
|
25
24
|
* Returns a `GateBypass` when all tokens are session-covered.
|
|
26
25
|
* Returns a `GateDescriptor` for the most restrictive token needing a check.
|
|
26
|
+
*
|
|
27
|
+
* The shell command (native `bash` or an aliased shell tool) is read from the
|
|
28
|
+
* injected `BashProgram`, which owns the source text it was parsed from, so
|
|
29
|
+
* this gate does not re-derive the input field name (#574).
|
|
27
30
|
*/
|
|
28
31
|
export function describeBashPathGate(
|
|
29
32
|
tcc: ToolCallContext,
|
|
30
33
|
bashProgram: BashProgram | null,
|
|
31
34
|
resolver: ScopedPermissionResolver,
|
|
32
35
|
): GateResult {
|
|
33
|
-
if (tcc.toolName !== "bash") return null;
|
|
34
|
-
|
|
35
|
-
const command = getNonEmptyString(toRecord(tcc.input).command);
|
|
36
|
-
if (!command) return null;
|
|
37
|
-
|
|
38
36
|
if (!bashProgram) return null;
|
|
37
|
+
const command = bashProgram.commandText();
|
|
39
38
|
|
|
40
39
|
const candidates = bashProgram.pathRuleCandidates();
|
|
41
40
|
if (candidates.length === 0) return null;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { getToolInputPath } from "#src/access-intent/tool-input-path";
|
|
1
2
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import { SessionApproval } from "#src/session-approval";
|
|
4
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
5
6
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
6
|
-
import { getToolInputPath } from "#src/tool-input-path";
|
|
7
7
|
import type { GateResult } from "./descriptor";
|
|
8
8
|
import { formatExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
9
9
|
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { getToolInputPath } from "#src/access-intent/tool-input-path";
|
|
1
2
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
2
3
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
3
4
|
import { SessionApproval } from "#src/session-approval";
|
|
4
5
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
5
6
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
6
|
-
import { getToolInputPath } from "#src/tool-input-path";
|
|
7
7
|
import type { GateDescriptor, GateResult } from "./descriptor";
|
|
8
8
|
import type { ToolCallContext } from "./types";
|
|
9
9
|
|
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
2
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
3
|
-
import {
|
|
3
|
+
import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
|
|
4
|
+
import {
|
|
5
|
+
resolveShellInvocation,
|
|
6
|
+
type ShellInvocation,
|
|
7
|
+
} from "#src/access-intent/tool-kind";
|
|
8
|
+
import type { ShellToolsConfig } from "#src/config-schema";
|
|
4
9
|
import type { PathNormalizer } from "#src/path-normalizer";
|
|
5
10
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
6
11
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
7
12
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
8
13
|
import type { ToolInputFormatterLookup } from "#src/tool-input-formatter-registry";
|
|
9
|
-
import { getPathBearingToolPath } from "#src/tool-input-path";
|
|
10
14
|
import {
|
|
11
15
|
ToolPreviewFormatter,
|
|
12
16
|
type ToolPreviewFormatterOptions,
|
|
13
17
|
} from "#src/tool-preview-formatter";
|
|
14
18
|
import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
|
|
15
|
-
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
16
19
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
17
20
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
18
21
|
import { describeBashPathGate } from "./bash-path";
|
|
@@ -43,6 +46,12 @@ export interface ToolCallGateInputs {
|
|
|
43
46
|
getToolPreviewLimits(): ToolPreviewFormatterOptions;
|
|
44
47
|
/** The session's path normalizer (platform + cwd baked in). */
|
|
45
48
|
getPathNormalizer(): PathNormalizer;
|
|
49
|
+
/**
|
|
50
|
+
* The configured shell-tool aliases (`shellTools`), or `undefined` when none
|
|
51
|
+
* are set. Consulted by {@link resolveShellInvocation} so an aliased shell
|
|
52
|
+
* tool is gated through the bash stack at parity with native `bash` (#574).
|
|
53
|
+
*/
|
|
54
|
+
getShellToolAliases(): ShellToolsConfig | undefined;
|
|
46
55
|
/**
|
|
47
56
|
* Predicate deciding whether a bare bash token should be promoted into the
|
|
48
57
|
* `path` rule-candidate surface (#509), scoped to the given agent.
|
|
@@ -73,20 +82,24 @@ export class ToolCallGatePipeline {
|
|
|
73
82
|
tcc: ToolCallContext,
|
|
74
83
|
runner: GateRunner,
|
|
75
84
|
): Promise<GateOutcome> {
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
85
|
+
// Resolve the shell invocation once: native `bash` and any tool recorded in
|
|
86
|
+
// `shellTools` both yield a command (+ optional workdir); every other tool
|
|
87
|
+
// yields null (#574). The three bash gates then share the single BashProgram
|
|
88
|
+
// parsed from that command instead of each re-parsing (#308).
|
|
89
|
+
const shell = resolveShellInvocation(
|
|
90
|
+
tcc.toolName,
|
|
91
|
+
tcc.input,
|
|
92
|
+
this.inputs.getShellToolAliases(),
|
|
93
|
+
);
|
|
79
94
|
const normalizer = this.inputs.getPathNormalizer();
|
|
80
|
-
const bashProgram =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
)
|
|
89
|
-
: null;
|
|
95
|
+
const bashProgram = shell?.command
|
|
96
|
+
? await BashProgram.parse(
|
|
97
|
+
shell.command,
|
|
98
|
+
normalizer,
|
|
99
|
+
this.inputs.getPromotablePathTokenMatcher(tcc.agentName ?? undefined),
|
|
100
|
+
{ workdir: shell.workdir },
|
|
101
|
+
)
|
|
102
|
+
: null;
|
|
90
103
|
|
|
91
104
|
const formatter = new ToolPreviewFormatter(
|
|
92
105
|
this.inputs.getToolPreviewLimits(),
|
|
@@ -115,8 +128,8 @@ export class ToolCallGatePipeline {
|
|
|
115
128
|
() => {
|
|
116
129
|
const { toolCheck, accessPath } = this.resolvePerToolCheck(
|
|
117
130
|
tcc,
|
|
131
|
+
shell,
|
|
118
132
|
bashProgram,
|
|
119
|
-
command,
|
|
120
133
|
normalizer,
|
|
121
134
|
);
|
|
122
135
|
const toolDescriptor = describeToolGate(
|
|
@@ -124,6 +137,7 @@ export class ToolCallGatePipeline {
|
|
|
124
137
|
toolCheck,
|
|
125
138
|
formatter,
|
|
126
139
|
accessPath,
|
|
140
|
+
shell,
|
|
127
141
|
);
|
|
128
142
|
toolDescriptor.preCheck = toolCheck;
|
|
129
143
|
return toolDescriptor;
|
|
@@ -156,18 +170,31 @@ export class ToolCallGatePipeline {
|
|
|
156
170
|
*/
|
|
157
171
|
private resolvePerToolCheck(
|
|
158
172
|
tcc: ToolCallContext,
|
|
173
|
+
shell: ShellInvocation | null,
|
|
159
174
|
bashProgram: BashProgram | null,
|
|
160
|
-
command: string | null,
|
|
161
175
|
normalizer: PathNormalizer,
|
|
162
176
|
): { toolCheck: PermissionCheckResult; accessPath?: AccessPath } {
|
|
163
|
-
if (
|
|
177
|
+
if (shell) {
|
|
178
|
+
if (bashProgram) {
|
|
179
|
+
return {
|
|
180
|
+
toolCheck: resolveBashCommandCheck(
|
|
181
|
+
bashProgram.commandText(),
|
|
182
|
+
bashProgram.commands(),
|
|
183
|
+
tcc.agentName ?? undefined,
|
|
184
|
+
this.resolver,
|
|
185
|
+
),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// A shell invocation whose command did not parse (e.g. empty) still
|
|
189
|
+
// resolves on the `bash` surface, so an aliased tool never falls through
|
|
190
|
+
// to its own extension-tool surface.
|
|
164
191
|
return {
|
|
165
|
-
toolCheck:
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
),
|
|
192
|
+
toolCheck: this.resolver.resolve({
|
|
193
|
+
kind: "tool",
|
|
194
|
+
surface: "bash",
|
|
195
|
+
input: { command: shell.command },
|
|
196
|
+
agentName: tcc.agentName ?? undefined,
|
|
197
|
+
}),
|
|
171
198
|
};
|
|
172
199
|
}
|
|
173
200
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
|
|
3
|
+
import { getPathBearingToolPath } from "#src/access-intent/tool-input-path";
|
|
4
|
+
import {
|
|
5
|
+
classifyToolKind,
|
|
6
|
+
type ShellInvocation,
|
|
7
|
+
} from "#src/access-intent/tool-kind";
|
|
4
8
|
import { suggestSessionPattern } from "#src/pattern-suggest";
|
|
5
9
|
import { formatAskPrompt } from "#src/permission-prompts";
|
|
6
10
|
import { SessionApproval } from "#src/session-approval";
|
|
7
|
-
import { getPathBearingToolPath } from "#src/tool-input-path";
|
|
8
11
|
import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
|
|
9
12
|
import type { PermissionCheckResult } from "#src/types";
|
|
10
13
|
import type { GateDescriptor } from "./descriptor";
|
|
@@ -20,11 +23,11 @@ import type { ToolCallContext } from "./types";
|
|
|
20
23
|
* others (or a path-bearing tool with no path) → catch-all wildcard.
|
|
21
24
|
*/
|
|
22
25
|
function deriveSuggestionValue(
|
|
23
|
-
|
|
26
|
+
toolName: string,
|
|
24
27
|
check: PermissionCheckResult,
|
|
25
28
|
accessPath?: AccessPath,
|
|
26
29
|
): string {
|
|
27
|
-
switch (classifyToolKind(
|
|
30
|
+
switch (classifyToolKind(toolName)) {
|
|
28
31
|
case "bash":
|
|
29
32
|
return check.command ?? "";
|
|
30
33
|
case "mcp":
|
|
@@ -45,7 +48,14 @@ export function describeToolGate(
|
|
|
45
48
|
check: PermissionCheckResult,
|
|
46
49
|
formatter: ToolPreviewFormatter,
|
|
47
50
|
accessPath?: AccessPath,
|
|
51
|
+
shell?: ShellInvocation | null,
|
|
48
52
|
): GateDescriptor {
|
|
53
|
+
// A shell invocation (native `bash` or an aliased shell tool) is gated on the
|
|
54
|
+
// `bash` surface — its session rule, decision value, and suggestion are
|
|
55
|
+
// bash-shaped — while the invoked tool name is preserved in the prompt and
|
|
56
|
+
// review log so a user sees which tool actually ran (#574).
|
|
57
|
+
const gateSurface = shell ? "bash" : tcc.toolName;
|
|
58
|
+
|
|
49
59
|
const permissionLogContext = formatter.getPermissionLogContext(
|
|
50
60
|
check,
|
|
51
61
|
tcc.input,
|
|
@@ -54,8 +64,8 @@ export function describeToolGate(
|
|
|
54
64
|
|
|
55
65
|
// Compute session approval suggestion for the "for this session" option.
|
|
56
66
|
const suggestion = suggestSessionPattern(
|
|
57
|
-
|
|
58
|
-
deriveSuggestionValue(
|
|
67
|
+
gateSurface,
|
|
68
|
+
deriveSuggestionValue(gateSurface, check, accessPath),
|
|
59
69
|
);
|
|
60
70
|
|
|
61
71
|
const askMessage = formatAskPrompt(
|
|
@@ -66,7 +76,7 @@ export function describeToolGate(
|
|
|
66
76
|
);
|
|
67
77
|
|
|
68
78
|
return {
|
|
69
|
-
surface:
|
|
79
|
+
surface: gateSurface,
|
|
70
80
|
input: tcc.input,
|
|
71
81
|
denialContext: {
|
|
72
82
|
kind: "tool",
|
|
@@ -95,9 +105,9 @@ export function describeToolGate(
|
|
|
95
105
|
...permissionLogContext,
|
|
96
106
|
},
|
|
97
107
|
decision: {
|
|
98
|
-
surface:
|
|
108
|
+
surface: gateSurface,
|
|
99
109
|
value: deriveDecisionValue(
|
|
100
|
-
|
|
110
|
+
gateSurface,
|
|
101
111
|
check,
|
|
102
112
|
getPathBearingToolPath(tcc.toolName, tcc.input) ?? undefined,
|
|
103
113
|
),
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { warmBashParser } from "./access-intent/bash/parser";
|
|
4
|
+
import { buildAccessIntentForSurface } from "./access-intent/input-normalizer";
|
|
4
5
|
import { AuthorizerSelection } from "./authority/authorizer-selection";
|
|
5
6
|
import {
|
|
6
7
|
ForwardedRequestServer,
|
|
@@ -29,7 +30,6 @@ import { GateRunner } from "./handlers/gates/runner";
|
|
|
29
30
|
import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
|
|
30
31
|
import { ToolCallGatePipeline } from "./handlers/gates/tool-call-gate-pipeline";
|
|
31
32
|
import { createFailClosedToolCall } from "./handlers/tool-call-boundary";
|
|
32
|
-
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
33
33
|
import { pathFlavorForPlatform } from "./path/path-flavor";
|
|
34
34
|
import { PermissionManager } from "./permission-manager";
|
|
35
35
|
import { PermissionResolver } from "./permission-resolver";
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
|
|
2
|
+
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
|
|
3
3
|
import { expandHomePath } from "#src/expand-home";
|
|
4
4
|
import type { PathFlavor } from "#src/path/path-flavor";
|
|
5
|
-
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/path-surfaces";
|
|
6
5
|
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
7
6
|
|
|
8
7
|
function containsGlobChars(value: string): boolean {
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { PATH_BEARING_TOOLS } from "./access-intent/path-surfaces";
|
|
1
2
|
import { prefix, stripBashCommentLines } from "./bash-arity";
|
|
2
|
-
import { PATH_BEARING_TOOLS } from "./path-surfaces";
|
|
3
3
|
import { deriveApprovalPattern } from "./session-rules";
|
|
4
4
|
|
|
5
5
|
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { ResolvedAccessIntent } from "./access-intent/access-intent";
|
|
3
|
+
import { normalizeInput } from "./access-intent/input-normalizer";
|
|
4
|
+
import { PATH_SURFACES } from "./access-intent/path-surfaces";
|
|
3
5
|
import { classifyToolKind } from "./access-intent/tool-kind";
|
|
4
6
|
import {
|
|
5
7
|
getGlobalConfigPath,
|
|
6
8
|
getProjectAgentsDir,
|
|
7
9
|
getProjectConfigPath,
|
|
8
10
|
} from "./config-paths";
|
|
9
|
-
import { normalizeInput } from "./input-normalizer";
|
|
10
11
|
import { normalizeFlatConfig } from "./normalize";
|
|
11
12
|
import { type PathFlavor, posixPathFlavor } from "./path/path-flavor";
|
|
12
|
-
import { PATH_SURFACES } from "./path-surfaces";
|
|
13
13
|
import {
|
|
14
14
|
FilePolicyLoader,
|
|
15
15
|
type PolicyLoader,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getActiveAgentNameFromSystemPrompt,
|
|
6
6
|
} from "./active-agent";
|
|
7
7
|
import type { AuthorizerSelectionLifecycle } from "./authority/authorizer-selection";
|
|
8
|
+
import type { ShellToolsConfig } from "./config-schema";
|
|
8
9
|
import type { SessionConfigStore } from "./config-store";
|
|
9
10
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
10
11
|
import type { ExtensionPaths } from "./extension-paths";
|
|
@@ -205,6 +206,16 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
205
206
|
return resolveToolPreviewLimits(this.config);
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The configured shell-tool aliases (`shellTools`), mapping a non-`bash` tool
|
|
211
|
+
* name to the input arguments holding its command and optional working
|
|
212
|
+
* directory. `undefined` when no aliases are configured. Consumed by the
|
|
213
|
+
* gate pipeline's {@link resolveShellInvocation} consult (#574).
|
|
214
|
+
*/
|
|
215
|
+
getShellToolAliases(): ShellToolsConfig | undefined {
|
|
216
|
+
return this.config.shellTools;
|
|
217
|
+
}
|
|
218
|
+
|
|
208
219
|
// ── Path normalization ────────────────────────────────────────────────
|
|
209
220
|
|
|
210
221
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AccessIntent } from "./access-intent/access-intent";
|
|
2
|
+
import { buildAccessIntentForSurface } from "./access-intent/input-normalizer";
|
|
2
3
|
import { resolveBashAdvisoryCheck } from "./bash-advisory-check";
|
|
3
|
-
import { buildAccessIntentForSurface } from "./input-normalizer";
|
|
4
4
|
import type { PathNormalizer } from "./path-normalizer";
|
|
5
5
|
import type { PermissionsService } from "./service";
|
|
6
6
|
import type {
|
package/src/rule.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PathFlavor } from "#src/path/path-flavor";
|
|
2
2
|
|
|
3
|
-
import { PATH_SURFACES } from "./path-surfaces";
|
|
3
|
+
import { PATH_SURFACES } from "./access-intent/path-surfaces";
|
|
4
4
|
import type { PermissionState } from "./types";
|
|
5
5
|
import { type WildcardMatchOptions, wildcardMatch } from "./wildcard-matcher";
|
|
6
6
|
|
|
File without changes
|