---
issue: 571
issue_title: "Unify subagent-context containment onto PathFlavor.isWithin"
---

# Unify subagent-context containment onto `PathFlavor.isWithin`

## Release Recommendation

**Release:** ship independently

Phase 11 Step 5 carries a `Release: independent` tag ([#571] in `docs/architecture/architecture.md`), with no batch membership.
The change is `refactor:` + `test:` only (both `hidden: true` changelog types), so it will not cut a release on its own — it lands on `main` and auto-batches into the next `feat:`/`fix:`/unhidden-`docs:` release for the package.
"Ship independently" here means it carries no cross-issue release coupling, not that it triggers a release by itself.

## Problem Statement

`src/authority/subagent-context.ts` carries its own containment helper, `isPathWithinDirectoryForSubagent`, a string-prefix check (`pathValue.startsWith(directory + sep)`).
It answers the same question as `PathFlavor.isWithin` (`src/path/path-flavor.ts`) — "is this path inside that directory under this platform?"
— but with a different algorithm: a `path.relative`-based geometry versus a raw prefix compare.
Two algorithms for one must-agree question is the connascence-of-algorithm smell [#562] set out to remove; [#562] deferred this site by design because unifying it is nominally behavior-affecting, not a pure relocation, and so needs its own change with targeted tests.

## Goals

- Replace `isPathWithinDirectoryForSubagent` with `flavor.isWithin(...)` inside `isSubagentExecutionContext`, so subagent filesystem detection uses the same containment geometry as the path gates.
- Delete the now-unused private helper.
- Pin the containment edge cases (`..` in a session dir, sibling directory sharing a string prefix, cross-root) with tests on both `posix` and `win32` flavors, documenting the intended post-change behavior.
- Drive the `startsWith(prefix)`-in-`subagent-context.ts` count from 1 to 0 (Phase 11 Step 5 outcome).

This change is **not breaking**.
It preserves the observable subagent-detection outcome for every realistic input (see Design Overview); it is an internal algorithm unification with a characterization-test safety net.

## Non-Goals

- No change to the registry or env-var detection branches of `isSubagentExecutionContext` — only the filesystem fallback's containment call changes.
- No change to `normalizeFilesystemPath` (the normalize + fold pre-step stays; it is what keeps `flavor.isWithin` case- and separator-correct on win32).
- No change to `PathFlavor.isWithin` itself — it is already the package's single containment primitive after [#562].
- No new public API, no config surface, no schema/docs/config-example touch.

## Background

- `isSubagentExecutionContext(ctx, subagentSessionsDir, flavor, registry?)` (`src/authority/subagent-context.ts`) decides whether the current process is a subagent via three branches, in priority order: (1) explicit registry membership, (2) subagent env hints, (3) a filesystem fallback — is the session dir within the known subagent-sessions root?
  Only branch 3 uses the prefix helper.
- Branch 3 first normalizes **both** operands through `normalizeFilesystemPath` (which calls `flavor.impl.normalize` then `flavor.fold` — so `..` segments are collapsed and win32 values are lowercased) before the containment compare.
- `PathFlavor.isWithin(pathValue, directory)` (`src/path/path-flavor.ts`) is `path.relative`-based: equal ⇒ true; otherwise `relative(directory, pathValue)` must be non-empty, not `..`, not `..`-prefixed, and not absolute.
  It does not fold case internally — callers pass already-folded values, exactly as branch 3 does.
- `isSubagentExecutionContext` already receives `flavor: PathFlavor` as a parameter, so `flavor.isWithin` is in scope with no signature change.
- AGENTS.md / package skill constraint: no `process.platform` read may enter `src/` outside `index.ts`; this change adds none — it removes hand-rolled `flavor.impl.sep` prefix logic in favor of the flavor's own `isWithin`, tightening the "every platform question delegates to `flavor`" invariant.

## Design Overview

### Behavioral parity finding

The two algorithms **agree on every realistic normalized-absolute input**, because branch 3 normalizes both operands before the compare.
Verified empirically against `path.posix` (identical logic for `path.win32`):

| `pathValue` (pre-norm)      | `directory`       | normalized          | prefix check | `isWithin` |
| --------------------------- | ----------------- | ------------------- | ------------ | ---------- |
| `/root/subagents/x`         | `/root/subagents` | `/root/subagents/x` | true         | true       |
| `/root/subagents`           | `/root/subagents` | `/root/subagents`   | true         | true       |
| `/root/subagents-extra/x`   | `/root/subagents` | (unchanged)         | false        | false      |
| `/root/main`                | `/root/subagents` | (unchanged)         | false        | false      |
| `/root/subagents/../evil/x` | `/root/subagents` | `/root/evil/x`      | false        | false      |
| `/root`                     | `/root/subagents` | `/root`             | false        | false      |
| `/root/subagents/a/../b`    | `/root/subagents` | `/root/subagents/b` | true         | true       |

The prefix check already appends the separator (`prefix = directory + sep`), so it rejects sibling-prefix dirs (`subagents-extra`) correctly; and `normalizeFilesystemPath` collapses `..` before either algorithm runs, so the theoretical `..`/cross-root divergences never reach the compare.
Session dirs are always absolute, so `..` cannot survive normalization as a leading segment.

The consequence: this is a behavior-preserving refactor with a characterization-test net, not a behavior change.
[#562]'s "behavior-affecting" caution was the reason to give it its own commit with tests — the tests **confirm** equivalence rather than reveal a change.
The plan states this honestly instead of asserting a divergence the inputs cannot produce.

### The change

`isSubagentExecutionContext` branch 3, after computing `normalizedSessionDir` and `normalizedSubagentRoot`:

```typescript
// before
return isPathWithinDirectoryForSubagent(
  normalizedSessionDir,
  normalizedSubagentRoot,
  flavor,
);

// after
return flavor.isWithin(normalizedSessionDir, normalizedSubagentRoot);
```

Then delete the private `isPathWithinDirectoryForSubagent` function entirely (it has exactly one call site, the line above).

`flavor.isWithin` carries its own empty-operand guard (`if (!pathValue || !directory) return false`) and equal-operand short-circuit, so the helper's own guards are subsumed — no behavior is lost by deleting it.

### Design-review check

No shared interface widens, no new collaborator is introduced, no layer wiring changes.
`flavor` is already a parameter; the change narrows `subagent-context.ts`'s dependency from "a private algorithm plus `flavor.impl.sep`" to "one `flavor` method call" — a strict reduction in surface.
No Tell-Don't-Ask or Law-of-Demeter concern: `flavor.isWithin(a, b)` is a single tell.

## Module-Level Changes

- `src/authority/subagent-context.ts`
  - Remove the private `isPathWithinDirectoryForSubagent` function.
  - Replace its sole call in `isSubagentExecutionContext` with `flavor.isWithin(normalizedSessionDir, normalizedSubagentRoot)`.
  - `normalizeFilesystemPath` and the two normalize calls are unchanged.
- `test/authority/subagent-context.test.ts`
  - Add characterization tests to the "session dir detection" describe block covering, on **both** `posixPathFlavor` and `win32PathFlavor`: `..` inside the session dir (normalizes to an outside path ⇒ false), a sibling directory sharing a string prefix (⇒ false), and a cross-root path (posix `/other/...`; win32 different drive letter ⇒ false), plus the nested-and-equal true cases for win32 (the posix nested/equal/sibling/outside cases already exist).
- `packages/pi-permission-system/docs/architecture/architecture.md` (implementation doc-update, per the package skill — lands in the same commit as the code change, not deferred to ship)
  - Mark Phase 11 Step 5 complete: `✅` on the `#### Step 5:` heading and on the `S5[...]` node in the "Step dependency diagram" Mermaid block.
  - The "Subagent prefix-containment sites" health-metric row already targets `0`; the recompute command (`grep -c 'startsWith(prefix)' ...`) now returns `0`, so the target is met — no row value edit is required, but confirm the row still reads correctly after the step is marked done.

No other `src/`, `test/`, `docs/`, or skill file references the private helper as a live symbol.
The historical mentions in `docs/plans/0382-*.md`, `docs/plans/0505-*.md`, `docs/plans/0510-*.md`, `docs/plans/0562-*.md`, and `docs/retro/0562-*.md` describe the deferral decision at the time and are intentionally left as-is (they are dated records, not current-state claims).
The package skill's containment-idiom guidance (`.pi/skills/package-pi-permission-system/SKILL.md`) is generic and already describes the `relative()`-based idiom this change adopts — no edit needed.

## Test Impact Analysis

1. **New tests enabled:** the swap enables (does not require) explicit win32-flavor edge-case coverage of the filesystem branch — previously the branch's win32 behavior was only implicitly covered by `normalizeFilesystemPath` unit tests plus posix session-dir tests.
   The new tests pin the containment geometry directly on both flavors.
2. **Redundant tests:** none become redundant.
   The existing posix "session dir detection" tests (nested / equal / sibling-prefix / outside / null / empty) remain valid and continue to characterize the exact behavior — they pass unchanged before and after the swap, which is precisely the safety-net property being asserted.
3. **Tests that must stay:** all existing `subagent-context.test.ts` cases stay — the registry and env-hint branches are untouched, and the posix session-dir cases are the primary equivalence anchor.

## Invariants at risk

The touched surface is `subagent-context.ts`'s filesystem-detection branch, last shaped by [#510] (inject `flavor`/platform) and referenced by [#562] (the deferral).
Invariants and their pinning tests:

- **Equal session-dir-and-root ⇒ subagent** — pinned by `"returns true when session dir equals subagent root"`; `flavor.isWithin` preserves it via its equal short-circuit.
- **Nested session dir ⇒ subagent** — pinned by `"returns true when session dir is within subagent root"`.
- **Sibling with shared string prefix ⇒ not subagent** — pinned by `"returns false when session dir is a sibling with shared prefix"`; `flavor.isWithin` preserves it (the `..`-prefixed `relative` result).
- **Empty / null session dir ⇒ not subagent** — pinned by the null/empty tests; `flavor.isWithin`'s empty-operand guard preserves it.
- **Registry and env-hint priority ordering** — pinned by the "registry detection" describe block; untouched by this change.

No earlier phase step's documented `Outcome:` is regressed — this step's own outcome (one containment algorithm) tightens [#562]'s invariant rather than loosening it.

## TDD Order

This is a behavior-preserving refactor, so the sequence is cover-then-refactor (tests green throughout) rather than red→green.

1. **Characterization tests** (`test:`)
   - Surface: `test/authority/subagent-context.test.ts`, "session dir detection" describe block.
   - Add win32-flavor nested / equal / sibling-prefix / cross-root (different drive) cases and posix/​win32 `..`-in-session-dir cases, asserting the current behavior.
   - These pass green against the existing `isPathWithinDirectoryForSubagent` implementation — they lock the behavior before the swap.
   - Commit: `test(permission-system): pin subagent-context containment edge cases (#571)`.

2. **Swap onto `flavor.isWithin` and delete the helper** (`refactor:`)
   - Replace the helper call with `flavor.isWithin(normalizedSessionDir, normalizedSubagentRoot)` and remove the private `isPathWithinDirectoryForSubagent` function.
   - All step-1 tests plus the pre-existing suite stay green; `grep -c 'startsWith(prefix)' src/authority/subagent-context.ts` returns `0`.
   - Mark Phase 11 Step 5 `✅` in `docs/architecture/architecture.md` (heading + Mermaid `S5` node) in this same commit.
   - Commit: `refactor(permission-system): unify subagent-context containment onto PathFlavor.isWithin (#571)`.

## Risks and Mitigations

- **Risk:** an untested edge case where the prefix check and `isWithin` diverge slips through, silently changing subagent detection.
  **Mitigation:** the step-1 characterization tests cover the three edge families named in the issue on both flavors, run green before the swap; the empirical parity table shows agreement on every case; the full existing suite is the regression net.
- **Risk:** dropping the helper's empty-operand / equal guards changes behavior.
  **Mitigation:** `flavor.isWithin` carries the same two guards; the null/empty and equal tests confirm parity.
- **Risk:** win32 case/separator handling regresses.
  **Mitigation:** the `normalizeFilesystemPath` normalize+fold pre-step is retained, and new tests exercise `win32PathFlavor` directly (no `vi.mock("node:path")`, per the package skill).

## Open Questions

None.
The change is fully specified by Phase 11 Step 5 and the parity finding above; no follow-up issues are warranted.

[#510]: https://github.com/gotgenes/pi-packages/issues/510
[#562]: https://github.com/gotgenes/pi-packages/issues/562
[#571]: https://github.com/gotgenes/pi-packages/issues/571
