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/README.md +21 -11
- package/SKILL.md +25 -14
- package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -228
- package/extensions/planning-with-files/package.json +17 -17
- package/extensions/planning-with-files/runtime.ts +785 -785
- package/package.json +5 -2
- package/scripts/attest-plan.ps1 +484 -137
- package/scripts/attest-plan.sh +206 -206
- package/scripts/check-complete.ps1 +253 -253
- package/scripts/check-complete.sh +253 -253
- package/scripts/gate-stop.sh +32 -32
- package/scripts/init-session.sh +19 -0
- package/scripts/inject-plan.sh +692 -104
- package/scripts/ledger-append.ps1 +180 -180
- package/scripts/ledger-append.sh +337 -337
- package/scripts/ledger-summary.ps1 +128 -128
- package/scripts/phase-status.ps1 +237 -175
- package/scripts/phase-status.sh +202 -158
- package/scripts/resolve-plan-dir.ps1 +77 -7
- package/scripts/resolve-plan-dir.sh +6 -1
- package/scripts/session-catchup.py +983 -876
- package/scripts/set-active-plan.ps1 +51 -51
- package/scripts/set-active-plan.sh +50 -50
- package/scripts/verify-shell-line-endings.mjs +50 -0
- package/templates/analytics_findings.md +67 -85
- package/templates/analytics_task_plan.md +81 -106
- package/templates/findings.md +47 -95
- package/templates/progress.md +58 -114
- package/templates/task_plan.md +89 -140
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planning-with-files",
|
|
3
|
-
"version": "3.
|
|
4
|
-
"description": "Persistent
|
|
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",
|
package/scripts/attest-plan.ps1
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|