Files
othmanadi__planning-with-files/scripts/resolve-plan-dir.ps1
T
OthmanAdi 3749f86ac3 fix: bind explicit plan selectors, floor slug modes on the project root, and classify doctor output on the data frame
Three defects reported by Raymond Manaloto (@sortakool), each reproduced on
Windows with Git Bash before any code changed. Two of them let a session work
on a plan nobody selected; the third made the diagnostic that should have
caught them report PASS.

closes #237: an explicit PLAN_ID was a hint, not a binding

resolve_from_env returned 1 both when no selector was set and when the selector
was rejected, so a PLAN_ID of valid slug shape naming no directory fell through
to .active_plan and then to newest-by-mtime. attest-plan.sh locked that other
plan at rc=0 and injection followed it. The guard in attest-plan.sh written for
exactly this case was unreachable, because the resolver never returned empty.
commands/plan-attest.md has promised the opposite since v3.9.0.

A non-empty PLAN_ID now resolves or stops, for every rejection reason: slug
shape (traversal included), naming no directory, or failing containment. An
empty value still means unset, which init-session.sh depends on. The rule went
into every resolver, because they disagreed otherwise: resolve-plan-dir.sh,
resolve-plan-dir.ps1, the inline resolver inside inject-plan.sh, and the
Hermes, OpenCode and Pi plugin resolvers. v3.14.0 had made the two plugin
resolvers fall through to match the shell; that parity was right and its target
was wrong.

Fixing the resolver alone would have moved the bug rather than removed it, so
every consumer was audited. check-complete.sh would have read the root plan's
completion state, which decides whether an autonomous run may stop.
ledger-summary.sh feeds phase counts into autonomous turns. phase-status.sh and
ledger-append.sh write into the directory they pick. The five Codex hooks call
the resolver directly and each fell through to the legacy root, stop.sh and
pre-compact.sh included. All of them now stop on a rejected selector, and the
once-per-turn routes print a notice, because a fail-closed path with no stated
cause would trade a wrong-plan bug for a silent one.

closes #238: a slug plan with no .mode bypassed the project's root .mode

A project makes attestation mandatory by committing a root .mode, a reviewed
project setting. inject-plan.sh read the slug's .mode and never the root's, and
init-session.sh writes no .mode unless --autonomous or --gated was passed, so
init-session.sh <name>, which /plan runs, produced a plan with no attestation
requirement and full injection. The policy was a flag the agent chose at plan
creation.

The root .mode is now a floor. Strictness-raising tokens count from either
file, so a slug may raise and never lower; the one strictness-lowering token,
plan-guard-off, needs the root's agreement. The same floor applies to the
completion gate in check-complete.sh and check-complete.ps1, which read .mode
the same way and had the same bypass, and to the Hermes and OpenCode plugins.
init-session.sh seeds a new slug from the root .mode so the effective policy is
visible in the plan directory, not only in the resolver. With no root .mode
present the effective token set is exactly the slug's, so existing projects are
unchanged.

closes #236: plan-doctor.sh reported PASS on a fully dark-hooks state

The injection check substring-matched five control strings against output that
carries the plan body verbatim inside the BEGIN-PWF-DATA fences. A plan whose
phase line read "fix the false PLAN TAMPERED warning in plan-doctor" reported a
hash mismatch while correctly attested. Worse, the PWF_PLAN_ROOT arm matched a
literal that is not a substring of what inject-plan.sh emits, so the arm was
dead code and execution fell to the success arm: PWF_PLAN_ROOT=/nonexistent
printed "PASS injection: emits plan context (123 bytes)" while nothing was
injected, counting the refusal notice's own bytes as plan context. The file's
own comment four lines above already warned against exactly that.

Classification now branches on the frame. Every refusal path exits before
frame_file runs, so a frame proves injection happened and unframed output is by
construction a notice. The default arm warns, so a reworded or translated
banner degrades noisily instead of silently. The stale literal is corrected
against the emitting echo.

Tests: Python suite 653 to 696, OpenCode Vitest 28 to 34. New files
test_plan_selector_binding.py, test_root_mode_floor.py and
test_plan_doctor_classification.py; new arms in test_resolver_parity.py,
test_resolve_plan_dir.py, test_resolver_plan_root_pin.py and
test_hermes_first_class.py. Five existing tests asserted the fall-through and
were rewritten to assert the refusal, keeping their original security
assertion. Each suite carries a control arm: two first drafts passed on
fixtures that could not have failed, a gate assertion over a plan with every
phase complete and a parallel-write-guard assertion over a plan with nothing
checked to lose.
2026-09-03 00:23:03 +02:00

185 lines
7.9 KiB
PowerShell

