planning-with-files 3.11.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.
@@ -31,42 +31,378 @@ param(
31
31
 
32
32
  $ErrorActionPreference = "Stop"
33
33
 
34
- function Resolve-PlanFile {
35
- $planRoot = Join-Path (Get-Location) ".planning"
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
+ }
36
70
 
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 }
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;
41
83
  }
42
84
 
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 }
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.");
50
129
  }
51
130
  }
52
131
 
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
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());
61
155
  }
156
+ try { ValidateRegular(handle, true); return handle; }
157
+ catch { handle.Dispose(); throw; }
62
158
  }
63
159
 
64
- $legacy = Join-Path (Get-Location) "task_plan.md"
65
- if (Test-Path -LiteralPath $legacy) {
66
- return (Resolve-Path -LiteralPath $legacy).Path
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
67
259
  }
260
+ }
68
261
 
69
- return $null
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)
70
406
  }
71
407
 
72
408
  function Get-AttestationPath {
@@ -86,18 +422,19 @@ if (-not $planFile) {
86
422
  }
87
423
 
88
424
  $attestationFile = Get-AttestationPath -PlanFile $planFile
425
+ $attestationDir = Split-Path -Parent $attestationFile
89
426
 
90
427
  if ($Show) {
91
- if (Test-Path -LiteralPath $attestationFile) {
428
+ if (Get-Item -LiteralPath $attestationFile -Force -ErrorAction SilentlyContinue) {
92
429
  Write-Output "Plan: $planFile"
93
430
  Write-Output "Attestation: $attestationFile"
94
- Write-Output ("SHA-256: " + (Get-Content -LiteralPath $attestationFile -Raw).Trim())
431
+ Write-Output ("SHA-256: " + (Read-SafeText -Path $attestationFile -ExpectedDirectory $attestationDir -MaxBytes 4096).Trim())
95
432
  # Nonce (security A1.4): surface the per-plan nonce if init-session
96
433
  # generated one next to the attestation. Informational only here; the
97
434
  # hooks consume it to build collision-proof BEGIN/END delimiters.
98
435
  $nonceFile = Join-Path (Split-Path -Parent $attestationFile) ".nonce"
99
- if (Test-Path -LiteralPath $nonceFile) {
100
- $nonceVal = (Get-Content -LiteralPath $nonceFile -Raw).Trim()
436
+ if (Get-Item -LiteralPath $nonceFile -Force -ErrorAction SilentlyContinue) {
437
+ $nonceVal = (Read-SafeText -Path $nonceFile -ExpectedDirectory $attestationDir -MaxBytes 4096).Trim()
101
438
  if ($nonceVal) { Write-Output "Nonce: $nonceVal" }
102
439
  }
103
440
  } else {
@@ -108,8 +445,8 @@ if ($Show) {
108
445
  }
109
446
 
110
447
  if ($Clear) {
111
- if (Test-Path -LiteralPath $attestationFile) {
112
- Remove-Item -LiteralPath $attestationFile -Force
448
+ if (Get-Item -LiteralPath $attestationFile -Force -ErrorAction SilentlyContinue) {
449
+ Remove-SafeFile -Path $attestationFile -ExpectedDirectory $attestationDir
113
450
  Write-Output "[plan-attest] Cleared attestation for $planFile."
114
451
  } else {
115
452
  Write-Output "[plan-attest] No attestation to clear."
@@ -117,13 +454,23 @@ if ($Clear) {
117
454
  exit 0
118
455
  }
119
456
 
120
- $hashVal = (Get-FileHash -LiteralPath $planFile -Algorithm SHA256).Hash.ToLowerInvariant()
121
- Set-Content -LiteralPath $attestationFile -Value $hashVal -NoNewline -Encoding ascii
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
122
470
 
123
471
  # Integrity verification (security A2.1): confirm the on-disk attestation
124
472
  # matches the intended hash before reporting success. A silent write failure
125
473
  # (permissions, full disk) must not leave a stale attestation and exit clean.
126
- $storedHash = (Get-Content -LiteralPath $attestationFile -Raw -ErrorAction SilentlyContinue)
127
474
  if ($null -ne $storedHash) { $storedHash = $storedHash.Trim() }
128
475
  if ($storedHash -ne $hashVal) {
129
476
  Write-Error "[plan-attest] Attestation write verification FAILED for $attestationFile. Expected $hashVal, found $storedHash. The plan is NOT attested."
@@ -24,6 +24,21 @@
24
24
 
25
25
  set -e
26
26
 
27
+ usage() {
28
+ cat << 'EOF'
29
+ Usage: init-session.sh [OPTIONS] [PROJECT NAME]
30
+
31
+ Initialize task_plan.md, findings.md, and progress.md for a planning session.
32
+
33
+ Options:
34
+ -t, --template TYPE Use the default or analytics template.
35
+ --plan-dir Create an isolated plan directory without a name.
36
+ --autonomous Enable autonomous mode and plan attestation.
37
+ --gated Enable autonomous mode with the completion gate.
38
+ -h, --help Print this help and exit without changing files.
39
+ EOF
40
+ }
41
+
27
42
  TEMPLATE="default"
28
43
  PROJECT_NAME=""
29
44
  USE_PLAN_DIR=0
@@ -51,6 +66,10 @@ while [ $# -gt 0 ]; do
51
66
  MODE="gated"
52
67
  shift
53
68
  ;;
69
+ --help|-h)
70
+ usage
71
+ exit 0
72
+ ;;
54
73
  *)
55
74
  if [ -z "$PROJECT_NAME" ]; then
56
75
  PROJECT_NAME="$1"