@gotgenes/pi-permission-system 16.2.0 → 17.0.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 +34 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +8 -10
- package/src/access-intent/access-path.ts +39 -16
- package/src/access-intent/bash/command-enumeration.ts +79 -2
- package/src/access-intent/bash/cwd-projection.ts +16 -14
- package/src/access-intent/bash/parser.ts +2 -0
- package/src/access-intent/bash/program.ts +3 -0
- package/src/builtin-tool-input-formatters.ts +1 -1
- package/src/config-loader.ts +7 -7
- package/src/forwarded-permissions/permission-forwarder.ts +1 -1
- package/src/handlers/gates/bash-command.ts +15 -1
- package/src/handlers/gates/bash-external-directory.ts +1 -1
- package/src/handlers/gates/bash-path.ts +14 -14
- package/src/handlers/gates/external-directory.ts +3 -4
- package/src/handlers/gates/path.ts +11 -7
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -1
- package/src/handlers/permission-gate-handler.ts +1 -2
- package/src/handlers/tool-call-boundary.ts +1 -2
- package/src/input-normalizer.ts +1 -1
- package/src/mcp-targets.ts +1 -1
- package/src/normalize.ts +1 -1
- package/src/path-utils.ts +1 -1
- package/src/permission-manager.ts +1 -1
- package/src/permission-prompts.ts +1 -1
- package/src/policy-loader.ts +2 -2
- package/src/tool-input-prompt-formatters.ts +1 -1
- package/src/tool-preview-formatter.ts +1 -1
- package/src/tool-registry.ts +1 -1
- package/src/{common.ts → value-guards.ts} +0 -66
- package/src/yaml-frontmatter.ts +65 -0
- package/test/access-intent/access-path.test.ts +83 -23
- package/test/access-intent/bash/node-text.test.ts +1 -0
- package/test/access-intent/bash/program.test.ts +116 -17
- package/test/handlers/external-directory-integration.test.ts +40 -153
- package/test/handlers/external-directory-session-dedup.test.ts +38 -262
- package/test/handlers/gates/bash-command.test.ts +63 -0
- package/test/handlers/gates/bash-external-directory.test.ts +1 -2
- package/test/handlers/gates/bash-path.test.ts +9 -9
- package/test/handlers/gates/external-directory-policy.test.ts +10 -18
- package/test/handlers/gates/path.test.ts +43 -12
- package/test/helpers/external-directory-fixtures.ts +269 -0
- package/test/helpers/gate-fixtures.ts +1 -2
- package/test/permission-resolver.test.ts +5 -32
- package/test/{common.test.ts → value-guards.test.ts} +2 -96
- package/test/yaml-frontmatter.test.ts +91 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ 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
|
+
## [17.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.2.1...pi-permission-system-v17.0.0) (2026-06-27)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** the path surface now also matches the canonical (symlink-resolved) form of bash path-rule tokens, so a path rule can fire on a symlink alias it previously missed, changing decisions on upgrade with no config edit.
|
|
14
|
+
* **pi-permission-system:** the path surface now also matches the canonical (symlink-resolved) form of a tool's file path. A path rule that previously matched only the as-typed spelling now also matches when the path resolves through a symlink to a target the pattern covers, which can change allow/deny decisions on upgrade with no config edit.
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** add AccessPath.forPath and forLiteral factories ([4323cae](https://github.com/gotgenes/pi-packages/commit/4323cae859907dd62d9cb401c194443717ab752c)), closes [#486](https://github.com/gotgenes/pi-packages/issues/486)
|
|
19
|
+
* **pi-permission-system:** match the canonical form on the bash-path gate ([6ce0c06](https://github.com/gotgenes/pi-packages/commit/6ce0c06b35c12eca32ffce5d4fe974c2f2dee393))
|
|
20
|
+
* **pi-permission-system:** match the canonical form on the path tool gate ([869ca76](https://github.com/gotgenes/pi-packages/commit/869ca761cf3d7a8367eb932106065a866d996ce4))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** document canonical path-surface matching ([9606dce](https://github.com/gotgenes/pi-packages/commit/9606dce744bc1f95bd8424d2bb115a376d9a1191)), closes [#486](https://github.com/gotgenes/pi-packages/issues/486)
|
|
26
|
+
|
|
27
|
+
## [16.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.2.0...pi-permission-system-v16.2.1) (2026-06-27)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Bug Fixes
|
|
31
|
+
|
|
32
|
+
* **pi-permission-system:** floor opaque bash -c/eval wrappers to ask ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([e69493c](https://github.com/gotgenes/pi-packages/commit/e69493c00a04fdcdc1a972ef04ffee5839c93d2a))
|
|
33
|
+
* **pi-permission-system:** strip env-var assignment prefix from bash command units ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([1c99fb3](https://github.com/gotgenes/pi-packages/commit/1c99fb3835442dce3cb01b89f26db0a39084b7af))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
|
|
38
|
+
* **pi-permission-system:** document env-prefix stripping and opaque bash-wrapper floor ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([40c0012](https://github.com/gotgenes/pi-packages/commit/40c001270249ace6a9d471c92063f9f09aa67238))
|
|
39
|
+
* **pi-permission-system:** note opaque-wrapper sentinel in README and skill ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([e226fe7](https://github.com/gotgenes/pi-packages/commit/e226fe7a49218fac73f2dcb8c84cb13608d48fa0))
|
|
40
|
+
* **pi-permission-system:** note prefix strip and opaque flag in commands() JSDoc ([#481](https://github.com/gotgenes/pi-packages/issues/481)) ([1892098](https://github.com/gotgenes/pi-packages/commit/18920980e0b17b6dd5e51a75fd103663f9313b32))
|
|
41
|
+
|
|
8
42
|
## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v16.1.0...pi-permission-system-v16.2.0) (2026-06-26)
|
|
9
43
|
|
|
10
44
|
|
package/README.md
CHANGED
|
@@ -17,9 +17,9 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
|
|
17
17
|
- **Enforces allow / ask / deny** at tool-call time with UI confirmation dialogs
|
|
18
18
|
- **Controls bash commands** with wildcard pattern matching (`git *: ask`, `rm -rf *: deny`)
|
|
19
19
|
- **Gates MCP and skill access** at server, tool, and skill-name granularity
|
|
20
|
-
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
|
|
20
|
+
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
|
|
21
21
|
- **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
|
|
22
|
-
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command prompts (`ask`) rather than passing silently
|
|
22
|
+
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an opaque `bash -c`/`eval` wrapper — prompts (`ask`) rather than passing silently
|
|
23
23
|
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
|
|
24
24
|
- **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
|
|
25
25
|
- **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
|
|
@@ -69,6 +69,7 @@ See [docs/session-approvals.md](docs/session-approvals.md) for details on sessio
|
|
|
69
69
|
|
|
70
70
|
The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
|
|
71
71
|
Extension and MCP tools that operate on paths (via `input.path`, MCP's `input.arguments.path`, or a registered access extractor) are gated by default, so a `path` deny cannot be overridden by a per-tool allow — making it the right place to protect sensitive files like `.env` or `~/.ssh/*` from every tool at once.
|
|
72
|
+
A `path` pattern matches both the path as the agent references it and its canonical (symlink-resolved) form, so a deny still fires when a symlink aliases a sensitive target.
|
|
72
73
|
|
|
73
74
|
For per-tool path patterns (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`.
|
|
74
75
|
This lets you express rules like "allow reads but deny `.env` files" at the individual tool level.
|
package/package.json
CHANGED
|
@@ -17,8 +17,9 @@ export interface ToolAccessIntent {
|
|
|
17
17
|
/**
|
|
18
18
|
* Precomputed equivalent policy values for a path-shaped surface.
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
20
|
+
* Not gate-emitted: the resolver produces it internally by unwrapping an
|
|
21
|
+
* `access-path` intent via `matchValues()`, keeping the low-level manager
|
|
22
|
+
* string-based (it never imports `AccessPath`). See {@link ResolvedAccessIntent}.
|
|
22
23
|
*/
|
|
23
24
|
export interface PathValuesAccessIntent {
|
|
24
25
|
kind: "path-values";
|
|
@@ -31,9 +32,9 @@ export interface PathValuesAccessIntent {
|
|
|
31
32
|
/**
|
|
32
33
|
* An `AccessPath` value object for a path-shaped surface.
|
|
33
34
|
*
|
|
34
|
-
*
|
|
35
|
-
* resolver as a first-class variant so the
|
|
36
|
-
* for `matchValues()` (Tell-Don't-Ask).
|
|
35
|
+
* Emitted by every path gate (the `path` and `external_directory` surfaces);
|
|
36
|
+
* lets `AccessPath` flow into the resolver as a first-class variant so the
|
|
37
|
+
* resolver — not the gate — asks it for `matchValues()` (Tell-Don't-Ask).
|
|
37
38
|
*/
|
|
38
39
|
export interface AccessPathAccessIntent {
|
|
39
40
|
kind: "access-path";
|
|
@@ -42,11 +43,8 @@ export interface AccessPathAccessIntent {
|
|
|
42
43
|
agentName?: string;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** What a gate emits —
|
|
46
|
-
export type AccessIntent =
|
|
47
|
-
| ToolAccessIntent
|
|
48
|
-
| PathValuesAccessIntent
|
|
49
|
-
| AccessPathAccessIntent;
|
|
46
|
+
/** What a gate emits — a raw tool input or an `AccessPath`. */
|
|
47
|
+
export type AccessIntent = ToolAccessIntent | AccessPathAccessIntent;
|
|
50
48
|
|
|
51
49
|
/**
|
|
52
50
|
* What the manager consumes — the `access-path` variant has already been
|
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
* - {@link value} returns `string` — the lexical absolute form, for display,
|
|
21
21
|
* approval patterns, decision values, and logs.
|
|
22
22
|
*
|
|
23
|
-
* Construct via {@link
|
|
23
|
+
* Construct via {@link forPath} (resolved, with optional cd-folded base) or
|
|
24
|
+
* {@link forLiteral} (literal-only, for an unknown base); the constructor is
|
|
25
|
+
* private.
|
|
24
26
|
*/
|
|
25
27
|
export class AccessPath {
|
|
26
28
|
private constructor(
|
|
@@ -65,24 +67,45 @@ export class AccessPath {
|
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
|
-
* Build an `AccessPath` for a tool-input or bash-token path resolved against
|
|
69
|
-
* `cwd
|
|
70
|
+
* Build an `AccessPath` for a tool-input or bash-token path, resolved against
|
|
71
|
+
* `resolveBase` (the cd-folded effective directory; defaults to `cwd`).
|
|
70
72
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* - `
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
73
|
+
* Serves every path surface: the tool path gate, the tool external-directory
|
|
74
|
+
* gate, and the bash path/external-directory gates (which pass a cd-resolved
|
|
75
|
+
* `resolveBase`).
|
|
76
|
+
*
|
|
77
|
+
* - `matchValues()` returns the lexical alias union from `getPathPolicyValues`
|
|
78
|
+
* plus the canonical alias from `canonicalNormalizePathForComparison`
|
|
79
|
+
* (#418), so a config pattern on either the typed or symlink-resolved form
|
|
80
|
+
* matches.
|
|
81
|
+
* - `boundaryValue()` returns
|
|
82
|
+
* `canonicalNormalizePathForComparison(pathValue, resolveBase)`, which is
|
|
83
|
+
* win32-lowercased (#382) — do not substitute a raw `canonicalizePath`
|
|
84
|
+
* output here.
|
|
85
|
+
* - `value()` returns `normalizePathForComparison(pathValue, resolveBase)`,
|
|
86
|
+
* the absolute lexical form.
|
|
80
87
|
*/
|
|
81
|
-
static
|
|
88
|
+
static forPath(
|
|
89
|
+
pathValue: string,
|
|
90
|
+
options: { cwd: string; resolveBase?: string },
|
|
91
|
+
): AccessPath {
|
|
92
|
+
const { cwd, resolveBase = cwd } = options;
|
|
82
93
|
return new AccessPath(
|
|
83
|
-
normalizePathForComparison(pathValue,
|
|
84
|
-
getPathPolicyValues(pathValue, { cwd }),
|
|
85
|
-
canonicalNormalizePathForComparison(pathValue,
|
|
94
|
+
normalizePathForComparison(pathValue, resolveBase),
|
|
95
|
+
getPathPolicyValues(pathValue, { cwd, resolveBase }),
|
|
96
|
+
canonicalNormalizePathForComparison(pathValue, resolveBase),
|
|
86
97
|
);
|
|
87
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build a literal-only `AccessPath` for a path whose effective base is
|
|
102
|
+
* unknown (a relative bash token after a non-literal `cd`).
|
|
103
|
+
*
|
|
104
|
+
* Carries no canonical alias and no absolute resolution — `matchValues()` is
|
|
105
|
+
* `[literal]` (or `[]` when empty) and `boundaryValue()` is `""` — so no
|
|
106
|
+
* spurious absolute or symlink-resolved rule can match (#393).
|
|
107
|
+
*/
|
|
108
|
+
static forLiteral(literal: string): AccessPath {
|
|
109
|
+
return new AccessPath(literal, literal ? [literal] : [], "");
|
|
110
|
+
}
|
|
88
111
|
}
|
|
@@ -18,6 +18,12 @@ export interface BashCommand {
|
|
|
18
18
|
* for a current-shell (top-level) command.
|
|
19
19
|
*/
|
|
20
20
|
readonly context?: BashCommandContext;
|
|
21
|
+
/**
|
|
22
|
+
* True when this is an opaque-payload wrapper (`bash -c`/`eval`) whose inner
|
|
23
|
+
* program is not re-parsed; its decision is floored to at least `ask` so it
|
|
24
|
+
* cannot ride a permissive `allow`.
|
|
25
|
+
*/
|
|
26
|
+
readonly opaque?: boolean;
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
// ── Command enumeration ──────────────────────────────────────────────────────
|
|
@@ -75,6 +81,9 @@ const NESTED_EXECUTION_CONTEXTS = new Map<string, BashCommandContext>([
|
|
|
75
81
|
*
|
|
76
82
|
* The enclosing command/subshell is always still emitted whole, so adding the
|
|
77
83
|
* nested units can only ever produce a more-restrictive decision, never weaker.
|
|
84
|
+
*
|
|
85
|
+
* Each emitted command unit has any leading `variable_assignment` prefix
|
|
86
|
+
* stripped (so an env-var prefix cannot defeat a command-pattern rule).
|
|
78
87
|
*/
|
|
79
88
|
export function collectCommands(node: TSNode): BashCommand[] {
|
|
80
89
|
const out: BashCommand[] = [];
|
|
@@ -93,7 +102,9 @@ function collectCommandsInto(
|
|
|
93
102
|
if (COMMAND_ENUM_SKIP.has(node.type)) return;
|
|
94
103
|
|
|
95
104
|
if (node.type === "command") {
|
|
96
|
-
out.push(
|
|
105
|
+
out.push(
|
|
106
|
+
makeUnit(commandUnitText(node), context, isOpaqueWrapperCommand(node)),
|
|
107
|
+
);
|
|
97
108
|
// A command's text already contains any substitution; descend its subtree
|
|
98
109
|
// to ALSO emit the inner commands of command/process substitutions.
|
|
99
110
|
collectSubstitutionCommands(node, out);
|
|
@@ -119,8 +130,74 @@ function collectCommandsInto(
|
|
|
119
130
|
function makeUnit(
|
|
120
131
|
text: string,
|
|
121
132
|
context: BashCommandContext | undefined,
|
|
133
|
+
opaque = false,
|
|
122
134
|
): BashCommand {
|
|
123
|
-
|
|
135
|
+
const unit: BashCommand = context ? { text, context } : { text };
|
|
136
|
+
return opaque ? { ...unit, opaque } : unit;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Shell command names whose `-c` flag introduces an opaque inline program.
|
|
141
|
+
*/
|
|
142
|
+
const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* True when a `command` node is an opaque-payload wrapper: a shell
|
|
146
|
+
* (`bash`/`sh`/`dash`/`zsh`/`ksh`) invoked with a `-c` flag, or `eval`. Its
|
|
147
|
+
* inner program is a quoted argument the enumerator does not re-parse, so the
|
|
148
|
+
* wrapper's decision is later floored to at least `ask`.
|
|
149
|
+
*
|
|
150
|
+
* The command name is matched on its basename (so `/bin/bash -c …` counts), and
|
|
151
|
+
* a `-c` flag is recognized within a short-flag cluster (`-c`, `-ec`, `-xc`).
|
|
152
|
+
* A leading `variable_assignment` prefix is skipped, matching `commandUnitText`.
|
|
153
|
+
*/
|
|
154
|
+
function isOpaqueWrapperCommand(node: TSNode): boolean {
|
|
155
|
+
let commandName: string | undefined;
|
|
156
|
+
let sawShortFlagC = false;
|
|
157
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
158
|
+
const child = node.child(i);
|
|
159
|
+
if (!child?.isNamed) continue;
|
|
160
|
+
if (child.type === "variable_assignment") continue;
|
|
161
|
+
if (commandName === undefined) {
|
|
162
|
+
commandName = basename(child.text);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const text = child.text;
|
|
166
|
+
if (text === "--") break;
|
|
167
|
+
if (text.startsWith("-") && !text.startsWith("--") && text.includes("c")) {
|
|
168
|
+
sawShortFlagC = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (commandName === undefined) return false;
|
|
172
|
+
if (commandName === "eval") return true;
|
|
173
|
+
return SHELL_WRAPPER_NAMES.has(commandName) && sawShortFlagC;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The final path segment of a command name (`/bin/bash` → `bash`). */
|
|
177
|
+
function basename(name: string): string {
|
|
178
|
+
const slash = name.lastIndexOf("/");
|
|
179
|
+
return slash === -1 ? name : name.slice(slash + 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The command-pattern text of a `command` node, with any leading
|
|
184
|
+
* `variable_assignment` prefix stripped.
|
|
185
|
+
*
|
|
186
|
+
* An env-var prefix (`AWS_PROFILE=prod aws …`, `PGPASSWORD=…`) is part of the
|
|
187
|
+
* `command` node's text but must not defeat a rule that gates the underlying
|
|
188
|
+
* command, so matching targets the text from the first non-assignment child
|
|
189
|
+
* (the `command_name`) onward, sliced verbatim to preserve spacing. A pure
|
|
190
|
+
* assignment (`FOO=bar`, no `command_name`) runs no command and is returned
|
|
191
|
+
* unchanged.
|
|
192
|
+
*/
|
|
193
|
+
function commandUnitText(node: TSNode): string {
|
|
194
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
195
|
+
const child = node.child(i);
|
|
196
|
+
if (child?.isNamed && child.type !== "variable_assignment") {
|
|
197
|
+
return node.text.slice(child.startIndex - node.startIndex);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return node.text;
|
|
124
201
|
}
|
|
125
202
|
|
|
126
203
|
function descendCommandChildren(
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
} from "#src/access-intent/bash/token-collection";
|
|
18
18
|
import { canonicalizePath } from "#src/canonicalize-path";
|
|
19
19
|
import {
|
|
20
|
-
getPathPolicyValues,
|
|
21
20
|
isPathWithinDirectory,
|
|
22
21
|
isSafeSystemPath,
|
|
23
22
|
normalizePathForComparison,
|
|
@@ -54,8 +53,8 @@ interface PathCandidate {
|
|
|
54
53
|
export interface BashPathRuleCandidate {
|
|
55
54
|
/** Raw path-like token shown in prompts, logs, and session approvals. */
|
|
56
55
|
readonly token: string;
|
|
57
|
-
/**
|
|
58
|
-
readonly
|
|
56
|
+
/** The path's lexical and canonical forms for permission policy matching. */
|
|
57
|
+
readonly path: AccessPath;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
// ── Walk-time constants ──────────────────────────────────────────────────────
|
|
@@ -379,18 +378,20 @@ function isRelativeCandidate(candidate: string): boolean {
|
|
|
379
378
|
return !candidate.startsWith("/") && !candidate.startsWith("~");
|
|
380
379
|
}
|
|
381
380
|
|
|
382
|
-
function
|
|
381
|
+
function buildRuleCandidatePath(
|
|
383
382
|
candidate: string,
|
|
384
383
|
base: EffectiveBase,
|
|
385
384
|
cwd: string,
|
|
386
|
-
):
|
|
385
|
+
): AccessPath {
|
|
386
|
+
// An unknown base + relative candidate stays literal-only: a resolved
|
|
387
|
+
// absolute or canonical alias would resolve against the wrong directory and
|
|
388
|
+
// could spuriously match a rule (#393).
|
|
387
389
|
if (base.kind === "unknown" && isRelativeCandidate(candidate)) {
|
|
388
|
-
|
|
389
|
-
return literal ? [literal] : [];
|
|
390
|
+
return AccessPath.forLiteral(normalizePathPolicyLiteral(candidate));
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
const resolveBase = base.kind === "known" ? resolve(cwd, base.offset) : cwd;
|
|
393
|
-
return
|
|
394
|
+
return AccessPath.forPath(candidate, { cwd, resolveBase });
|
|
394
395
|
}
|
|
395
396
|
|
|
396
397
|
// ── Projection functions ─────────────────────────────────────────────────────
|
|
@@ -435,7 +436,7 @@ export function projectExternalPaths(
|
|
|
435
436
|
seen.add(canonical);
|
|
436
437
|
// The factory recomputes the canonical via canonicalNormalizePathForComparison
|
|
437
438
|
// (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
|
|
438
|
-
externalPaths.push(AccessPath.
|
|
439
|
+
externalPaths.push(AccessPath.forPath(lexical, { cwd }));
|
|
439
440
|
}
|
|
440
441
|
continue;
|
|
441
442
|
}
|
|
@@ -457,7 +458,7 @@ export function projectExternalPaths(
|
|
|
457
458
|
seen.add(canonical);
|
|
458
459
|
// The factory recomputes the canonical via canonicalNormalizePathForComparison
|
|
459
460
|
// (win32-lowercased, #382) rather than reusing the raw canonicalizePath output.
|
|
460
|
-
externalPaths.push(AccessPath.
|
|
461
|
+
externalPaths.push(AccessPath.forPath(lexical, { cwd }));
|
|
461
462
|
}
|
|
462
463
|
}
|
|
463
464
|
|
|
@@ -485,13 +486,14 @@ export function projectRuleCandidates(
|
|
|
485
486
|
const candidate = classifyTokenAsRuleCandidate(token);
|
|
486
487
|
if (!candidate) continue;
|
|
487
488
|
|
|
488
|
-
const
|
|
489
|
-
|
|
489
|
+
const path = buildRuleCandidatePath(candidate, base, cwd);
|
|
490
|
+
const matchValues = path.matchValues();
|
|
491
|
+
if (matchValues.length === 0) continue;
|
|
490
492
|
|
|
491
|
-
const key =
|
|
493
|
+
const key = matchValues.join("\0");
|
|
492
494
|
if (seen.has(key)) continue;
|
|
493
495
|
seen.add(key);
|
|
494
|
-
result.push({ token: candidate,
|
|
496
|
+
result.push({ token: candidate, path });
|
|
495
497
|
}
|
|
496
498
|
|
|
497
499
|
return result;
|
|
@@ -8,6 +8,8 @@ import { memoizeAsyncWithRetry } from "#src/async-cache";
|
|
|
8
8
|
export interface TSNode {
|
|
9
9
|
readonly type: string;
|
|
10
10
|
readonly text: string;
|
|
11
|
+
/** Absolute byte offset of this node's start in the parsed source. */
|
|
12
|
+
readonly startIndex: number;
|
|
11
13
|
readonly childCount: number;
|
|
12
14
|
/** False for anonymous tokens (operators, delimiters); true for named nodes. */
|
|
13
15
|
readonly isNamed: boolean;
|
|
@@ -61,6 +61,9 @@ export class BashProgram {
|
|
|
61
61
|
* Splits on the shell chain operators (`&&`, `||`, `;`, `|`, `&`, newlines);
|
|
62
62
|
* quotes, command substitution, and subshells are respected by the parser and
|
|
63
63
|
* are NOT split — a subshell or other compound statement is emitted whole.
|
|
64
|
+
* Each unit has any leading `variable_assignment` prefix stripped, and an
|
|
65
|
+
* opaque-payload wrapper (`bash -c`/`eval`) is flagged `opaque` so its decision
|
|
66
|
+
* is floored to `ask`.
|
|
64
67
|
* May be empty (e.g. an empty command or a comment-only line); callers fall
|
|
65
68
|
* back to the whole command so the surface is never evaluated weaker than
|
|
66
69
|
* before.
|
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* through exactly the same path a third-party extension would use.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { toRecord } from "./common";
|
|
9
8
|
import type {
|
|
10
9
|
ToolInputFormatter,
|
|
11
10
|
ToolInputFormatterRegistry,
|
|
12
11
|
} from "./tool-input-formatter-registry";
|
|
13
12
|
import { truncateInlineText } from "./tool-input-preview";
|
|
13
|
+
import { toRecord } from "./value-guards";
|
|
14
14
|
|
|
15
15
|
/** Maximum total length of the generated argument summary (before "with " prefix). */
|
|
16
16
|
const MCP_ARGS_SUMMARY_MAX_LENGTH = 160;
|
package/src/config-loader.ts
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { normalize } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
isDenyWithReason,
|
|
5
|
-
isPermissionState,
|
|
6
|
-
normalizeOptionalPositiveInt,
|
|
7
|
-
normalizeOptionalStringArray,
|
|
8
|
-
toRecord,
|
|
9
|
-
} from "./common";
|
|
10
3
|
import {
|
|
11
4
|
getGlobalConfigPath,
|
|
12
5
|
getLegacyExtensionConfigPath,
|
|
@@ -16,6 +9,13 @@ import {
|
|
|
16
9
|
} from "./config-paths";
|
|
17
10
|
import { mergeFlatPermissions } from "./permission-merge";
|
|
18
11
|
import type { FlatPermissionConfig, PatternValue } from "./types";
|
|
12
|
+
import {
|
|
13
|
+
isDenyWithReason,
|
|
14
|
+
isPermissionState,
|
|
15
|
+
normalizeOptionalPositiveInt,
|
|
16
|
+
normalizeOptionalStringArray,
|
|
17
|
+
toRecord,
|
|
18
|
+
} from "./value-guards";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Unified config shape combining runtime knobs and flat permission policy.
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
getActiveAgentNameFromSystemPrompt,
|
|
6
6
|
type SessionEntryView,
|
|
7
7
|
} from "#src/active-agent";
|
|
8
|
-
import { toRecord } from "#src/common";
|
|
9
8
|
import type { ConfigReader } from "#src/config-store";
|
|
10
9
|
import type {
|
|
11
10
|
PermissionDecisionUi,
|
|
@@ -31,6 +30,7 @@ import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
|
|
|
31
30
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
32
31
|
import { isSubagentExecutionContext } from "#src/subagent-context";
|
|
33
32
|
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
33
|
+
import { toRecord } from "#src/value-guards";
|
|
34
34
|
import { shouldAutoApprovePermissionState } from "#src/yolo-mode";
|
|
35
35
|
|
|
36
36
|
import {
|
|
@@ -19,6 +19,12 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
19
19
|
* `commandContext` (set only for a nested command), so the prompt,
|
|
20
20
|
* session-approval suggestion, and decision event scope to that command.
|
|
21
21
|
*
|
|
22
|
+
* An opaque-payload wrapper unit (`bash -c`/`eval`, flagged `opaque` by the
|
|
23
|
+
* enumerator) has its inner program hidden behind a quoted argument, so an
|
|
24
|
+
* `allow` is floored up to a synthetic `ask` (the `<opaque-bash-wrapper>`
|
|
25
|
+
* pattern) to keep it from riding a permissive rule; an explicit `deny`/`ask`
|
|
26
|
+
* on the wrapper is left untouched (`deny > ask > allow`).
|
|
27
|
+
*
|
|
22
28
|
* When `commands` is empty there are two cases. A trivially-empty command (an
|
|
23
29
|
* empty, whitespace-only, or comment-only line) has genuinely nothing to gate,
|
|
24
30
|
* so the whole `command` is resolved as before. A non-empty command that parsed
|
|
@@ -56,12 +62,20 @@ export function resolveBashCommandCheck(
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
const results = commands.map((cmd) => {
|
|
59
|
-
const
|
|
65
|
+
const base = resolver.resolve({
|
|
60
66
|
kind: "tool",
|
|
61
67
|
surface: "bash",
|
|
62
68
|
input: { command: cmd.text },
|
|
63
69
|
agentName,
|
|
64
70
|
});
|
|
71
|
+
const result =
|
|
72
|
+
cmd.opaque && base.state === "allow"
|
|
73
|
+
? {
|
|
74
|
+
...base,
|
|
75
|
+
state: "ask" as const,
|
|
76
|
+
matchedPattern: "<opaque-bash-wrapper>",
|
|
77
|
+
}
|
|
78
|
+
: base;
|
|
65
79
|
return cmd.context ? { ...result, commandContext: cmd.context } : result;
|
|
66
80
|
});
|
|
67
81
|
return (
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
2
|
-
import { getNonEmptyString, toRecord } from "#src/common";
|
|
3
2
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
3
|
import { SessionApproval } from "#src/session-approval";
|
|
5
4
|
import { deriveApprovalPattern } from "#src/session-rules";
|
|
5
|
+
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
6
6
|
import type { GateResult } from "./descriptor";
|
|
7
7
|
import { formatBashExternalDirectoryAskPrompt } from "./external-directory-messages";
|
|
8
8
|
import { selectUncoveredExternalPaths } from "./external-directory-policy";
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import type { AccessPath } from "#src/access-intent/access-path";
|
|
1
2
|
import type { BashProgram } from "#src/access-intent/bash/program";
|
|
2
|
-
import { getNonEmptyString, toRecord } from "#src/common";
|
|
3
3
|
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";
|
|
7
8
|
import { pickMostRestrictive } from "./candidate-check";
|
|
8
9
|
import type { GateResult } from "./descriptor";
|
|
9
10
|
import { formatPathAskPrompt } from "./path";
|
|
@@ -41,20 +42,20 @@ export function describeBashPathGate(
|
|
|
41
42
|
const tokens = candidates.map(({ token }) => token);
|
|
42
43
|
|
|
43
44
|
// Tokens whose resolved state needs a check (deny/ask), paired with the raw
|
|
44
|
-
// token (prompt/decision display) and its
|
|
45
|
-
//
|
|
45
|
+
// token (prompt/decision display) and its `AccessPath` (whose `value()` is
|
|
46
|
+
// the lexical absolute path the approval pattern is derived from).
|
|
46
47
|
const uncovered: Array<{
|
|
47
48
|
token: string;
|
|
48
|
-
|
|
49
|
+
path: AccessPath;
|
|
49
50
|
check: PermissionCheckResult;
|
|
50
51
|
}> = [];
|
|
51
52
|
let allSessionCovered = true;
|
|
52
53
|
|
|
53
|
-
for (const { token,
|
|
54
|
+
for (const { token, path } of candidates) {
|
|
54
55
|
const check = resolver.resolve({
|
|
55
|
-
kind: "path
|
|
56
|
+
kind: "access-path",
|
|
56
57
|
surface: "path",
|
|
57
|
-
|
|
58
|
+
path,
|
|
58
59
|
agentName: tcc.agentName ?? undefined,
|
|
59
60
|
});
|
|
60
61
|
|
|
@@ -71,11 +72,11 @@ export function describeBashPathGate(
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
if (check.state === "deny") {
|
|
74
|
-
uncovered.push({ token,
|
|
75
|
+
uncovered.push({ token, path, check });
|
|
75
76
|
break; // Short-circuit on deny.
|
|
76
77
|
}
|
|
77
78
|
if (check.state === "ask") {
|
|
78
|
-
uncovered.push({ token,
|
|
79
|
+
uncovered.push({ token, path, check });
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -108,11 +109,10 @@ export function describeBashPathGate(
|
|
|
108
109
|
// All tokens evaluate to allow — no restriction.
|
|
109
110
|
if (!worstCheck || !worstToken || !worstEntry) return null;
|
|
110
111
|
|
|
111
|
-
// Derive the pattern from the
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
const
|
|
115
|
-
const pattern = deriveApprovalPattern(approvalBase);
|
|
112
|
+
// Derive the pattern from the lexical absolute form (the cd-aware resolved
|
|
113
|
+
// path), so it matches the values a later call produces. For an unknown base
|
|
114
|
+
// (`forLiteral`) `value()` is the raw token.
|
|
115
|
+
const pattern = deriveApprovalPattern(worstEntry.path.value());
|
|
116
116
|
const askMessage = formatPathAskPrompt(
|
|
117
117
|
tcc.toolName,
|
|
118
118
|
worstToken,
|
|
@@ -42,10 +42,9 @@ export function describeExternalDirectoryGate(
|
|
|
42
42
|
// The boundary decision (above) and the infrastructure-read containment
|
|
43
43
|
// check (below) use the canonical, symlink-resolved path; pattern matching
|
|
44
44
|
// uses the typed and resolved aliases (#418).
|
|
45
|
-
const accessPath = AccessPath.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
45
|
+
const accessPath = AccessPath.forPath(externalDirectoryPath, {
|
|
46
|
+
cwd: tcc.cwd,
|
|
47
|
+
});
|
|
49
48
|
const canonicalExtPath = accessPath.boundaryValue();
|
|
50
49
|
|
|
51
50
|
// ── Pi infrastructure read bypass ──────────────────────────────────────
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AccessPath } from "#src/access-intent/access-path";
|
|
2
|
+
import { getToolInputPath } from "#src/path-utils";
|
|
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";
|
|
@@ -22,10 +23,14 @@ export function describePathGate(
|
|
|
22
23
|
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
|
|
23
24
|
if (!filePath) return null;
|
|
24
25
|
|
|
26
|
+
// Emit an access-path intent so the resolver matches the lexical aliases
|
|
27
|
+
// *and* the canonical (symlink-resolved) form, the same set
|
|
28
|
+
// `external_directory` matches (#418, #486).
|
|
29
|
+
const accessPath = AccessPath.forPath(filePath, { cwd: tcc.cwd });
|
|
25
30
|
const check = resolver.resolve({
|
|
26
|
-
kind: "
|
|
31
|
+
kind: "access-path",
|
|
27
32
|
surface: "path",
|
|
28
|
-
|
|
33
|
+
path: accessPath,
|
|
29
34
|
agentName: tcc.agentName ?? undefined,
|
|
30
35
|
});
|
|
31
36
|
|
|
@@ -36,10 +41,9 @@ export function describePathGate(
|
|
|
36
41
|
// "path" key should not trigger path-level prompts (#58).
|
|
37
42
|
if (check.matchedPattern === undefined) return null;
|
|
38
43
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
const pattern = deriveApprovalPattern(approvalPath);
|
|
44
|
+
// Derive the approval pattern from the lexical absolute form so it matches
|
|
45
|
+
// the policy values a later call produces.
|
|
46
|
+
const pattern = deriveApprovalPattern(accessPath.value());
|
|
43
47
|
|
|
44
48
|
const descriptor: GateDescriptor = {
|
|
45
49
|
surface: "path",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { toRecord } from "#src/common";
|
|
2
1
|
import { normalizePathForComparison } from "#src/path-utils";
|
|
3
2
|
import { formatSkillPathAskPrompt } from "#src/permission-prompts";
|
|
4
3
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
5
4
|
import { findSkillPathMatch } from "#src/skill-prompt-sanitizer";
|
|
5
|
+
import { toRecord } from "#src/value-guards";
|
|
6
6
|
import type { GateDescriptor } from "./descriptor";
|
|
7
7
|
import type { ToolCallContext } from "./types";
|
|
8
8
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { BashProgram } from "#src/access-intent/bash/program";
|
|
2
|
-
import { getNonEmptyString, toRecord } from "#src/common";
|
|
3
2
|
import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
4
3
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
5
4
|
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
@@ -8,6 +7,7 @@ import {
|
|
|
8
7
|
ToolPreviewFormatter,
|
|
9
8
|
type ToolPreviewFormatterOptions,
|
|
10
9
|
} from "#src/tool-preview-formatter";
|
|
10
|
+
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
11
11
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
12
12
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
13
13
|
import { describeBashPathGate } from "./bash-path";
|