planning-with-files 3.10.2 → 3.12.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "planning-with-files",
3
- "version": "3.10.2",
4
- "description": "Persistent file-based planning for AI coding agents: keeps task_plan.md, findings.md and progress.md on disk so the plan survives /clear, context loss and compaction. Manus-style. Ships the skill plus a Pi Coding Agent extension.",
3
+ "version": "3.12.0",
4
+ "description": "Persistent project planning with selected context injection. Automatic recovery uses project files only; explicit catchup modes read same-project local session records for aggregate counts or bounded replay. The host-aware gate never runs Markdown-declared commands. No network upload path. Ships the skill plus a Pi Coding Agent extension.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-skill",
@@ -24,6 +24,9 @@
24
24
  "extensions/planning-with-files/index.ts"
25
25
  ]
26
26
  },
27
+ "scripts": {
28
+ "prepack": "node scripts/verify-shell-line-endings.mjs"
29
+ },
27
30
  "files": [
28
31
  "README.md",
29
32
  "SKILL.md",
@@ -1,137 +1,484 @@
1
- #requires -Version 5.0
2
- <#
3
- .SYNOPSIS
4
- Lock the current task_plan.md content with a SHA-256 attestation.
5
-
6
- .DESCRIPTION
7
- Use after you finalise (or intentionally edit) a plan. The hooks then refuse
8
- to inject plan content into the model context if the file diverges from the
9
- attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
10
-
11
- Plan resolution:
12
- 1. $env:PLAN_ID -> ./.planning/$PLAN_ID/
13
- 2. ./.planning/.active_plan
14
- 3. Newest ./.planning/<dir>/ by LastWriteTime
15
- 4. Legacy ./task_plan.md at project root
16
-
17
- .PARAMETER Show
18
- Print the stored hash for the active plan.
19
-
20
- .PARAMETER Clear
21
- Remove the attestation (re-open the plan).
22
- #>
23
- [CmdletBinding(DefaultParameterSetName = "Attest")]
24
- param(
25
- [Parameter(ParameterSetName = "Show")]
26
- [switch] $Show,
27
-
28
- [Parameter(ParameterSetName = "Clear")]
29
- [switch] $Clear
30
- )
31
-
32
- $ErrorActionPreference = "Stop"
33
-
34
- function Resolve-PlanFile {
35
- $planRoot = Join-Path (Get-Location) ".planning"
36
-
37
- if ($env:PLAN_ID) {
38
- $candidate = Join-Path $planRoot $env:PLAN_ID
39
- $planFile = Join-Path $candidate "task_plan.md"
40
- if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
41
- }
42
-
43
- $activePointer = Join-Path $planRoot ".active_plan"
44
- if (Test-Path -LiteralPath $activePointer) {
45
- $planId = (Get-Content -LiteralPath $activePointer -Raw).Trim()
46
- if ($planId) {
47
- $candidate = Join-Path $planRoot $planId
48
- $planFile = Join-Path $candidate "task_plan.md"
49
- if (Test-Path -LiteralPath $planFile) { return (Resolve-Path -LiteralPath $planFile).Path }
50
- }
51
- }
52
-
53
- if (Test-Path -LiteralPath $planRoot) {
54
- $newest = Get-ChildItem -LiteralPath $planRoot -Directory -ErrorAction SilentlyContinue |
55
- Where-Object { -not $_.Name.StartsWith(".") } |
56
- Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "task_plan.md") } |
57
- Sort-Object LastWriteTime -Descending |
58
- Select-Object -First 1
59
- if ($newest) {
60
- return (Resolve-Path -LiteralPath (Join-Path $newest.FullName "task_plan.md")).Path
61
- }
62
- }
63
-
64
- $legacy = Join-Path (Get-Location) "task_plan.md"
65
- if (Test-Path -LiteralPath $legacy) {
66
- return (Resolve-Path -LiteralPath $legacy).Path
67
- }
68
-
69
- return $null
70
- }
71
-
72
- function Get-AttestationPath {
73
- param([string] $PlanFile)
74
- $planDir = Split-Path -Parent $PlanFile
75
- $cwd = (Get-Location).Path
76
- if ($planDir -eq $cwd) {
77
- return (Join-Path $cwd ".plan-attestation")
78
- }
79
- return (Join-Path $planDir ".attestation")
80
- }
81
-
82
- $planFile = Resolve-PlanFile
83
- if (-not $planFile) {
84
- Write-Error "[plan-attest] No task_plan.md found. Create a plan first."
85
- exit 1
86
- }
87
-
88
- $attestationFile = Get-AttestationPath -PlanFile $planFile
89
-
90
- if ($Show) {
91
- if (Test-Path -LiteralPath $attestationFile) {
92
- Write-Output "Plan: $planFile"
93
- Write-Output "Attestation: $attestationFile"
94
- Write-Output ("SHA-256: " + (Get-Content -LiteralPath $attestationFile -Raw).Trim())
95
- # Nonce (security A1.4): surface the per-plan nonce if init-session
96
- # generated one next to the attestation. Informational only here; the
97
- # hooks consume it to build collision-proof BEGIN/END delimiters.
98
- $nonceFile = Join-Path (Split-Path -Parent $attestationFile) ".nonce"
99
- if (Test-Path -LiteralPath $nonceFile) {
100
- $nonceVal = (Get-Content -LiteralPath $nonceFile -Raw).Trim()
101
- if ($nonceVal) { Write-Output "Nonce: $nonceVal" }
102
- }
103
- } else {
104
- Write-Output "[plan-attest] No attestation set for $planFile."
105
- exit 1
106
- }
107
- exit 0
108
- }
109
-
110
- if ($Clear) {
111
- if (Test-Path -LiteralPath $attestationFile) {
112
- Remove-Item -LiteralPath $attestationFile -Force
113
- Write-Output "[plan-attest] Cleared attestation for $planFile."
114
- } else {
115
- Write-Output "[plan-attest] No attestation to clear."
116
- }
117
- exit 0
118
- }
119
-
120
- $hashVal = (Get-FileHash -LiteralPath $planFile -Algorithm SHA256).Hash.ToLowerInvariant()
121
- Set-Content -LiteralPath $attestationFile -Value $hashVal -NoNewline -Encoding ascii
122
-
123
- # Integrity verification (security A2.1): confirm the on-disk attestation
124
- # matches the intended hash before reporting success. A silent write failure
125
- # (permissions, full disk) must not leave a stale attestation and exit clean.
126
- $storedHash = (Get-Content -LiteralPath $attestationFile -Raw -ErrorAction SilentlyContinue)
127
- if ($null -ne $storedHash) { $storedHash = $storedHash.Trim() }
128
- if ($storedHash -ne $hashVal) {
129
- Write-Error "[plan-attest] Attestation write verification FAILED for $attestationFile. Expected $hashVal, found $storedHash. The plan is NOT attested."
130
- exit 1
131
- }
132
-
133
- $short = $hashVal.Substring(0, 12)
134
- Write-Output "[plan-attest] Locked $planFile"
135
- Write-Output "[plan-attest] SHA-256: $short... (stored in $attestationFile)"
136
- Write-Output "[plan-attest] Hooks will block injection if the file is modified without re-running this command."
137
- exit 0
1
+ #requires -Version 5.0
2
+ <#
3
+ .SYNOPSIS
4
+ Lock the current task_plan.md content with a SHA-256 attestation.
5
+
6
+ .DESCRIPTION
7
+ Use after you finalise (or intentionally edit) a plan. The hooks then refuse
8
+ to inject plan content into the model context if the file diverges from the
9
+ attested hash, surfacing a "[PLAN TAMPERED]" warning instead.
10
+
11
+ Plan resolution:
12
+ 1. $env:PLAN_ID -> ./.planning/$PLAN_ID/
13
+ 2. ./.planning/.active_plan
14
+ 3. Newest ./.planning/<dir>/ by LastWriteTime
15
+ 4. Legacy ./task_plan.md at project root
16
+
17
+ .PARAMETER Show
18
+ Print the stored hash for the active plan.
19
+
20
+ .PARAMETER Clear
21
+ Remove the attestation (re-open the plan).
22
+ #>
23
+ [CmdletBinding(DefaultParameterSetName = "Attest")]
24
+ param(
25
+ [Parameter(ParameterSetName = "Show")]
26
+ [switch] $Show,
27
+
28
+ [Parameter(ParameterSetName = "Clear")]
29
+ [switch] $Clear
30
+ )
31
+
32
+ $ErrorActionPreference = "Stop"
33
+
34
+ $script:IsWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT
35
+ if (-not $script:IsWindowsHost) {
36
+ throw "Safe no-follow descriptor operations are unavailable in this PowerShell script on Unix. Use scripts/attest-plan.sh instead."
37
+ }
38
+ if ($script:IsWindowsHost -and -not ("PwfAttestationNative" -as [type])) {
39
+ Add-Type -TypeDefinition @'
40
+ using System;
41
+ using System.ComponentModel;
42
+ using System.IO;
43
+ using System.Runtime.InteropServices;
44
+ using System.Text;
45
+ using Microsoft.Win32.SafeHandles;
46
+
47
+ public static class PwfAttestationNative {
48
+ private const uint GENERIC_READ = 0x80000000;
49
+ private const uint GENERIC_WRITE = 0x40000000;
50
+ private const uint DELETE = 0x00010000;
51
+ private const uint FILE_READ_ATTRIBUTES = 0x00000080;
52
+ private const uint FILE_SHARE_READ = 0x00000001;
53
+ private const uint FILE_SHARE_WRITE = 0x00000002;
54
+ private const uint FILE_SHARE_DELETE = 0x00000004;
55
+ private const uint CREATE_NEW = 1;
56
+ private const uint OPEN_EXISTING = 3;
57
+ private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
58
+ private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
59
+ private const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400;
60
+ private const int FileAttributeTagInfo = 9;
61
+ private const int FileDispositionInfo = 4;
62
+ private const int ERROR_FILE_EXISTS = 80;
63
+ private const int ERROR_ALREADY_EXISTS = 183;
64
+
65
+ [StructLayout(LayoutKind.Sequential)]
66
+ private struct FILE_ATTRIBUTE_TAG_INFO {
67
+ public uint FileAttributes;
68
+ public uint ReparseTag;
69
+ }
70
+
71
+ [StructLayout(LayoutKind.Sequential)]
72
+ private struct BY_HANDLE_FILE_INFORMATION {
73
+ public uint FileAttributes;
74
+ public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
75
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
76
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
77
+ public uint VolumeSerialNumber;
78
+ public uint FileSizeHigh;
79
+ public uint FileSizeLow;
80
+ public uint NumberOfLinks;
81
+ public uint FileIndexHigh;
82
+ public uint FileIndexLow;
83
+ }
84
+
85
+ [StructLayout(LayoutKind.Sequential)]
86
+ private struct FILE_DISPOSITION_INFO {
87
+ [MarshalAs(UnmanagedType.Bool)] public bool DeleteFile;
88
+ }
89
+
90
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
91
+ private static extern SafeFileHandle CreateFileW(
92
+ string name, uint access, uint share, IntPtr security,
93
+ uint creation, uint flags, IntPtr template);
94
+
95
+ [DllImport("kernel32.dll", SetLastError = true)]
96
+ private static extern bool GetFileInformationByHandleEx(
97
+ SafeFileHandle handle, int infoClass,
98
+ out FILE_ATTRIBUTE_TAG_INFO info, uint size);
99
+
100
+ [DllImport("kernel32.dll", SetLastError = true)]
101
+ private static extern bool GetFileInformationByHandle(
102
+ SafeFileHandle handle, out BY_HANDLE_FILE_INFORMATION info);
103
+
104
+ [DllImport("kernel32.dll", SetLastError = true)]
105
+ private static extern bool SetFileInformationByHandle(
106
+ SafeFileHandle handle, int infoClass,
107
+ ref FILE_DISPOSITION_INFO info, uint size);
108
+
109
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
110
+ private static extern uint GetFinalPathNameByHandleW(
111
+ SafeFileHandle handle, StringBuilder path, uint length, uint flags);
112
+
113
+ private static void ValidateRegular(SafeFileHandle handle, bool singleLink) {
114
+ FILE_ATTRIBUTE_TAG_INFO tag;
115
+ if (!GetFileInformationByHandleEx(
116
+ handle, FileAttributeTagInfo, out tag,
117
+ (uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
118
+ throw new Win32Exception(Marshal.GetLastWin32Error());
119
+ if ((tag.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0)
120
+ throw new IOException("Refusing a reparse-point file.");
121
+ if ((tag.FileAttributes & (uint)FileAttributes.Directory) != 0)
122
+ throw new IOException("Refusing a directory where a regular file is required.");
123
+ if (singleLink) {
124
+ BY_HANDLE_FILE_INFORMATION info;
125
+ if (!GetFileInformationByHandle(handle, out info))
126
+ throw new Win32Exception(Marshal.GetLastWin32Error());
127
+ if (info.NumberOfLinks != 1)
128
+ throw new IOException("Refusing a multiply-linked attestation file.");
129
+ }
130
+ }
131
+
132
+ public static SafeFileHandle OpenRead(string path, bool singleLink) {
133
+ SafeFileHandle handle = CreateFileW(
134
+ path, GENERIC_READ | FILE_READ_ATTRIBUTES,
135
+ FILE_SHARE_READ, IntPtr.Zero, OPEN_EXISTING,
136
+ FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
137
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
138
+ try { ValidateRegular(handle, singleLink); return handle; }
139
+ catch { handle.Dispose(); throw; }
140
+ }
141
+
142
+ public static SafeFileHandle OpenAttestationWrite(string path) {
143
+ uint access = GENERIC_READ | GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES;
144
+ SafeFileHandle handle = CreateFileW(
145
+ path, access, 0, IntPtr.Zero, CREATE_NEW,
146
+ FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
147
+ if (handle.IsInvalid) {
148
+ int error = Marshal.GetLastWin32Error();
149
+ if (error != ERROR_FILE_EXISTS && error != ERROR_ALREADY_EXISTS)
150
+ throw new Win32Exception(error);
151
+ handle = CreateFileW(
152
+ path, access, 0, IntPtr.Zero, OPEN_EXISTING,
153
+ FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
154
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
155
+ }
156
+ try { ValidateRegular(handle, true); return handle; }
157
+ catch { handle.Dispose(); throw; }
158
+ }
159
+
160
+ public static SafeFileHandle OpenDelete(string path) {
161
+ SafeFileHandle handle = CreateFileW(
162
+ path, DELETE | FILE_READ_ATTRIBUTES, 0, IntPtr.Zero, OPEN_EXISTING,
163
+ FILE_FLAG_OPEN_REPARSE_POINT, IntPtr.Zero);
164
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
165
+ try { ValidateRegular(handle, true); return handle; }
166
+ catch { handle.Dispose(); throw; }
167
+ }
168
+
169
+ public static void DeleteOpened(SafeFileHandle handle) {
170
+ FILE_DISPOSITION_INFO info = new FILE_DISPOSITION_INFO { DeleteFile = true };
171
+ if (!SetFileInformationByHandle(
172
+ handle, FileDispositionInfo, ref info,
173
+ (uint)Marshal.SizeOf(typeof(FILE_DISPOSITION_INFO))))
174
+ throw new Win32Exception(Marshal.GetLastWin32Error());
175
+ }
176
+
177
+ public static string FinalPath(SafeFileHandle handle) {
178
+ StringBuilder buffer = new StringBuilder(32768);
179
+ uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0);
180
+ if (length == 0 || length >= buffer.Capacity)
181
+ throw new Win32Exception(Marshal.GetLastWin32Error());
182
+ string result = buffer.ToString();
183
+ if (result.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase))
184
+ return @"\\" + result.Substring(8);
185
+ if (result.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase))
186
+ return result.Substring(4);
187
+ return result;
188
+ }
189
+
190
+ public static string FileIdentity(SafeFileHandle handle) {
191
+ BY_HANDLE_FILE_INFORMATION info;
192
+ if (!GetFileInformationByHandle(handle, out info))
193
+ throw new Win32Exception(Marshal.GetLastWin32Error());
194
+ return info.VolumeSerialNumber.ToString("X8") + ":" +
195
+ info.FileIndexHigh.ToString("X8") + info.FileIndexLow.ToString("X8");
196
+ }
197
+
198
+ public static string FinalDirectoryPath(string path) {
199
+ using (SafeFileHandle handle = OpenDirectory(path)) {
200
+ return FinalPath(handle);
201
+ }
202
+ }
203
+
204
+ public static SafeFileHandle OpenDirectory(string path) {
205
+ SafeFileHandle handle = CreateFileW(
206
+ path, 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
207
+ IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, IntPtr.Zero);
208
+ if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error());
209
+ return handle;
210
+ }
211
+ }
212
+ '@
213
+ }
214
+
215
+ $script:SecurityRootHandle = $null
216
+ $script:SecurityRootFinalPath = $null
217
+ if ($script:IsWindowsHost) {
218
+ $securityRootPath = (Get-Location).Path
219
+ if ($env:PWF_PLAN_ROOT) {
220
+ $pin = $env:PWF_PLAN_ROOT
221
+ $isUnc = $pin.StartsWith('\\') -or $pin.StartsWith('//')
222
+ if (-not [IO.Path]::IsPathFullyQualified($pin) -or $isUnc) {
223
+ throw "[plan-attest] PWF_PLAN_ROOT must be an absolute local path."
224
+ }
225
+ $securityRootPath = $pin
226
+ }
227
+ $script:SecurityRootHandle = [PwfAttestationNative]::OpenDirectory($securityRootPath)
228
+ $script:SecurityRootFinalPath = [PwfAttestationNative]::FinalPath($script:SecurityRootHandle).TrimEnd('\', '/')
229
+ }
230
+
231
+ function Test-FinalPathWithinSecurityRoot {
232
+ param([string] $FinalPath)
233
+ $candidate = $FinalPath.TrimEnd('\', '/')
234
+ if ([string]::Equals($candidate, $script:SecurityRootFinalPath, [StringComparison]::OrdinalIgnoreCase)) {
235
+ return $true
236
+ }
237
+ return $candidate.StartsWith(
238
+ $script:SecurityRootFinalPath + [IO.Path]::DirectorySeparatorChar,
239
+ [StringComparison]::OrdinalIgnoreCase
240
+ )
241
+ }
242
+
243
+ function Open-TrustedDirectory {
244
+ param([string] $ExpectedDirectory)
245
+ $handle = [PwfAttestationNative]::OpenDirectory($ExpectedDirectory)
246
+ try {
247
+ $finalPath = [PwfAttestationNative]::FinalPath($handle).TrimEnd('\', '/')
248
+ if (-not (Test-FinalPathWithinSecurityRoot $finalPath)) {
249
+ throw "Refusing a plan directory outside the frozen project root."
250
+ }
251
+ return [PSCustomObject]@{
252
+ Handle = $handle
253
+ FinalPath = $finalPath
254
+ Identity = [PwfAttestationNative]::FileIdentity($handle)
255
+ }
256
+ } catch {
257
+ $handle.Dispose()
258
+ throw
259
+ }
260
+ }
261
+
262
+ function Assert-HandleParent {
263
+ param(
264
+ [Microsoft.Win32.SafeHandles.SafeFileHandle] $Handle,
265
+ [string] $ExpectedDirectoryFinal,
266
+ [string] $ExpectedDirectoryIdentity
267
+ )
268
+ if (-not $script:IsWindowsHost) { return }
269
+ $openedPath = [PwfAttestationNative]::FinalPath($Handle)
270
+ $openedParent = (Split-Path -Parent $openedPath).TrimEnd('\', '/')
271
+ if (-not [string]::Equals($openedParent, $ExpectedDirectoryFinal, [StringComparison]::OrdinalIgnoreCase)) {
272
+ throw "Refusing file outside the expected plan directory."
273
+ }
274
+ $parentHandle = [PwfAttestationNative]::OpenDirectory($openedParent)
275
+ try {
276
+ $openedIdentity = [PwfAttestationNative]::FileIdentity($parentHandle)
277
+ if (-not [string]::Equals($openedIdentity, $ExpectedDirectoryIdentity, [StringComparison]::Ordinal)) {
278
+ throw "Refusing a file whose parent directory identity changed."
279
+ }
280
+ } finally {
281
+ $parentHandle.Dispose()
282
+ }
283
+ }
284
+
285
+ function Open-SafeReadStream {
286
+ param([string] $Path, [string] $ExpectedDirectory, [switch] $SingleLink)
287
+ if (-not $script:IsWindowsHost) {
288
+ throw "[plan-attest] Safe no-follow descriptor operations are unavailable in this PowerShell script on Unix. Use scripts/attest-plan.sh instead."
289
+ }
290
+ $directory = Open-TrustedDirectory -ExpectedDirectory $ExpectedDirectory
291
+ try {
292
+ $handle = [PwfAttestationNative]::OpenRead($Path, [bool]$SingleLink)
293
+ try {
294
+ Assert-HandleParent -Handle $handle -ExpectedDirectoryFinal $directory.FinalPath -ExpectedDirectoryIdentity $directory.Identity
295
+ return New-Object System.IO.FileStream($handle, [IO.FileAccess]::Read)
296
+ } catch {
297
+ $handle.Dispose()
298
+ throw
299
+ }
300
+ } finally {
301
+ $directory.Handle.Dispose()
302
+ }
303
+ }
304
+
305
+ function Read-SafeText {
306
+ param([string] $Path, [string] $ExpectedDirectory, [int64] $MaxBytes)
307
+ $stream = Open-SafeReadStream -Path $Path -ExpectedDirectory $ExpectedDirectory -SingleLink
308
+ try {
309
+ if ($stream.Length -gt $MaxBytes) { throw "Refusing oversized metadata file."
310
+ }
311
+ $buffer = New-Object byte[] ([int]$stream.Length)
312
+ $offset = 0
313
+ while ($offset -lt $buffer.Length) {
314
+ $read = $stream.Read($buffer, $offset, $buffer.Length - $offset)
315
+ if ($read -le 0) { break }
316
+ $offset += $read
317
+ }
318
+ return [Text.Encoding]::UTF8.GetString($buffer, 0, $offset)
319
+ } finally {
320
+ $stream.Dispose()
321
+ }
322
+ }
323
+
324
+ function Write-SafeAscii {
325
+ param([string] $Path, [string] $ExpectedDirectory, [string] $Value)
326
+ $bytes = [Text.Encoding]::ASCII.GetBytes($Value)
327
+ if (-not $script:IsWindowsHost) {
328
+ throw "[plan-attest] Safe no-follow descriptor operations are unavailable in this PowerShell script on Unix. Use scripts/attest-plan.sh instead."
329
+ }
330
+ $directory = Open-TrustedDirectory -ExpectedDirectory $ExpectedDirectory
331
+ try {
332
+ $handle = [PwfAttestationNative]::OpenAttestationWrite($Path)
333
+ try {
334
+ Assert-HandleParent -Handle $handle -ExpectedDirectoryFinal $directory.FinalPath -ExpectedDirectoryIdentity $directory.Identity
335
+ $stream = New-Object System.IO.FileStream($handle, [IO.FileAccess]::ReadWrite)
336
+ $handle = $null
337
+ try {
338
+ $stream.SetLength(0)
339
+ $stream.Write($bytes, 0, $bytes.Length)
340
+ $stream.Flush($true)
341
+ $stream.Position = 0
342
+ $verify = New-Object byte[] $bytes.Length
343
+ $read = $stream.Read($verify, 0, $verify.Length)
344
+ return [Text.Encoding]::ASCII.GetString($verify, 0, $read)
345
+ } finally {
346
+ $stream.Dispose()
347
+ }
348
+ } finally {
349
+ if ($handle) { $handle.Dispose() }
350
+ }
351
+ } finally {
352
+ $directory.Handle.Dispose()
353
+ }
354
+ }
355
+
356
+ function Remove-SafeFile {
357
+ param([string] $Path, [string] $ExpectedDirectory)
358
+ if (-not $script:IsWindowsHost) {
359
+ throw "[plan-attest] Safe no-follow descriptor operations are unavailable in this PowerShell script on Unix. Use scripts/attest-plan.sh instead."
360
+ }
361
+ $directory = Open-TrustedDirectory -ExpectedDirectory $ExpectedDirectory
362
+ try {
363
+ $handle = [PwfAttestationNative]::OpenDelete($Path)
364
+ try {
365
+ Assert-HandleParent -Handle $handle -ExpectedDirectoryFinal $directory.FinalPath -ExpectedDirectoryIdentity $directory.Identity
366
+ [PwfAttestationNative]::DeleteOpened($handle)
367
+ } finally {
368
+ $handle.Dispose()
369
+ }
370
+ } finally {
371
+ $directory.Handle.Dispose()
372
+ }
373
+ }
374
+
375
+ function Resolve-ContainedPlanFile {
376
+ param(
377
+ [string] $Candidate,
378
+ [string] $ExpectedDirectory
379
+ )
380
+ if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) { return $null }
381
+ try {
382
+ $stream = Open-SafeReadStream -Path $Candidate -ExpectedDirectory $ExpectedDirectory
383
+ $stream.Dispose()
384
+ return (Get-Item -LiteralPath $Candidate -Force -ErrorAction Stop).FullName
385
+ } catch { return $null }
386
+ }
387
+
388
+ function Resolve-PlanFile {
389
+ $resolver = Join-Path $PSScriptRoot "resolve-plan-dir.ps1"
390
+ if (-not (Test-Path -LiteralPath $resolver -PathType Leaf)) { return $null }
391
+ $resolvedDir = @(& $resolver | Where-Object { $_ }) | Select-Object -First 1
392
+ if ($resolvedDir) {
393
+ $planFile = Join-Path $resolvedDir "task_plan.md"
394
+ return (Resolve-ContainedPlanFile -Candidate $planFile -ExpectedDirectory $resolvedDir)
395
+ }
396
+
397
+ # An explicit pin or a scoped selector that failed validation must never
398
+ # fall through and attest an unrelated legacy-root plan.
399
+ if ($env:PWF_PLAN_ROOT -or $env:PLAN_ID) { return $null }
400
+ $activePointer = Join-Path (Join-Path (Get-Location) ".planning") ".active_plan"
401
+ $activePointerItem = Get-Item -LiteralPath $activePointer -Force -ErrorAction SilentlyContinue
402
+ if ($activePointerItem) { return $null }
403
+
404
+ $legacy = Join-Path (Get-Location) "task_plan.md"
405
+ return (Resolve-ContainedPlanFile -Candidate $legacy -ExpectedDirectory (Get-Location).Path)
406
+ }
407
+
408
+ function Get-AttestationPath {
409
+ param([string] $PlanFile)
410
+ $planDir = Split-Path -Parent $PlanFile
411
+ $cwd = (Get-Location).Path
412
+ if ($planDir -eq $cwd) {
413
+ return (Join-Path $cwd ".plan-attestation")
414
+ }
415
+ return (Join-Path $planDir ".attestation")
416
+ }
417
+
418
+ $planFile = Resolve-PlanFile
419
+ if (-not $planFile) {
420
+ Write-Error "[plan-attest] No task_plan.md found. Create a plan first."
421
+ exit 1
422
+ }
423
+
424
+ $attestationFile = Get-AttestationPath -PlanFile $planFile
425
+ $attestationDir = Split-Path -Parent $attestationFile
426
+
427
+ if ($Show) {
428
+ if (Get-Item -LiteralPath $attestationFile -Force -ErrorAction SilentlyContinue) {
429
+ Write-Output "Plan: $planFile"
430
+ Write-Output "Attestation: $attestationFile"
431
+ Write-Output ("SHA-256: " + (Read-SafeText -Path $attestationFile -ExpectedDirectory $attestationDir -MaxBytes 4096).Trim())
432
+ # Nonce (security A1.4): surface the per-plan nonce if init-session
433
+ # generated one next to the attestation. Informational only here; the
434
+ # hooks consume it to build collision-proof BEGIN/END delimiters.
435
+ $nonceFile = Join-Path (Split-Path -Parent $attestationFile) ".nonce"
436
+ if (Get-Item -LiteralPath $nonceFile -Force -ErrorAction SilentlyContinue) {
437
+ $nonceVal = (Read-SafeText -Path $nonceFile -ExpectedDirectory $attestationDir -MaxBytes 4096).Trim()
438
+ if ($nonceVal) { Write-Output "Nonce: $nonceVal" }
439
+ }
440
+ } else {
441
+ Write-Output "[plan-attest] No attestation set for $planFile."
442
+ exit 1
443
+ }
444
+ exit 0
445
+ }
446
+
447
+ if ($Clear) {
448
+ if (Get-Item -LiteralPath $attestationFile -Force -ErrorAction SilentlyContinue) {
449
+ Remove-SafeFile -Path $attestationFile -ExpectedDirectory $attestationDir
450
+ Write-Output "[plan-attest] Cleared attestation for $planFile."
451
+ } else {
452
+ Write-Output "[plan-attest] No attestation to clear."
453
+ }
454
+ exit 0
455
+ }
456
+
457
+ $planStream = Open-SafeReadStream -Path $planFile -ExpectedDirectory (Split-Path -Parent $planFile)
458
+ try {
459
+ $sha256 = [Security.Cryptography.SHA256]::Create()
460
+ try {
461
+ $hashBytes = $sha256.ComputeHash($planStream)
462
+ } finally {
463
+ $sha256.Dispose()
464
+ }
465
+ } finally {
466
+ $planStream.Dispose()
467
+ }
468
+ $hashVal = ([BitConverter]::ToString($hashBytes)).Replace("-", "").ToLowerInvariant()
469
+ $storedHash = Write-SafeAscii -Path $attestationFile -ExpectedDirectory $attestationDir -Value $hashVal
470
+
471
+ # Integrity verification (security A2.1): confirm the on-disk attestation
472
+ # matches the intended hash before reporting success. A silent write failure
473
+ # (permissions, full disk) must not leave a stale attestation and exit clean.
474
+ if ($null -ne $storedHash) { $storedHash = $storedHash.Trim() }
475
+ if ($storedHash -ne $hashVal) {
476
+ Write-Error "[plan-attest] Attestation write verification FAILED for $attestationFile. Expected $hashVal, found $storedHash. The plan is NOT attested."
477
+ exit 1
478
+ }
479
+
480
+ $short = $hashVal.Substring(0, 12)
481
+ Write-Output "[plan-attest] Locked $planFile"
482
+ Write-Output "[plan-attest] SHA-256: $short... (stored in $attestationFile)"
483
+ Write-Output "[plan-attest] Hooks will block injection if the file is modified without re-running this command."
484
+ exit 0