# planning-with-files: resolve active plan directory (PowerShell mirror).
#
# Resolution order matches scripts/resolve-plan-dir.sh:
# 1. $env:PLAN_ID -> .\.planning\$PLAN_ID\
# 2. .\.planning\.active_plan content
# 3. Newest .\.planning\<dir>\ by LastWriteTime
# 4. Empty (legacy fallback to .\task_plan.md handled by caller)
#
# v3.8.0 parity with the sh resolver: slug validation on every branch, the
# newest-dir scan requires task_plan.md inside the candidate (a sessions/ or
# artifacts/ dir must never win), and containment fails CLOSED when
# canonicalization fails. Only successful canonicalization can rule out a
# junction/symlink escape; slug validation alone blocks textual traversal.
param(
[string]$PlanRoot = (Join-Path (Get-Location) ".planning")
)
$projectRoot = (Get-Location).Path
# Resolve-Path is lexical for Windows junctions: it can return the junction's
# spelling rather than the directory opened by the filesystem. Use a directory
# handle and GetFinalPathNameByHandleW on Windows so containment is decided from
# the object the kernel actually opened.
$script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
if ($script:IsWindowsHost -and -not ("PwfResolverNative" -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;
public static class PwfResolverNative {
private const uint FILE_SHARE_READ = 0x00000001;
private const uint FILE_SHARE_WRITE = 0x00000002;
private const uint FILE_SHARE_DELETE = 0x00000004;
private const uint OPEN_EXISTING = 3;
private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string name, uint access, uint share, IntPtr security,
uint creation, uint flags, IntPtr template);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle handle, StringBuilder path, uint length, uint flags);
public static string FinalDirectoryPath(string path) {
using (SafeFileHandle handle = CreateFileW(
path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero)) {
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
StringBuilder buffer = new StringBuilder(32768);
uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
if (length == 0 || length >= buffer.Capacity)
throw new Win32Exception(Marshal.GetLastWin32Error());
string result = buffer.ToString();
if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
return @"\\" + result.Substring(8);
if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
return result.Substring(4);
return result;
}
}
}
'@
}
function Get-FinalDirectoryPath {
param([string]$Path)
if ($script:IsWindowsHost) {
return [PwfResolverNative]::FinalDirectoryPath($Path)
}
return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
}
# PWF_PLAN_ROOT: absolute plan-root binding (issue #212), mirroring
# resolve-plan-dir.sh. A thread whose cwd is a shared PARENT of the real
# project resolves the parent's plan and never sees the nested one;
# PWF_PLAN_ROOT names the project root whose .planning must be used. Highest
# precedence: it overrides both the cwd default and the -PlanRoot argument
# (an adapter passing ".planning" is spelling out the cwd default, not
# overriding a user's deliberate pin). A pin that is not a directory fails
# CLOSED: the resolver emits nothing, so no caller can be handed the
# ambiguous cwd plan the pin was escaping (injection routes own the
# user-facing notice; stdout here is the data channel). Containment is then
# checked against the pinned root. Unset keeps legacy behavior unchanged.
if ($env:PWF_PLAN_ROOT) {
$pin = $env:PWF_PLAN_ROOT
$isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
$isAbsolute = [System.IO.Path]::IsPathFullyQualified($pin)
if ($isAbsolute -and -not $isUnc -and (Test-Path -LiteralPath $pin -PathType Container)) {
$projectRoot = $pin
$PlanRoot = Join-Path $pin ".planning"
} else {
exit 0
}
}
# Same shape as the sh resolver's slug_is_valid: first char [A-Za-z0-9_],
# rest [A-Za-z0-9._-]. Blocks traversal tokens before any path is built.
function Test-ValidSlug {
param([string]$Name)
if (-not $Name) { return $false }
return $Name -match '^[A-Za-z0-9_][A-Za-z0-9._-]*$'
}
# Containment guard (security A1.3): a resolved plan dir must canonicalize to
# a path under the project root. A directory symlink/junction inside a valid
# slug pointing outside the workspace would otherwise let the hooks hash and
# inject an arbitrary file. Resolve-Path follows reparse points; we compare
# the real paths. Fails CLOSED on canonicalization failure, matching
# resolve-plan-dir.sh.
function Test-WithinRoot {
param([string]$Candidate)
try {
$rootReal = Get-FinalDirectoryPath $projectRoot
$candReal = Get-FinalDirectoryPath $Candidate
} catch {
return $false
}
if (-not $rootReal -or -not $candReal) { return $false }
$rootNorm = $rootReal.TrimEnd('\', '/')
$candNorm = $candReal.TrimEnd('\', '/')
if ($candNorm -eq $rootNorm) { return $true }
return $candNorm.StartsWith($rootNorm + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
}
$activeFile = Join-Path $PlanRoot ".active_plan"
# A set PLAN_ID is a BINDING, not a hint (issue #237). A selector that names
# no directory, fails slug validation, or fails containment terminates
# resolution instead of falling through to .active_plan and newest-by-mtime:
# the fall-through let a one-character typo attest and inject a DIFFERENT plan
# at rc=0. Emptiness is the fail-closed signal on this channel, matching
# resolve-plan-dir.sh and the PWF_PLAN_ROOT pin. An empty $env:PLAN_ID is
# falsy here and still means "unset".
if ($env:PLAN_ID) {
if (Test-ValidSlug $env:PLAN_ID) {
$candidate = Join-Path $PlanRoot $env:PLAN_ID
if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
}
exit 0
}
# Get-Item observes the link object even when its target is missing, unlike
# Test-Path which follows the target. An active pointer that is a directory or
# reparse point is an unsafe/ambiguous selector and must terminate resolution;
# falling through would silently select and expose the newest unrelated plan.
$activeItem = Get-Item -LiteralPath $activeFile -Force -ErrorAction SilentlyContinue
if ($activeItem) {
if ($activeItem.PSIsContainer -or
(($activeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
exit 0
}
$planId = (Get-Content -LiteralPath $activeFile -Raw).Trim()
if ($planId -and (Test-ValidSlug $planId)) {
$candidate = Join-Path $PlanRoot $planId
if ((Test-Path $candidate -PathType Container) -and (Test-WithinRoot $candidate)) {
Write-Output $candidate
exit 0
}
}
}
if (Test-Path $PlanRoot -PathType Container) {
$latest = Get-ChildItem -Path $PlanRoot -Directory |
Where-Object { -not $_.Name.StartsWith('.') } |
Where-Object { Test-ValidSlug $_.Name } |
Where-Object { Test-Path (Join-Path $_.FullName "task_plan.md") -PathType Leaf } |
Where-Object { Test-WithinRoot $_.FullName } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if ($latest) {
Write-Output $latest.FullName
}
}
exit 0