mirror of
https://github.com/OthmanAdi/planning-with-files.git
synced 2026-09-14 16:09:40 +08:00
ef32a32464
Plan resolution was cwd relative with no notion of a thread: PLAN_ID, then .planning/.active_plan, then the newest plan directory by mtime, every read taken relative to the process cwd. A Codex thread sitting at a shared parent such as /workspace therefore saw only the parent's pointer, and the plan of an unrelated project was injected on every prompt and every matched tool call while the nested project's own .active_plan was never consulted (#212). PWF_PLAN_ROOT binds resolution to a project root by absolute path, which a cwd relative PLAN_ID slug structurally cannot express. A pin that does not resolve fails closed with a notice rather than falling back to the ambiguous plan the caller was escaping. Containment is checked against the pin. When the plan was chosen by a cwd guess (the shared pointer, newest by mtime, or a legacy root task_plan.md) and a project one directory below carries its own live plan, nothing is injected and the notice names both escape hatches. An explicit PLAN_ID, a pin, or an attached session stays authoritative and skips the check. Detection is one glob at depth one, which is the whole per fire budget. A nested pointer that is empty or names a deleted directory does not compete, so an abandoned subproject cannot kill injection at the root. The session attachment guard from #146 now runs on this route too. It is not silent: on hosts that never set PWF_SESSION_ID every session is unattached by construction, so a stale .planning/sessions/ directory would otherwise kill injection permanently with no symptom, and .planning/ is gitignored so the state is invisible to review. The notice is turn scoped so it cannot become per tool call spam. Also fixed here, both found by the new tests: - ledger-summary.sh now takes the resolved plan directory as an argument. Under a pin it previously re-resolved from the process cwd, so an autonomous loop could read the parent's "1/1 complete" while its own pinned plan had an open phase. With no plan directory determinable it says so instead of reporting a confident 0/0. - GNU sha256sum prefixes its output line with a backslash when the filename needs escaping, which any Windows style pin triggers, so every attested pinned plan reported PLAN TAMPERED on every fire. plan-doctor reports a refusal as its own state with the remedy. It previously counted the refusal notice as bytes of plan context and printed PASS, so the one tool a dark user is pointed at gave a green light. The legacy invariant holds: with no pin, no sessions directory and no nested competing plan, output is byte identical to the previous release across every context and mode, verified by diffing captured stdout against HEAD.
107 lines
4.2 KiB
PowerShell
107 lines
4.2 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
|
|
|
|
# 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) {
|
|
if (Test-Path -LiteralPath $env:PWF_PLAN_ROOT -PathType Container) {
|
|
$projectRoot = $env:PWF_PLAN_ROOT
|
|
$PlanRoot = Join-Path $env:PWF_PLAN_ROOT ".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 = (Resolve-Path -LiteralPath $projectRoot -ErrorAction Stop).Path
|
|
$candReal = (Resolve-Path -LiteralPath $Candidate -ErrorAction Stop).Path
|
|
} 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"
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Test-Path $activeFile) {
|
|
$planId = (Get-Content $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
|