@gotgenes/pi-permission-system 4.9.0 → 5.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 +37 -0
- package/package.json +1 -1
- package/src/config-modal.ts +25 -3
- package/src/external-directory.ts +238 -14
- package/src/index.ts +4 -0
- package/src/normalize.ts +2 -2
- package/src/permission-manager.ts +72 -17
- package/src/rule.ts +26 -2
- package/src/session-rules.ts +7 -1
- package/src/synthesize.ts +7 -2
- package/src/tool-input-preview.ts +7 -1
- package/src/types.ts +6 -0
- package/tests/bash-external-directory.test.ts +227 -0
- package/tests/config-modal.test.ts +83 -0
- package/tests/handlers/tool-call.test.ts +2 -1
- package/tests/normalize.test.ts +64 -22
- package/tests/permission-manager-unified.test.ts +215 -0
- package/tests/permission-prompts.test.ts +8 -1
- package/tests/permission-system.test.ts +12 -0
- package/tests/rule.test.ts +76 -8
- package/tests/session-rules.test.ts +7 -1
- package/tests/skill-prompt-sanitizer.test.ts +1 -1
- package/tests/synthesize.test.ts +64 -4
- package/tests/tool-input-preview.test.ts +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,43 @@ 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
|
+
## [5.1.0](https://github.com/gotgenes/pi-permission-system/compare/v5.0.0...v5.1.0) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* command-aware path extraction for pattern-first commands ([#91](https://github.com/gotgenes/pi-permission-system/issues/91)) ([befca23](https://github.com/gotgenes/pi-permission-system/commit/befca2341e1b54d9ed7e6ff3c3d465776afcc50d))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* plan command-aware path extraction for sed/awk/grep/rg/sd ([#91](https://github.com/gotgenes/pi-permission-system/issues/91)) ([be88a6a](https://github.com/gotgenes/pi-permission-system/commit/be88a6ab66ab386ce5843b3dd12218fc7968ee15))
|
|
19
|
+
* **retro:** add retro notes for issue [#88](https://github.com/gotgenes/pi-permission-system/issues/88) ([453a8ba](https://github.com/gotgenes/pi-permission-system/commit/453a8ba69fb68f24200be7a604f4fac4738c0cfe))
|
|
20
|
+
|
|
21
|
+
## [5.0.0](https://github.com/gotgenes/pi-permission-system/compare/v4.9.0...v5.0.0) (2026-05-05)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### ⚠ BREAKING CHANGES
|
|
25
|
+
|
|
26
|
+
* Rule.origin and PermissionCheckResult.origin are now required fields. Code that constructs Rule or PermissionCheckResult literals must include an origin value.
|
|
27
|
+
|
|
28
|
+
### Features
|
|
29
|
+
|
|
30
|
+
* add RuleOrigin type and origin field to Rule ([b4452d1](https://github.com/gotgenes/pi-permission-system/commit/b4452d1cc9e87a8315edcd6f5f2b1425310bd0b6))
|
|
31
|
+
* display rule origins in /permission-system show output ([af34c8e](https://github.com/gotgenes/pi-permission-system/commit/af34c8e808c7fa67bbe68635f776ec0fd8717bfa))
|
|
32
|
+
* include rule origin in permission review log entries ([b19fdf6](https://github.com/gotgenes/pi-permission-system/commit/b19fdf69b48248430410643ee20bee58535b99d9))
|
|
33
|
+
* make Rule.origin and PermissionCheckResult.origin required ([937a9f5](https://github.com/gotgenes/pi-permission-system/commit/937a9f5c4a9442611606fa3b27962555ed8c25a9))
|
|
34
|
+
* propagate origin to synthesized default rule ([04f9130](https://github.com/gotgenes/pi-permission-system/commit/04f91304ec5ba975ac512989c90757528a30ef7b))
|
|
35
|
+
* track and propagate rule origin through checkPermission ([327bc60](https://github.com/gotgenes/pi-permission-system/commit/327bc60e7f79aafd19995337f62244fd8b0c191f))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
* plan rule origin provenance tracking ([#88](https://github.com/gotgenes/pi-permission-system/issues/88)) ([d8f8840](https://github.com/gotgenes/pi-permission-system/commit/d8f884028f03682a896dd0d6e8e5a335d8e669f5))
|
|
41
|
+
* **retro:** add retro notes for issue [#48](https://github.com/gotgenes/pi-permission-system/issues/48) ([2187a53](https://github.com/gotgenes/pi-permission-system/commit/2187a53d2af30d6a68f664b1ce4af0dc30b39061))
|
|
42
|
+
* update target architecture for required Rule.origin ([edf0620](https://github.com/gotgenes/pi-permission-system/commit/edf06209ce148b70131a5abf361070571db51e7b))
|
|
43
|
+
* update target architecture for rule origin provenance ([c82435b](https://github.com/gotgenes/pi-permission-system/commit/c82435bb75dd7b22331986c6a23bfe5cf1849ca7))
|
|
44
|
+
|
|
8
45
|
## [4.9.0](https://github.com/gotgenes/pi-permission-system/compare/v4.8.0...v4.9.0) (2026-05-05)
|
|
9
46
|
|
|
10
47
|
|
package/package.json
CHANGED
package/src/config-modal.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_EXTENSION_CONFIG,
|
|
10
10
|
type PermissionSystemExtensionConfig,
|
|
11
11
|
} from "./extension-config";
|
|
12
|
+
import type { Ruleset } from "./rule";
|
|
12
13
|
|
|
13
14
|
interface PermissionSystemConfigController {
|
|
14
15
|
getConfig(): PermissionSystemExtensionConfig;
|
|
@@ -17,6 +18,8 @@ interface PermissionSystemConfigController {
|
|
|
17
18
|
ctx: ExtensionCommandContext,
|
|
18
19
|
): void;
|
|
19
20
|
getConfigPath(): string;
|
|
21
|
+
/** Optional: returns the composed config-layer ruleset for origin display. */
|
|
22
|
+
getComposedRules?(): Ruleset;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const ON_OFF = ["on", "off"];
|
|
@@ -57,12 +60,30 @@ function toOnOff(value: boolean): string {
|
|
|
57
60
|
return value ? "on" : "off";
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
function
|
|
61
|
-
|
|
63
|
+
function formatRulesSummary(rules: Ruleset): string {
|
|
64
|
+
const configRules = rules.filter((r) => r.layer === "config" && r.origin);
|
|
65
|
+
if (configRules.length === 0) return "";
|
|
66
|
+
const formatted = configRules
|
|
67
|
+
.map((r) => {
|
|
68
|
+
const key =
|
|
69
|
+
r.pattern === "*" ? r.surface : `${r.surface}["${r.pattern}"]`;
|
|
70
|
+
return `${key}=${r.action} (${r.origin})`;
|
|
71
|
+
})
|
|
72
|
+
.join(", ");
|
|
73
|
+
return `\n rules: ${formatted}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function summarizeConfig(
|
|
77
|
+
config: PermissionSystemExtensionConfig,
|
|
78
|
+
rules?: Ruleset,
|
|
79
|
+
): string {
|
|
80
|
+
const knobs = [
|
|
62
81
|
`yoloMode=${toOnOff(config.yoloMode)}`,
|
|
63
82
|
`permissionReviewLog=${toOnOff(config.permissionReviewLog)}`,
|
|
64
83
|
`debugLog=${toOnOff(config.debugLog)}`,
|
|
65
84
|
].join(", ");
|
|
85
|
+
const rulesSuffix = rules ? formatRulesSummary(rules) : "";
|
|
86
|
+
return `${knobs}${rulesSuffix}`;
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
function buildSettingItems(
|
|
@@ -183,8 +204,9 @@ function handleArgs(
|
|
|
183
204
|
}
|
|
184
205
|
|
|
185
206
|
if (normalized === "show") {
|
|
207
|
+
const rules = controller.getComposedRules?.();
|
|
186
208
|
ctx.ui.notify(
|
|
187
|
-
`permission-system: ${summarizeConfig(controller.getConfig())}`,
|
|
209
|
+
`permission-system: ${summarizeConfig(controller.getConfig(), rules)}`,
|
|
188
210
|
"info",
|
|
189
211
|
);
|
|
190
212
|
return true;
|
|
@@ -352,17 +352,252 @@ function resolveNodeText(node: TSNode): string {
|
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
// ── Pattern-first command config ───────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
interface PatternCommandConfig {
|
|
358
|
+
/** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
|
|
359
|
+
readonly argConsumingFlags: ReadonlySet<string>;
|
|
360
|
+
/** Flags that consume the next argument as a file path */
|
|
361
|
+
readonly fileConsumingFlags: ReadonlySet<string>;
|
|
362
|
+
/**
|
|
363
|
+
* Number of leading positional arguments that are patterns/scripts, not paths.
|
|
364
|
+
* Default: 1 (covers sed, awk, grep, rg).
|
|
365
|
+
* sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
|
|
366
|
+
*/
|
|
367
|
+
readonly patternPositionals?: number;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Commands whose first N positional arguments are inline patterns/scripts,
|
|
372
|
+
* not filesystem paths. The map stores per-command flag configuration so
|
|
373
|
+
* the walker can correctly identify which arguments are consumed by flags
|
|
374
|
+
* vs. which are positional.
|
|
375
|
+
*/
|
|
376
|
+
const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
|
|
377
|
+
new Map([
|
|
378
|
+
[
|
|
379
|
+
"sed",
|
|
380
|
+
{
|
|
381
|
+
argConsumingFlags: new Set(["-e", "-i"]),
|
|
382
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
[
|
|
386
|
+
"awk",
|
|
387
|
+
{
|
|
388
|
+
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
389
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
[
|
|
393
|
+
"gawk",
|
|
394
|
+
{
|
|
395
|
+
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
396
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
[
|
|
400
|
+
"nawk",
|
|
401
|
+
{
|
|
402
|
+
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
403
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
[
|
|
407
|
+
"grep",
|
|
408
|
+
{
|
|
409
|
+
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
410
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
[
|
|
414
|
+
"egrep",
|
|
415
|
+
{
|
|
416
|
+
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
417
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
[
|
|
421
|
+
"fgrep",
|
|
422
|
+
{
|
|
423
|
+
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
424
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
[
|
|
428
|
+
"rg",
|
|
429
|
+
{
|
|
430
|
+
argConsumingFlags: new Set([
|
|
431
|
+
"-e",
|
|
432
|
+
"-A",
|
|
433
|
+
"-B",
|
|
434
|
+
"-C",
|
|
435
|
+
"-m",
|
|
436
|
+
"-g",
|
|
437
|
+
"-t",
|
|
438
|
+
"-T",
|
|
439
|
+
"-j",
|
|
440
|
+
"-M",
|
|
441
|
+
"-r",
|
|
442
|
+
"-E",
|
|
443
|
+
]),
|
|
444
|
+
fileConsumingFlags: new Set(["-f"]),
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
[
|
|
448
|
+
"sd",
|
|
449
|
+
{
|
|
450
|
+
argConsumingFlags: new Set(["-n", "-f"]),
|
|
451
|
+
fileConsumingFlags: new Set([]),
|
|
452
|
+
patternPositionals: 2,
|
|
453
|
+
},
|
|
454
|
+
],
|
|
455
|
+
]);
|
|
456
|
+
|
|
457
|
+
/** Node types that represent argument values in the AST. */
|
|
458
|
+
const ARG_NODE_TYPES = new Set([
|
|
459
|
+
"word",
|
|
460
|
+
"concatenation",
|
|
461
|
+
"string",
|
|
462
|
+
"raw_string",
|
|
463
|
+
]);
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Extract the command name from a `command` node.
|
|
467
|
+
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
|
|
468
|
+
* if the command name cannot be determined (e.g. variable expansion).
|
|
469
|
+
*/
|
|
470
|
+
function extractCommandName(node: TSNode): string | undefined {
|
|
471
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
472
|
+
const child = node.child(i);
|
|
473
|
+
if (!child) continue;
|
|
474
|
+
if (child.type === "command_name") {
|
|
475
|
+
const text = resolveNodeText(child);
|
|
476
|
+
return text ? basename(text) : undefined;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Collect path-candidate tokens from a command known to have
|
|
484
|
+
* pattern/script arguments in leading positional slots.
|
|
485
|
+
*
|
|
486
|
+
* Uses position-based skipping: the first N positional arguments
|
|
487
|
+
* (where N = patternPositionals, default 1) are assumed to be
|
|
488
|
+
* inline patterns/scripts and are skipped. Remaining positional
|
|
489
|
+
* arguments are collected as path candidates.
|
|
490
|
+
*
|
|
491
|
+
* Flags listed in `argConsumingFlags` consume the next argument
|
|
492
|
+
* (skipped). Flags in `fileConsumingFlags` consume the next
|
|
493
|
+
* argument as a file path (collected). The flags `-e` and `-f`
|
|
494
|
+
* additionally signal that an explicit script was provided via
|
|
495
|
+
* flag, so no inline positional script is expected.
|
|
496
|
+
*/
|
|
497
|
+
function collectPatternCommandTokens(
|
|
498
|
+
node: TSNode,
|
|
499
|
+
tokens: string[],
|
|
500
|
+
config: PatternCommandConfig,
|
|
501
|
+
): void {
|
|
502
|
+
const patternPositionals = config.patternPositionals ?? 1;
|
|
503
|
+
let hasExplicitScript = false;
|
|
504
|
+
let positionalsSeen = 0;
|
|
505
|
+
let nextArgAction: "skip" | "extract" | null = null;
|
|
506
|
+
let pastEndOfFlags = false;
|
|
507
|
+
|
|
508
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
509
|
+
const child = node.child(i);
|
|
510
|
+
if (!child) continue;
|
|
511
|
+
|
|
512
|
+
// Skip command_name and variable_assignment nodes.
|
|
513
|
+
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
514
|
+
continue;
|
|
515
|
+
|
|
516
|
+
// Only process argument-like nodes; recurse into others
|
|
517
|
+
// (e.g. command_substitution) for nested commands.
|
|
518
|
+
if (!ARG_NODE_TYPES.has(child.type)) {
|
|
519
|
+
collectPathCandidateTokens(child, tokens);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const text = resolveNodeText(child);
|
|
524
|
+
|
|
525
|
+
// Handle consumed argument from previous flag.
|
|
526
|
+
if (nextArgAction === "skip") {
|
|
527
|
+
nextArgAction = null;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (nextArgAction === "extract") {
|
|
531
|
+
tokens.push(text);
|
|
532
|
+
nextArgAction = null;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Flag detection (only before "--" end-of-flags marker).
|
|
537
|
+
if (
|
|
538
|
+
!pastEndOfFlags &&
|
|
539
|
+
child.type === "word" &&
|
|
540
|
+
text.startsWith("-") &&
|
|
541
|
+
text.length > 1
|
|
542
|
+
) {
|
|
543
|
+
if (text === "--") {
|
|
544
|
+
pastEndOfFlags = true;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
if (config.argConsumingFlags.has(text)) {
|
|
548
|
+
nextArgAction = "skip";
|
|
549
|
+
if (text === "-e" || text === "-f") {
|
|
550
|
+
hasExplicitScript = true;
|
|
551
|
+
}
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (config.fileConsumingFlags.has(text)) {
|
|
555
|
+
nextArgAction = "extract";
|
|
556
|
+
hasExplicitScript = true;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
// Regular flag — skip it.
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Positional argument.
|
|
564
|
+
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
|
565
|
+
positionalsSeen++;
|
|
566
|
+
continue; // Skip: this is an inline pattern/script.
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// File argument — collect as path candidate.
|
|
570
|
+
tokens.push(text);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
355
574
|
/**
|
|
356
575
|
* Recursively visit the AST and collect resolved text of nodes that
|
|
357
576
|
* represent command arguments or redirect destinations.
|
|
358
577
|
*
|
|
359
578
|
* Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
|
|
579
|
+
*
|
|
580
|
+
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
|
|
581
|
+
* argument skipping to avoid collecting inline patterns/scripts
|
|
582
|
+
* as path candidates. For all other commands, collects all
|
|
583
|
+
* arguments generically.
|
|
360
584
|
*/
|
|
361
585
|
function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
|
|
362
586
|
if (SKIP_SUBTREE_TYPES.has(node.type)) return;
|
|
363
587
|
|
|
364
|
-
// Extract arguments from `command` nodes
|
|
588
|
+
// Extract arguments from `command` nodes.
|
|
365
589
|
if (node.type === "command") {
|
|
590
|
+
const commandName = extractCommandName(node);
|
|
591
|
+
const patternConfig = commandName
|
|
592
|
+
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
593
|
+
: undefined;
|
|
594
|
+
|
|
595
|
+
if (patternConfig) {
|
|
596
|
+
collectPatternCommandTokens(node, tokens, patternConfig);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Generic extraction: collect all arguments (skip command name).
|
|
366
601
|
let seenCommandName = false;
|
|
367
602
|
for (let i = 0; i < node.childCount; i++) {
|
|
368
603
|
const child = node.child(i);
|
|
@@ -377,24 +612,13 @@ function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
|
|
|
377
612
|
|
|
378
613
|
// If there was no explicit command_name node, the first word-like
|
|
379
614
|
// child is the command name itself — skip it.
|
|
380
|
-
if (
|
|
381
|
-
!seenCommandName &&
|
|
382
|
-
(child.type === "word" ||
|
|
383
|
-
child.type === "concatenation" ||
|
|
384
|
-
child.type === "string" ||
|
|
385
|
-
child.type === "raw_string")
|
|
386
|
-
) {
|
|
615
|
+
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
|
|
387
616
|
seenCommandName = true;
|
|
388
617
|
continue;
|
|
389
618
|
}
|
|
390
619
|
|
|
391
620
|
// Argument nodes: resolve their text and collect.
|
|
392
|
-
if (
|
|
393
|
-
child.type === "word" ||
|
|
394
|
-
child.type === "concatenation" ||
|
|
395
|
-
child.type === "string" ||
|
|
396
|
-
child.type === "raw_string"
|
|
397
|
-
) {
|
|
621
|
+
if (ARG_NODE_TYPES.has(child.type)) {
|
|
398
622
|
tokens.push(resolveNodeText(child));
|
|
399
623
|
continue;
|
|
400
624
|
}
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
58
58
|
getConfig: () => runtime.config,
|
|
59
59
|
setConfig: (next, ctx) => saveExtensionConfig(runtime, next, ctx),
|
|
60
60
|
getConfigPath: () => getGlobalConfigPath(runtime.agentDir),
|
|
61
|
+
getComposedRules: () =>
|
|
62
|
+
runtime.permissionManager.getComposedConfigRules(
|
|
63
|
+
runtime.lastKnownActiveAgentName ?? undefined,
|
|
64
|
+
),
|
|
61
65
|
});
|
|
62
66
|
|
|
63
67
|
const createPermissionRequestId = (prefix: string): string =>
|
package/src/normalize.ts
CHANGED
|
@@ -18,12 +18,12 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
|
|
|
18
18
|
for (const [surface, value] of Object.entries(permission)) {
|
|
19
19
|
if (typeof value === "string") {
|
|
20
20
|
if (isPermissionState(value)) {
|
|
21
|
-
rules.push({ surface, pattern: "*", action: value });
|
|
21
|
+
rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
|
|
22
22
|
}
|
|
23
23
|
} else if (typeof value === "object" && value !== null) {
|
|
24
24
|
for (const [pattern, action] of Object.entries(value)) {
|
|
25
25
|
if (isPermissionState(action)) {
|
|
26
|
-
rules.push({ surface, pattern, action });
|
|
26
|
+
rules.push({ surface, pattern, action, origin: "builtin" });
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
}
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { getGlobalConfigPath } from "./config-paths";
|
|
17
17
|
import { normalizeInput } from "./input-normalizer";
|
|
18
18
|
import { normalizeFlatConfig } from "./normalize";
|
|
19
|
-
import type { Rule, Ruleset } from "./rule";
|
|
19
|
+
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
20
20
|
import { evaluate, evaluateFirst } from "./rule";
|
|
21
21
|
import {
|
|
22
22
|
composeRuleset,
|
|
@@ -354,19 +354,55 @@ export class PermissionManager {
|
|
|
354
354
|
const projectAgentConfig = this.loadProjectScopeConfig(agentName);
|
|
355
355
|
|
|
356
356
|
// Merge permission objects across scopes (lowest → highest precedence).
|
|
357
|
+
// Build a parallel origin map that tracks which scope contributed each
|
|
358
|
+
// (surface, pattern) entry, mirroring mergeFlatPermissions() semantics.
|
|
359
|
+
type OriginMap = Map<string, Map<string, RuleOrigin>>;
|
|
360
|
+
const origins: OriginMap = new Map();
|
|
357
361
|
let mergedPermission: FlatPermissionConfig = {};
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
362
|
+
|
|
363
|
+
for (const [scopeName, scope] of [
|
|
364
|
+
["global", globalConfig],
|
|
365
|
+
["project", projectConfig],
|
|
366
|
+
["agent", agentConfig],
|
|
367
|
+
["project-agent", projectAgentConfig],
|
|
368
|
+
] as const) {
|
|
369
|
+
if (!scope.permission) continue;
|
|
370
|
+
|
|
371
|
+
for (const [surface, value] of Object.entries(scope.permission)) {
|
|
372
|
+
const baseVal = mergedPermission[surface];
|
|
373
|
+
const bothObjects =
|
|
374
|
+
typeof baseVal === "object" &&
|
|
375
|
+
baseVal !== null &&
|
|
376
|
+
typeof value === "object" &&
|
|
377
|
+
value !== null;
|
|
378
|
+
|
|
379
|
+
if (bothObjects) {
|
|
380
|
+
// Shallow-merge: each incoming pattern is attributed to this scope;
|
|
381
|
+
// existing patterns from lower scopes keep their earlier origin.
|
|
382
|
+
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
383
|
+
for (const pattern of Object.keys(value as Record<string, unknown>)) {
|
|
384
|
+
origins.get(surface)!.set(pattern, scopeName);
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
// Full replacement: this scope takes over the entire surface entry.
|
|
388
|
+
const surfaceOrigins = new Map<string, RuleOrigin>();
|
|
389
|
+
if (typeof value === "string") {
|
|
390
|
+
surfaceOrigins.set("*", scopeName);
|
|
391
|
+
} else if (typeof value === "object" && value !== null) {
|
|
392
|
+
for (const pattern of Object.keys(
|
|
393
|
+
value as Record<string, unknown>,
|
|
394
|
+
)) {
|
|
395
|
+
surfaceOrigins.set(pattern, scopeName);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
origins.set(surface, surfaceOrigins);
|
|
399
|
+
}
|
|
369
400
|
}
|
|
401
|
+
|
|
402
|
+
mergedPermission = mergeFlatPermissions(
|
|
403
|
+
mergedPermission,
|
|
404
|
+
scope.permission,
|
|
405
|
+
);
|
|
370
406
|
}
|
|
371
407
|
|
|
372
408
|
// Extract the universal fallback from permission["*"].
|
|
@@ -375,19 +411,28 @@ export class PermissionManager {
|
|
|
375
411
|
const universalFallback = isPermissionState(mergedPermission["*"])
|
|
376
412
|
? (mergedPermission["*"] as PermissionState)
|
|
377
413
|
: DEFAULT_UNIVERSAL_FALLBACK;
|
|
414
|
+
// Track which scope contributed the universal fallback.
|
|
415
|
+
const universalFallbackOrigin: RuleOrigin =
|
|
416
|
+
origins.get("*")?.get("*") ?? "builtin";
|
|
378
417
|
|
|
379
418
|
// Build config rules from everything except the universal "*" key.
|
|
380
419
|
const permissionWithoutUniversal: FlatPermissionConfig = Object.fromEntries(
|
|
381
420
|
Object.entries(mergedPermission).filter(([k]) => k !== "*"),
|
|
382
421
|
);
|
|
383
422
|
|
|
384
|
-
// Normalize to config rules, tagged with "config" layer.
|
|
423
|
+
// Normalize to config rules, tagged with "config" layer and their origin.
|
|
385
424
|
const configRules: Ruleset = normalizeFlatConfig(
|
|
386
425
|
permissionWithoutUniversal,
|
|
387
|
-
).map(
|
|
426
|
+
).map(
|
|
427
|
+
(r): Rule => ({
|
|
428
|
+
...r,
|
|
429
|
+
layer: "config",
|
|
430
|
+
origin: origins.get(r.surface)?.get(r.pattern) ?? "builtin",
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
388
433
|
|
|
389
434
|
const composedRules = composeRuleset(
|
|
390
|
-
synthesizeDefaults(universalFallback),
|
|
435
|
+
synthesizeDefaults(universalFallback, universalFallbackOrigin),
|
|
391
436
|
synthesizeBaseline(configRules),
|
|
392
437
|
configRules,
|
|
393
438
|
);
|
|
@@ -415,6 +460,17 @@ export class PermissionManager {
|
|
|
415
460
|
return value;
|
|
416
461
|
}
|
|
417
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Return the composed config-layer rules for the given agent scope.
|
|
465
|
+
* Used by the `/permission-system show` command to display effective rules
|
|
466
|
+
* with their origin annotations.
|
|
467
|
+
* Session rules are not included — they are runtime-only.
|
|
468
|
+
*/
|
|
469
|
+
getComposedConfigRules(agentName?: string): Ruleset {
|
|
470
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
471
|
+
return composedRules.filter((r) => r.layer === "config");
|
|
472
|
+
}
|
|
473
|
+
|
|
418
474
|
/**
|
|
419
475
|
* Get the tool-level permission state for a tool, without considering
|
|
420
476
|
* command-level rules. Used for tool injection decisions.
|
|
@@ -480,6 +536,7 @@ export class PermissionManager {
|
|
|
480
536
|
? rule.pattern
|
|
481
537
|
: undefined,
|
|
482
538
|
source: deriveSource(rule, normalizedToolName),
|
|
539
|
+
origin: rule.origin,
|
|
483
540
|
...extras,
|
|
484
541
|
};
|
|
485
542
|
}
|
|
@@ -493,7 +550,6 @@ export class PermissionManager {
|
|
|
493
550
|
*
|
|
494
551
|
* - session → "session" (always, all surfaces)
|
|
495
552
|
* - mcp + default → "default"
|
|
496
|
-
* - mcp + override → "tool"
|
|
497
553
|
* - mcp + other → "mcp"
|
|
498
554
|
* - special → "special" (always)
|
|
499
555
|
* - skill → "skill" (always)
|
|
@@ -509,7 +565,6 @@ function deriveSource(
|
|
|
509
565
|
|
|
510
566
|
if (toolName === "mcp") {
|
|
511
567
|
if (rule.layer === "default") return "default";
|
|
512
|
-
if (rule.layer === "override") return "tool";
|
|
513
568
|
return "mcp";
|
|
514
569
|
}
|
|
515
570
|
|
package/src/rule.ts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import type { PermissionState } from "./types";
|
|
2
2
|
import { wildcardMatch } from "./wildcard-matcher";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Provenance of a rule — which source contributed it.
|
|
6
|
+
*
|
|
7
|
+
* Config scopes: "global", "project", "agent", "project-agent".
|
|
8
|
+
* Synthesized: "builtin" (universal default / evaluate() fallback),
|
|
9
|
+
* "baseline" (conditional MCP metadata auto-allow).
|
|
10
|
+
* Runtime: "session" (session approvals).
|
|
11
|
+
*/
|
|
12
|
+
export type RuleOrigin =
|
|
13
|
+
| "global"
|
|
14
|
+
| "project"
|
|
15
|
+
| "agent"
|
|
16
|
+
| "project-agent"
|
|
17
|
+
| "builtin"
|
|
18
|
+
| "baseline"
|
|
19
|
+
| "session";
|
|
20
|
+
|
|
4
21
|
/** A single permission rule — the atomic unit of policy. */
|
|
5
22
|
export interface Rule {
|
|
6
23
|
/** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
@@ -13,7 +30,9 @@ export interface Rule {
|
|
|
13
30
|
* Origin layer — used to derive PermissionCheckResult.source after evaluation.
|
|
14
31
|
* Not used by evaluate(); purely informational metadata.
|
|
15
32
|
*/
|
|
16
|
-
layer?: "default" | "
|
|
33
|
+
layer?: "default" | "baseline" | "config" | "session";
|
|
34
|
+
/** Which source contributed this rule. */
|
|
35
|
+
origin: RuleOrigin;
|
|
17
36
|
}
|
|
18
37
|
|
|
19
38
|
/** An ordered list of rules. Later rules take priority (last-match-wins). */
|
|
@@ -39,7 +58,12 @@ export function evaluate(
|
|
|
39
58
|
wildcardMatch(r.surface, surface) && wildcardMatch(r.pattern, pattern),
|
|
40
59
|
);
|
|
41
60
|
if (rule !== undefined) return rule;
|
|
42
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
surface,
|
|
63
|
+
pattern,
|
|
64
|
+
action: defaultAction ?? "ask",
|
|
65
|
+
origin: "builtin",
|
|
66
|
+
};
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
/**
|
package/src/session-rules.ts
CHANGED
|
@@ -15,7 +15,13 @@ export class SessionRules {
|
|
|
15
15
|
|
|
16
16
|
/** Record a wildcard pattern as approved for the given surface. */
|
|
17
17
|
approve(surface: string, pattern: string): void {
|
|
18
|
-
this.rules.push({
|
|
18
|
+
this.rules.push({
|
|
19
|
+
surface,
|
|
20
|
+
pattern,
|
|
21
|
+
action: "allow",
|
|
22
|
+
layer: "session",
|
|
23
|
+
origin: "session",
|
|
24
|
+
});
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
/** Return a defensive copy of the current session ruleset. */
|
package/src/synthesize.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Rule, Ruleset } from "./rule";
|
|
1
|
+
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
2
2
|
import type { PermissionState } from "./types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -11,13 +11,17 @@ import type { PermissionState } from "./types";
|
|
|
11
11
|
* regular config rules from `normalizeFlatConfig()` and sit at higher indices
|
|
12
12
|
* in the composed array, so they override this default via last-match-wins.
|
|
13
13
|
*/
|
|
14
|
-
export function synthesizeDefaults(
|
|
14
|
+
export function synthesizeDefaults(
|
|
15
|
+
universalDefault: PermissionState,
|
|
16
|
+
origin: RuleOrigin = "builtin",
|
|
17
|
+
): Ruleset {
|
|
15
18
|
return [
|
|
16
19
|
{
|
|
17
20
|
surface: "*",
|
|
18
21
|
pattern: "*",
|
|
19
22
|
action: universalDefault,
|
|
20
23
|
layer: "default",
|
|
24
|
+
origin,
|
|
21
25
|
},
|
|
22
26
|
];
|
|
23
27
|
}
|
|
@@ -63,6 +67,7 @@ export function synthesizeBaseline(configRules: Ruleset): Ruleset {
|
|
|
63
67
|
pattern: target,
|
|
64
68
|
action: "allow",
|
|
65
69
|
layer: "baseline",
|
|
70
|
+
origin: "baseline",
|
|
66
71
|
}),
|
|
67
72
|
);
|
|
68
73
|
}
|
|
@@ -193,7 +193,12 @@ export function getPermissionLogContext(
|
|
|
193
193
|
result: PermissionCheckResult,
|
|
194
194
|
input: unknown,
|
|
195
195
|
pathBearingTools: ReadonlySet<string>,
|
|
196
|
-
): {
|
|
196
|
+
): {
|
|
197
|
+
command?: string;
|
|
198
|
+
target?: string;
|
|
199
|
+
toolInputPreview?: string;
|
|
200
|
+
origin?: string;
|
|
201
|
+
} {
|
|
197
202
|
return {
|
|
198
203
|
command: result.command,
|
|
199
204
|
target: result.target,
|
|
@@ -202,5 +207,6 @@ export function getPermissionLogContext(
|
|
|
202
207
|
input,
|
|
203
208
|
pathBearingTools,
|
|
204
209
|
),
|
|
210
|
+
origin: result.origin,
|
|
205
211
|
};
|
|
206
212
|
}
|