@gotgenes/pi-permission-system 39.0.4 → 39.1.1
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 +27 -0
- package/dist/public.d.ts +23 -4
- package/docs/configuration.md +38 -4
- package/docs/subagent-integration.md +1 -0
- package/package.json +1 -1
- package/src/access-intent/bash/command-enumeration.ts +2 -4
- package/src/access-intent/bash/wrapper-analysis.ts +207 -17
- package/src/authority/forwarded-request-server.ts +14 -4
- package/src/authority/forwarding-io.ts +5 -0
- package/src/authority/permission-forwarding.ts +8 -0
- package/src/handlers/gates/bash-command.ts +51 -11
- package/src/handlers/gates/tool.ts +17 -2
- package/src/index.ts +3 -15
- package/src/policy/serving-policy.ts +63 -0
- package/src/types.ts +15 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.1.0...pi-permission-system-v39.1.1) (2026-10-06)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** a subagent's floored bash ask prompts on the parent instead of riding its allow rule ([b33d6df](https://github.com/gotgenes/pi-packages/commit/b33d6df6e7bf430d5df898e190394c2d5d3ac9b7)), closes [#1029](https://github.com/gotgenes/pi-packages/issues/1029)
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** a forwarded ask keeps its floor ([0db97e4](https://github.com/gotgenes/pi-packages/commit/0db97e48bf6deea75bba36212e62d3c12502c43c)), closes [#1029](https://github.com/gotgenes/pi-packages/issues/1029)
|
|
18
|
+
|
|
19
|
+
## [39.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.4...pi-permission-system-v39.1.0) (2026-10-05)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
* **pi-permission-system:** time, timeout, nice, stdbuf, and setsid resolve by the command they run ([8cf8fb0](https://github.com/gotgenes/pi-packages/commit/8cf8fb0e62681f49059f093bd1a1ff4d7fa78ece)), closes [#963](https://github.com/gotgenes/pi-packages/issues/963)
|
|
25
|
+
* **pi-permission-system:** time -p and timeout -v keep the execution-modifier exemption ([#963](https://github.com/gotgenes/pi-packages/issues/963)) ([98157a3](https://github.com/gotgenes/pi-packages/commit/98157a3f1a9033172618a40738ad671a0f5d8604))
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** measure the execution-modifier exemption ([#963](https://github.com/gotgenes/pi-packages/issues/963)) ([157f07a](https://github.com/gotgenes/pi-packages/commit/157f07a69c1f7f8103482ae7d8b6c15116970579))
|
|
30
|
+
* **pi-permission-system:** document the execution-modifier exemption and mark Phase 15's #963 step complete ([03017b3](https://github.com/gotgenes/pi-packages/commit/03017b3422b412fc94afc8fe77d6126889bb4903))
|
|
31
|
+
* **pi-permission-system:** measure the literal-word rule on execution modifiers ([#963](https://github.com/gotgenes/pi-packages/issues/963)) ([1c61a2a](https://github.com/gotgenes/pi-packages/commit/1c61a2a3b4707dc873b927ca84bed4c1a611b97a))
|
|
32
|
+
* **pi-permission-system:** document the literal-word and -- rules for execution modifiers ([#963](https://github.com/gotgenes/pi-packages/issues/963)) ([17270c3](https://github.com/gotgenes/pi-packages/commit/17270c36b40debbddcc04fe3752a760dae62bb94))
|
|
33
|
+
* **pi-permission-system:** note that a modifier's environment assignment may be computed ([#963](https://github.com/gotgenes/pi-packages/issues/963)) ([1ed420e](https://github.com/gotgenes/pi-packages/commit/1ed420e0095e37e76660583db42c6d5c11993103))
|
|
34
|
+
|
|
8
35
|
## [39.0.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.3...pi-permission-system-v39.0.4) (2026-10-05)
|
|
9
36
|
|
|
10
37
|
|
package/dist/public.d.ts
CHANGED
|
@@ -29,12 +29,16 @@ type BashCommandContext = "command_substitution" | "process_substitution" | "sub
|
|
|
29
29
|
* core, so it is read-only for any argument feed and the floor's reason (an
|
|
30
30
|
* unknown direction behind the wrapper) does not hold.
|
|
31
31
|
*
|
|
32
|
+
* `"execution-modifier"` — every wrapper layer (`time`, `timeout`, `nice`,
|
|
33
|
+
* `stdbuf`, `setsid`) changes only how the visible inner command runs, so the
|
|
34
|
+
* unit is decided by that command's own rule whatever it does (#963).
|
|
35
|
+
*
|
|
32
36
|
* A named reason rather than a boolean, so the review log states *why* a
|
|
33
37
|
* wrapper was let through, and so a later source (a chain verdict, a user
|
|
34
38
|
* declaration) is an added member rather than a second flag. ADR 0013 §11
|
|
35
|
-
* keeps
|
|
39
|
+
* keeps both reasons package-audited; user declarations do not lift the floor.
|
|
36
40
|
*/
|
|
37
|
-
type FloorExemption = "core-reader";
|
|
41
|
+
type FloorExemption = "core-reader" | "execution-modifier";
|
|
38
42
|
interface PermissionCheckResult {
|
|
39
43
|
toolName: string;
|
|
40
44
|
state: PermissionState;
|
|
@@ -56,15 +60,22 @@ interface PermissionCheckResult {
|
|
|
56
60
|
* The command the winning bash unit actually runs, when it is a wrapper whose
|
|
57
61
|
* inner command differs from the unit text (#713). Display-only: the gate
|
|
58
62
|
* decides on `command`, and on `executedUnit`'s rules only when
|
|
59
|
-
* {@link floorExemption} says the
|
|
63
|
+
* {@link floorExemption} says the wrapper's floor has no reason to hold.
|
|
60
64
|
*/
|
|
61
65
|
executedUnit?: string;
|
|
62
66
|
/**
|
|
63
67
|
* Set when the winning bash unit is a wrapper the floor no longer covers,
|
|
64
|
-
* naming why (#803). Recorded in the review log so an allow the floor would
|
|
68
|
+
* naming why (#803, #963). Recorded in the review log so an allow the floor would
|
|
65
69
|
* once have prompted for is auditable to the reason that let it through.
|
|
66
70
|
*/
|
|
67
71
|
floorExemption?: FloorExemption;
|
|
72
|
+
/**
|
|
73
|
+
* The synthetic pattern of a floor that raised this ask on the bash chain —
|
|
74
|
+
* on the deciding unit, or on another asking unit of the same command. Only
|
|
75
|
+
* the child's parse knows it, so a forwarded ask carries it for the serving
|
|
76
|
+
* node to clamp its own `allow` with.
|
|
77
|
+
*/
|
|
78
|
+
floor?: string;
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
/**
|
|
@@ -366,6 +377,14 @@ interface ForwardedAccessFacts {
|
|
|
366
377
|
matchValues: string[];
|
|
367
378
|
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
|
368
379
|
boundaryValue: string | null;
|
|
380
|
+
/**
|
|
381
|
+
* The synthetic pattern of a floor that raised a bash ask on the child
|
|
382
|
+
* (`<indirection-bash-wrapper>`, `<opaque-bash-wrapper>`,
|
|
383
|
+
* `<unparsed-bash-subtree>`, `<unparseable-bash-command>`). Only the child's
|
|
384
|
+
* parse knows it, so the serving node clamps its own `allow` with it rather
|
|
385
|
+
* than recomputing it. Absent when no floor was raised.
|
|
386
|
+
*/
|
|
387
|
+
floor?: string;
|
|
369
388
|
}
|
|
370
389
|
|
|
371
390
|
type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
package/docs/configuration.md
CHANGED
|
@@ -532,12 +532,16 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
|
|
|
532
532
|
- An indirection wrapper — `sudo`, `env`, `xargs`, `time`, `nohup`, `timeout`, `nice`, `parallel`, `rust-parallel`, `rush`, `doas`, `setsid`, `stdbuf`, `watch`, `flock`, or `find`/`fd` carrying a per-result exec flag (`find` with `-exec`/`-execdir`/`-ok`/`-okdir`, `fd` with `-x`/`--exec`/`-X`/`--exec-batch`) — runs a following command that a rule on the wrapper text would otherwise never gate, so its decision is floored the same way (the synthetic `<indirection-bash-wrapper>` pattern in the review log).
|
|
533
533
|
So `sudo aws s3 rm s3://bucket` prompts rather than riding an `aws *: allow`, while a bare `find . -name '*.py'` search (no exec flag) is unaffected.
|
|
534
534
|
An `allow` is clamped to `ask`, and an explicit `deny` still denies.
|
|
535
|
-
The
|
|
535
|
+
The [two exceptions](#wrapper-transparency) are a wrapper running a pure-reader command, whose direction is provable however unknown its argument feed is, and a wrapper that only modifies how a command runs (`time`, `timeout`, `nice`, `stdbuf`, `setsid`), which is decided exactly as the command it runs.
|
|
536
536
|
|
|
537
537
|
Every synthetic `ask` above — the two parse sentinels and both wrapper floors — is auto-approved under `yoloMode: true`, which is an explicit full-permissive opt-in rather than a rule that could ride through.
|
|
538
538
|
An explicit `deny` still denies under yolo, and with yolo off the floors are unaffected.
|
|
539
539
|
Approving one for the session works normally: the floors clamp the decision and leave the grant's provenance intact, so a command you have already approved does not prompt again.
|
|
540
540
|
|
|
541
|
+
A subagent's floored ask keeps its floor when it is forwarded to the parent session.
|
|
542
|
+
The parent's `allow` rule does not answer it, so `sudo rm x` from a subagent prompts in the parent even under a parent `bash: *` allow.
|
|
543
|
+
The parent's `deny` still denies, and a grant for the whole parent session or the parent's `yoloMode` still approves.
|
|
544
|
+
|
|
541
545
|
Because of this, set an explicit `bash` policy rather than relying on a permissive top-level `*`.
|
|
542
546
|
A config whose top-level `*` is `"allow"` with no `bash` `*` policy lets every bash command silently inherit `allow`; the extension emits a startup warning in that case.
|
|
543
547
|
To gate bash commands, add `"bash": { "*": "ask" }` (or `"deny"`).
|
|
@@ -1018,7 +1022,12 @@ Two more shapes withdraw the claim:
|
|
|
1018
1022
|
#### Wrapper transparency
|
|
1019
1023
|
|
|
1020
1024
|
The [indirection-wrapper floor](#fail-closed-behavior) exists because a wrapper hides the command that should be gated.
|
|
1021
|
-
|
|
1025
|
+
Two kinds of wrapper unit have no reason to be floored: one running a [pure reader](#a-wrapper-running-a-pure-reader), and one that only [modifies how a command runs](#a-wrapper-that-only-modifies-execution).
|
|
1026
|
+
Each resolves by the inner command's own `bash` rules, and the review log records which reason applied as `floorExemption`.
|
|
1027
|
+
|
|
1028
|
+
##### A wrapper running a pure reader
|
|
1029
|
+
|
|
1030
|
+
For this class the hiding is immaterial: a pure-reader command is read-only for **any** arguments, so `xargs grep -l foo` is provably a read even though what `xargs` feeds it is unknowable.
|
|
1022
1031
|
The floor guards unknowability of *scope*, and scope stays the path surfaces' job — for a wrapped command exactly as for a bare one.
|
|
1023
1032
|
|
|
1024
1033
|
Such a unit is therefore **not** floored.
|
|
@@ -1034,7 +1043,7 @@ All four of these must hold, and each is a way the floor's reason could still ap
|
|
|
1034
1043
|
4. The enclosing statement provably writes no file through a redirect.
|
|
1035
1044
|
A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
|
|
1036
1045
|
|
|
1037
|
-
So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, and `find . -name '*.ts' -exec cat {} +` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`,
|
|
1046
|
+
So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, and `find . -name '*.ts' -exec cat {} +` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, and `find . -exec sh -c '…' \;` still prompt.
|
|
1038
1047
|
|
|
1039
1048
|
Three things this does **not** change:
|
|
1040
1049
|
|
|
@@ -1046,10 +1055,35 @@ Three things this does **not** change:
|
|
|
1046
1055
|
The exemption decides the `bash` surface only, and every path token the command projects still goes through `path` and `external_directory` with the direction its command proved.
|
|
1047
1056
|
Clause 4 is what keeps that from being a weaker promise than it sounds: a redirect destination the parse cannot resolve (`> $OUT`, `> $(mktemp)`) is not projected onto those surfaces either, so the wrapper keeps its floor rather than relying on a gate that would not see the write.
|
|
1048
1057
|
|
|
1058
|
+
##### A wrapper that only modifies execution
|
|
1059
|
+
|
|
1060
|
+
`time`, `timeout`, `nice`, `stdbuf`, and `setsid` change only *how* the same visible command runs: its timing, kill deadline, scheduling, buffering, or session.
|
|
1061
|
+
Every operand is on the command line, and the wrapper adds no privilege, environment, or argument feed, so the floor's reason does not hold whatever the inner command does.
|
|
1062
|
+
Such a unit resolves exactly as the command it runs, and the review log records `floorExemption: "execution-modifier"`.
|
|
1063
|
+
|
|
1064
|
+
All four of these must hold, and each keeps the decision about the command that really runs:
|
|
1065
|
+
|
|
1066
|
+
1. **Every** wrapper layer is one of the five.
|
|
1067
|
+
`time sudo rm -rf x` and `sudo time pnpm test` stay floored, as do `nohup` (it may write `nohup.out`) and `flock` (it creates its lock file).
|
|
1068
|
+
2. Every option on each layer is one that wrapper is known to take, spelled in full, and every option, value, and operand is written literally (an environment assignment, which the shell does not split, may be computed).
|
|
1069
|
+
A word the shell rewrites (`timeout $D …`, `timeout {5,sudo} …`) may become several, one of them a wrapper, so it keeps the floor.
|
|
1070
|
+
So `timeout --sig KILL 5 …` (an abbreviation) and `nice -5 …` stay floored, and so do `time`'s file-writing `-o`, `--output`, and `-a`.
|
|
1071
|
+
The admitted options are `time -p`/`-l`/`-h`/`-f`; `timeout -s`/`-k`/`-f`/`-p`/`-v` and their long forms; `nice -n`/`--adjustment`; and `stdbuf -i`/`-o`/`-e` and their long forms.
|
|
1072
|
+
3. The command it runs is not itself a wrapper or an inline shell, so `timeout 5 bash -c '…'` stays floored.
|
|
1073
|
+
4. The command it runs is named literally.
|
|
1074
|
+
`time ( … )` and `time { …; }` stay floored: the parser reads the group as `time`'s arguments, so the commands inside are not yet gated on their own rules.
|
|
1075
|
+
|
|
1076
|
+
So under `bash: {"*": "allow", "git push *": "deny"}`, `time pnpm run lint >/tmp/lint.txt 2>&1` is allowed and `timeout 60 git push --force` is denied, exactly as without the wrapper.
|
|
1077
|
+
No redirect refusal applies here: the redirect destination goes through `path` and `external_directory` as it does for the bare command, and a destination the parse cannot resolve (`> $OUT`) is unprojected for both forms alike.
|
|
1078
|
+
When a unit qualifies for both exemptions (`time grep foo`), the review log records `core-reader`.
|
|
1079
|
+
|
|
1080
|
+
##### Declarations and privilege
|
|
1081
|
+
|
|
1082
|
+
Neither exemption is extended by configuration.
|
|
1049
1083
|
A user `commandEffects` declaration participates in effect classification but does **not** lift the floor.
|
|
1050
1084
|
The core's argument-independence is audited here; a claim about a wrapped command is not, and a wrong claim behind a wrapper fails open.
|
|
1051
1085
|
|
|
1052
|
-
`sudo` and `doas` are ordinary wrappers to
|
|
1086
|
+
`sudo` and `doas` are ordinary wrappers to the pure-reader rule, and never execution modifiers.
|
|
1053
1087
|
The path surfaces gate `sudo cat /etc/shadow` exactly as they gate `cat /etc/shadow`, so nothing about the *file set* changes — what `sudo` adds is that the operating system would have refused, which this extension has never modelled.
|
|
1054
1088
|
If you run a permissive `bash` policy and want privilege elevation to prompt regardless, say so directly:
|
|
1055
1089
|
|
|
@@ -148,6 +148,7 @@ When `@gotgenes/pi-permission-system` is not installed, an implementation emits
|
|
|
148
148
|
When a delegated or routed subagent cannot decide an `ask` where it runs — because it has no UI of its own, or because it names a parent session that is answering for it — the confirmation request is forwarded through Pi session directories instead.
|
|
149
149
|
The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
|
|
150
150
|
A parent `allow`/`deny` rule governs a child's escalation directly (the serving node resolves it as recorded authority before prompting), and a "whole session" grant recorded on the parent auto-approves later forwards of the same pattern.
|
|
151
|
+
The exception is a bash ask the child raised through a [synthetic floor](configuration.md#fail-closed-behavior) (a wrapper such as `sudo` or `bash -c`, or a command the parser could not resolve): the child forwards the floor with the ask, and a parent `allow` rule escalates it to the parent's prompt instead of approving it.
|
|
151
152
|
|
|
152
153
|
This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
|
|
153
154
|
|
package/package.json
CHANGED
|
@@ -8,8 +8,8 @@ import {
|
|
|
8
8
|
type CommandWord,
|
|
9
9
|
classifyWrapperWords,
|
|
10
10
|
executedUnitOf,
|
|
11
|
+
floorExemptionOf,
|
|
11
12
|
inlineShellPayloadIndex,
|
|
12
|
-
isTransparentWrapper,
|
|
13
13
|
type WrapperKind,
|
|
14
14
|
} from "./wrapper-analysis";
|
|
15
15
|
|
|
@@ -468,9 +468,7 @@ function makeCommandUnit(node: TSNode, scope: UnitScope): BashCommand {
|
|
|
468
468
|
spellings: homeSpelling === undefined ? undefined : [homeSpelling],
|
|
469
469
|
wrapperKind: classifyWrapperWords(words),
|
|
470
470
|
executedUnit: executedUnitOf(text, words) ?? undefined,
|
|
471
|
-
floorExemption:
|
|
472
|
-
? "core-reader"
|
|
473
|
-
: undefined,
|
|
471
|
+
floorExemption: floorExemptionOf(words, redirectedScope(node, scope)),
|
|
474
472
|
});
|
|
475
473
|
}
|
|
476
474
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* classifiers over the same vocabulary would drift.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import type { FloorExemption } from "#src/types";
|
|
12
13
|
import { proveCommandEffect } from "./command-effects";
|
|
13
14
|
import type { ArgWord } from "./node-text";
|
|
14
15
|
|
|
@@ -143,9 +144,12 @@ export function executedUnitOf(
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
147
|
+
* Why a wrapper unit's floor has no reason left to hold, or `undefined` when it
|
|
148
|
+
* still does.
|
|
149
|
+
*
|
|
150
|
+
* `"core-reader"`: the command it runs is in the pure-reader core, so its
|
|
151
|
+
* *direction* is provable however unknown its argument feed is (ADR 0013 §11,
|
|
152
|
+
* #803).
|
|
149
153
|
*
|
|
150
154
|
* The floor exists because a wrapper hides the command that should be gated,
|
|
151
155
|
* and the unknowability it guards is unknowability of scope — which stays the
|
|
@@ -153,7 +157,7 @@ export function executedUnitOf(
|
|
|
153
157
|
* alike. Argument-independence is the core's admission bar, so there are no
|
|
154
158
|
* arguments that make `grep` write a file.
|
|
155
159
|
*
|
|
156
|
-
* Four things must hold, and each is a way the reason could still hold:
|
|
160
|
+
* Four things must hold for it, and each is a way the reason could still hold:
|
|
157
161
|
*
|
|
158
162
|
* 1. The unit is an indirection wrapper — an ordinary command has no floor.
|
|
159
163
|
* 2. Unwrapping reached the inner command without passing through an opaque
|
|
@@ -165,21 +169,128 @@ export function executedUnitOf(
|
|
|
165
169
|
* not transparent.
|
|
166
170
|
* 4. The unit writes no file through a redirect, which the caller reads off
|
|
167
171
|
* the parse tree and this module never sees.
|
|
172
|
+
*
|
|
173
|
+
* `"execution-modifier"`: every wrapper layer only changes *how* the visible
|
|
174
|
+
* inner command runs, so the unit resolves by that command's own rule whatever
|
|
175
|
+
* it does (see {@link onlyModifiesExecution}). The first two conditions above
|
|
176
|
+
* hold for it too; the core-reader reason is preferred when both apply.
|
|
168
177
|
*/
|
|
169
|
-
export function
|
|
178
|
+
export function floorExemptionOf(
|
|
170
179
|
words: readonly CommandWord[],
|
|
171
180
|
statement: { readonly writesViaRedirect: boolean },
|
|
172
|
-
):
|
|
173
|
-
if (
|
|
174
|
-
if (classifyWrapperWords(words) !== "indirection") return false;
|
|
181
|
+
): FloorExemption | undefined {
|
|
182
|
+
if (classifyWrapperWords(words) !== "indirection") return undefined;
|
|
175
183
|
|
|
176
184
|
// Only the peeled words matter here, so the walk is handed no source span to
|
|
177
185
|
// cut from — the text slice is `executedUnitOf`'s product, not this one's.
|
|
178
186
|
const unwrapped = unwrapIndirection(words, "");
|
|
179
|
-
if (unwrapped.kind === "opaque" || unwrapped.
|
|
187
|
+
if (unwrapped.kind === "opaque" || unwrapped.peeled.length === 0) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
180
190
|
|
|
181
191
|
const head = unwrapped.words.at(0)?.text ?? "";
|
|
182
|
-
|
|
192
|
+
const provesRead =
|
|
193
|
+
proveCommandEffect(head, unwrapped.words.slice(1)).effect === "read";
|
|
194
|
+
if (provesRead && !statement.writesViaRedirect) return "core-reader";
|
|
195
|
+
return onlyModifiesExecution(unwrapped.peeled, unwrapped.words)
|
|
196
|
+
? "execution-modifier"
|
|
197
|
+
: undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* True when every peeled layer only changes *how* the inner command runs, and
|
|
202
|
+
* that command is a literal name the gate can resolve by its own rule.
|
|
203
|
+
*
|
|
204
|
+
* The floor's reason (a wrapper hides the command that should be gated) is
|
|
205
|
+
* false for an execution modifier whatever the inner command does: every
|
|
206
|
+
* operand is on the command line, and the wrapper adds no privilege,
|
|
207
|
+
* environment, or argument feed. So the unit inherits the inner verdict rather
|
|
208
|
+
* than being classified a read, which is why no redirect refusal applies —
|
|
209
|
+
* the destination is gated by the path surfaces exactly as for the bare
|
|
210
|
+
* command.
|
|
211
|
+
*
|
|
212
|
+
* Each condition is a way the inherited verdict could name the wrong command:
|
|
213
|
+
*
|
|
214
|
+
* 1. Every layer is a modifier. The peel looks through `sudo` and `env` too,
|
|
215
|
+
* so `time sudo rm` would otherwise inherit `rm`'s verdict.
|
|
216
|
+
* 2. Every option in every layer is on that modifier's allowlist. The real
|
|
217
|
+
* tools accept abbreviations (`timeout --sig KILL 5 rm`) the inner-command
|
|
218
|
+
* search does not know, and one of them misplaces where the command starts.
|
|
219
|
+
* Each option, value, and operand must also be literal: the shell splits
|
|
220
|
+
* `timeout $D pnpm` or expands `timeout {5,sudo} rm` into extra words before
|
|
221
|
+
* the modifier runs, and one of those may be a wrapper or the real command.
|
|
222
|
+
* 3. The peel ended at an ordinary command, not a wrapper it could not see
|
|
223
|
+
* past.
|
|
224
|
+
* 4. The inner head is a literal command name. The grammar has no `time`
|
|
225
|
+
* keyword, so `time { …; }` and `time ( … )` reach here with shell syntax
|
|
226
|
+
* where the command name should be.
|
|
227
|
+
*/
|
|
228
|
+
function onlyModifiesExecution(
|
|
229
|
+
peeled: readonly (readonly CommandWord[])[],
|
|
230
|
+
inner: readonly CommandWord[],
|
|
231
|
+
): boolean {
|
|
232
|
+
return (
|
|
233
|
+
peeled.every(isAdmittedModifierLayer) &&
|
|
234
|
+
classifyWrapperWords(inner) === undefined &&
|
|
235
|
+
isLiteralCommandName(inner.at(0)?.text ?? "")
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* True when a peeled layer is an execution modifier whose every word before
|
|
241
|
+
* the inner command is one it admits.
|
|
242
|
+
*
|
|
243
|
+
* Walks the words with the same value-taking table {@link innerCommandIndex}
|
|
244
|
+
* skips by, so an admitted option's value can never be taken for the command.
|
|
245
|
+
*/
|
|
246
|
+
function isAdmittedModifierLayer(layer: readonly CommandWord[]): boolean {
|
|
247
|
+
const name = wrapperName(layer);
|
|
248
|
+
const flags =
|
|
249
|
+
name === undefined ? undefined : EXECUTION_MODIFIER_FLAGS.get(name);
|
|
250
|
+
if (name === undefined || flags === undefined) return false;
|
|
251
|
+
|
|
252
|
+
const valueTaking = admittedValueTaking(name);
|
|
253
|
+
for (let index = 1; index < layer.length; index++) {
|
|
254
|
+
const word = layer[index].text;
|
|
255
|
+
if (isEnvironmentAssignment(word)) continue;
|
|
256
|
+
// A word the shell rewrites (`$D`, `{5,sudo}`, `*`) may become several, one
|
|
257
|
+
// of them a wrapper or the real command, so only a literal word is admitted.
|
|
258
|
+
if (layer[index].computed) return false;
|
|
259
|
+
if (word === "--") continue;
|
|
260
|
+
if (!word.startsWith("-")) continue;
|
|
261
|
+
if (flags.has(word)) continue;
|
|
262
|
+
if (valueTaking.has(word)) {
|
|
263
|
+
index++;
|
|
264
|
+
if (layer[index]?.computed) return false;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (!hasAttachedValue(word, valueTaking)) return false;
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** A modifier's value-taking options, less any that write a file. */
|
|
273
|
+
function admittedValueTaking(name: string): ReadonlySet<string> {
|
|
274
|
+
const valueTaking = VALUE_TAKING_FLAGS.get(name) ?? EMPTY_FLAGS;
|
|
275
|
+
const writing = WRITING_OPTIONS.get(name) ?? EMPTY_FLAGS;
|
|
276
|
+
return new Set([...valueTaking].filter((flag) => !writing.has(flag)));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** `--long=value`, or a short option with its value attached (`-sKILL`). */
|
|
280
|
+
function hasAttachedValue(
|
|
281
|
+
word: string,
|
|
282
|
+
valueTaking: ReadonlySet<string>,
|
|
283
|
+
): boolean {
|
|
284
|
+
if (word.startsWith("--")) {
|
|
285
|
+
const equals = word.indexOf("=");
|
|
286
|
+
return equals !== -1 && valueTaking.has(word.slice(0, equals));
|
|
287
|
+
}
|
|
288
|
+
return word.length > 2 && valueTaking.has(word.slice(0, 2));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A command name spelled literally: no quoting, expansion, or shell syntax. */
|
|
292
|
+
function isLiteralCommandName(text: string): boolean {
|
|
293
|
+
return LITERAL_COMMAND_NAME.test(text) && !RESERVED_WORDS.has(text);
|
|
183
294
|
}
|
|
184
295
|
|
|
185
296
|
// ── Unwrapping ───────────────────────────────────────────────────────────────
|
|
@@ -200,8 +311,11 @@ type UnwrapResult =
|
|
|
200
311
|
readonly kind: "peeled";
|
|
201
312
|
readonly text: string;
|
|
202
313
|
readonly words: readonly CommandWord[];
|
|
203
|
-
/**
|
|
204
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Each peeled layer's words before its inner command: the wrapper name,
|
|
316
|
+
* its options, and any leading operand. Empty when none came off.
|
|
317
|
+
*/
|
|
318
|
+
readonly peeled: readonly (readonly CommandWord[])[];
|
|
205
319
|
};
|
|
206
320
|
|
|
207
321
|
/**
|
|
@@ -210,7 +324,7 @@ type UnwrapResult =
|
|
|
210
324
|
*
|
|
211
325
|
* Stopping early is not an error: the words peeled so far are returned, and
|
|
212
326
|
* each caller decides what an incomplete peel is worth — `executedUnitOf`
|
|
213
|
-
* shows it, {@link
|
|
327
|
+
* shows it, {@link floorExemptionOf} declines it because the head word it
|
|
214
328
|
* would judge is the wrapper's own.
|
|
215
329
|
*
|
|
216
330
|
* `unitText` is the span the peeled `text` is cut from; a caller that wants
|
|
@@ -222,7 +336,7 @@ function unwrapIndirection(
|
|
|
222
336
|
): UnwrapResult {
|
|
223
337
|
let text = unitText;
|
|
224
338
|
let current = words;
|
|
225
|
-
|
|
339
|
+
const peeled: (readonly CommandWord[])[] = [];
|
|
226
340
|
|
|
227
341
|
for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
|
|
228
342
|
const kind = classifyWrapperWords(current);
|
|
@@ -238,11 +352,11 @@ function unwrapIndirection(
|
|
|
238
352
|
if (start === -1 || start >= current.length) break;
|
|
239
353
|
const end = execTerminatorIndex(current, start);
|
|
240
354
|
text = sliceWords(text, current, start, end).trimEnd();
|
|
355
|
+
peeled.push(current.slice(0, start));
|
|
241
356
|
current = rebase(current, start, end);
|
|
242
|
-
layers++;
|
|
243
357
|
}
|
|
244
358
|
|
|
245
|
-
return { kind: "peeled", text, words: current,
|
|
359
|
+
return { kind: "peeled", text, words: current, peeled };
|
|
246
360
|
}
|
|
247
361
|
|
|
248
362
|
/** How many wrapper layers to unwrap before giving up. */
|
|
@@ -304,7 +418,11 @@ function innerCommandIndex(words: readonly CommandWord[]): number {
|
|
|
304
418
|
|
|
305
419
|
while (index < words.length) {
|
|
306
420
|
const word = words[index].text;
|
|
307
|
-
if (word === "--")
|
|
421
|
+
if (word === "--") {
|
|
422
|
+
// The end of options, not of operands: `timeout -- 5 cmd` still takes
|
|
423
|
+
// its duration before the command.
|
|
424
|
+
return operandPending ? index + 2 : index + 1;
|
|
425
|
+
}
|
|
308
426
|
if (isEnvironmentAssignment(word)) {
|
|
309
427
|
index++;
|
|
310
428
|
continue;
|
|
@@ -435,6 +553,78 @@ const VALUE_TAKING_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
|
435
553
|
|
|
436
554
|
const EMPTY_FLAGS: ReadonlySet<string> = new Set<string>();
|
|
437
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Wrappers that change only *how* the same visible command runs — timing, kill
|
|
558
|
+
* deadline, scheduling, buffering, session — each with the flags (options
|
|
559
|
+
* taking no value) it admits. Value-taking options are admitted from
|
|
560
|
+
* {@link VALUE_TAKING_FLAGS}, less {@link WRITING_OPTIONS}, so the two tables
|
|
561
|
+
* cannot disagree about where the inner command starts. Any other option
|
|
562
|
+
* refuses the exemption.
|
|
563
|
+
*/
|
|
564
|
+
const EXECUTION_MODIFIER_FLAGS = new Map<string, ReadonlySet<string>>([
|
|
565
|
+
// BSD `man 1 time`: `time [-al] [-h | -p] [-o file]`; `-p` is also the bash
|
|
566
|
+
// keyword's only option. `-a` appends to the `-o` file, so it is not listed.
|
|
567
|
+
["time", new Set(["-p", "-l", "-h"])],
|
|
568
|
+
// GNU coreutils `timeout --help`.
|
|
569
|
+
[
|
|
570
|
+
"timeout",
|
|
571
|
+
new Set([
|
|
572
|
+
"-f",
|
|
573
|
+
"--foreground",
|
|
574
|
+
"-p",
|
|
575
|
+
"--preserve-status",
|
|
576
|
+
"-v",
|
|
577
|
+
"--verbose",
|
|
578
|
+
]),
|
|
579
|
+
],
|
|
580
|
+
// `nice` and `stdbuf` take only value options; util-linux `setsid`'s flags
|
|
581
|
+
// are unverified on this host, so none is admitted.
|
|
582
|
+
["nice", EMPTY_FLAGS],
|
|
583
|
+
["stdbuf", EMPTY_FLAGS],
|
|
584
|
+
["setsid", EMPTY_FLAGS],
|
|
585
|
+
]);
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Each modifier's value-taking options that write a file, never admitted.
|
|
589
|
+
* Per wrapper: `time -o` names an output file, while `stdbuf -o` sets a mode.
|
|
590
|
+
*/
|
|
591
|
+
const WRITING_OPTIONS = new Map<string, ReadonlySet<string>>([
|
|
592
|
+
["time", new Set(["-o", "--output"])],
|
|
593
|
+
]);
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* A command name with no quoting, expansion, glob, or grouping character, and
|
|
597
|
+
* not an option: a name led by `-` is one {@link executedUnitOf} declines to
|
|
598
|
+
* name, so it could never be resolved.
|
|
599
|
+
*/
|
|
600
|
+
const LITERAL_COMMAND_NAME = /^[A-Za-z0-9_./+@%,:][A-Za-z0-9_./+@%,:-]*$/;
|
|
601
|
+
|
|
602
|
+
/** Bash reserved words, which open syntax rather than name a command. */
|
|
603
|
+
const RESERVED_WORDS: ReadonlySet<string> = new Set([
|
|
604
|
+
"!",
|
|
605
|
+
"{",
|
|
606
|
+
"}",
|
|
607
|
+
"[[",
|
|
608
|
+
"]]",
|
|
609
|
+
"case",
|
|
610
|
+
"coproc",
|
|
611
|
+
"do",
|
|
612
|
+
"done",
|
|
613
|
+
"elif",
|
|
614
|
+
"else",
|
|
615
|
+
"esac",
|
|
616
|
+
"fi",
|
|
617
|
+
"for",
|
|
618
|
+
"function",
|
|
619
|
+
"if",
|
|
620
|
+
"in",
|
|
621
|
+
"select",
|
|
622
|
+
"then",
|
|
623
|
+
"time",
|
|
624
|
+
"until",
|
|
625
|
+
"while",
|
|
626
|
+
]);
|
|
627
|
+
|
|
438
628
|
/**
|
|
439
629
|
* Wrappers whose first bare word is an operand (a duration, a lock file) rather
|
|
440
630
|
* than the start of the inner command.
|
|
@@ -56,10 +56,13 @@ export interface InboxProcessor {
|
|
|
56
56
|
* (`principal.agentName`, ADR 0008 §3) — the child-fixed `matchValues` are
|
|
57
57
|
* used as-is, never re-derived through this session's `PathNormalizer`/cwd.
|
|
58
58
|
*
|
|
59
|
+
* The answer honors the floor the child raised (`intent.floor`): a serving
|
|
60
|
+
* `allow` rule does not answer an ask the child's own gate floored.
|
|
61
|
+
*
|
|
59
62
|
* Narrow by design (ISP): the server needs one decision, not the whole
|
|
60
|
-
* resolver. The composition root satisfies it with
|
|
61
|
-
* `
|
|
62
|
-
* `
|
|
63
|
+
* resolver. The composition root satisfies it with `ResolverServingPolicy`
|
|
64
|
+
* (`src/policy/serving-policy.ts`), over the same `resolve` entry point
|
|
65
|
+
* `LocalPermissionsService` composes.
|
|
63
66
|
*/
|
|
64
67
|
export interface ServingPolicy {
|
|
65
68
|
resolve(intent: ForwardedAccessIntent): PermissionCheckResult;
|
|
@@ -151,6 +154,7 @@ function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
|
|
|
151
154
|
surface: intent.surface,
|
|
152
155
|
matchValues: intent.matchValues,
|
|
153
156
|
boundaryValue: intent.boundaryValue,
|
|
157
|
+
...(intent.floor === undefined ? {} : { floor: intent.floor }),
|
|
154
158
|
};
|
|
155
159
|
}
|
|
156
160
|
|
|
@@ -465,7 +469,13 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
465
469
|
: { ...createDeniedPermissionDecision(check.reason), decidedBy };
|
|
466
470
|
}
|
|
467
471
|
|
|
468
|
-
this
|
|
472
|
+
// The child's floor is why an ask this node's `allow` would once have
|
|
473
|
+
// answered reached a human instead, so the entry says so.
|
|
474
|
+
const floor = request.accessIntent?.floor;
|
|
475
|
+
this.logger.review(
|
|
476
|
+
"forwarded_permission.prompted",
|
|
477
|
+
floor === undefined ? logDetails : { ...logDetails, floor },
|
|
478
|
+
);
|
|
469
479
|
const details = buildForwardedAskDetails(request);
|
|
470
480
|
const decision = await this.escalateAsk(details);
|
|
471
481
|
// Announced before the grant-scope translation and before the response is
|
|
@@ -124,6 +124,7 @@ function asForwardedAccessIntent(
|
|
|
124
124
|
surface?: unknown;
|
|
125
125
|
matchValues?: unknown;
|
|
126
126
|
boundaryValue?: unknown;
|
|
127
|
+
floor?: unknown;
|
|
127
128
|
requesterCwd?: unknown;
|
|
128
129
|
principal?: unknown;
|
|
129
130
|
};
|
|
@@ -135,6 +136,9 @@ function asForwardedAccessIntent(
|
|
|
135
136
|
candidate.boundaryValue === null ||
|
|
136
137
|
typeof candidate.boundaryValue === "string"
|
|
137
138
|
) ||
|
|
139
|
+
// A malformed floor drops the whole intent, which escalates: it must
|
|
140
|
+
// never read as "no floor".
|
|
141
|
+
!(candidate.floor === undefined || typeof candidate.floor === "string") ||
|
|
138
142
|
typeof candidate.requesterCwd !== "string" ||
|
|
139
143
|
typeof candidate.principal !== "object" ||
|
|
140
144
|
candidate.principal === null
|
|
@@ -155,6 +159,7 @@ function asForwardedAccessIntent(
|
|
|
155
159
|
surface: candidate.surface,
|
|
156
160
|
matchValues: [...candidate.matchValues],
|
|
157
161
|
boundaryValue: candidate.boundaryValue,
|
|
162
|
+
...(candidate.floor === undefined ? {} : { floor: candidate.floor }),
|
|
158
163
|
requesterCwd: candidate.requesterCwd,
|
|
159
164
|
principal: {
|
|
160
165
|
sessionId: principal.sessionId,
|
|
@@ -123,6 +123,14 @@ export interface ForwardedAccessFacts {
|
|
|
123
123
|
matchValues: string[];
|
|
124
124
|
/** `AccessPath.boundaryValue()` (canonical) for a path surface; `null` for a non-path surface. */
|
|
125
125
|
boundaryValue: string | null;
|
|
126
|
+
/**
|
|
127
|
+
* The synthetic pattern of a floor that raised a bash ask on the child
|
|
128
|
+
* (`<indirection-bash-wrapper>`, `<opaque-bash-wrapper>`,
|
|
129
|
+
* `<unparsed-bash-subtree>`, `<unparseable-bash-command>`). Only the child's
|
|
130
|
+
* parse knows it, so the serving node clamps its own `allow` with it rather
|
|
131
|
+
* than recomputing it. Absent when no floor was raised.
|
|
132
|
+
*/
|
|
133
|
+
floor?: string;
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
/**
|
|
@@ -68,6 +68,12 @@ const WRAPPER_SENTINEL: Record<WrapperKind, string> = {
|
|
|
68
68
|
*/
|
|
69
69
|
const UNPARSED_SUBTREE_SENTINEL = "<unparsed-bash-subtree>";
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The synthetic `matchedPattern` recorded when a command the parse matched
|
|
73
|
+
* nothing in is floored to `ask` (#452).
|
|
74
|
+
*/
|
|
75
|
+
const UNPARSEABLE_COMMAND_SENTINEL = "<unparseable-bash-command>";
|
|
76
|
+
|
|
71
77
|
export function resolveBashCommandCheck(
|
|
72
78
|
command: string,
|
|
73
79
|
commands: BashCommand[],
|
|
@@ -97,7 +103,8 @@ export function resolveBashCommandCheck(
|
|
|
97
103
|
source: "bash",
|
|
98
104
|
origin: "builtin",
|
|
99
105
|
command,
|
|
100
|
-
matchedPattern:
|
|
106
|
+
matchedPattern: UNPARSEABLE_COMMAND_SENTINEL,
|
|
107
|
+
floor: UNPARSEABLE_COMMAND_SENTINEL,
|
|
101
108
|
};
|
|
102
109
|
}
|
|
103
110
|
}
|
|
@@ -105,10 +112,32 @@ export function resolveBashCommandCheck(
|
|
|
105
112
|
const results = commands.map((cmd) =>
|
|
106
113
|
resolveCommandUnit(cmd, command, agentName, resolver),
|
|
107
114
|
);
|
|
108
|
-
|
|
115
|
+
const winner =
|
|
109
116
|
pickMostRestrictive(results) ??
|
|
110
|
-
resolveOnBashSurface(command, [], agentName, resolver)
|
|
117
|
+
resolveOnBashSurface(command, [], agentName, resolver);
|
|
118
|
+
return withChainFloor(winner, results);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Carry onto an asking winner the floor another asking unit of the chain
|
|
123
|
+
* raised.
|
|
124
|
+
*
|
|
125
|
+
* The winner is the first of the most restrictive units, so in
|
|
126
|
+
* `git push x && sudo rm y` a `git push *: ask` rule wins the tie and the
|
|
127
|
+
* wrapper's floor would otherwise go unrecorded. A forwarded ask is judged on
|
|
128
|
+
* the winner's value alone, so the floor has to ride the result for the
|
|
129
|
+
* serving node to keep it (#1029). A unit the session already granted is left
|
|
130
|
+
* out: the user approved exactly that command.
|
|
131
|
+
*/
|
|
132
|
+
function withChainFloor(
|
|
133
|
+
winner: PermissionCheckResult,
|
|
134
|
+
results: readonly PermissionCheckResult[],
|
|
135
|
+
): PermissionCheckResult {
|
|
136
|
+
if (winner.state !== "ask" || winner.floor !== undefined) return winner;
|
|
137
|
+
const floored = results.find(
|
|
138
|
+
(result) => result.floor !== undefined && result.source !== "session",
|
|
111
139
|
);
|
|
140
|
+
return floored ? { ...winner, floor: floored.floor } : winner;
|
|
112
141
|
}
|
|
113
142
|
|
|
114
143
|
/**
|
|
@@ -168,11 +197,25 @@ function floorUnparsedUnit(
|
|
|
168
197
|
resolved: PermissionCheckResult,
|
|
169
198
|
): PermissionCheckResult {
|
|
170
199
|
if (!cmd.parseUnresolved || resolved.state !== "allow") return resolved;
|
|
200
|
+
return { ...floorToAsk(resolved, UNPARSED_SUBTREE_SENTINEL), command };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Clamp a resolved check up to a synthetic `ask` naming the floor that raised
|
|
205
|
+
* it.
|
|
206
|
+
*
|
|
207
|
+
* Spreads `resolved`, so a `source: "session"` grant survives to `GateRunner`'s
|
|
208
|
+
* session fast path.
|
|
209
|
+
*/
|
|
210
|
+
function floorToAsk(
|
|
211
|
+
resolved: PermissionCheckResult,
|
|
212
|
+
sentinel: string,
|
|
213
|
+
): PermissionCheckResult {
|
|
171
214
|
return {
|
|
172
215
|
...resolved,
|
|
173
216
|
state: "ask",
|
|
174
|
-
|
|
175
|
-
|
|
217
|
+
matchedPattern: sentinel,
|
|
218
|
+
floor: sentinel,
|
|
176
219
|
};
|
|
177
220
|
}
|
|
178
221
|
|
|
@@ -183,7 +226,8 @@ function floorUnparsedUnit(
|
|
|
183
226
|
* is clamped up to a synthetic `ask` naming the kind that caused it — unless
|
|
184
227
|
* the enumerator established that the floor has no reason left to hold, in
|
|
185
228
|
* which case the unit is resolved by the rules of the command it runs (ADR 0013
|
|
186
|
-
* §11
|
|
229
|
+
* §11): its inner command is a proven pure reader (#803), or every wrapper
|
|
230
|
+
* layer only modifies how that command runs (#963).
|
|
187
231
|
*
|
|
188
232
|
* Only an `allow` reaches here, which is what makes the exemption unable to
|
|
189
233
|
* weaken anything: an explicit `deny` or `ask` on the wrapper is decided before
|
|
@@ -198,11 +242,7 @@ function resolveWrapperUnit(
|
|
|
198
242
|
): PermissionCheckResult {
|
|
199
243
|
const inner = cmd.floorExemption && cmd.executedUnit;
|
|
200
244
|
if (!inner) {
|
|
201
|
-
return
|
|
202
|
-
...base,
|
|
203
|
-
state: "ask",
|
|
204
|
-
matchedPattern: WRAPPER_SENTINEL[wrapperKind],
|
|
205
|
-
};
|
|
245
|
+
return floorToAsk(base, WRAPPER_SENTINEL[wrapperKind]);
|
|
206
246
|
}
|
|
207
247
|
// The inner command's rule decides, but the unit is still what runs: the
|
|
208
248
|
// prompt, the decision value, and the session-approval suggestion all read
|
|
@@ -173,10 +173,14 @@ export function describeToolGate(
|
|
|
173
173
|
);
|
|
174
174
|
|
|
175
175
|
// A path-bearing tool carries the AccessPath's alias set; every other surface
|
|
176
|
-
// (bash command, MCP target, plain tool) carries its already-portable value
|
|
176
|
+
// (bash command, MCP target, plain tool) carries its already-portable value,
|
|
177
|
+
// and a bash ask the floor that raised it.
|
|
177
178
|
const accessIntent = pathAccess
|
|
178
179
|
? accessFactsFromPath(gateSurface, pathAccess.path)
|
|
179
|
-
:
|
|
180
|
+
: {
|
|
181
|
+
...accessFactsFromValue(gateSurface, decisionValue),
|
|
182
|
+
...floorFact(check),
|
|
183
|
+
};
|
|
180
184
|
|
|
181
185
|
return {
|
|
182
186
|
surface: gateSurface,
|
|
@@ -226,3 +230,14 @@ function floorExemptionFact(
|
|
|
226
230
|
? {}
|
|
227
231
|
: { floorExemption: check.floorExemption };
|
|
228
232
|
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The floor that raised a bash ask, as a child-fixed fact for the wire.
|
|
236
|
+
*
|
|
237
|
+
* Only the child's parse knows a unit was a wrapper or failed to parse, so the
|
|
238
|
+
* serving node of a forwarded ask cannot recompute it (ADR 0008). Absent when
|
|
239
|
+
* no floor was raised, so every other ask's facts keep their exact shape.
|
|
240
|
+
*/
|
|
241
|
+
function floorFact(check: PermissionCheckResult): { floor?: string } {
|
|
242
|
+
return check.floor === undefined ? {} : { floor: check.floor };
|
|
243
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
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 "#src/access-intent/bash/parser";
|
|
4
|
-
import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
|
|
5
4
|
import { AskDialogQueue } from "#src/authority/ask-dialog-queue";
|
|
6
5
|
import { AuthorizerChainAudit } from "#src/authority/authorizer-chain-audit";
|
|
7
6
|
import {
|
|
@@ -10,10 +9,7 @@ import {
|
|
|
10
9
|
} from "#src/authority/authorizer-registry";
|
|
11
10
|
import { AuthorizerSelection } from "#src/authority/authorizer-selection";
|
|
12
11
|
import { ChildNodeAudit } from "#src/authority/child-node-audit";
|
|
13
|
-
import {
|
|
14
|
-
ForwardedRequestServer,
|
|
15
|
-
type ServingPolicy,
|
|
16
|
-
} from "#src/authority/forwarded-request-server";
|
|
12
|
+
import { ForwardedRequestServer } from "#src/authority/forwarded-request-server";
|
|
17
13
|
import {
|
|
18
14
|
ForwardingLivenessJudge,
|
|
19
15
|
ServingHeartbeatStore,
|
|
@@ -52,6 +48,7 @@ import { PermissionSessionLogger } from "#src/logging/session-logger";
|
|
|
52
48
|
import { pathFlavorForPlatform } from "#src/path/path-flavor";
|
|
53
49
|
import { PermissionManager } from "#src/policy/permission-manager";
|
|
54
50
|
import { PermissionResolver } from "#src/policy/permission-resolver";
|
|
51
|
+
import { ResolverServingPolicy } from "#src/policy/serving-policy";
|
|
55
52
|
import { resolveRenderBudget } from "#src/presentation/dialog-renderer";
|
|
56
53
|
import { LocalPermissionsService } from "#src/service/permissions-service";
|
|
57
54
|
import { PermissionServiceLifecycle } from "#src/service/service-lifecycle";
|
|
@@ -198,16 +195,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
198
195
|
// composed ruleset, agent-scoped to the requester (§3) — the match values
|
|
199
196
|
// are used as fixed by the child, never re-derived through this session's
|
|
200
197
|
// PathNormalizer/cwd (#597).
|
|
201
|
-
const servingPolicy
|
|
202
|
-
resolve: (intent) =>
|
|
203
|
-
resolver.resolve(
|
|
204
|
-
buildResolvedIntentFromMatchValues(
|
|
205
|
-
intent.surface,
|
|
206
|
-
intent.matchValues,
|
|
207
|
-
intent.principal.agentName,
|
|
208
|
-
),
|
|
209
|
-
),
|
|
210
|
-
};
|
|
198
|
+
const servingPolicy = new ResolverServingPolicy(resolver, isYoloEnabled);
|
|
211
199
|
|
|
212
200
|
// Constructed here rather than beside the gate runner below: the serving
|
|
213
201
|
// side broadcasts its own decisions, so both readers share one reporter over
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { buildResolvedIntentFromMatchValues } from "#src/access-intent/input-normalizer";
|
|
2
|
+
import type { ServingPolicy } from "#src/authority/forwarded-request-server";
|
|
3
|
+
import type { ForwardedAccessIntent } from "#src/authority/permission-forwarding";
|
|
4
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
5
|
+
import type { PermissionResolver } from "./permission-resolver";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The serving node's recorded authority over a forwarded request: the
|
|
9
|
+
* child-fixed facts (ADR 0008) resolved against this node's composed ruleset,
|
|
10
|
+
* agent-scoped to the requester (`principal.agentName`, §3), with the floor the
|
|
11
|
+
* child raised honored.
|
|
12
|
+
*
|
|
13
|
+
* The `matchValues` are used as the child fixed them, never re-derived through
|
|
14
|
+
* this session's `PathNormalizer`/cwd. The resolver's surface-family fold is
|
|
15
|
+
* what keeps a parent's bare `path` deny answering a child's request, which is
|
|
16
|
+
* why this goes through `resolve` rather than the manager directly (#712, #806).
|
|
17
|
+
*/
|
|
18
|
+
export class ResolverServingPolicy implements ServingPolicy {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly resolver: Pick<PermissionResolver, "resolve">,
|
|
21
|
+
private readonly isYoloEnabled: () => boolean,
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
resolve(intent: ForwardedAccessIntent): PermissionCheckResult {
|
|
25
|
+
const check = this.resolver.resolve(
|
|
26
|
+
buildResolvedIntentFromMatchValues(
|
|
27
|
+
intent.surface,
|
|
28
|
+
intent.matchValues,
|
|
29
|
+
intent.principal.agentName,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
return honorChildFloor(check, intent.floor, this.isYoloEnabled());
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Clamp this node's `allow` with the floor the child raised, as the child's
|
|
38
|
+
* own gate clamped its own (#1029).
|
|
39
|
+
*
|
|
40
|
+
* Only the child's parse knows a bash unit was a wrapper or failed to parse,
|
|
41
|
+
* so without this a permissive serving rule answers exactly the ask the floor
|
|
42
|
+
* exists to raise. The floor leaves three answers standing, each for the
|
|
43
|
+
* reason it stands locally: a `deny` or `ask` decides before any floor is
|
|
44
|
+
* consulted; a session grant names a command the user already approved, which
|
|
45
|
+
* the local gate honors through its session fast path; and yolo approves every
|
|
46
|
+
* synthetic ask, keeping the floor's sentinel as the reported pattern.
|
|
47
|
+
*/
|
|
48
|
+
function honorChildFloor(
|
|
49
|
+
check: PermissionCheckResult,
|
|
50
|
+
floor: string | undefined,
|
|
51
|
+
yoloEnabled: boolean,
|
|
52
|
+
): PermissionCheckResult {
|
|
53
|
+
if (
|
|
54
|
+
floor === undefined ||
|
|
55
|
+
check.state !== "allow" ||
|
|
56
|
+
check.source === "session"
|
|
57
|
+
) {
|
|
58
|
+
return check;
|
|
59
|
+
}
|
|
60
|
+
return yoloEnabled
|
|
61
|
+
? { ...check, origin: "yolo", matchedPattern: floor }
|
|
62
|
+
: { ...check, state: "ask", matchedPattern: floor };
|
|
63
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -48,12 +48,16 @@ export type BashCommandContext =
|
|
|
48
48
|
* core, so it is read-only for any argument feed and the floor's reason (an
|
|
49
49
|
* unknown direction behind the wrapper) does not hold.
|
|
50
50
|
*
|
|
51
|
+
* `"execution-modifier"` — every wrapper layer (`time`, `timeout`, `nice`,
|
|
52
|
+
* `stdbuf`, `setsid`) changes only how the visible inner command runs, so the
|
|
53
|
+
* unit is decided by that command's own rule whatever it does (#963).
|
|
54
|
+
*
|
|
51
55
|
* A named reason rather than a boolean, so the review log states *why* a
|
|
52
56
|
* wrapper was let through, and so a later source (a chain verdict, a user
|
|
53
57
|
* declaration) is an added member rather than a second flag. ADR 0013 §11
|
|
54
|
-
* keeps
|
|
58
|
+
* keeps both reasons package-audited; user declarations do not lift the floor.
|
|
55
59
|
*/
|
|
56
|
-
export type FloorExemption = "core-reader";
|
|
60
|
+
export type FloorExemption = "core-reader" | "execution-modifier";
|
|
57
61
|
|
|
58
62
|
export interface PermissionCheckResult {
|
|
59
63
|
toolName: string;
|
|
@@ -76,15 +80,22 @@ export interface PermissionCheckResult {
|
|
|
76
80
|
* The command the winning bash unit actually runs, when it is a wrapper whose
|
|
77
81
|
* inner command differs from the unit text (#713). Display-only: the gate
|
|
78
82
|
* decides on `command`, and on `executedUnit`'s rules only when
|
|
79
|
-
* {@link floorExemption} says the
|
|
83
|
+
* {@link floorExemption} says the wrapper's floor has no reason to hold.
|
|
80
84
|
*/
|
|
81
85
|
executedUnit?: string;
|
|
82
86
|
/**
|
|
83
87
|
* Set when the winning bash unit is a wrapper the floor no longer covers,
|
|
84
|
-
* naming why (#803). Recorded in the review log so an allow the floor would
|
|
88
|
+
* naming why (#803, #963). Recorded in the review log so an allow the floor would
|
|
85
89
|
* once have prompted for is auditable to the reason that let it through.
|
|
86
90
|
*/
|
|
87
91
|
floorExemption?: FloorExemption;
|
|
92
|
+
/**
|
|
93
|
+
* The synthetic pattern of a floor that raised this ask on the bash chain —
|
|
94
|
+
* on the deciding unit, or on another asking unit of the same command. Only
|
|
95
|
+
* the child's parse knows it, so a forwarded ask carries it for the serving
|
|
96
|
+
* node to clamp its own `allow` with.
|
|
97
|
+
*/
|
|
98
|
+
floor?: string;
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
export function isPermissionState(value: unknown): value is PermissionState {
|