@gotgenes/pi-permission-system 39.0.3 → 39.1.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 CHANGED
@@ -5,6 +5,30 @@ 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.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.4...pi-permission-system-v39.1.0) (2026-10-05)
9
+
10
+
11
+ ### Features
12
+
13
+ * **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)
14
+ * **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))
15
+
16
+ ### Documentation
17
+
18
+ * **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))
19
+ * **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))
20
+ * **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))
21
+ * **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))
22
+ * **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))
23
+
24
+ ## [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)
25
+
26
+
27
+ ### Bug Fixes
28
+
29
+ * **pi-permission-system:** show a broken config file's error once, not twice, at session start ([67ddf4f](https://github.com/gotgenes/pi-packages/commit/67ddf4f637bd6f4e15246093dc3d1c459552c7f2)), closes [#953](https://github.com/gotgenes/pi-packages/issues/953)
30
+ * **pi-permission-system:** report a policy file that fails closed mid-session ([100a0d3](https://github.com/gotgenes/pi-packages/commit/100a0d3e1b49c92454163b270df52df7cc78ca1f)), closes [#953](https://github.com/gotgenes/pi-packages/issues/953)
31
+
8
32
  ## [39.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v39.0.2...pi-permission-system-v39.0.3) (2026-10-03)
9
33
 
10
34
 
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 v1 at the audited core alone.
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,12 +60,12 @@ 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 inner command is a proven pure reader.
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;
@@ -532,7 +532,7 @@ 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 one exception is a wrapper running a [pure-reader command](#wrapper-transparency), whose direction is provable however unknown its argument feed is.
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.
@@ -1018,7 +1018,12 @@ Two more shapes withdraw the claim:
1018
1018
  #### Wrapper transparency
1019
1019
 
1020
1020
  The [indirection-wrapper floor](#fail-closed-behavior) exists because a wrapper hides the command that should be gated.
1021
- For one 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.
1021
+ 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).
1022
+ Each resolves by the inner command's own `bash` rules, and the review log records which reason applied as `floorExemption`.
1023
+
1024
+ ##### A wrapper running a pure reader
1025
+
1026
+ 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
1027
  The floor guards unknowability of *scope*, and scope stays the path surfaces' job — for a wrapped command exactly as for a bare one.
1023
1028
 
1024
1029
  Such a unit is therefore **not** floored.
@@ -1034,7 +1039,7 @@ All four of these must hold, and each is a way the floor's reason could still ap
1034
1039
  4. The enclosing statement provably writes no file through a redirect.
1035
1040
  A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
1036
1041
 
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`, `time pnpm test`, and `find . -exec sh -c '…' \;` still prompt.
1042
+ 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
1043
 
1039
1044
  Three things this does **not** change:
1040
1045
 
@@ -1046,10 +1051,35 @@ Three things this does **not** change:
1046
1051
  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
1052
  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
1053
 
1054
+ ##### A wrapper that only modifies execution
1055
+
1056
+ `time`, `timeout`, `nice`, `stdbuf`, and `setsid` change only *how* the same visible command runs: its timing, kill deadline, scheduling, buffering, or session.
1057
+ 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.
1058
+ Such a unit resolves exactly as the command it runs, and the review log records `floorExemption: "execution-modifier"`.
1059
+
1060
+ All four of these must hold, and each keeps the decision about the command that really runs:
1061
+
1062
+ 1. **Every** wrapper layer is one of the five.
1063
+ `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).
1064
+ 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).
1065
+ A word the shell rewrites (`timeout $D …`, `timeout {5,sudo} …`) may become several, one of them a wrapper, so it keeps the floor.
1066
+ So `timeout --sig KILL 5 …` (an abbreviation) and `nice -5 …` stay floored, and so do `time`'s file-writing `-o`, `--output`, and `-a`.
1067
+ 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.
1068
+ 3. The command it runs is not itself a wrapper or an inline shell, so `timeout 5 bash -c '…'` stays floored.
1069
+ 4. The command it runs is named literally.
1070
+ `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.
1071
+
1072
+ 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.
1073
+ 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.
1074
+ When a unit qualifies for both exemptions (`time grep foo`), the review log records `core-reader`.
1075
+
1076
+ ##### Declarations and privilege
1077
+
1078
+ Neither exemption is extended by configuration.
1049
1079
  A user `commandEffects` declaration participates in effect classification but does **not** lift the floor.
1050
1080
  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
1081
 
1052
- `sudo` and `doas` are ordinary wrappers to this rule.
1082
+ `sudo` and `doas` are ordinary wrappers to the pure-reader rule, and never execution modifiers.
1053
1083
  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
1084
  If you run a permissive `bash` policy and want privilege elevation to prompt regardless, say so directly:
1055
1085
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "39.0.3",
3
+ "version": "39.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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: isTransparentWrapper(words, redirectedScope(node, scope))
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
- * True when a wrapper unit's floor has no reason left to hold: the command it
147
- * runs is in the pure-reader core, so its *direction* is provable however
148
- * unknown its argument feed is (ADR 0013 §11, #803).
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 isTransparentWrapper(
178
+ export function floorExemptionOf(
170
179
  words: readonly CommandWord[],
171
180
  statement: { readonly writesViaRedirect: boolean },
172
- ): boolean {
173
- if (statement.writesViaRedirect) return false;
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.layers === 0) return false;
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
- return proveCommandEffect(head, unwrapped.words.slice(1)).effect === "read";
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
- /** How many indirection layers came off; `0` means none did. */
204
- readonly layers: number;
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 isTransparentWrapper} declines it because the head word it
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
- let layers = 0;
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, layers };
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 === "--") return index + 1;
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.
@@ -101,7 +101,7 @@ export class ConfigStore
101
101
  *
102
102
  * This store answers; `ConfigIssueReporter` decides whether the operator has
103
103
  * heard it yet (#933). Not to be confused with
104
- * `PermissionResolver.getConfigIssues(agentName?)`, which answers for the
104
+ * `PermissionResolver.getPolicyIssues(agentName?)`, which answers for the
105
105
  * *policy* files rather than the extension config.
106
106
  */
107
107
  getConfigIssues(): readonly string[] {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * policy-issue-reporter.ts — Tell the operator what composing their policy
3
+ * revealed, once per notice, for as long as it holds.
4
+ *
5
+ * The policy side answers only what it alone knows: the fail-closed notice for
6
+ * a rejected non-global scope (its `allow` rules are clamped to `ask`) and the
7
+ * port notice for relocated MCP tool keys. A config file's own schema errors
8
+ * are `ConfigIssueReporter`'s; both reporters reading them showed the operator
9
+ * each one twice (#953).
10
+ *
11
+ * Policy is re-read from file mtimes whenever it is consulted, so a file broken
12
+ * mid-session is clamped mid-session. Reporting only at `session_start` left
13
+ * that clamp unexplained; this is driven there and on every
14
+ * `before_agent_start`, so the operator hears about it on the next turn.
15
+ *
16
+ * The notices are agent-scoped, so the agent name is an argument rather than
17
+ * something this reads for itself: `AgentPrepHandler` resolves it from the
18
+ * `<active_agent>` prompt tag — the only name a pi-subagents child has — after
19
+ * turn prep runs, and hands it here.
20
+ *
21
+ * The latch is `ConfigIssueReporter`'s: per notice, delivered as one joined
22
+ * message per report, and *replaced* each report rather than added to — so a
23
+ * file fixed and broken again, or an agent switched away from and back into
24
+ * with a broken scope, is announced again.
25
+ */
26
+
27
+ import type { ConfigIssueWarner } from "./config-issue-reporter";
28
+
29
+ /** The policy seam this reads (ISP): the notices composing an agent's policy produced. */
30
+ export interface PolicyIssueSource {
31
+ getPolicyIssues(agentName?: string): readonly string[];
32
+ }
33
+
34
+ /** The seam the session-start and agent-prep handlers drive. */
35
+ export interface PolicyIssueReporting {
36
+ report(agentName: string | undefined): void;
37
+ }
38
+
39
+ export class PolicyIssueReporter implements PolicyIssueReporting {
40
+ private reported: ReadonlySet<string> = new Set();
41
+
42
+ constructor(
43
+ private readonly source: PolicyIssueSource,
44
+ private readonly log: ConfigIssueWarner,
45
+ ) {}
46
+
47
+ report(agentName: string | undefined): void {
48
+ const current = this.source.getPolicyIssues(agentName);
49
+ const unreported = current.filter((issue) => !this.reported.has(issue));
50
+ if (unreported.length > 0) {
51
+ this.log.warn(unreported.join("\n"));
52
+ }
53
+ this.reported = new Set(current);
54
+ }
55
+ }
@@ -81,8 +81,8 @@ export interface ResolvedPolicyPaths {
81
81
 
82
82
  /**
83
83
  * Abstraction over file I/O for loading permission policy from disk.
84
- * Implementations handle caching, path resolution, and config-issue
85
- * accumulation. `PermissionManager` depends on this interface so that
84
+ * Implementations handle caching, path resolution, and marking a rejected
85
+ * scope `invalid`. `PermissionManager` depends on this interface so that
86
86
  * merge + evaluation logic can be tested with an in-memory stub.
87
87
  */
88
88
  export interface PolicyLoader {
@@ -93,8 +93,6 @@ export interface PolicyLoader {
93
93
  getConfiguredMcpServerNames(): readonly string[];
94
94
  /** Combined mtime stamp for cache invalidation. */
95
95
  getCacheStamp(agentName?: string): string;
96
- /** Accumulated config-parse issues across all loads. */
97
- getConfigIssues(): string[];
98
96
  /** Resolved paths for the /permission-system show command. */
99
97
  getResolvedPolicyPaths(): ResolvedPolicyPaths;
100
98
  }
@@ -172,7 +170,6 @@ export class FilePolicyLoader implements PolicyLoader {
172
170
  private configuredMcpServerNamesCache: FileCacheEntry<
173
171
  readonly string[]
174
172
  > | null = null;
175
- private accumulatedConfigIssues: string[] = [];
176
173
 
177
174
  constructor(options: PolicyLoaderOptions = {}) {
178
175
  this.globalConfigPath =
@@ -194,21 +191,11 @@ export class FilePolicyLoader implements PolicyLoader {
194
191
  : null;
195
192
  }
196
193
 
197
- // ── Config issue accumulation ────────────────────────────────────────
198
-
199
- private accumulateConfigIssues(issues: string[]): void {
200
- for (const issue of issues) {
201
- if (!this.accumulatedConfigIssues.includes(issue)) {
202
- this.accumulatedConfigIssues.push(issue);
203
- }
204
- }
205
- }
206
-
207
- getConfigIssues(): string[] {
208
- return [...this.accumulatedConfigIssues];
209
- }
210
-
211
194
  // ── Scope loaders ────────────────────────────────────────────────────
195
+ //
196
+ // A config file's schema errors are not kept here: `ConfigStore` loads the
197
+ // same files through the same `loadUnifiedConfig` and reports them, so this
198
+ // loader reads them only to decide whether a scope fails closed (#953).
212
199
 
213
200
  loadGlobalConfig(): ScopeConfig {
214
201
  const stamp = getFileStamp(this.globalConfigPath);
@@ -216,8 +203,7 @@ export class FilePolicyLoader implements PolicyLoader {
216
203
  return this.globalConfigCache.value;
217
204
  }
218
205
 
219
- const { config, issues } = loadUnifiedConfig(this.globalConfigPath);
220
- this.accumulateConfigIssues(issues);
206
+ const { config } = loadUnifiedConfig(this.globalConfigPath);
221
207
 
222
208
  const value: ScopeConfig = {
223
209
  permission: config.permission,
@@ -238,7 +224,6 @@ export class FilePolicyLoader implements PolicyLoader {
238
224
  }
239
225
 
240
226
  const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
241
- this.accumulateConfigIssues(issues);
242
227
 
243
228
  // A present-but-rejected file yields issues (parse error or schema
244
229
  // rejection); an absent file yields none. Fail closed on the former.
@@ -4,6 +4,7 @@ import type {
4
4
  NormalizedBuildSystemPromptOptions,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import type { SubagentDetector } from "#src/authority/subagent-detection";
7
+ import type { PolicyIssueReporting } from "#src/config/policy-issue-reporter";
7
8
  import {
8
9
  visibleSkillPromptEntries,
9
10
  withoutDeniedSkills,
@@ -78,6 +79,10 @@ export function shouldExposeTool(
78
79
  * - `logger` — records each change to the effective tool surface
79
80
  * - `detector` — tells a subagent child from a root, which decides whether a
80
81
  * custom prompt gets this node's tool surface
82
+ * - `policyIssues` — reports what composing policy revealed for the agent the
83
+ * prompt names; driven here rather than in turn prep because the
84
+ * `<active_agent>` tag, the only name a pi-subagents child has, is read here
85
+ * (#953)
81
86
  *
82
87
  * The active set is recomputed from the session's pre-filter tool surface
83
88
  * every turn, so relaxing a rule restores the tool it had withheld (#873).
@@ -90,6 +95,7 @@ export class AgentPrepHandler {
90
95
  private readonly toolRegistry: ToolRegistry,
91
96
  private readonly logger: DebugLogger,
92
97
  private readonly detector: SubagentDetector,
98
+ private readonly policyIssues: PolicyIssueReporting,
93
99
  ) {}
94
100
 
95
101
  // eslint-disable-next-line @typescript-eslint/require-await
@@ -105,6 +111,9 @@ export class AgentPrepHandler {
105
111
  ? event.systemPrompt
106
112
  : event.systemPrompt.join("\n");
107
113
  const agentName = this.session.resolveAgentName(ctx, systemPrompt);
114
+ // Policy is re-read by mtime, so this turn may compose a clamp the file
115
+ // just caused; say so for the agent this turn runs as.
116
+ this.policyIssues.report(agentName ?? undefined);
108
117
  const registered = readRegisteredTools(this.toolRegistry.getAll());
109
118
  const surface = this.session.resolveExposedTools(
110
119
  this.observeToolSurface(registered),
@@ -183,7 +183,8 @@ function floorUnparsedUnit(
183
183
  * is clamped up to a synthetic `ask` naming the kind that caused it — unless
184
184
  * the enumerator established that the floor has no reason left to hold, in
185
185
  * which case the unit is resolved by the rules of the command it runs (ADR 0013
186
- * §11, #803).
186
+ * §11): its inner command is a proven pure reader (#803), or every wrapper
187
+ * layer only modifies how that command runs (#963).
187
188
  *
188
189
  * Only an `allow` reaches here, which is what makes the exemption unable to
189
190
  * weaken anything: an explicit `deny` or `ask` on the wrapper is decided before
@@ -1,10 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AskDialogRelease } from "#src/authority/ask-dialog-queue";
3
3
  import type { ConfigIssueReporting } from "#src/config/config-issue-reporter";
4
+ import type { PolicyIssueReporting } from "#src/config/policy-issue-reporter";
4
5
  import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/config/status";
5
6
  import type { DecisionSummaryWriter } from "#src/logging/decision-audit";
6
7
  import type { SessionLogger } from "#src/logging/session-logger";
7
- import type { PermissionResolver } from "#src/policy/permission-resolver";
8
8
  import type { ServiceLifecycle } from "#src/service/service-lifecycle";
9
9
  import type { PermissionSession } from "#src/session/permission-session";
10
10
 
@@ -41,7 +41,9 @@ export const UNTRUSTED_PROJECT_MESSAGE =
41
41
  *
42
42
  * Constructor deps:
43
43
  * - `session` — encapsulates all mutable session state and lifecycle operations
44
- * - `resolver` — owns permission-query surface: `getConfigIssues`
44
+ * - `policyIssues` — reports what composing policy revealed (a fail-closed clamp,
45
+ * a port notice) for the agent the session names, latched per notice; driven
46
+ * here and on every turn, so a clamp caused mid-session is explained (#953)
45
47
  * - `serviceLifecycle` — owns the process-global service publication;
46
48
  * `activate` publishes (skipped for registered subagent children) and emits
47
49
  * the ready event; `teardown` unsubscribes all session listeners and unpublishes
@@ -56,7 +58,7 @@ export const UNTRUSTED_PROJECT_MESSAGE =
56
58
  export class SessionLifecycleHandler {
57
59
  constructor(
58
60
  private readonly session: PermissionSession,
59
- private readonly resolver: PermissionResolver,
61
+ private readonly policyIssues: PolicyIssueReporting,
60
62
  private readonly serviceLifecycle: ServiceLifecycle,
61
63
  private readonly logger: SessionLogger,
62
64
  private readonly audit: DecisionSummaryWriter,
@@ -84,10 +86,7 @@ export class SessionLifecycleHandler {
84
86
  }
85
87
 
86
88
  const agentName = this.session.resolveAgentName(ctx);
87
- const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
88
- for (const issue of policyIssues) {
89
- this.logger.warn(issue);
90
- }
89
+ this.policyIssues.report(agentName ?? undefined);
91
90
 
92
91
  if (event.reason === "reload") {
93
92
  this.logger.debug("lifecycle.reload", {
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import { ConfigStore } from "#src/config/config-store";
41
41
  import { resolveDialogKeys } from "#src/config/dialog-keys";
42
42
  import { isYoloModeEnabled } from "#src/config/extension-config";
43
43
  import { computeExtensionPaths } from "#src/config/extension-paths";
44
+ import { PolicyIssueReporter } from "#src/config/policy-issue-reporter";
44
45
  import { GateRunner } from "#src/handlers/gates/runner";
45
46
  import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
46
47
  import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
@@ -322,9 +323,12 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
322
323
  // was recorded as delivered and never shown (#933). Driven at session_start
323
324
  // and on every turn; the latch keeps an unchanged issue quiet.
324
325
  const configIssueReporter = new ConfigIssueReporter(configStore, logger);
326
+ // What composing policy revealed (a fail-closed clamp, a port notice) is
327
+ // agent-scoped, so each driver hands over the agent name it resolved (#953).
328
+ const policyIssueReporter = new PolicyIssueReporter(resolver, logger);
325
329
  const lifecycle = new SessionLifecycleHandler(
326
330
  session,
327
- resolver,
331
+ policyIssueReporter,
328
332
  serviceLifecycle,
329
333
  logger,
330
334
  audit,
@@ -346,6 +350,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
346
350
  toolRegistry,
347
351
  logger,
348
352
  subagentDetection,
353
+ policyIssueReporter,
349
354
  );
350
355
 
351
356
  const gateRunner = new GateRunner(
@@ -62,12 +62,12 @@ type ResolvedPermissions = {
62
62
  /**
63
63
  * Non-global scopes whose config file failed to load or validate. When
64
64
  * non-empty the composed ruleset has been floored allow→ask (#646); the
65
- * names also drive the fail-closed notice in {@link getConfigIssues}.
65
+ * names also drive the fail-closed notice in {@link getPolicyIssues}.
66
66
  */
67
67
  failClosedScopes: RuleOrigin[];
68
68
  /**
69
69
  * Top-level permission keys naming Pi MCP tools, relocated onto the `mcp`
70
- * surface; they drive the port notice in {@link getConfigIssues}.
70
+ * surface; they drive the port notice in {@link getPolicyIssues}.
71
71
  */
72
72
  legacyMcpToolKeys: string[];
73
73
  };
@@ -92,7 +92,7 @@ export interface ScopedPermissionManager {
92
92
  ): PermissionCheckResult;
93
93
  getToolPermission(toolName: string, agentName?: string): PermissionState;
94
94
  isToolFullyDenied(toolName: string, agentName?: string): boolean;
95
- getConfigIssues(agentName?: string): string[];
95
+ getPolicyIssues(agentName?: string): string[];
96
96
  }
97
97
 
98
98
  export interface PermissionManagerOptions extends PolicyLoaderOptions {
@@ -158,11 +158,20 @@ export class PermissionManager implements ScopedPermissionManager {
158
158
  this.resolvedPermissionsCache.clear();
159
159
  }
160
160
 
161
- getConfigIssues(agentName?: string): string[] {
162
- // Trigger a load/resolve to ensure issues are collected.
161
+ /**
162
+ * What composing `agentName`'s policy revealed: the fail-closed notice for
163
+ * a rejected non-global scope and the port notice for relocated MCP tool
164
+ * keys. Recomputed on every resolve, so a notice disappears once its cause is
165
+ * fixed.
166
+ *
167
+ * A config file's own schema errors are not here: `ConfigStore` loads the
168
+ * same files through the same `loadUnifiedConfig` and owns reporting them,
169
+ * so listing them here too showed the operator each one twice (#953).
170
+ */
171
+ getPolicyIssues(agentName?: string): string[] {
163
172
  const { failClosedScopes, legacyMcpToolKeys } =
164
173
  this.resolvePermissions(agentName);
165
- const issues = [...this.loader.getConfigIssues()];
174
+ const issues: string[] = [];
166
175
  if (failClosedScopes.length > 0) {
167
176
  issues.push(
168
177
  `Invalid ${failClosedScopes.join(", ")} configuration detected — ` +
@@ -4,6 +4,7 @@ import type {
4
4
  ResolvedAccessIntent,
5
5
  } from "#src/access-intent/access-intent";
6
6
  import { surfaceFamilyMembers } from "#src/access-intent/path-surfaces";
7
+ import type { PolicyIssueSource } from "#src/config/policy-issue-reporter";
7
8
  import type { SkillPermissionChecker } from "#src/exposure/skill-prompt-sanitizer";
8
9
  import type { SessionRules } from "#src/session/session-rules";
9
10
  import type { PermissionCheckResult, PermissionState } from "#src/types";
@@ -64,7 +65,7 @@ function toResolvedIntent(
64
65
  * - `sessionRules` — narrowed to `getRuleset` (ISP: the resolver only reads, never records)
65
66
  */
66
67
  export class PermissionResolver
67
- implements ScopedPermissionResolver, SkillPermissionChecker
68
+ implements ScopedPermissionResolver, SkillPermissionChecker, PolicyIssueSource
68
69
  {
69
70
  constructor(
70
71
  private readonly permissionManager: ScopedPermissionManager,
@@ -139,7 +140,7 @@ export class PermissionResolver
139
140
  return this.permissionManager.isToolFullyDenied(toolName, agentName);
140
141
  }
141
142
 
142
- getConfigIssues(agentName?: string): string[] {
143
- return this.permissionManager.getConfigIssues(agentName);
143
+ getPolicyIssues(agentName?: string): string[] {
144
+ return this.permissionManager.getPolicyIssues(agentName);
144
145
  }
145
146
  }
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 v1 at the audited core alone.
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,12 +80,12 @@ 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 inner command is a proven pure reader.
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;