@caddis/cli 0.4.12 → 0.4.14
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/bundles/antigravity-plugin/guard_agy.py +62 -13
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/repo_freshness.py +146 -0
- package/bundles/antigravity-plugin/scripts/caddis-run.ps1 +11 -0
- package/bundles/antigravity-plugin/scripts/caddis-run.sh +9 -0
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +24 -3
- package/bundles/antigravity-plugin/scripts/caddis_governor.py +542 -0
- package/bundles/antigravity-plugin/scripts/caddis_host_guard.py +689 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +7 -3
- package/bundles/antigravity-plugin/scripts/caddis_lanes.py +1632 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +159 -14
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +59 -7
- package/bundles/antigravity-plugin/scripts/install-host-guard.ps1 +393 -0
- package/bundles/antigravity-plugin/skills/feature-plan/SKILL.md +30 -4
- package/bundles/antigravity-plugin/skills/implement/SKILL.md +48 -1
- package/bundles/antigravity-plugin/skills/kb/SKILL.md +4 -0
- package/bundles/antigravity-plugin/skills/spawn-session/SKILL.md +89 -16
- package/bundles/antigravity-plugin/warm_start_agy.py +38 -4
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
[CmdletBinding()]
|
|
2
|
+
param(
|
|
3
|
+
[switch]$Apply,
|
|
4
|
+
[string]$NssmSha256,
|
|
5
|
+
[ValidateSet('report', 'enforce')]
|
|
6
|
+
[string]$Mode = 'report'
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
Set-StrictMode -Version Latest
|
|
10
|
+
$ErrorActionPreference = 'Stop'
|
|
11
|
+
|
|
12
|
+
$sourceNssm = 'C:\Tools\nssm\nssm.exe'
|
|
13
|
+
$serviceName = 'caddis-host-guard'
|
|
14
|
+
$programRoot = Join-Path $env:ProgramFiles 'caddis'
|
|
15
|
+
$nssmDir = Join-Path $programRoot 'nssm'
|
|
16
|
+
$nssm = Join-Path $nssmDir 'nssm.exe'
|
|
17
|
+
$pythonDir = Join-Path $env:ProgramFiles 'caddis\python'
|
|
18
|
+
$privatePython = Join-Path $pythonDir 'python.exe'
|
|
19
|
+
$serviceDir = Join-Path $env:ProgramData 'caddis'
|
|
20
|
+
$guardScript = Join-Path $serviceDir 'caddis_host_guard.py'
|
|
21
|
+
$governorScript = Join-Path $serviceDir 'caddis_governor.py'
|
|
22
|
+
$budget = Join-Path $env:ProgramData 'caddis\host-budget.toml'
|
|
23
|
+
$stdoutLog = Join-Path $serviceDir 'guard.out.log'
|
|
24
|
+
$stderrLog = Join-Path $serviceDir 'guard.err.log'
|
|
25
|
+
$sourceGuard = Join-Path $PSScriptRoot 'caddis_host_guard.py'
|
|
26
|
+
$sourceGovernor = Join-Path $PSScriptRoot 'caddis_governor.py'
|
|
27
|
+
|
|
28
|
+
if ($Apply -and $NssmSha256 -notmatch '\A[0-9a-fA-F]{64}\z') {
|
|
29
|
+
[Console]::Error.WriteLine('-Apply requires -NssmSha256 with the trusted NSSM SHA256 (64 hexadecimal characters).')
|
|
30
|
+
exit 3
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (-not (Test-Path -LiteralPath $sourceNssm -PathType Leaf)) {
|
|
34
|
+
[Console]::Error.WriteLine("NSSM was not found at $sourceNssm.")
|
|
35
|
+
exit 3
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (-not $Apply) {
|
|
39
|
+
Write-Output '# Dry run. Run again with -Apply -NssmSha256 <trusted SHA256> from an elevated shell.'
|
|
40
|
+
Write-Output "New-Item -ItemType Directory -Force -Path `"$programRoot`""
|
|
41
|
+
Write-Output "New-Item -ItemType Directory -Force -Path `"$serviceDir`""
|
|
42
|
+
Write-Output "Get-ChildItem -LiteralPath `"$programRoot`" -Force -Recurse # refuse reparse points"
|
|
43
|
+
Write-Output "Get-ChildItem -LiteralPath `"$serviceDir`" -Force -Recurse # refuse reparse points"
|
|
44
|
+
Write-Output "icacls `"$programRoot`" /setowner `"*S-1-5-32-544`" /T /C"
|
|
45
|
+
Write-Output "icacls `"$programRoot`" /reset /T /C"
|
|
46
|
+
Write-Output "icacls `"$programRoot`" /inheritance:r /grant:r `"*S-1-5-32-544:(OI)(CI)F`" `"*S-1-5-18:(OI)(CI)F`" `"*S-1-5-32-545:(OI)(CI)RX`""
|
|
47
|
+
Write-Output "icacls `"$serviceDir`" /setowner `"*S-1-5-32-544`" /T /C"
|
|
48
|
+
Write-Output "icacls `"$serviceDir`" /reset /T /C"
|
|
49
|
+
Write-Output "icacls `"$serviceDir`" /inheritance:r /grant:r `"*S-1-5-32-544:(OI)(CI)F`" `"*S-1-5-18:(OI)(CI)F`" `"*S-1-5-32-545:(OI)(CI)RX`""
|
|
50
|
+
Write-Output "New-Item -ItemType Directory -Force -Path `"$nssmDir`""
|
|
51
|
+
Write-Output "Lock-CaddisTree -Path `"$nssmDir`""
|
|
52
|
+
Write-Output "Copy-Item -LiteralPath `"$sourceNssm`" -Destination `"$nssm`" -Force"
|
|
53
|
+
Write-Output "Lock-CaddisTree -Path `"$nssmDir`""
|
|
54
|
+
Write-Output "Get-TrustedFileHash -Path `"$nssm`" # must equal -NssmSha256; refuse with exit 3 otherwise"
|
|
55
|
+
Write-Output '$sourcePython = (Get-Command python -CommandType Application -ErrorAction Stop).Source # require a valid PSF signature'
|
|
56
|
+
Write-Output '$basePrefix = Split-Path -Parent $sourcePython # refuse venvs, python*._pth and python*.zip'
|
|
57
|
+
Write-Output ('robocopy "$basePrefix" "{0}" /MIR /XD Lib\site-packages /XF python*._pth python*.zip' -f $pythonDir)
|
|
58
|
+
Write-Output ('& "{0}" -I -S -c ''import sys; print(sys.base_prefix)'' # must resolve to the private copy' -f $privatePython)
|
|
59
|
+
Write-Output "Copy-Item -LiteralPath `"$sourceGuard`" -Destination `"$guardScript`" -Force"
|
|
60
|
+
Write-Output "Copy-Item -LiteralPath `"$sourceGovernor`" -Destination `"$governorScript`" -Force"
|
|
61
|
+
Write-Output "Write default host budget to `"$budget`" with guard mode `"$Mode`" if it is missing"
|
|
62
|
+
Write-Output "icacls `"$programRoot`" /setowner `"*S-1-5-32-544`" /T /C"
|
|
63
|
+
Write-Output "icacls `"$serviceDir`" /setowner `"*S-1-5-32-544`" /T /C"
|
|
64
|
+
Write-Output "& `"$nssm`" install $serviceName `"$privatePython`" -I -S `"$guardScript`" run --budget `"$budget`""
|
|
65
|
+
Write-Output "Update the existing $serviceName service PathName to the quoted protected path `"$nssm`" using Win32_Service.Change."
|
|
66
|
+
Write-Output "& `"$nssm`" set $serviceName AppStdout `"$stdoutLog`""
|
|
67
|
+
Write-Output "& `"$nssm`" set $serviceName AppStderr `"$stderrLog`""
|
|
68
|
+
Write-Output "& `"$nssm`" set $serviceName ObjectName LocalSystem"
|
|
69
|
+
Write-Output "& `"$nssm`" set $serviceName Start SERVICE_AUTO_START"
|
|
70
|
+
Write-Output "& `"$nssm`" start $serviceName"
|
|
71
|
+
exit 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
|
75
|
+
[Security.Principal.WindowsBuiltInRole]::Administrator
|
|
76
|
+
)
|
|
77
|
+
if (-not $isAdmin) {
|
|
78
|
+
[Console]::Error.WriteLine('install-host-guard.ps1 -Apply requires an elevated shell.')
|
|
79
|
+
exit 3
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
foreach ($source in @($sourceGuard, $sourceGovernor)) {
|
|
83
|
+
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
|
|
84
|
+
throw "Required service file not found: $source"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
$serviceAlreadyInstalled = $null -ne (Get-Service -Name $serviceName -ErrorAction SilentlyContinue)
|
|
89
|
+
$budgetAlreadyExists = Test-Path -LiteralPath $budget -PathType Leaf
|
|
90
|
+
if ($budgetAlreadyExists -and -not $serviceAlreadyInstalled) {
|
|
91
|
+
[Console]::Error.WriteLine(
|
|
92
|
+
"Refusing the pre-existing $budget because no installed service proves that it is admin-owned. " +
|
|
93
|
+
'Inspect it, remove it, and run the installer again.'
|
|
94
|
+
)
|
|
95
|
+
exit 3
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function Lock-CaddisTree {
|
|
99
|
+
param([Parameter(Mandatory)][string]$Path)
|
|
100
|
+
|
|
101
|
+
$rootItem = Get-Item -LiteralPath $Path -Force
|
|
102
|
+
$reparsePoint = @($rootItem) + @(Get-ChildItem -LiteralPath $Path -Force -Recurse)
|
|
103
|
+
$reparsePoint = $reparsePoint | Where-Object {
|
|
104
|
+
($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0
|
|
105
|
+
} | Select-Object -First 1
|
|
106
|
+
if ($null -ne $reparsePoint) {
|
|
107
|
+
throw "Refusing reparse point in service tree: $($reparsePoint.FullName)"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Remove inherited and explicit grants from any pre-existing tree before the LocalSystem
|
|
111
|
+
# service can read it. New files then inherit only the three grants below.
|
|
112
|
+
& icacls.exe $Path '/setowner' '*S-1-5-32-544' '/T' '/C'
|
|
113
|
+
if ($LASTEXITCODE -ne 0) {
|
|
114
|
+
throw "icacls owner change failed for $Path with exit code $LASTEXITCODE"
|
|
115
|
+
}
|
|
116
|
+
& icacls.exe $Path '/reset' '/T' '/C'
|
|
117
|
+
if ($LASTEXITCODE -ne 0) {
|
|
118
|
+
throw "icacls reset failed for $Path with exit code $LASTEXITCODE"
|
|
119
|
+
}
|
|
120
|
+
& icacls.exe $Path '/inheritance:r' '/grant:r' '*S-1-5-32-544:(OI)(CI)F' '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-545:(OI)(CI)RX'
|
|
121
|
+
if ($LASTEXITCODE -ne 0) {
|
|
122
|
+
throw "icacls lock failed for $Path with exit code $LASTEXITCODE"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function Set-CaddisTreeOwner {
|
|
127
|
+
param([Parameter(Mandatory)][string]$Path)
|
|
128
|
+
|
|
129
|
+
& icacls.exe $Path '/setowner' '*S-1-5-32-544' '/T' '/C'
|
|
130
|
+
if ($LASTEXITCODE -ne 0) {
|
|
131
|
+
throw "icacls owner change failed for $Path with exit code $LASTEXITCODE"
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function Assert-NoPythonPathOverride {
|
|
136
|
+
param([Parameter(Mandatory)][string]$Path)
|
|
137
|
+
|
|
138
|
+
if (Test-Path -LiteralPath $Path -PathType Container) {
|
|
139
|
+
foreach ($pattern in @('python*._pth', 'python*.zip')) {
|
|
140
|
+
$override = Get-ChildItem -LiteralPath $Path -Filter $pattern -File -Force |
|
|
141
|
+
Select-Object -First 1
|
|
142
|
+
if ($null -ne $override) {
|
|
143
|
+
[Console]::Error.WriteLine("Refusing Python path override file: $($override.FullName)")
|
|
144
|
+
exit 3
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function Assert-AdminOwned {
|
|
151
|
+
param(
|
|
152
|
+
[Parameter(Mandatory)][string]$Path,
|
|
153
|
+
[switch]$RequireReadOnlyFile
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
$acl = Get-Acl -LiteralPath $Path
|
|
157
|
+
$ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate(
|
|
158
|
+
[Security.Principal.SecurityIdentifier]
|
|
159
|
+
).Value
|
|
160
|
+
$trustedSids = @('S-1-5-18', 'S-1-5-32-544')
|
|
161
|
+
if ($ownerSid -notin $trustedSids) {
|
|
162
|
+
[Console]::Error.WriteLine("Refusing non-admin-owned path: $Path (owner $ownerSid)")
|
|
163
|
+
exit 3
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if ($RequireReadOnlyFile) {
|
|
167
|
+
$writeRights = [Security.AccessControl.FileSystemRights]::Write -bor
|
|
168
|
+
[Security.AccessControl.FileSystemRights]::Delete -bor
|
|
169
|
+
[Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
|
170
|
+
[Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
|
171
|
+
[Security.AccessControl.FileSystemRights]::TakeOwnership
|
|
172
|
+
foreach ($rule in $acl.Access) {
|
|
173
|
+
$ruleSid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value
|
|
174
|
+
if ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and
|
|
175
|
+
$ruleSid -notin $trustedSids -and ($rule.FileSystemRights -band $writeRights)) {
|
|
176
|
+
[Console]::Error.WriteLine("Refusing user-writable path: $Path ($ruleSid)")
|
|
177
|
+
exit 3
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function Get-TrustedFileHash {
|
|
184
|
+
param([Parameter(Mandatory)][string]$Path)
|
|
185
|
+
|
|
186
|
+
$stream = [IO.File]::Open(
|
|
187
|
+
$Path,
|
|
188
|
+
[IO.FileMode]::Open,
|
|
189
|
+
[IO.FileAccess]::Read,
|
|
190
|
+
[IO.FileShare]::Read
|
|
191
|
+
)
|
|
192
|
+
try {
|
|
193
|
+
# The open handle denies writes and deletes, so this ACL check and hash describe
|
|
194
|
+
# the same file even when the parent directory permits users to add files.
|
|
195
|
+
Assert-AdminOwned -Path $Path -RequireReadOnlyFile
|
|
196
|
+
$sha256 = [Security.Cryptography.SHA256]::Create()
|
|
197
|
+
try {
|
|
198
|
+
return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '')
|
|
199
|
+
} finally {
|
|
200
|
+
$sha256.Dispose()
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
$stream.Dispose()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function Get-TrustedPythonFiles {
|
|
208
|
+
param([Parameter(Mandatory)][string]$BasePrefix)
|
|
209
|
+
|
|
210
|
+
$trustedFiles = [Collections.Generic.Dictionary[string, string]]::new(
|
|
211
|
+
[StringComparer]::OrdinalIgnoreCase
|
|
212
|
+
)
|
|
213
|
+
$sitePackages = [IO.Path]::GetFullPath((Join-Path $BasePrefix 'Lib\site-packages')).TrimEnd('\') + '\'
|
|
214
|
+
foreach ($item in Get-ChildItem -LiteralPath $BasePrefix -Force -Recurse -ErrorAction Stop) {
|
|
215
|
+
$fullPath = [IO.Path]::GetFullPath($item.FullName)
|
|
216
|
+
if ($fullPath.StartsWith($sitePackages, [StringComparison]::OrdinalIgnoreCase)) {
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if ($item.PSIsContainer) {
|
|
221
|
+
Assert-AdminOwned -Path $fullPath
|
|
222
|
+
continue
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
$relativePath = [IO.Path]::GetRelativePath($BasePrefix, $fullPath)
|
|
226
|
+
if ($item.Extension -in @('.dll', '.pyd') -and
|
|
227
|
+
-not $relativePath.StartsWith('tcl\', [StringComparison]::OrdinalIgnoreCase)) {
|
|
228
|
+
$signature = Get-AuthenticodeSignature -LiteralPath $fullPath
|
|
229
|
+
if ($signature.Status -ne 'Valid' -or
|
|
230
|
+
$signature.SignerCertificate.Subject -notmatch
|
|
231
|
+
'O=(?:Python Software Foundation|Microsoft Corporation)(?:,|$)') {
|
|
232
|
+
[Console]::Error.WriteLine("Refusing an unsigned or unexpected Python binary: $fullPath")
|
|
233
|
+
exit 3
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
$trustedFiles[$relativePath] = Get-TrustedFileHash -Path $fullPath
|
|
237
|
+
}
|
|
238
|
+
return ,$trustedFiles
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
$sourcePython = (Get-Command python -CommandType Application -ErrorAction Stop).Source
|
|
242
|
+
$sourcePythonDir = Split-Path -Parent $sourcePython
|
|
243
|
+
Assert-NoPythonPathOverride -Path $sourcePythonDir
|
|
244
|
+
$sourcePythonItem = Get-Item -LiteralPath $sourcePython -Force
|
|
245
|
+
if (($sourcePythonItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
246
|
+
[Console]::Error.WriteLine("Refusing a reparse-point Python executable: $sourcePython")
|
|
247
|
+
exit 3
|
|
248
|
+
}
|
|
249
|
+
Assert-AdminOwned -Path $sourcePython -RequireReadOnlyFile
|
|
250
|
+
$sourceSignature = Get-AuthenticodeSignature -LiteralPath $sourcePython
|
|
251
|
+
if ($sourceSignature.Status -ne 'Valid' -or
|
|
252
|
+
$sourceSignature.SignerCertificate.Subject -notmatch 'O=Python Software Foundation(?:,|$)') {
|
|
253
|
+
[Console]::Error.WriteLine("Refusing an untrusted Python executable: $sourcePython")
|
|
254
|
+
exit 3
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
$basePrefix = $sourcePythonDir
|
|
258
|
+
if (-not (Test-Path -LiteralPath (Join-Path $basePrefix 'Lib') -PathType Container) -or
|
|
259
|
+
(Test-Path -LiteralPath (Join-Path (Split-Path -Parent $basePrefix) 'pyvenv.cfg') -PathType Leaf)) {
|
|
260
|
+
[Console]::Error.WriteLine('The Python on PATH must be a base installation, not a virtual environment.')
|
|
261
|
+
exit 3
|
|
262
|
+
}
|
|
263
|
+
$sourceReparsePoint = Get-ChildItem -LiteralPath $basePrefix -Force -Recurse | Where-Object {
|
|
264
|
+
($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0
|
|
265
|
+
} | Select-Object -First 1
|
|
266
|
+
if ($null -ne $sourceReparsePoint) {
|
|
267
|
+
[Console]::Error.WriteLine("Refusing reparse point in the source Python tree: $($sourceReparsePoint.FullName)")
|
|
268
|
+
exit 3
|
|
269
|
+
}
|
|
270
|
+
Assert-NoPythonPathOverride -Path $basePrefix
|
|
271
|
+
$trustedSourceFiles = Get-TrustedPythonFiles -BasePrefix $basePrefix
|
|
272
|
+
|
|
273
|
+
New-Item -ItemType Directory -Force -Path $programRoot | Out-Null
|
|
274
|
+
New-Item -ItemType Directory -Force -Path $serviceDir | Out-Null
|
|
275
|
+
Lock-CaddisTree -Path $programRoot
|
|
276
|
+
Lock-CaddisTree -Path $serviceDir
|
|
277
|
+
|
|
278
|
+
New-Item -ItemType Directory -Force -Path $nssmDir | Out-Null
|
|
279
|
+
Lock-CaddisTree -Path $nssmDir
|
|
280
|
+
Copy-Item -LiteralPath $sourceNssm -Destination $nssm -Force
|
|
281
|
+
Lock-CaddisTree -Path $nssmDir
|
|
282
|
+
Assert-AdminOwned -Path $nssmDir -RequireReadOnlyFile
|
|
283
|
+
if ((Get-TrustedFileHash -Path $nssm) -ine $NssmSha256) {
|
|
284
|
+
[Console]::Error.WriteLine('The protected NSSM copy does not match -NssmSha256.')
|
|
285
|
+
exit 3
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
& robocopy.exe $basePrefix $pythonDir /MIR /XD 'Lib\site-packages' /XF 'python*._pth' 'python*.zip'
|
|
289
|
+
if ($LASTEXITCODE -ge 8) {
|
|
290
|
+
throw "robocopy failed with exit code $LASTEXITCODE"
|
|
291
|
+
}
|
|
292
|
+
if (-not (Test-Path -LiteralPath $privatePython -PathType Leaf)) {
|
|
293
|
+
throw "The private Python copy does not contain $privatePython"
|
|
294
|
+
}
|
|
295
|
+
Assert-NoPythonPathOverride -Path $pythonDir
|
|
296
|
+
Set-CaddisTreeOwner -Path $programRoot
|
|
297
|
+
$trustedPrivateFiles = Get-TrustedPythonFiles -BasePrefix $pythonDir
|
|
298
|
+
if ($trustedPrivateFiles.Count -ne $trustedSourceFiles.Count) {
|
|
299
|
+
throw 'The private Python copy does not contain the validated source file set.'
|
|
300
|
+
}
|
|
301
|
+
foreach ($entry in $trustedSourceFiles.GetEnumerator()) {
|
|
302
|
+
$privateHash = $null
|
|
303
|
+
if (-not $trustedPrivateFiles.TryGetValue($entry.Key, [ref]$privateHash) -or
|
|
304
|
+
$privateHash -ne $entry.Value) {
|
|
305
|
+
throw "The private Python copy does not match the validated source: $($entry.Key)"
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
$privateSignature = Get-AuthenticodeSignature -LiteralPath $privatePython
|
|
309
|
+
if ($privateSignature.Status -ne 'Valid' -or
|
|
310
|
+
$privateSignature.SignerCertificate.Subject -notmatch 'O=Python Software Foundation(?:,|$)') {
|
|
311
|
+
throw "The private Python copy failed signature validation: $privatePython"
|
|
312
|
+
}
|
|
313
|
+
$copiedBasePrefix = (& $privatePython -I -S -c 'import sys; print(sys.base_prefix)').Trim()
|
|
314
|
+
if ($LASTEXITCODE -ne 0 -or $copiedBasePrefix -ine $pythonDir) {
|
|
315
|
+
throw "The private Python resolved sys.base_prefix to $copiedBasePrefix instead of $pythonDir"
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
Copy-Item -LiteralPath $sourceGuard -Destination $guardScript -Force
|
|
319
|
+
Copy-Item -LiteralPath $sourceGovernor -Destination $governorScript -Force
|
|
320
|
+
|
|
321
|
+
if (-not (Test-Path -LiteralPath $budget -PathType Leaf)) {
|
|
322
|
+
$budgetText = @"
|
|
323
|
+
[session]
|
|
324
|
+
cpu_percent = 50
|
|
325
|
+
memory_mb = 24576
|
|
326
|
+
max_processes = 256
|
|
327
|
+
|
|
328
|
+
[lane]
|
|
329
|
+
cpu_percent = 25
|
|
330
|
+
memory_mb = 8192
|
|
331
|
+
max_processes = 64
|
|
332
|
+
|
|
333
|
+
[guard]
|
|
334
|
+
mode = "$Mode"
|
|
335
|
+
host_cpu_percent = 85
|
|
336
|
+
sustain_seconds = 60
|
|
337
|
+
sample_seconds = 10
|
|
338
|
+
min_free_memory_mb = 4096
|
|
339
|
+
root_images = ["claude.exe", "codex.exe", "agy.exe"]
|
|
340
|
+
"@
|
|
341
|
+
[System.IO.File]::WriteAllText($budget, $budgetText, [System.Text.UTF8Encoding]::new($false))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
$modeReader = 'import pathlib, sys, tomllib; p = pathlib.Path(sys.argv[1]); data = tomllib.loads(p.read_text(encoding="utf-8")); print(data.get("guard", {}).get("mode", "enforce"))'
|
|
345
|
+
$configuredMode = (& $privatePython -I -S -c $modeReader $budget).Trim()
|
|
346
|
+
if ($LASTEXITCODE -ne 0 -or -not $configuredMode) {
|
|
347
|
+
throw "Could not read the guard mode from $budget"
|
|
348
|
+
}
|
|
349
|
+
if ($configuredMode -ne $Mode) {
|
|
350
|
+
[Console]::Error.WriteLine(
|
|
351
|
+
"$budget is in $configuredMode mode, not requested $Mode mode. Change it from an elevated shell and run again."
|
|
352
|
+
)
|
|
353
|
+
exit 3
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
Set-CaddisTreeOwner -Path $programRoot
|
|
357
|
+
Set-CaddisTreeOwner -Path $serviceDir
|
|
358
|
+
|
|
359
|
+
if ($serviceAlreadyInstalled) {
|
|
360
|
+
# Updating Application alone leaves the old wrapper in the service ImagePath.
|
|
361
|
+
$service = Get-CimInstance -ClassName Win32_Service -Filter "Name='$serviceName'"
|
|
362
|
+
$change = Invoke-CimMethod -InputObject $service -MethodName Change -Arguments @{
|
|
363
|
+
PathName = '"{0}"' -f $nssm
|
|
364
|
+
}
|
|
365
|
+
if ($change.ReturnValue -ne 0) {
|
|
366
|
+
throw "Service binary update failed with code $($change.ReturnValue)"
|
|
367
|
+
}
|
|
368
|
+
$appParameters = '-I -S "{0}" run --budget "{1}"' -f $guardScript, $budget
|
|
369
|
+
& $nssm set $serviceName Application $privatePython
|
|
370
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm Application failed with exit code $LASTEXITCODE" }
|
|
371
|
+
& $nssm set $serviceName AppParameters $appParameters
|
|
372
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm AppParameters failed with exit code $LASTEXITCODE" }
|
|
373
|
+
} else {
|
|
374
|
+
& $nssm install $serviceName $privatePython '-I' '-S' $guardScript 'run' '--budget' $budget
|
|
375
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm install failed with exit code $LASTEXITCODE" }
|
|
376
|
+
}
|
|
377
|
+
& $nssm set $serviceName AppStdout $stdoutLog
|
|
378
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm AppStdout failed with exit code $LASTEXITCODE" }
|
|
379
|
+
& $nssm set $serviceName AppStderr $stderrLog
|
|
380
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm AppStderr failed with exit code $LASTEXITCODE" }
|
|
381
|
+
& $nssm set $serviceName ObjectName LocalSystem
|
|
382
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm ObjectName failed with exit code $LASTEXITCODE" }
|
|
383
|
+
& $nssm set $serviceName Start SERVICE_AUTO_START
|
|
384
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm Start failed with exit code $LASTEXITCODE" }
|
|
385
|
+
if ($serviceAlreadyInstalled) {
|
|
386
|
+
& $nssm restart $serviceName
|
|
387
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm restart failed with exit code $LASTEXITCODE" }
|
|
388
|
+
} else {
|
|
389
|
+
& $nssm start $serviceName
|
|
390
|
+
if ($LASTEXITCODE -ne 0) { throw "nssm start failed with exit code $LASTEXITCODE" }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
Write-Output "installed in $Mode mode; switch with: set mode in $budget then & `"$nssm`" restart $serviceName"
|
|
@@ -126,6 +126,24 @@ A tier can be served by an OSS provider instead of Anthropic (cheat sheet:
|
|
|
126
126
|
`claude-glm -p "/caddis:implement <plan-path> — Phase N only"` (from the repo root, on the
|
|
127
127
|
feature branch). The local-coder gate below exists exactly so these phases survive a weaker
|
|
128
128
|
implementer — a phase may only take this lane if it passes that gate.
|
|
129
|
+
- **codex-sol / codex-astra / agy / glm (via caddis_lanes)** — select one of these five task types
|
|
130
|
+
and its default chain:
|
|
131
|
+
- `backend`: `glm`, `codex-sol`, `claude`
|
|
132
|
+
- `hard`: `claude`, `codex-astra`
|
|
133
|
+
- `ui`: `claude`, `codex-astra`
|
|
134
|
+
- `tests`: `codex-sol`, `glm`, `claude`
|
|
135
|
+
- `docs`: `agy`, `glm`, `claude`
|
|
136
|
+
Use `python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" run --plan <plan-path> --phase N
|
|
137
|
+
--type <type>` as the launch command. The chains are defaults, not limits on Claude: while Claude
|
|
138
|
+
has budget, give any phase the `claude` lane. Override chains and model pins per project in
|
|
139
|
+
`.caddis/config.toml` `[lanes.chains]` and `[lanes.models]`.
|
|
140
|
+
|
|
141
|
+
**Evidence standard — never plan a load test on a shared host.** Prove timing robustness with
|
|
142
|
+
event-driven fakes, ordering assertions and an A/B check that fails against the racy version. A phase
|
|
143
|
+
must never ask for CPU burners, stress tests, load tests or "every core". `caddis_lanes.py` refuses
|
|
144
|
+
this wording. The host guard kills runaway agent processes. On 2026-09-13, 32 all-core busy loops
|
|
145
|
+
froze CI on the dev box.
|
|
146
|
+
|
|
129
147
|
- **review lane (every phase that touches code)** — cross-vendor review by a vendor that did NOT
|
|
130
148
|
write the code: `/caddis:cross-review --range main..HEAD`
|
|
131
149
|
(Claude or GLM implemented) or `--provider glm` / a Claude `code-reviewer` pass (DeepSeek-adjacent
|
|
@@ -219,6 +237,7 @@ Creating Model: <model-id>
|
|
|
219
237
|
**Milestone:** M1 *(omit on plans without milestones)*
|
|
220
238
|
**Model:** <tier> (`/model <alias>`) — <one-line rationale tied to this phase's difficulty>
|
|
221
239
|
**Lane:** claude — this session | glm-headless — `claude-glm -p "/caddis:implement <this plan's path> — Phase 1 only"`
|
|
240
|
+
**Type:** backend | hard | ui | tests | docs *(required when the Lane runs through caddis_lanes.py)*
|
|
222
241
|
**Session:** continue | **fresh** (start a new session for this phase — note why: lane change / heavy context)
|
|
223
242
|
**Risk:** high — token-verification seam; a wrong answer here is a silent auth bypass *(omit this line when normal)*
|
|
224
243
|
**Goal:** <one sentence>
|
|
@@ -273,6 +292,11 @@ yourself (relay + Tracker) before stopping — do not hand back "run `/caddis:ha
|
|
|
273
292
|
- **Ship gate:** NEVER push or merge to main from a phase — phases only commit to the feature branch.
|
|
274
293
|
When all phases are ✅: `/caddis:ship-pr` (stops at green CI), then STOP and wait for the human's
|
|
275
294
|
explicit go before `/caddis:ship-merge`.
|
|
295
|
+
- **Finish:** after the last phase, run `caddis_lanes.py finish --plan <plan>`; the plan is not done
|
|
296
|
+
until it prints `LEFTOVERS: 0` (merged lane worktrees and branches removed, unmerged or dirty ones
|
|
297
|
+
reported, feature branch pushed; merging to main stays with `/caddis:ship-merge`).
|
|
298
|
+
- **No load wording** — no phase asks for a CPU burner, stress test, load test or "every core"; prove
|
|
299
|
+
timing by design.
|
|
276
300
|
|
|
277
301
|
## Affected files
|
|
278
302
|
| File | Action |
|
|
@@ -303,16 +327,18 @@ done:
|
|
|
303
327
|
- **No abbreviation** — never "etc.", "similar to Phase 1", "and so on". Write every item in full.
|
|
304
328
|
- **Model tier named** — every phase names a tier (cheap/mid/frontier/ultra) + a one-line rationale; no
|
|
305
329
|
phase silently defaults to the most expensive model. Default mid; frontier/ultra only where justified.
|
|
306
|
-
- **Lane + reviewer named** — every phase names its execution lane (`claude
|
|
307
|
-
the literal launch command for
|
|
308
|
-
|
|
309
|
-
slack for reasoning out gaps.
|
|
330
|
+
- **Lane + reviewer named** — every phase names its execution lane (`claude`, `glm-headless`, or a
|
|
331
|
+
`caddis_lanes.py` lane with its `**Type:**`, with the literal launch command for every non-claude
|
|
332
|
+
phase) and a cross-review provider that is a different vendor than the implementer. A glm-headless
|
|
333
|
+
phase MUST pass every bullet of this gate — that lane has no slack for reasoning out gaps.
|
|
310
334
|
- **Launch command is spawnable** — every `glm-headless` phase's command carries **`-p`** (or
|
|
311
335
|
`--print`) and ends with **`— Phase N only`**. Both are load-bearing, not style: the launchers set
|
|
312
336
|
`CADDIS_HEADLESS` *only* on `-p`, and that variable is the guard that stops a spawned session
|
|
313
337
|
spawning another one forever; the `Phase N only` suffix is what stops the child running to the end
|
|
314
338
|
of the plan. The command is executed verbatim by a model with no slack to notice a missing flag,
|
|
315
339
|
and `/caddis:implement` now refuses a command without `-p` rather than running it.
|
|
340
|
+
- **caddis_lanes command carries --phase** — a `caddis_lanes.py` launch command must name `--phase N`
|
|
341
|
+
and `--type`; lane-check refuses one without `--phase`.
|
|
316
342
|
- **Gate-detectable** — if this phase's instruction were WRONG, its exit gate would go red. A phase
|
|
317
343
|
whose gates pass either way (copy, nav entries, badges, ordering, wording, config defaults) must
|
|
318
344
|
not take a lane that cannot escalate, no matter how completely it is specified. Every other bullet
|
|
@@ -101,6 +101,51 @@ the bug the lane routing exists to fix.
|
|
|
101
101
|
lane the plan planned. A deviation then shows up as a diff between the phase block and the tracker
|
|
102
102
|
row, visible to any later reader without an Advisory Hub having to catch it live.
|
|
103
103
|
|
|
104
|
+
**Multi-lane phases — `caddis_lanes.py`.** Use this procedure when the phase launch command is
|
|
105
|
+
`python "${CLAUDE_PLUGIN_ROOT}/scripts/caddis_lanes.py" run …`.
|
|
106
|
+
|
|
107
|
+
1. In an interactive session, run the phase's literal command. The script creates a temporary
|
|
108
|
+
worktree. It runs the lane or its fallback chain inside a resource-capped job. It re-runs the
|
|
109
|
+
gates with the repo interpreter. It runs the DeepSeek review. It then prints a `NEXT:` line.
|
|
110
|
+
2. Obey `NEXT:`:
|
|
111
|
+
- `review` → read the packet JSON and inspect `git -C <worktree> diff`. Then decide ACCEPT or REDO.
|
|
112
|
+
- `redo` → run `abandon`. Then run `run` with `--attempt 2 --findings <file with your findings>`.
|
|
113
|
+
- `takeover` → run `abandon`. Then implement the phase yourself on the `claude` lane.
|
|
114
|
+
- `claude-implement` → write the code in the printed worktree. Then run `check`.
|
|
115
|
+
- `stop` → report the result and halt. `stop (host guard killed the lane…)` means
|
|
116
|
+
`FAILED-RESOURCE`. The lane saturated the host. Never re-run it with more load. Fix the phase
|
|
117
|
+
so it proves timing by design.
|
|
118
|
+
3. **ACCEPT** means run `caddis_lanes.py accept --plan <plan> --phase N --attempt K --message
|
|
119
|
+
"<the phase's commit message>"`. It runs only on the programme branch, never on `main`. It
|
|
120
|
+
refuses a `BLOCKING` review. If you checked the finding and it is false, add
|
|
121
|
+
`--override-review "<why the finding is false>"`. The script records that reason in the packet.
|
|
122
|
+
4. Allow at most **2 attempts** per phase on a non-Claude lane. An out-of-budget switch is not an
|
|
123
|
+
attempt. The owner can request TAKEOVER at any time.
|
|
124
|
+
5. A UI phase needs a `ui-design-reviewer` screenshot review before ACCEPT. This rule applies
|
|
125
|
+
whichever lane wrote the phase.
|
|
126
|
+
6. A phase that Claude wrote needs a vendor review that is not MISSING. If `review.status` is
|
|
127
|
+
`MISSING`, run `codex exec -m gpt-5.6-sol -s read-only -C <worktree> --skip-git-repo-check -`.
|
|
128
|
+
Give the diff to the command on stdin.
|
|
129
|
+
6b. **Orchestrator artefacts live under `.caddis/orchestrator/`.** Store packets and locks in
|
|
130
|
+
`lane-runs/`. Store evidence notes as markdown directly in `.caddis/orchestrator/`. Follow its
|
|
131
|
+
`AGENTS.md`. Never write orchestration notes to `.caddis/kb/`.
|
|
132
|
+
7. **Never write a load or stress test into a lane prompt or findings file.** The script refuses
|
|
133
|
+
this wording without `--allow-load`. Only the owner gives that flag.
|
|
134
|
+
8. Record these Tracker values:
|
|
135
|
+
- `Model` = the packet's `model`.
|
|
136
|
+
- `Lane` = the lanes tried, joined with ` -> ` and followed by the reason. For example,
|
|
137
|
+
`glm -> codex-sol (glm out of budget)`.
|
|
138
|
+
- `Cross-review` = `CLEAN`, `BLOCKING` or `missing`.
|
|
139
|
+
- `Notes` = `attempt K, ACCEPT|TAKEOVER|FAILED-RESOURCE`.
|
|
140
|
+
9. The PR body lists every phase accepted with `review.status == MISSING`. It also lists every phase
|
|
141
|
+
accepted while Codex astra stood in for Claude.
|
|
142
|
+
9b. **Finish.** After the last phase, run `caddis_lanes.py finish --plan <plan>`. It removes merged
|
|
143
|
+
lane worktrees and branches. It reports unmerged or dirty ones. It pushes the feature branch.
|
|
144
|
+
The plan is not done until it prints `LEFTOVERS: 0`. Then run `/caddis:ship-pr`. Merging and
|
|
145
|
+
post-merge cleanup stay with `/caddis:ship-merge` after the owner's go.
|
|
146
|
+
10. **When Claude is out of budget**, the owner opens `caddis-run codex -m gpt-6-astra` and asks it
|
|
147
|
+
to follow this same subsection.
|
|
148
|
+
|
|
104
149
|
**Advisory-Hub mode (conditional — OFF by default).** Check whether a companion file
|
|
105
150
|
`<plan-dir>/<plan-stem>-advisory-context.md` exists (e.g. plan `.caddis/plans/foo.md` → look for
|
|
106
151
|
`.caddis/plans/foo-advisory-context.md`). **If it does not exist — the normal case, including every
|
|
@@ -187,7 +232,9 @@ report — do not redo completed work.
|
|
|
187
232
|
first, every phase, not just the one you resumed at. Step 1's check only ever saw the *resume*
|
|
188
233
|
phase: on a plan whose phase 1 is `claude` and phase 2 is `glm-headless`, it finds no mismatch and
|
|
189
234
|
this loop then carries you straight through phase 2 — so the lane silently executes nothing for
|
|
190
|
-
every phase after the first, which is the original bug wearing a different hat.
|
|
235
|
+
every phase after the first, which is the original bug wearing a different hat. For a
|
|
236
|
+
`caddis_lanes.py` launch command, follow "Multi-lane phases" above instead of spawning the command
|
|
237
|
+
blind.
|
|
191
238
|
- **RED** — write the failing test(s) the phase names (`<test file>::<case>`). Run them; confirm they
|
|
192
239
|
fail for the right reason (the missing behavior, not an import error). If a phase genuinely has no
|
|
193
240
|
testable surface, say so in the review file and implement the minimal change directly.
|
|
@@ -21,6 +21,10 @@ It is **additive and safe** — never deletes your rows:
|
|
|
21
21
|
- **Missing map** → creates `.caddis/kb/DOC-MAP.md` from a scaffold, pre-linking the repo's obvious
|
|
22
22
|
reference docs (README, `docs/…`) and any existing `.caddis/kb/*.md` notes.
|
|
23
23
|
- **Existing map** → indexes any KB note that isn't yet linked (adds a row with a placeholder description).
|
|
24
|
+
It appends to the `## Knowledge base` table, or — in a map written by hand with no such heading —
|
|
25
|
+
to the first table that already links sibling notes (`[x](x.md)`).
|
|
26
|
+
- **Could not index a note** (no table of notes anywhere) → says so and **exits 1**. Report it; do not
|
|
27
|
+
treat the run as done.
|
|
24
28
|
- **Dangling links** (a linked file that's gone) → **reported, not removed** — handle them in Step 3.
|
|
25
29
|
|
|
26
30
|
## Step 3 — finish by hand
|