@gotgenes/pi-permission-system 21.0.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ 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
+ ## [23.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v22.0.0...pi-permission-system-v23.0.0) (2026-07-24)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** path values embedded in `--opt=value` bash tokens are now extracted and gated by the `path` and `external_directory` surfaces. Previously they were invisible to both, so a permissive `bash` rule such as `grep *` allowed them. Add an allow pattern on `external_directory` or `path` for an intended target.
14
+ * **pi-permission-system:** bash commands referencing existing bare-named files or in-project symlinks are now gated by `path` rules (matched against the canonical, symlink-resolved form) and by `external_directory` when they resolve outside the working directory. Previously a permissive `bash` allow rule such as `cat *` bypassed both. A bare token naming no file is still dropped, so `git status`-style commands are unaffected. To restore prior behavior for an intended target, add an allow pattern on `external_directory` (for outside-CWD paths) or on `path`.
15
+
16
+ ### Bug Fixes
17
+
18
+ * **pi-permission-system:** classify path values embedded in --opt=value tokens ([0be19fd](https://github.com/gotgenes/pi-packages/commit/0be19fd209254ea76840747c128d3bc5112a912b)), closes [#645](https://github.com/gotgenes/pi-packages/issues/645)
19
+ * **pi-permission-system:** gate existing bare-named files and symlinks in bash commands ([9467858](https://github.com/gotgenes/pi-packages/commit/9467858cdb8824cbdcf5a05994dd0d6b3784cbaa)), closes [#645](https://github.com/gotgenes/pi-packages/issues/645)
20
+
21
+
22
+ ### Documentation
23
+
24
+ * **pi-permission-system:** update architecture and skill docs for probe-based path candidacy ([90c402d](https://github.com/gotgenes/pi-packages/commit/90c402d54edbc857597aca66e0cb5f01bade550f)), closes [#645](https://github.com/gotgenes/pi-packages/issues/645)
25
+
26
+ ## [22.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v21.0.0...pi-permission-system-v22.0.0) (2026-07-24)
27
+
28
+
29
+ ### ⚠ BREAKING CHANGES
30
+
31
+ * **pi-permission-system:** In an untrusted project, project-scoped permission configuration (project config.json and project-agent frontmatter) and project-scoped runtime config (yoloMode, permissionReviewLog, etc.) are no longer loaded until the user grants project trust. Only global policy applies. Grant project trust, or set defaultProjectTrust, to restore the prior behavior.
32
+
33
+ ### Features
34
+
35
+ * **pi-permission-system:** support skipping project scope in loadAndMergeConfigs ([e5a2e57](https://github.com/gotgenes/pi-packages/commit/e5a2e57b39c7bae44e7ac126b24094c2d5dce155))
36
+
37
+
38
+ ### Bug Fixes
39
+
40
+ * **pi-permission-system:** gate project-scoped config on project trust ([f264e71](https://github.com/gotgenes/pi-packages/commit/f264e711b90c7947d805bec654bc78199a76fada))
41
+
42
+
43
+ ### Documentation
44
+
45
+ * **pi-permission-system:** document project-trust gating for project config ([e955a29](https://github.com/gotgenes/pi-packages/commit/e955a299156215be57144000d5157481615d8060))
46
+
8
47
  ## [21.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.10.0...pi-permission-system-v21.0.0) (2026-07-24)
9
48
 
10
49
 
package/README.md CHANGED
@@ -107,6 +107,7 @@ Config lives in one JSON file per scope:
107
107
  | Project | `<cwd>/.pi/extensions/pi-permission-system/config.json` |
108
108
 
109
109
  Project overrides global; per-agent YAML frontmatter overrides both.
110
+ Project config (policy and runtime knobs) is loaded only once the project is trusted — in an untrusted directory only global config applies, so an untrusted repository cannot loosen your global policy (see [Upgrading](#2200--project-config-requires-project-trust)).
110
111
 
111
112
  Within a surface map like `bash` or `mcp`, **last matching rule wins** — put broad catch-alls first and specific overrides after.
112
113
 
@@ -120,6 +121,13 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
120
121
 
121
122
  ## Upgrading
122
123
 
124
+ ### 22.0.0 — project config requires project trust
125
+
126
+ Project-scoped configuration (the project `config.json` and project-agent frontmatter — both permission policy and runtime knobs such as `yoloMode`) is now loaded only when Pi reports the project as trusted.
127
+ In an untrusted directory, only global config applies; a skip is surfaced with a warning and a `project_trust.skipped` review-log entry.
128
+ Grant project trust (or set `defaultProjectTrust`) to load a project's config.
129
+ See [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md).
130
+
123
131
  ### 16.0.0 — the bash gate now fails closed
124
132
 
125
133
  The permission gate fails closed: an internal gate error blocks the tool (with a `gate_error` review-log entry) instead of running it ungated, and a non-empty bash command that cannot be parsed resolves to `ask` (sentinel `<unparseable-bash-command>`) rather than falling through to a permissive top-level `*`.
@@ -140,6 +148,7 @@ If you relied on the old permissive behavior for bash, set an explicit permissiv
140
148
  | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
141
149
  | [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
142
150
  | [docs/migration/strict-config-validation.md](docs/migration/strict-config-validation.md) | Strict config validation (breaking) — rejected configs, and the cross-scope fail-closed clamp |
151
+ | [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md) | Project-trust gating (breaking) — project config loads only after project trust |
143
152
 
144
153
  ## Development
145
154
 
@@ -11,6 +11,12 @@ One unified config file per scope:
11
11
 
12
12
  Project config overrides global config; per-agent frontmatter overrides both.
13
13
 
14
+ **Project config requires project trust.**
15
+ Project and project-agent scopes (both permission policy and runtime config such as `yoloMode`) are loaded only when Pi reports the project as trusted (`ctx.isProjectTrusted()`).
16
+ In an untrusted directory, only global (and global-agent) config applies, so an untrusted repository cannot loosen your global policy; the extension surfaces a loud warning plus a `project_trust.skipped` review-log entry when it skips a project scope.
17
+ Grant project trust (or configure `defaultProjectTrust`) to load the project's config; a trust grant reloads project policy on the next `resources_discover` reload.
18
+ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating.md).
19
+
14
20
  > **Coming from OpenCode?**
15
21
  > This extension's permission model was inspired by OpenCode's.
16
22
  > See [OpenCode Compatibility](opencode-compatibility.md) for shared concepts, divergences, and a porting guide.
@@ -463,8 +469,12 @@ For bash commands, the extension extracts path-candidate tokens from the command
463
469
  The most restrictive result across all tokens determines the outcome.
464
470
  When the current working directory is known, relative bash tokens are matched with cwd-normalized policy values, resolved against the effective directory after literal `cd` commands; a token after a non-literal `cd` (e.g. `cd "$DIR"`) stays conservative and matches only its literal form.
465
471
 
466
- A bare filename with no path shape at all (e.g. `id_rsa` in `cat id_rsa`) is also gated when it matches an active, specific (non-`*`) `path` deny/ask rule — so `"id_rsa": "deny"` or `"*.pem": "deny"` blocks the file whether it is referenced by a bare name, a relative path, or the `read` tool.
467
- A bare token that matches no specific `path` rule (e.g. `status` in `git status`) is left alone, and this promotion never fires against a `"*"` catch-all only a config that already declares a specific `path` rule is affected.
472
+ A bare filename with no path shape at all (e.g. `id_rsa` in `cat id_rsa`) is also gated, provided it names a file that actually exists — so `"id_rsa": "deny"` or `"*.pem": "deny"` blocks the file whether it is referenced by a bare name, a relative path, or the `read` tool.
473
+ Because the resolved path is matched, this covers a bare **symlink** whose target a rule names: with `".some.secret": "deny"`, `cat a_sym` is denied when `a_sym` points at `.some.secret`.
474
+ A bare token that names nothing (e.g. `status` in `git status`, `build` in `npm run build`) is left alone, so ordinary subcommands and branch names never prompt.
475
+ An existing file that matches no `path` rule is likewise left alone — the catch-all `"*"` entry alone does not gate it.
476
+
477
+ A path embedded in a long option (e.g. `--file=/tmp/patterns` in `grep --file=/tmp/patterns target`) is extracted and gated like any other path token; an option value that is not path-shaped (e.g. `--format=json`) is ignored.
468
478
 
469
479
  On Windows, where a backslash is a path separator, a backslash-relative bash argument (e.g. `dir\file` in `cat dir\file`) is gated by a `path` rule the same as its forward-slash equivalent (`dir/file`) and the same as the file accessed through the `read` tool.
470
480
  On other platforms a backslash is a legal filename character, so such a token is not treated as a path.
@@ -0,0 +1,32 @@
1
+ # Migration guide: project-trust gating
2
+
3
+ Starting with the release that closes #644, the permission-system loads project-scoped configuration only after Pi reports the project as **trusted** (`ctx.isProjectTrusted()`).
4
+ This is a **breaking change** in how config is loaded in an untrusted directory.
5
+
6
+ ## What changed
7
+
8
+ The extension used to load project-scoped config from the current working directory unconditionally — it never consulted Pi's project-trust decision.
9
+ Because project scope has higher precedence than global, an untrusted repository could ship a `.pi/extensions/pi-permission-system/config.json` that **loosened** an operator's global policy before the user granted trust — for example flipping a global `bash: deny` to `bash: allow`, or setting `yoloMode: true`.
10
+
11
+ Now, when the project is **not** trusted:
12
+
13
+ - Project and project-agent **permission policy** scopes are not loaded — only global (and global-agent) policy participates in resolution.
14
+ - Project **runtime config** (`yoloMode`, `permissionReviewLog`, `piInfrastructureReadPaths`, `shellTools`, `authorizerChain`, …) is not merged.
15
+ - Each skip is surfaced loudly: a UI warning and a `project_trust.skipped` entry in the permission review log.
16
+
17
+ This aligns the extension with Pi's own trust model, which already withholds project-local skills, prompts, and agents from untrusted directories.
18
+
19
+ ## Timing and recovery
20
+
21
+ Pi resolves the trust decision (including any `defaultProjectTrust` setting) before `session_start`, so the guard sees the effective decision from the first tool call.
22
+ If you grant trust after the session starts, Pi fires `resources_discover` with `reason: "reload"`, and the extension re-reads trust and loads the project **policy** at that point.
23
+ Project **runtime** config (e.g. `yoloMode`) is re-read on the next session start.
24
+
25
+ ## What you need to do
26
+
27
+ If you only use global config, nothing changes.
28
+
29
+ If you rely on a project's `.pi/extensions/pi-permission-system/config.json`, **grant the project trust** when Pi prompts (or configure `defaultProjectTrust` to always trust).
30
+ Until then, the project's permission rules and runtime knobs are ignored and only your global policy applies.
31
+
32
+ If a project's rules stop taking effect after upgrading (surfaces you allowed at the project scope start prompting or denying per global policy), check whether the project is trusted — the review log will contain a `project_trust.skipped` entry naming the untrusted `cwd`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "21.0.0",
3
+ "version": "23.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,7 +5,7 @@ import {
5
5
  } from "#src/access-intent/bash/node-text";
6
6
  import type { TSNode } from "#src/access-intent/bash/parser";
7
7
  import {
8
- classifyPromotedRuleCandidate,
8
+ classifyBareTokenCandidate,
9
9
  classifyTokenAsPathCandidate,
10
10
  classifyTokenAsRuleCandidate,
11
11
  } from "#src/access-intent/bash/token-classification";
@@ -18,10 +18,6 @@ import {
18
18
  import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
19
19
  import type { PathNormalizer } from "#src/path-normalizer";
20
20
  import { isSafeSystemPath } from "#src/safe-system-paths";
21
- import type { PathRuleTokenMatcher } from "#src/types";
22
-
23
- /** Default promotion predicate: promotes nothing (#509). */
24
- const NO_PROMOTION: PathRuleTokenMatcher = () => false;
25
21
 
26
22
  // ── Internal types ───────────────────────────────────────────────────────────
27
23
 
@@ -85,22 +81,20 @@ const UNKNOWN_BASE: EffectiveBase = { kind: "unknown" };
85
81
  * (`forPath`/`forLiteral`/`resolveBase`), and the outside-cwd boundary
86
82
  * decision — so no walk step re-reads the platform or threads the cwd.
87
83
  *
88
- * Also holds an `isPromotablePathToken` predicate (default: promotes nothing)
89
- * deciding whether a bare token that fails the broad rule-candidate shape gate
90
- * should still be promoted because it matches an active, specific `path` rule
91
- * (#509). The resolver never sees the rules themselves — the predicate is
92
- * built and owned by `PermissionManager.getPromotablePathTokenMatcher`.
84
+ * A bare token that fails both shape gates is admitted when the normalizer's
85
+ * existence probe says it names a real filesystem entry (ADR 0009, #645). The
86
+ * resolver consults no ruleset: candidacy is a filesystem question, and the
87
+ * policy decision belongs to the gates downstream.
93
88
  *
94
89
  * Tell-don't-ask: callers hand it a parsed tree and receive the resolved
95
90
  * {@link ResolvedBashPaths} slices in one {@link resolve} call; the AST walk,
96
91
  * the `cd`-folding state, and the intermediate path candidates stay private.
97
92
  * One instance per parse ({@link BashProgram.parse} constructs it with the
98
- * session normalizer and promotion predicate).
93
+ * session normalizer).
99
94
  */
100
95
  export class BashPathResolver {
101
96
  constructor(
102
97
  private readonly normalizer: PathNormalizer,
103
- private readonly isPromotablePathToken: PathRuleTokenMatcher = NO_PROMOTION,
104
98
  private readonly workdir?: string,
105
99
  ) {}
106
100
 
@@ -426,7 +420,14 @@ export class BashPathResolver {
426
420
 
427
421
  for (const { token, base } of candidates) {
428
422
  const candidate = classifyTokenAsPathCandidate(token);
429
- if (!candidate) continue;
423
+ if (!candidate) {
424
+ // A bare token the strict shape gate rejects can still escape the tree
425
+ // through a symlink, so probe it and apply the ordinary boundary
426
+ // decision to whatever it resolves to (#645).
427
+ const probed = this.probeBareToken(token, base);
428
+ if (probed) this.collectIfExternal(probed.path, seen, externalPaths);
429
+ continue;
430
+ }
430
431
 
431
432
  // Unknown effective directory: a relative candidate could resolve
432
433
  // anywhere, so flag it conservatively (resolved against the baked cwd
@@ -446,45 +447,58 @@ export class BashPathResolver {
446
447
  base.kind === "known"
447
448
  ? this.normalizer.resolveBase(base.offset)
448
449
  : undefined;
449
- const accessPath = this.normalizer.forBashToken(candidate, {
450
- resolveBase,
451
- });
452
- const lexical = accessPath.value();
453
- if (!lexical) continue;
454
- // The boundary decision and dedup identity use the canonical
455
- // (symlink-resolved) form the AccessPath already derived, but the returned
456
- // value is the lexical form so config patterns match the path as the user
457
- // typed it (#418). A win32 device path preserves `/dev/null` as its
458
- // boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
459
- // safe-path exclusion (#533).
460
- const canonical = accessPath.boundaryValue();
461
- // A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
462
- // has no canonical form; it is foreign to the win32 cwd, so it is always
463
- // external. Its lexical value is the dedup identity so two distinct
464
- // literal-only paths do not collapse (#533).
465
- const isExternal = canonical
466
- ? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
467
- : true;
468
- const dedupKey = canonical || lexical;
469
-
470
- if (isExternal && !seen.has(dedupKey)) {
471
- seen.add(dedupKey);
472
- externalPaths.push(accessPath);
473
- }
450
+ this.collectIfExternal(
451
+ this.normalizer.forBashToken(candidate, { resolveBase }),
452
+ seen,
453
+ externalPaths,
454
+ );
474
455
  }
475
456
 
476
457
  return externalPaths;
477
458
  }
478
459
 
460
+ /**
461
+ * Record `accessPath` when it resolves outside the working directory and has
462
+ * not already been collected.
463
+ *
464
+ * The boundary decision and dedup identity use the canonical
465
+ * (symlink-resolved) form the {@link AccessPath} already derived, while the
466
+ * stored value keeps the lexical form so config patterns match the path as
467
+ * the user typed it (#418). A win32 device path preserves `/dev/null` as its
468
+ * boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
469
+ * safe-path exclusion (#533).
470
+ *
471
+ * A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
472
+ * has no canonical form; it is foreign to the win32 cwd, so it is always
473
+ * external. Its lexical value is the dedup identity so two distinct
474
+ * literal-only paths do not collapse (#533).
475
+ */
476
+ private collectIfExternal(
477
+ accessPath: AccessPath,
478
+ seen: Set<string>,
479
+ out: AccessPath[],
480
+ ): void {
481
+ const lexical = accessPath.value();
482
+ if (!lexical) return;
483
+ const canonical = accessPath.boundaryValue();
484
+ const isExternal = canonical
485
+ ? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
486
+ : true;
487
+ const dedupKey = canonical || lexical;
488
+ if (isExternal && !seen.has(dedupKey)) {
489
+ seen.add(dedupKey);
490
+ out.push(accessPath);
491
+ }
492
+ }
493
+
479
494
  /**
480
495
  * Project the collected candidates into rule candidates with their cd-aware
481
496
  * policy lookup values.
482
497
  *
483
498
  * Filters candidates through the broad path classifier
484
- * (`classifyTokenAsRuleCandidate`), falling back to the rule-driven promoted
485
- * classifier (`classifyPromotedRuleCandidate`, #509) for a bare token the
486
- * broad classifier rejects for shape promoted only when the injected
487
- * `isPromotablePathToken` predicate matches an active, specific `path` rule.
499
+ * (`classifyTokenAsRuleCandidate`), falling back to {@link probeBareToken}
500
+ * for a bare token the broad classifier rejects for shape — admitted only
501
+ * when it names an existing filesystem entry (#645).
488
502
  * On win32 the broad classifier is told to treat a backslash as a path
489
503
  * separator, so a backslash-relative token (`dir\file`) is recognized as a
490
504
  * rule candidate the same as its forward-slash equivalent (#520); on POSIX
@@ -501,24 +515,62 @@ export class BashPathResolver {
501
515
  const result: BashPathRuleCandidate[] = [];
502
516
 
503
517
  for (const { token, base } of candidates) {
518
+ const shaped = classifyTokenAsRuleCandidate(
519
+ token,
520
+ this.normalizer.flavor,
521
+ );
504
522
  const candidate =
505
- classifyTokenAsRuleCandidate(token, this.normalizer.flavor) ??
506
- classifyPromotedRuleCandidate(token, this.isPromotablePathToken);
523
+ shaped === null
524
+ ? this.probeBareToken(token, base)
525
+ : { token: shaped, path: this.buildRuleCandidatePath(shaped, base) };
507
526
  if (!candidate) continue;
508
527
 
509
- const path = this.buildRuleCandidatePath(candidate, base);
510
- const matchValues = path.matchValues();
528
+ const matchValues = candidate.path.matchValues();
511
529
  if (matchValues.length === 0) continue;
512
530
 
513
531
  const key = matchValues.join("\0");
514
532
  if (seen.has(key)) continue;
515
533
  seen.add(key);
516
- result.push({ token: candidate, path });
534
+ result.push(candidate);
517
535
  }
518
536
 
519
537
  return result;
520
538
  }
521
539
 
540
+ /**
541
+ * Promote a bare token the shape gates rejected, when it names an existing
542
+ * filesystem entry — the existence probe (ADR 0009, #645).
543
+ *
544
+ * Most bash argument tokens are not paths (`status`, `build`, `main`), so a
545
+ * bare token is admitted only when the filesystem confirms it names something
546
+ * real. Candidacy therefore comes from the filesystem and never from the
547
+ * ruleset, which keeps the classifiers pure and lets a symlink be matched by
548
+ * rules naming its *target* — the case raw-token matching could not see.
549
+ *
550
+ * Returns `null` when the token's shape rules out a path, when the effective
551
+ * base is unknown (no concrete directory to resolve against, so the token
552
+ * stays unpromoted per #393 conservatism), or when nothing exists at the
553
+ * resolved location.
554
+ *
555
+ * Shared by both projections so a promoted token is identical whether it is
556
+ * being matched against `path` rules or tested against the cwd boundary.
557
+ */
558
+ private probeBareToken(
559
+ token: string,
560
+ base: EffectiveBase,
561
+ ): BashPathRuleCandidate | null {
562
+ const bare = classifyBareTokenCandidate(token);
563
+ if (bare === null) return null;
564
+ if (base.kind !== "known") return null;
565
+
566
+ const path = this.normalizer.forBashToken(bare, {
567
+ resolveBase: this.normalizer.resolveBase(base.offset),
568
+ });
569
+ const lexical = path.value();
570
+ if (!lexical || !this.normalizer.entryExists(lexical)) return null;
571
+ return { token: bare, path };
572
+ }
573
+
522
574
  private buildRuleCandidatePath(
523
575
  candidate: string,
524
576
  base: EffectiveBase,
@@ -9,7 +9,6 @@ import {
9
9
  } from "#src/access-intent/bash/command-enumeration";
10
10
  import { getParser } from "#src/access-intent/bash/parser";
11
11
  import type { PathNormalizer } from "#src/path-normalizer";
12
- import type { PathRuleTokenMatcher } from "#src/types";
13
12
 
14
13
  export type { BashCommand, BashPathRuleCandidate };
15
14
 
@@ -40,11 +39,10 @@ export class BashProgram {
40
39
  * Heredoc bodies, comments, and other non-argument content are skipped. An
41
40
  * unparseable command yields an empty program.
42
41
  *
43
- * `isPromotablePathToken`, when supplied, promotes a bare filename token
44
- * (e.g. `id_rsa`) into `pathRuleCandidates()` when it matches an active,
45
- * specific `path` deny/ask rule (#509). Defaults to promoting nothing, so
46
- * callers that only read `externalPaths()` (e.g. `bash-path-extractor.ts`)
47
- * are unaffected.
42
+ * A bare token (e.g. `id_rsa`, `outside-link`) enters both slices when it
43
+ * names an existing filesystem entry the existence probe the resolver owns
44
+ * (ADR 0009, #645). No policy is consulted, so every caller gets identical
45
+ * slices for a given command and working directory.
48
46
  *
49
47
  * `options.workdir`, when supplied (an aliased shell tool's working directory,
50
48
  * #574), seeds the initial effective base — as if the command were prefixed
@@ -54,7 +52,6 @@ export class BashProgram {
54
52
  static async parse(
55
53
  command: string,
56
54
  normalizer: PathNormalizer,
57
- isPromotablePathToken?: PathRuleTokenMatcher,
58
55
  options?: { workdir?: string },
59
56
  ): Promise<BashProgram> {
60
57
  const parser = await getParser();
@@ -64,7 +61,6 @@ export class BashProgram {
64
61
  try {
65
62
  const { externalPaths, ruleCandidates } = new BashPathResolver(
66
63
  normalizer,
67
- isPromotablePathToken,
68
64
  options?.workdir,
69
65
  ).resolve(tree.rootNode);
70
66
  return new BashProgram(
@@ -4,9 +4,16 @@
4
4
  * Exports three classifiers consumed by `bash-path-resolver.ts`:
5
5
  * - `classifyTokenAsPathCandidate` — strict gate for the external-directory guard.
6
6
  * - `classifyTokenAsRuleCandidate` — broader gate for cross-cutting `path` rules.
7
- * - `classifyPromotedRuleCandidate` — rule-driven promotion of a bare filename
8
- * (e.g. `id_rsa`) that `classifyTokenAsRuleCandidate` rejects for shape, but
9
- * which matches an active, specific (non-`*`) `path` deny/ask rule (#509).
7
+ * - `classifyBareTokenCandidate` — prelude-only gate for a bare token (e.g.
8
+ * `id_rsa`, `outside-link`) that `classifyTokenAsRuleCandidate` rejects for
9
+ * shape. It answers only "is this shape capable of naming a path?"; whether
10
+ * it *does* name one is settled by the resolver's existence probe (#645).
11
+ *
12
+ * Token classification is three-valued: definitely-path (shape), definitely-not
13
+ * (prelude), and unknown (a bare word). These functions own the first two; the
14
+ * third is resolved against the filesystem rather than against policy, so no
15
+ * classifier here consults the ruleset — see
16
+ * `docs/decisions/0009-bash-path-projection-completeness-contract.md`.
10
17
  *
11
18
  * All three classifiers share the private `rejectNonPathToken` predicate that
12
19
  * captures the six rejection cases common to them (the production clone this
@@ -28,7 +35,6 @@
28
35
  * never reads `process.platform` itself.
29
36
  */
30
37
  import type { PathFlavor } from "#src/path/path-flavor";
31
- import type { PathRuleTokenMatcher } from "#src/types";
32
38
 
33
39
  // ── Public classifiers ─────────────────────────────────────────────────────
34
40
 
@@ -91,28 +97,28 @@ export function classifyTokenAsRuleCandidate(
91
97
  }
92
98
 
93
99
  /**
94
- * Rule-driven promotion classifier for bare filenames (#509).
100
+ * Prelude-only classifier for a bare token (#645).
101
+ *
102
+ * A bare token (`id_rsa`, `outside-link`) has none of the shapes
103
+ * `classifyTokenAsRuleCandidate` accepts, because most bash argument tokens are
104
+ * not file paths (subcommands, branch names, search patterns). This classifier
105
+ * answers the narrower question the existence probe needs: could this token's
106
+ * *shape* name a path at all?
95
107
  *
96
- * A bare token (`id_rsa`) has none of the shapes `classifyTokenAsRuleCandidate`
97
- * accepts, so it is dropped before rule evaluation by default — most bash
98
- * argument tokens are not file paths (subcommands, branch names, search
99
- * patterns). This classifier promotes a bare token into the rule-candidate
100
- * surface only when the caller-supplied `isPromotable` predicate says it
101
- * matches an active, specific `path` deny/ask rule, closing the bypass without
102
- * treating every bare argument as a path.
108
+ * It runs only the shared `rejectNonPathToken` prelude, so a flag,
109
+ * env-assignment, URL, `@scope` token, or regex-shaped token is never a
110
+ * candidate. Everything else is returned for the caller to probe.
103
111
  *
104
- * Still runs the shared `rejectNonPathToken` prelude first, so a flag,
105
- * env-assignment, URL, `@scope` token, or regex-shaped token is never
106
- * promoted even if it happens to match a configured pattern.
112
+ * Deliberately consults no policy: candidacy is settled by the filesystem and
113
+ * the decision by the ruleset, which keeps this module a pure shape function
114
+ * (ADR 0009). It replaced the rule-driven promotion of #509, which matched a
115
+ * token's *spelling* against `path` rules and so could never see that a
116
+ * symlink's target is what a rule names.
107
117
  *
108
118
  * Returns the raw token string if it qualifies, or `null` to skip.
109
119
  */
110
- export function classifyPromotedRuleCandidate(
111
- token: string,
112
- isPromotable: PathRuleTokenMatcher,
113
- ): string | null {
114
- if (rejectNonPathToken(token)) return null;
115
- return isPromotable(token) ? token : null;
120
+ export function classifyBareTokenCandidate(token: string): string | null {
121
+ return rejectNonPathToken(token) ? null : token;
116
122
  }
117
123
 
118
124
  // ── Private rejection predicate ────────────────────────────────────────────
@@ -42,9 +42,10 @@ export function collectCommandTokens(node: TSNode): string[] {
42
42
  const config = commandName
43
43
  ? PATTERN_FIRST_COMMANDS.get(commandName)
44
44
  : undefined;
45
- return config
45
+ const tokens = config
46
46
  ? collectPatternCommandTokens(node, config)
47
47
  : collectGenericCommandTokens(node);
48
+ return [...tokens, ...collectEmbeddedOptionValues(node)];
48
49
  }
49
50
 
50
51
  /**
@@ -81,6 +82,42 @@ export function extractCommandName(node: TSNode): string | undefined {
81
82
 
82
83
  // ── Private helpers and config ─────────────────────────────────────────────
83
84
 
85
+ /**
86
+ * A long or short option carrying its value inline: one or two leading dashes,
87
+ * a name containing no `=` or whitespace, then `=` and a non-empty value.
88
+ * Only the first `=` separates, so `--opt=/tmp/a=b` yields `/tmp/a=b`.
89
+ */
90
+ const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
91
+
92
+ /**
93
+ * The values embedded in this command's `--opt=value` argument tokens.
94
+ *
95
+ * Read straight from the argument nodes rather than from the collected token
96
+ * list, because a pattern-first command's collector classifies a flag and never
97
+ * emits it — so `grep --file=/tmp/patterns` would otherwise lose the path.
98
+ *
99
+ * This is token *preprocessing*, not classification: the extracted value is
100
+ * handed to the ordinary shape classifiers and existence probe, so
101
+ * `--file=/tmp/patterns` reaches the path surfaces while `--format=json`
102
+ * yields a bare `json` that names nothing and is dropped. Keeping the split
103
+ * here is what lets the projection see option-embedded paths without per-command
104
+ * option tables (ADR 0009, #645).
105
+ */
106
+ function collectEmbeddedOptionValues(node: TSNode): string[] {
107
+ const values: string[] = [];
108
+ for (let i = 0; i < node.childCount; i++) {
109
+ const child = node.child(i);
110
+ if (!child) continue;
111
+ if (child.type === "command_name" || child.type === "variable_assignment")
112
+ continue;
113
+ if (!ARG_NODE_TYPES.has(child.type)) continue;
114
+
115
+ const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
116
+ if (value !== undefined) values.push(value);
117
+ }
118
+ return values;
119
+ }
120
+
84
121
  interface PatternCommandConfig {
85
122
  /** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
86
123
  readonly argConsumingFlags: ReadonlySet<string>;
@@ -286,12 +286,20 @@ export interface MergedConfigResult {
286
286
  * Legacy files are detected and warned about. Their content is parsed with the
287
287
  * flat-format parser — legacy-format keys (defaultPolicy, tools, bash, etc.)
288
288
  * are not translated and contribute no permission rules.
289
+ *
290
+ * When `options.includeProjectScope` is `false`, the project-scope steps (4 and
291
+ * 5) are skipped entirely — neither the legacy project policy nor the new
292
+ * project config is read or merged. This gates project-local config on project
293
+ * trust: an untrusted repository cannot loosen the operator's global policy
294
+ * (#644). It defaults to `true`, preserving the trusted / caller-agnostic path.
289
295
  */
290
296
  export function loadAndMergeConfigs(
291
297
  agentDir: string,
292
298
  cwd: string,
293
299
  extensionRoot: string,
300
+ options: { includeProjectScope?: boolean } = {},
294
301
  ): MergedConfigResult {
302
+ const includeProjectScope = options.includeProjectScope !== false;
295
303
  const allIssues: string[] = [];
296
304
 
297
305
  const newGlobalPath = getGlobalConfigPath(agentDir);
@@ -339,8 +347,8 @@ export function loadAndMergeConfigs(
339
347
  const globalConfig = globalResult.config;
340
348
  merged = mergeUnifiedConfigs(merged, globalConfig);
341
349
 
342
- // 4. Legacy project policy
343
- if (existsSync(legacyProjectPolicyPath)) {
350
+ // 4. Legacy project policy — skipped when the project scope is withheld.
351
+ if (includeProjectScope && existsSync(legacyProjectPolicyPath)) {
344
352
  const legacy = loadUnifiedConfig(legacyProjectPolicyPath);
345
353
  allIssues.push(
346
354
  `Legacy project policy found at '${legacyProjectPolicyPath}'. ` +
@@ -351,8 +359,11 @@ export function loadAndMergeConfigs(
351
359
  merged = mergeUnifiedConfigs(merged, legacy.config);
352
360
  }
353
361
 
354
- // 5. New project config
355
- const projectResult = loadUnifiedConfig(newProjectPath);
362
+ // 5. New project config — skipped when the project scope is withheld, so an
363
+ // untrusted project contributes nothing and `project` reports empty.
364
+ const projectResult = includeProjectScope
365
+ ? loadUnifiedConfig(newProjectPath)
366
+ : { config: {}, issues: [] };
356
367
  allIssues.push(...projectResult.issues);
357
368
  const projectConfig = projectResult.config;
358
369
  merged = mergeUnifiedConfigs(merged, projectConfig);
@@ -41,7 +41,7 @@ export interface ConfigReader {
41
41
  * coupling between the class and test doubles.
42
42
  */
43
43
  export interface SessionConfigStore extends ConfigReader {
44
- refresh(ctx?: ExtensionContext): void;
44
+ refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void;
45
45
  logResolvedPaths(cwd?: string): void;
46
46
  }
47
47
 
@@ -96,14 +96,17 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
96
96
  * Reload merged config from disk.
97
97
  *
98
98
  * If `ctx` is provided, uses it to derive the cwd and sync UI status.
99
- * Equivalent to `refreshExtensionConfig(runtime, ctx?)`.
99
+ * When `projectTrusted` is `false`, the project scope is withheld so an
100
+ * untrusted repository's runtime config (`yoloMode`, `permissionReviewLog`,
101
+ * …) cannot loosen the operator's global config (#644).
100
102
  */
101
- refresh(ctx?: ExtensionContext): void {
103
+ refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void {
102
104
  const cwd = ctx?.cwd ?? null;
103
105
  const mergeResult = loadAndMergeConfigs(
104
106
  this.deps.agentDir,
105
107
  cwd ?? "",
106
108
  EXTENSION_ROOT,
109
+ { includeProjectScope: projectTrusted },
107
110
  );
108
111
  const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
109
112
  this.config = runtimeConfig;
@@ -127,6 +130,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
127
130
  debugLog: runtimeConfig.debugLog,
128
131
  permissionReviewLog: runtimeConfig.permissionReviewLog,
129
132
  yoloMode: runtimeConfig.yoloMode,
133
+ projectTrusted,
130
134
  });
131
135
  }
132
136
 
@@ -62,7 +62,11 @@ export class AgentPrepHandler {
62
62
  // to whole-string matching.
63
63
  this.warmParser();
64
64
  this.session.activate(ctx);
65
- this.session.refreshConfig(ctx);
65
+ // Gate the mid-session runtime-config refresh on project trust too, so an
66
+ // untrusted project cannot slip its runtime config (e.g. `yoloMode`) in
67
+ // right before agent start after session_start withheld it (#644). The
68
+ // session_start handler already warned; do not re-warn on every start.
69
+ this.session.refreshConfig(ctx, ctx.isProjectTrusted());
66
70
 
67
71
  const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
68
72
  const activeTools = this.toolRegistry.getActive();
@@ -15,7 +15,7 @@ import {
15
15
  ToolPreviewFormatter,
16
16
  type ToolPreviewFormatterOptions,
17
17
  } from "#src/tool-preview-formatter";
18
- import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
18
+ import type { PermissionCheckResult } from "#src/types";
19
19
  import { resolveBashCommandCheck } from "./bash-command";
20
20
  import { describeBashExternalDirectoryGate } from "./bash-external-directory";
21
21
  import { describeBashPathGate } from "./bash-path";
@@ -52,11 +52,6 @@ export interface ToolCallGateInputs {
52
52
  * tool is gated through the bash stack at parity with native `bash` (#574).
53
53
  */
54
54
  getShellToolAliases(): ShellToolsConfig | undefined;
55
- /**
56
- * Predicate deciding whether a bare bash token should be promoted into the
57
- * `path` rule-candidate surface (#509), scoped to the given agent.
58
- */
59
- getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
60
55
  }
61
56
 
62
57
  /**
@@ -93,12 +88,9 @@ export class ToolCallGatePipeline {
93
88
  );
94
89
  const normalizer = this.inputs.getPathNormalizer();
95
90
  const bashProgram = shell?.command
96
- ? await BashProgram.parse(
97
- shell.command,
98
- normalizer,
99
- this.inputs.getPromotablePathTokenMatcher(tcc.agentName ?? undefined),
100
- { workdir: shell.workdir },
101
- )
91
+ ? await BashProgram.parse(shell.command, normalizer, {
92
+ workdir: shell.workdir,
93
+ })
102
94
  : null;
103
95
 
104
96
  const formatter = new ToolPreviewFormatter(
@@ -17,6 +17,15 @@ interface ResourcesDiscoverPayload {
17
17
  reason: string;
18
18
  }
19
19
 
20
+ /**
21
+ * Shown when project config is skipped because the project is untrusted, so the
22
+ * reduced-scope state is never silent (#644). Exported for assertion in tests.
23
+ */
24
+ export const UNTRUSTED_PROJECT_MESSAGE =
25
+ "pi-permission-system: project is not trusted — skipping project-scoped " +
26
+ "permission configuration. Only global policy applies. Grant project trust " +
27
+ "to load this project's permission rules.";
28
+
20
29
  /**
21
30
  * Handles session lifecycle events: start, reload, and shutdown.
22
31
  *
@@ -42,9 +51,13 @@ export class SessionLifecycleHandler {
42
51
  event: SessionStartPayload,
43
52
  ctx: ExtensionContext,
44
53
  ): Promise<void> {
45
- this.session.refreshConfig(ctx);
46
- this.session.resetForNewSession(ctx);
54
+ const projectTrusted = ctx.isProjectTrusted();
55
+ this.session.refreshConfig(ctx, projectTrusted);
56
+ this.session.resetForNewSession(ctx, projectTrusted);
47
57
  this.session.logResolvedConfigPaths();
58
+ if (!projectTrusted) {
59
+ this.warnProjectUntrusted(ctx, "session_start");
60
+ }
48
61
 
49
62
  const agentName = this.session.resolveAgentName(ctx);
50
63
  const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
@@ -68,12 +81,19 @@ export class SessionLifecycleHandler {
68
81
  return Promise.resolve();
69
82
  }
70
83
 
71
- handleResourcesDiscover(event: ResourcesDiscoverPayload): Promise<void> {
84
+ handleResourcesDiscover(
85
+ event: ResourcesDiscoverPayload,
86
+ ctx: ExtensionContext,
87
+ ): Promise<void> {
72
88
  if (event.reason !== "reload") {
73
89
  return Promise.resolve();
74
90
  }
75
91
 
76
- this.session.reload();
92
+ const projectTrusted = ctx.isProjectTrusted();
93
+ this.session.reload(projectTrusted);
94
+ if (!projectTrusted) {
95
+ this.warnProjectUntrusted(ctx, "resources_discover");
96
+ }
77
97
  this.logger.debug("lifecycle.reload", {
78
98
  triggeredBy: "resources_discover",
79
99
  reason: event.reason,
@@ -82,6 +102,18 @@ export class SessionLifecycleHandler {
82
102
  return Promise.resolve();
83
103
  }
84
104
 
105
+ /**
106
+ * Record the project-trust skip in the review log and surface a loud warning
107
+ * to the user, so the reduced (global-only) scope is never silent (#644).
108
+ */
109
+ private warnProjectUntrusted(
110
+ ctx: ExtensionContext,
111
+ phase: "session_start" | "resources_discover",
112
+ ): void {
113
+ this.logger.review("project_trust.skipped", { cwd: ctx.cwd, phase });
114
+ this.logger.warn(UNTRUSTED_PROJECT_MESSAGE);
115
+ }
116
+
85
117
  handleSessionShutdown(): Promise<void> {
86
118
  const ctx = this.session.getRuntimeContext();
87
119
  if (ctx) {
package/src/index.ts CHANGED
@@ -171,7 +171,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
171
171
  // refresh() must run after `session` is assigned: a debug-write IO failure
172
172
  // triggers the logger's notify sink — `session.notify(m)` — which no-ops
173
173
  // on the null context but requires `session` to be bound.
174
- configStore.refresh();
174
+ // No ctx/trust decision exists at factory init, so withhold the project
175
+ // scope (fail closed); session_start reloads with the real trust decision.
176
+ configStore.refresh(undefined, false);
175
177
 
176
178
  const configPath = getGlobalConfigPath(agentDir);
177
179
  registerPermissionSystemCommand(pi, {
@@ -258,8 +260,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
258
260
  pi.on("session_start", (event, ctx) =>
259
261
  lifecycle.handleSessionStart(event, ctx),
260
262
  );
261
- pi.on("resources_discover", (event) =>
262
- lifecycle.handleResourcesDiscover(event),
263
+ pi.on("resources_discover", (event, ctx) =>
264
+ lifecycle.handleResourcesDiscover(event, ctx),
263
265
  );
264
266
  pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
265
267
  pi.on("before_agent_start", (event, ctx) => agentPrep.handle(event, ctx));
@@ -1,3 +1,5 @@
1
+ import { lstatSync } from "node:fs";
2
+
1
3
  import type { PathFlavor } from "#src/path/path-flavor";
2
4
 
3
5
  import { AccessPath } from "./access-intent/access-path";
@@ -203,4 +205,30 @@ export class PathNormalizer {
203
205
  this.flavor,
204
206
  );
205
207
  }
208
+
209
+ /**
210
+ * True when `absolutePath` names an existing filesystem entry.
211
+ *
212
+ * The existence probe that resolves an *unknown* bash token: a bare word is a
213
+ * path candidate iff it names something real (ADR 0009, #645). Uses `lstat`,
214
+ * not `stat`, so a symlink counts as an entry even when its target is
215
+ * dangling — the link is the operand the command names, and dropping it would
216
+ * reopen the bypass this probe closes.
217
+ *
218
+ * Any error (ENOENT, ENOTDIR, EACCES, ELOOP) answers `false`: an entry the
219
+ * gate cannot confirm is not promoted, leaving the token exactly as
220
+ * unrestricted as it is today.
221
+ *
222
+ * Lives here beside {@link forPath}'s canonicalization so the package keeps a
223
+ * single filesystem edge for path interpretation.
224
+ */
225
+ entryExists(absolutePath: string): boolean {
226
+ if (!absolutePath) return false;
227
+ try {
228
+ lstatSync(absolutePath);
229
+ return true;
230
+ } catch {
231
+ return false;
232
+ }
233
+ }
206
234
  }
@@ -22,7 +22,6 @@ import {
22
22
  evaluateAnyValue,
23
23
  evaluateFirst,
24
24
  floorAllowsToAsk,
25
- pathMatchOptions,
26
25
  rewriteAsksToYolo,
27
26
  } from "./rule";
28
27
  import { mergeScopesWithOrigins } from "./scope-merge";
@@ -33,21 +32,16 @@ import {
33
32
  } from "./synthesize";
34
33
  import type {
35
34
  FlatPermissionConfig,
36
- PathRuleTokenMatcher,
37
35
  PermissionCheckResult,
38
36
  PermissionState,
39
37
  } from "./types";
40
38
  import { isPermissionState } from "./types";
41
- import { wildcardMatch } from "./wildcard-matcher";
42
39
 
43
40
  const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
44
41
 
45
42
  /** Universal fallback when permission["*"] is absent from all scopes. */
46
43
  const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
47
44
 
48
- /** Promotion predicate matching no token — the no-`path`-rules default (#509). */
49
- const NO_PROMOTION: PathRuleTokenMatcher = () => false;
50
-
51
45
  /** Default yolo reader — yolo disabled unless the composition root injects one. */
52
46
  const YOLO_DISABLED = (): boolean => false;
53
47
 
@@ -90,15 +84,6 @@ export interface ScopedPermissionManager {
90
84
  ): PermissionCheckResult;
91
85
  getToolPermission(toolName: string, agentName?: string): PermissionState;
92
86
  getConfigIssues(agentName?: string): string[];
93
- /**
94
- * Build a predicate deciding whether a bare bash token should be promoted
95
- * into the `path` rule-candidate surface (#509).
96
- *
97
- * Matches against specific (non-`*`) `path`-surface config rules whose
98
- * action is `deny` or `ask` — an allow rule never gates, and `"*"` would
99
- * promote every bare bash argument.
100
- */
101
- getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
102
87
  }
103
88
 
104
89
  export interface PermissionManagerOptions extends PolicyLoaderOptions {
@@ -271,37 +256,6 @@ export class PermissionManager implements ScopedPermissionManager {
271
256
  return composedRules.filter((r) => r.layer === "config");
272
257
  }
273
258
 
274
- /**
275
- * Build a predicate deciding whether a bare bash token should be promoted
276
- * into the `path` rule-candidate surface (#509).
277
- *
278
- * Filters the composed config ruleset to specific (non-`*`) `path`-surface
279
- * deny/ask patterns, then returns a closure matching a token against them
280
- * with the platform-correct fold (Windows case-and-separator matching, same
281
- * as {@link pathMatchOptions} applies for evaluation) so promotion agrees
282
- * with the later `path`-surface decision.
283
- *
284
- * Returns a matcher rejecting every token when no such rule exists — the
285
- * default-config case is unaffected by promotion.
286
- */
287
- getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
288
- const { composedRules } = this.resolvePermissions(agentName);
289
- const patterns = composedRules
290
- .filter(
291
- (r) =>
292
- r.layer === "config" &&
293
- r.surface === "path" &&
294
- r.pattern !== "*" &&
295
- r.action !== "allow",
296
- )
297
- .map((r) => r.pattern);
298
- if (patterns.length === 0) return NO_PROMOTION;
299
-
300
- const matchOptions = pathMatchOptions("path", this.flavor);
301
- return (token) =>
302
- patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
303
- }
304
-
305
259
  /**
306
260
  * Get the tool-level permission state for a tool, without considering
307
261
  * command-level rules. Used for tool injection decisions.
@@ -20,7 +20,6 @@ import {
20
20
  resolveToolPreviewLimits,
21
21
  type ToolPreviewFormatterOptions,
22
22
  } from "./tool-preview-formatter";
23
- import type { PathRuleTokenMatcher } from "./types";
24
23
 
25
24
  /**
26
25
  * Encapsulates all mutable session state and exposes operations instead of
@@ -99,11 +98,15 @@ export class PermissionSession implements ToolCallGateInputs {
99
98
  /**
100
99
  * Reset all mutable state for a new session.
101
100
  *
102
- * Configures the injected PermissionManager for `ctx.cwd`, clears skill
101
+ * Configures the injected PermissionManager for `ctx.cwd` (or global-only
102
+ * when `projectTrusted` is `false`, withholding the project cwd so an
103
+ * untrusted project's policy scopes are not loaded, #644), clears skill
103
104
  * entries, and activates the new context.
104
105
  */
105
- resetForNewSession(ctx: ExtensionContext): void {
106
- this.permissionManager.configureForCwd(ctx.cwd);
106
+ resetForNewSession(ctx: ExtensionContext, projectTrusted: boolean): void {
107
+ this.permissionManager.configureForCwd(
108
+ projectTrusted ? ctx.cwd : undefined,
109
+ );
107
110
  this.skillEntries = [];
108
111
  this.activate(ctx);
109
112
  }
@@ -121,9 +124,15 @@ export class PermissionSession implements ToolCallGateInputs {
121
124
  /**
122
125
  * Reload permission manager and clear skill entries for the current context.
123
126
  * Used on config reload (e.g. `resources_discover` with reason "reload").
127
+ *
128
+ * When `projectTrusted` is `false` the project cwd is withheld, so a reload
129
+ * in an untrusted project reloads only global policy; a trust grant on a
130
+ * later reload re-includes the project scope (#644).
124
131
  */
125
- reload(): void {
126
- this.permissionManager.configureForCwd(this.context?.cwd);
132
+ reload(projectTrusted: boolean): void {
133
+ this.permissionManager.configureForCwd(
134
+ projectTrusted ? this.context?.cwd : undefined,
135
+ );
127
136
  this.skillEntries = [];
128
137
  }
129
138
 
@@ -168,9 +177,16 @@ export class PermissionSession implements ToolCallGateInputs {
168
177
 
169
178
  // ── Config ─────────────────────────────────────────────────────────────
170
179
 
171
- /** Reload merged config from disk; optionally update the stored runtime context. */
172
- refreshConfig(ctx?: ExtensionContext): void {
173
- this.configStore.refresh(ctx);
180
+ /**
181
+ * Reload merged config from disk; optionally update the stored runtime
182
+ * context. When `projectTrusted` is `false`, the project scope is withheld
183
+ * so an untrusted project's runtime config is not merged (#644).
184
+ */
185
+ refreshConfig(
186
+ ctx: ExtensionContext | undefined,
187
+ projectTrusted: boolean,
188
+ ): void {
189
+ this.configStore.refresh(ctx, projectTrusted);
174
190
  }
175
191
 
176
192
  /** Write the resolved config path set to the review and debug logs. */
@@ -226,15 +242,4 @@ export class PermissionSession implements ToolCallGateInputs {
226
242
  getPathNormalizer(): PathNormalizer {
227
243
  return this.pathNormalizer;
228
244
  }
229
-
230
- /**
231
- * Predicate deciding whether a bare bash token should be promoted into the
232
- * `path` rule-candidate surface (#509), scoped to the given agent.
233
- *
234
- * Straight delegate to `permissionManager.getPromotablePathTokenMatcher` —
235
- * the manager owns the composed ruleset and the platform-correct match.
236
- */
237
- getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
238
- return this.permissionManager.getPromotablePathTokenMatcher(agentName);
239
- }
240
245
  }
package/src/types.ts CHANGED
@@ -17,17 +17,6 @@ export type {
17
17
  RuleOrigin,
18
18
  };
19
19
 
20
- /**
21
- * Predicate deciding whether a bare bash token should be promoted into the
22
- * `path` rule-candidate surface.
23
- *
24
- * Built by `PermissionManager.getPromotablePathTokenMatcher` from the
25
- * composed config ruleset (specific, non-`*` `path` deny/ask patterns) and
26
- * threaded through to `BashPathResolver` so promotion policy stays in the
27
- * manager while the bash layer only asks the predicate.
28
- */
29
- export type PathRuleTokenMatcher = (token: string) => boolean;
30
-
31
20
  /**
32
21
  * Per-scope permission config shape after loading and validation.
33
22
  * Holds only the flat permission map — all policy is expressed there.