mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-16 09:55:29 +02:00
v1.87.0.0 feat: add verified CSO audits and replayable repair bundles (#2852)
* feat(cso): add verified audits and replayable repair bundles * fix(cso): harden qualification and setup boundaries * fix(cso): assemble security canaries at runtime * fix(cso): bound release proof and maintenance work Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): require complete evaluation reports Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): replay expired snapshots from supplied source Co-Authored-By: OpenAI Codex <noreply@openai.com> * test(cso): synchronize DNS cancellation assertion Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore(ship): exempt repository owner from liveness proof Co-Authored-By: OpenAI Codex <noreply@openai.com> * test(cso): make recheck retention overlap deterministic Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: bump version and changelog (v1.85.0.0) Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): pass native release gates Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: move release to v1.86.0.0 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): resolve rechecks by finding Co-Authored-By: OpenAI Codex <noreply@openai.com> * chore: move release to v1.87.0.0 Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): pass macOS and Windows release gates Normalize BSD wc output, compare Windows paths by filesystem identity, preserve portable snapshot race coverage, and narrow POSIX-only Windows fixtures. Co-Authored-By: OpenAI Codex <noreply@openai.com> * fix(cso): harden native verification gates * fix(cso): refine Windows native diagnostics * test(cso): isolate Windows Git startup failure * test(cso): stabilize Windows native diagnostics * fix(cso): support hardened Git on Windows * fix(cso): close final verification gaps * test(cso): bound cold Docker fixture setup * fix(cso): restore cross-platform free-suite gates --------- Co-authored-by: OpenAI Codex <noreply@openai.com>
This commit is contained in:
co-authored by
OpenAI Codex
parent
9f81911136
commit
4a3c6a8a3c
@@ -0,0 +1,88 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[string]$OutputPath,
|
||||
[string]$LockOutputPath,
|
||||
[string]$CoreSha256,
|
||||
[Parameter(Mandatory = $true)][string]$GitExePath,
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
$stagedOutput = $null
|
||||
$stagedLockOutput = $null
|
||||
$gitItem = Get-Item -LiteralPath ([System.IO.Path]::GetFullPath($GitExePath)) -Force
|
||||
if ($gitItem.PSIsContainer -or ($gitItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or
|
||||
-not $gitItem.Name.Equals('git.exe', [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'CSO Windows build requires the resolved regular git.exe used by setup.'
|
||||
}
|
||||
$resolvedGit = $gitItem.FullName
|
||||
if (-not $CheckOnly) {
|
||||
if (-not $OutputPath -or -not $LockOutputPath -or $CoreSha256 -notmatch '^[a-f0-9]{64}$') { throw 'Normal CSO Windows builds require staged outputs and a lowercase SHA-256 core binding.' }
|
||||
$resolvedRepo = (Get-Item -LiteralPath $RepoRoot -Force).FullName
|
||||
$binRoot = [System.IO.Path]::GetFullPath((Join-Path $resolvedRepo 'bin')).TrimEnd('\')
|
||||
function Resolve-StagedOutput([string]$Value, [string]$ExpectedName) {
|
||||
$resolved = [System.IO.Path]::GetFullPath($Value)
|
||||
$parent = [System.IO.Path]::GetDirectoryName($resolved)
|
||||
$parentItem = Get-Item -LiteralPath $parent -Force
|
||||
$directParent = [System.IO.Path]::GetDirectoryName($parent)
|
||||
if ([System.IO.Path]::GetFileName($resolved) -cne $ExpectedName -or
|
||||
[System.IO.Path]::GetFileName($parent) -notlike '.gstack-cso-stage.*' -or
|
||||
-not $directParent.Equals($binRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
($parentItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
|
||||
throw 'CSO Windows native output must be a direct, non-reparse staging directory under the repository bin directory.'
|
||||
}
|
||||
return $resolved
|
||||
}
|
||||
$stagedOutput = Resolve-StagedOutput $OutputPath 'gstack-cso-launcher.exe'
|
||||
$stagedLockOutput = Resolve-StagedOutput $LockOutputPath 'gstack-cso-publish-lock.exe'
|
||||
}
|
||||
|
||||
# Use the installed MSVC toolchain, not a Bun-hosted launcher or downloaded
|
||||
# compiler. This works from Git Bash without a preconfigured Developer Prompt.
|
||||
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||
if (-not (Test-Path -LiteralPath $vswhere -PathType Leaf)) {
|
||||
throw 'CSO Windows build requires Visual Studio 2022 Build Tools with the Desktop development with C++ workload.'
|
||||
}
|
||||
$installation = & $vswhere -latest -products '*' -version '[17.0,)' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
|
||||
if ($LASTEXITCODE -ne 0 -or -not $installation) {
|
||||
throw 'CSO Windows build requires the Visual Studio 2022 MSVC x64 toolchain and Windows SDK.'
|
||||
}
|
||||
$devShell = Join-Path ([string]$installation) 'Common7\Tools\Launch-VsDevShell.ps1'
|
||||
if (-not (Test-Path -LiteralPath $devShell -PathType Leaf)) { throw 'MSVC Developer PowerShell initialization is unavailable.' }
|
||||
& $devShell -Arch amd64 -HostArch amd64 -SkipAutomaticLocation | Out-Null
|
||||
$compiler = (Get-Command cl.exe -ErrorAction Stop).Source
|
||||
$installationPrefix = [System.IO.Path]::GetFullPath([string]$installation).TrimEnd('\') + '\'
|
||||
if (-not [System.IO.Path]::GetFullPath($compiler).StartsWith($installationPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'MSVC initialization selected a compiler outside the discovered Visual Studio installation.'
|
||||
}
|
||||
$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ('gstack-cso-msvc-' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $temporary | Out-Null
|
||||
try {
|
||||
if ($CheckOnly) {
|
||||
$source = Join-Path $temporary 'probe.c'
|
||||
$output = Join-Path $temporary 'probe.exe'
|
||||
Set-Content -LiteralPath $source -Encoding Ascii -NoNewline -Value 'int main(void) { return 0; }'
|
||||
$object = Join-Path $temporary 'probe.obj'
|
||||
& $compiler /nologo /std:c11 /W4 /WX /O2 /MT /GS /guard:cf /D_CRT_SECURE_NO_WARNINGS "/Fo$object" "/Fe$output" $source /link /DYNAMICBASE /NXCOMPAT /HIGHENTROPYVA
|
||||
if ($LASTEXITCODE -ne 0) { throw "Native CSO Windows compiler probe failed ($LASTEXITCODE)." }
|
||||
if (-not (Test-Path -LiteralPath $output -PathType Leaf)) { throw 'Native CSO Windows compiler probe was not produced.' }
|
||||
} else {
|
||||
$binding = Join-Path $temporary 'core-binding.h'
|
||||
$gitLiteral = $resolvedGit.Replace('\', '\\').Replace('"', '\"')
|
||||
Set-Content -LiteralPath $binding -Encoding Ascii -Value "#define GSTACK_CSO_CORE_SHA256 `"$CoreSha256`"`n#define GSTACK_CSO_GIT_PATH L`"$gitLiteral`""
|
||||
$builds = @(
|
||||
@{ Source = (Join-Path $RepoRoot 'lib\cso\launcher-windows.c'); Output = [string]$stagedOutput; Object = 'launcher.obj'; Binding = $true },
|
||||
@{ Source = (Join-Path $RepoRoot 'lib\cso\publish-lock.c'); Output = [string]$stagedLockOutput; Object = 'publish-lock.obj'; Binding = $false }
|
||||
)
|
||||
foreach ($build in $builds) {
|
||||
$object = Join-Path $temporary $build.Object
|
||||
$forcedInclude = if ($build.Binding) { "/FI$binding" } else { @() }
|
||||
& $compiler /nologo /std:c11 /W4 /WX /O2 /MT /GS /guard:cf /D_CRT_SECURE_NO_WARNINGS $forcedInclude "/Fo$object" "/Fe$($build.Output)" $build.Source /link /DYNAMICBASE /NXCOMPAT /HIGHENTROPYVA
|
||||
if ($LASTEXITCODE -ne 0) { throw "Native CSO Windows helper compilation failed ($LASTEXITCODE)." }
|
||||
if (-not (Test-Path -LiteralPath $build.Output -PathType Leaf)) { throw 'Native CSO Windows output was not produced.' }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $temporary -Recurse -Force
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
CSO_BUILD_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
cd "$CSO_BUILD_ROOT"
|
||||
umask 077
|
||||
BUN_CMD="${BUN_CMD:-bun}"
|
||||
CSO_CC="${CSO_CC:-cc}"
|
||||
CSO_EXE=""
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*|Windows_NT) CSO_EXE=".exe" ;;
|
||||
esac
|
||||
|
||||
CSO_FINAL_CORE="$CSO_BUILD_ROOT/bin/gstack-cso-core$CSO_EXE"
|
||||
CSO_FINAL_LAUNCHER="$CSO_BUILD_ROOT/bin/gstack-cso-launcher$CSO_EXE"
|
||||
CSO_FINAL_WATCHDOG="$CSO_BUILD_ROOT/bin/gstack-cso-watchdog"
|
||||
CSO_FINAL_GENERATION="$CSO_BUILD_ROOT/bin/.gstack-cso-generation"
|
||||
CSO_STAGE=""
|
||||
CSO_PUBLISHING=0
|
||||
CSO_COMMITTED=0
|
||||
CSO_OLD_COHERENT=0
|
||||
CSO_OLD_LAUNCHER_MOVED=0
|
||||
CSO_OLD_CORE_MOVED=0
|
||||
CSO_OLD_WATCHDOG_MOVED=0
|
||||
CSO_OLD_GENERATION_MOVED=0
|
||||
CSO_NEW_LAUNCHER_INSTALLED=0
|
||||
CSO_NEW_CORE_INSTALLED=0
|
||||
CSO_NEW_WATCHDOG_INSTALLED=0
|
||||
CSO_NEW_GENERATION_INSTALLED=0
|
||||
|
||||
cso_checkpoint() {
|
||||
[ "${GSTACK_CSO_BUILD_TESTING:-0}" = 1 ] || return 0
|
||||
if [ "${GSTACK_CSO_BUILD_TEST_FAIL_AFTER:-}" = "$1" ]; then
|
||||
echo "Injected CSO build failure after $1" >&2
|
||||
exit 86
|
||||
fi
|
||||
if [ "${GSTACK_CSO_BUILD_TEST_KILL_AFTER:-}" = "$1" ]; then
|
||||
kill -KILL "$$"
|
||||
fi
|
||||
}
|
||||
|
||||
cso_restore_previous() {
|
||||
rollback_ok=1
|
||||
# A public launcher is removed before any supporting artifact is restored.
|
||||
if [ "$CSO_NEW_LAUNCHER_INSTALLED" -eq 1 ]; then rm -f "$CSO_FINAL_LAUNCHER" || rollback_ok=0; fi
|
||||
if [ "$CSO_NEW_CORE_INSTALLED" -eq 1 ]; then rm -f "$CSO_FINAL_CORE" || rollback_ok=0; fi
|
||||
if [ -z "$CSO_EXE" ] && [ "$CSO_NEW_WATCHDOG_INSTALLED" -eq 1 ]; then rm -f "$CSO_FINAL_WATCHDOG" || rollback_ok=0; fi
|
||||
if [ "$CSO_NEW_GENERATION_INSTALLED" -eq 1 ];then rm -f "$CSO_FINAL_GENERATION"||rollback_ok=0;fi
|
||||
if [ "$CSO_OLD_CORE_MOVED" -eq 1 ]; then
|
||||
if [ "${GSTACK_CSO_BUILD_TESTING:-0}" = 1 ] && [ "${GSTACK_CSO_BUILD_TEST_FAIL_RESTORE:-}" = core ];then rollback_ok=0
|
||||
elif cso_present "$CSO_STAGE/previous/core";then cso_move "$CSO_STAGE/previous/core" "$CSO_FINAL_CORE" || rollback_ok=0
|
||||
elif ! cso_valid_artifact "$CSO_FINAL_CORE";then rollback_ok=0;fi
|
||||
fi
|
||||
if [ -z "$CSO_EXE" ] && [ "$CSO_OLD_WATCHDOG_MOVED" -eq 1 ]; then
|
||||
if cso_present "$CSO_STAGE/previous/watchdog";then cso_move "$CSO_STAGE/previous/watchdog" "$CSO_FINAL_WATCHDOG" || rollback_ok=0
|
||||
elif ! cso_valid_artifact "$CSO_FINAL_WATCHDOG";then rollback_ok=0;fi
|
||||
fi
|
||||
if [ "$CSO_OLD_GENERATION_MOVED" -eq 1 ];then
|
||||
if cso_present "$CSO_STAGE/previous/generation";then cso_move "$CSO_STAGE/previous/generation" "$CSO_FINAL_GENERATION"||rollback_ok=0
|
||||
elif ! cso_valid_generation "$CSO_FINAL_GENERATION";then rollback_ok=0;fi
|
||||
fi
|
||||
# Restore the old launcher last, and only beside the complete old support set.
|
||||
if [ "$CSO_OLD_LAUNCHER_MOVED" -eq 1 ]; then
|
||||
if [ "$rollback_ok" -eq 1 ] && [ "$CSO_OLD_COHERENT" -eq 1 ]; then
|
||||
if cso_present "$CSO_STAGE/previous/launcher";then cso_move "$CSO_STAGE/previous/launcher" "$CSO_FINAL_LAUNCHER" || rollback_ok=0
|
||||
elif ! cso_valid_artifact "$CSO_FINAL_LAUNCHER";then rollback_ok=0;fi
|
||||
else
|
||||
rollback_ok=0
|
||||
fi
|
||||
fi
|
||||
if [ "$rollback_ok" -ne 1 ]; then
|
||||
rm -f "$CSO_FINAL_LAUNCHER"
|
||||
: > "$CSO_STAGE/.retain-recovery"
|
||||
echo "CSO build rollback was incomplete; the public launcher is withheld and recovery files remain in $CSO_STAGE" >&2
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
cso_cleanup() {
|
||||
status=$1
|
||||
trap - EXIT
|
||||
trap '' HUP INT TERM
|
||||
set +e
|
||||
cleanup_stage=1
|
||||
if [ "$CSO_PUBLISHING" -eq 1 ] && [ "$CSO_COMMITTED" -ne 1 ]; then
|
||||
if ! cso_restore_previous; then status=1; cleanup_stage=0; fi
|
||||
fi
|
||||
if [ "$cleanup_stage" -eq 1 ] && [ -n "$CSO_STAGE" ] && [ ! -f "$CSO_STAGE/.retain-recovery" ]; then rm -rf "$CSO_STAGE"; fi
|
||||
exit "$status"
|
||||
}
|
||||
trap 'cso_cleanup $?' EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
cso_valid_artifact() { [ -f "$1" ] && [ ! -L "$1" ] && [ -x "$1" ]; }
|
||||
cso_present() { [ -e "$1" ] || [ -L "$1" ]; }
|
||||
cso_valid_generation() {
|
||||
[ -f "$1" ]&&[ ! -L "$1" ]||return 1
|
||||
generation_size="$(wc -c < "$1" 2>/dev/null||true)"
|
||||
generation_size="${generation_size//[[:space:]]/}"
|
||||
[ "$generation_size" = 65 ]||return 1
|
||||
generation="$(cat "$1" 2>/dev/null)";[ "${#generation}" -eq 64 ]||return 1
|
||||
case "$generation" in *[!a-f0-9]*) return 1;;esac
|
||||
}
|
||||
cso_move() {
|
||||
move_attempts=0
|
||||
while ! mv "$1" "$2";do
|
||||
move_attempts=$((move_attempts+1))
|
||||
if [ -z "$CSO_EXE" ] || [ "$move_attempts" -ge 50 ];then return 1;fi
|
||||
sleep .02
|
||||
done
|
||||
}
|
||||
|
||||
cso_sign_macos_artifact() {
|
||||
artifact=$1
|
||||
hardened=$2
|
||||
if [ "$(uname -m)" = arm64 ]; then
|
||||
sig_end="$(otool -l "$artifact" 2>/dev/null | awk '/LC_CODE_SIGNATURE/{f=1} f&&/dataoff/{o=$2} f&&/datasize/{print o+$2; exit}')"
|
||||
file_size="$(stat -f%z "$artifact" 2>/dev/null || true)"
|
||||
if [ -n "$sig_end" ] && [ -n "$file_size" ] && [ "$sig_end" -gt 0 ] 2>/dev/null && [ "$sig_end" -lt "$file_size" ] 2>/dev/null; then
|
||||
truncated="$CSO_STAGE/.codesign-truncated"
|
||||
head -c "$sig_end" "$artifact" > "$truncated"
|
||||
cat "$truncated" > "$artifact"
|
||||
rm -f "$truncated"
|
||||
chmod +x "$artifact"
|
||||
fi
|
||||
fi
|
||||
codesign --remove-signature "$artifact" 2>/dev/null || true
|
||||
if [ "$hardened" -eq 1 ]; then codesign --force --sign - --options runtime "$artifact"
|
||||
else codesign --force --sign - "$artifact"; fi
|
||||
codesign --verify --strict "$artifact"
|
||||
if [ "$hardened" -eq 1 ]; then codesign -d --verbose=4 "$artifact" 2>&1 | grep -q 'runtime'; fi
|
||||
}
|
||||
|
||||
cso_sha256() {
|
||||
if [ -x /usr/bin/sha256sum ];then digest="$(/usr/bin/sha256sum "$1")";digest=${digest%% *}
|
||||
elif [ -x /usr/bin/shasum ];then digest="$(/usr/bin/shasum -a 256 "$1")";digest=${digest%% *}
|
||||
else echo 'CSO build requires sha256sum or shasum to bind the native launcher to its core.' >&2;return 1;fi
|
||||
case "$digest" in *[!a-f0-9]*|'') echo 'CSO build received an invalid core SHA-256 digest.' >&2;return 1;;esac
|
||||
[ "${#digest}" -eq 64 ] || { echo 'CSO build received an invalid core SHA-256 digest.' >&2;return 1; }
|
||||
printf '%s\n' "$digest"
|
||||
}
|
||||
|
||||
cso_publish_locked() {
|
||||
[ "${GSTACK_CSO_PUBLISH_LOCKED:-}" = 1 ] || { echo 'CSO publication requires the native publisher lock.' >&2;return 73; }
|
||||
[ "$#" -eq 1 ] && [ -d "$1" ] && [ ! -L "$1" ] || { echo 'CSO publication stage is invalid.' >&2;return 69; }
|
||||
CSO_STAGE="$(cd "$1" && pwd -P)"
|
||||
case "$CSO_STAGE" in "$CSO_BUILD_ROOT"/bin/.gstack-cso-stage.*) ;; *) echo 'CSO publication stage escaped the trusted bin directory.' >&2;return 69;;esac
|
||||
CSO_STAGE_CORE="$CSO_STAGE/gstack-cso-core$CSO_EXE"
|
||||
CSO_STAGE_LAUNCHER="$CSO_STAGE/gstack-cso-launcher$CSO_EXE"
|
||||
CSO_STAGE_WATCHDOG="$CSO_STAGE/gstack-cso-watchdog"
|
||||
CSO_STAGE_GENERATION="$CSO_STAGE/.gstack-cso-generation"
|
||||
cso_valid_artifact "$CSO_STAGE_CORE" || { echo 'Staged CSO core is not one executable regular file.' >&2;return 1; }
|
||||
cso_valid_artifact "$CSO_STAGE_LAUNCHER" || { echo 'Staged CSO launcher is not one executable regular file.' >&2;return 1; }
|
||||
if [ -z "$CSO_EXE" ];then cso_valid_artifact "$CSO_STAGE_WATCHDOG" || { echo 'Staged CSO watchdog is not one executable regular file.' >&2;return 1; };fi
|
||||
cso_valid_generation "$CSO_STAGE_GENERATION"||{ echo 'Staged CSO generation manifest is invalid.' >&2;return 1; }
|
||||
|
||||
old_present=0
|
||||
for artifact in "$CSO_FINAL_LAUNCHER" "$CSO_FINAL_CORE";do
|
||||
if cso_present "$artifact";then cso_valid_artifact "$artifact" || { echo 'Existing CSO artifacts contain an unsafe file.' >&2;return 1; };old_present=$((old_present+1));fi
|
||||
done
|
||||
if [ -z "$CSO_EXE" ]&&cso_present "$CSO_FINAL_WATCHDOG";then cso_valid_artifact "$CSO_FINAL_WATCHDOG" || { echo 'Existing CSO artifacts contain an unsafe file.' >&2;return 1; };old_present=$((old_present+1));fi
|
||||
if cso_present "$CSO_FINAL_GENERATION";then cso_valid_generation "$CSO_FINAL_GENERATION"||{ echo 'Existing CSO generation manifest is unsafe.' >&2;return 1; };old_present=$((old_present+1));fi
|
||||
expected=3;[ -n "$CSO_EXE" ]||expected=4
|
||||
if [ "$old_present" -eq "$expected" ];then CSO_OLD_COHERENT=1;fi
|
||||
|
||||
CSO_PUBLISHING=1
|
||||
# Ignored signals remain ignored in mv children. SIGKILL is handled by the
|
||||
# launcher-first withdrawal and launcher-last commit order.
|
||||
trap '' HUP INT TERM
|
||||
if cso_present "$CSO_FINAL_LAUNCHER";then
|
||||
CSO_OLD_LAUNCHER_MOVED=1
|
||||
cso_move "$CSO_FINAL_LAUNCHER" "$CSO_STAGE/previous/launcher"
|
||||
cso_checkpoint withdraw-launcher
|
||||
fi
|
||||
if cso_present "$CSO_FINAL_CORE";then CSO_OLD_CORE_MOVED=1;cso_move "$CSO_FINAL_CORE" "$CSO_STAGE/previous/core";fi
|
||||
if [ -z "$CSO_EXE" ]&&cso_present "$CSO_FINAL_WATCHDOG";then CSO_OLD_WATCHDOG_MOVED=1;cso_move "$CSO_FINAL_WATCHDOG" "$CSO_STAGE/previous/watchdog";fi
|
||||
if cso_present "$CSO_FINAL_GENERATION";then CSO_OLD_GENERATION_MOVED=1;cso_move "$CSO_FINAL_GENERATION" "$CSO_STAGE/previous/generation";fi
|
||||
|
||||
CSO_NEW_CORE_INSTALLED=1
|
||||
cso_move "$CSO_STAGE_CORE" "$CSO_FINAL_CORE"
|
||||
cso_checkpoint publish-core
|
||||
if [ -z "$CSO_EXE" ];then
|
||||
CSO_NEW_WATCHDOG_INSTALLED=1
|
||||
cso_move "$CSO_STAGE_WATCHDOG" "$CSO_FINAL_WATCHDOG"
|
||||
cso_checkpoint publish-watchdog
|
||||
fi
|
||||
CSO_NEW_GENERATION_INSTALLED=1
|
||||
cso_move "$CSO_STAGE_GENERATION" "$CSO_FINAL_GENERATION"
|
||||
cso_checkpoint before-publish-launcher
|
||||
CSO_NEW_LAUNCHER_INSTALLED=1
|
||||
cso_move "$CSO_STAGE_LAUNCHER" "$CSO_FINAL_LAUNCHER"
|
||||
cso_checkpoint publish-launcher
|
||||
CSO_COMMITTED=1
|
||||
}
|
||||
|
||||
if [ "${1:-}" = __publish_locked ];then
|
||||
shift
|
||||
cso_publish_locked "$@"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# This entrypoint can be run directly. Once it may publish a CSO generation,
|
||||
# the previous whole-build completion proof no longer describes all outputs.
|
||||
rm -f "$CSO_BUILD_ROOT/browse/dist/.build-complete"
|
||||
|
||||
if ! command -v "$CSO_CC" >/dev/null 2>&1 && [ -z "$CSO_EXE" ]; then
|
||||
echo 'CSO build requires a C compiler (cc/clang/gcc) for the trusted launcher and watchdog.' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -s)" = Darwin ] && ! command -v codesign >/dev/null 2>&1; then
|
||||
echo 'CSO build requires macOS codesign so staged artifacts can be verified before publication.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CSO_STAGE="$(mktemp -d "$CSO_BUILD_ROOT/bin/.gstack-cso-stage.XXXXXX")"
|
||||
CSO_STAGE_CORE="$CSO_STAGE/gstack-cso-core$CSO_EXE"
|
||||
CSO_STAGE_LAUNCHER="$CSO_STAGE/gstack-cso-launcher$CSO_EXE"
|
||||
CSO_STAGE_WATCHDOG="$CSO_STAGE/gstack-cso-watchdog"
|
||||
CSO_STAGE_GENERATION="$CSO_STAGE/.gstack-cso-generation"
|
||||
CSO_STAGE_LOCKER="$CSO_STAGE/gstack-cso-publish-lock$CSO_EXE"
|
||||
mkdir -m 700 "$CSO_STAGE/previous"
|
||||
if [ -n "$CSO_EXE" ];then : > "$CSO_STAGE/.gstack-cso-generation.lock";fi
|
||||
|
||||
# These are compile-time switches. Bun otherwise discovers configuration in
|
||||
# the audited working directory before the helper can apply its own policy.
|
||||
"$BUN_CMD" build --compile \
|
||||
--no-compile-autoload-dotenv \
|
||||
--no-compile-autoload-bunfig \
|
||||
--no-compile-autoload-tsconfig \
|
||||
--no-compile-autoload-package-json \
|
||||
lib/cso/cli.ts --outfile "$CSO_STAGE_CORE"
|
||||
chmod +x "$CSO_STAGE_CORE"
|
||||
cso_checkpoint core
|
||||
|
||||
# POSIX process groups are required by the detached watchdog. Windows keeps
|
||||
# comprehensive execution unavailable instead of substituting a weaker timer.
|
||||
if [ -z "$CSO_EXE" ]; then
|
||||
"$CSO_CC" -std=c11 -D_POSIX_C_SOURCE=200809L -O2 -Wall -Wextra \
|
||||
lib/cso/watchdog.c -o "$CSO_STAGE_WATCHDOG"
|
||||
chmod +x "$CSO_STAGE_WATCHDOG"
|
||||
cso_checkpoint watchdog
|
||||
fi
|
||||
|
||||
if [ "$(uname -s)" = Darwin ]; then
|
||||
cso_sign_macos_artifact "$CSO_STAGE_CORE" 0
|
||||
cso_sign_macos_artifact "$CSO_STAGE_WATCHDOG" 0
|
||||
fi
|
||||
CSO_CORE_SHA256="$(cso_sha256 "$CSO_STAGE_CORE")"
|
||||
printf '%s\n' "$CSO_CORE_SHA256" > "$CSO_STAGE_GENERATION"
|
||||
|
||||
if [ -n "$CSO_EXE" ];then
|
||||
if ! command -v powershell.exe >/dev/null 2>&1||! command -v cygpath >/dev/null 2>&1;then
|
||||
echo 'CSO Windows build requires Git Bash and Windows PowerShell with MSVC Build Tools.' >&2;exit 1
|
||||
fi
|
||||
CSO_WINDOWS_GIT="$(type -P git 2>/dev/null || true)"
|
||||
[ -n "$CSO_WINDOWS_GIT" ] && [ -f "$CSO_WINDOWS_GIT" ] || { echo 'CSO Windows build requires the Git for Windows git.exe selected by Git Bash.' >&2;exit 1; }
|
||||
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass \
|
||||
-File "$(cygpath -w "$CSO_BUILD_ROOT/scripts/build-cso-windows.ps1")" \
|
||||
-RepoRoot "$(cygpath -w "$CSO_BUILD_ROOT")" \
|
||||
-OutputPath "$(cygpath -w "$CSO_STAGE_LAUNCHER")" \
|
||||
-LockOutputPath "$(cygpath -w "$CSO_STAGE_LOCKER")" \
|
||||
-CoreSha256 "$CSO_CORE_SHA256" \
|
||||
-GitExePath "$(cygpath -aw "$CSO_WINDOWS_GIT")"
|
||||
else
|
||||
CSO_LAUNCHER_FLAGS=""
|
||||
case "$(uname -s)" in
|
||||
Linux) CSO_LAUNCHER_FLAGS="-static" ;;
|
||||
# Local ad-hoc signatures do not distinguish the launcher from another
|
||||
# ad-hoc dylib. This section makes dyld prune DYLD_* before constructors.
|
||||
Darwin) CSO_LAUNCHER_FLAGS="-Wl,-sectcreate,__RESTRICT,__restrict,/dev/null" ;;
|
||||
esac
|
||||
# shellcheck disable=SC2086 -- the optional platform flags are fixed literals.
|
||||
"$CSO_CC" -std=c11 -D_POSIX_C_SOURCE=200809L -O2 -Wall -Wextra $CSO_LAUNCHER_FLAGS \
|
||||
"-DGSTACK_CSO_CORE_SHA256=\"$CSO_CORE_SHA256\"" lib/cso/launcher.c -o "$CSO_STAGE_LAUNCHER"
|
||||
"$CSO_CC" -std=c11 -D_POSIX_C_SOURCE=200809L -O2 -Wall -Wextra lib/cso/publish-lock.c -o "$CSO_STAGE_LOCKER"
|
||||
fi
|
||||
chmod +x "$CSO_STAGE_LAUNCHER" "$CSO_STAGE_LOCKER"
|
||||
cso_checkpoint launcher
|
||||
cso_checkpoint publish-lock
|
||||
|
||||
if [ "$(uname -s)" = Darwin ];then cso_sign_macos_artifact "$CSO_STAGE_LAUNCHER" 1;fi
|
||||
|
||||
cso_valid_artifact "$CSO_STAGE_CORE" || { echo 'Staged CSO core is not one executable regular file.' >&2; exit 1; }
|
||||
cso_valid_artifact "$CSO_STAGE_LAUNCHER" || { echo 'Staged CSO launcher is not one executable regular file.' >&2; exit 1; }
|
||||
cso_valid_artifact "$CSO_STAGE_LOCKER" || { echo 'Staged CSO publisher lock is not one executable regular file.' >&2; exit 1; }
|
||||
if [ -z "$CSO_EXE" ]; then cso_valid_artifact "$CSO_STAGE_WATCHDOG" || { echo 'Staged CSO watchdog is not one executable regular file.' >&2; exit 1; }; fi
|
||||
cso_valid_generation "$CSO_STAGE_GENERATION"||{ echo 'Staged CSO generation manifest is invalid.' >&2;exit 1; }
|
||||
[ "$(cso_sha256 "$CSO_STAGE_CORE")" = "$CSO_CORE_SHA256" ] || { echo 'Staged CSO core changed after launcher binding.' >&2;exit 1; }
|
||||
"$CSO_STAGE_LAUNCHER" --version >/dev/null
|
||||
cso_checkpoint validated
|
||||
|
||||
if [ -n "$CSO_EXE" ];then
|
||||
[ -f /usr/bin/bash.exe ] || { echo 'CSO publication requires the Git Bash executable.' >&2;exit 1; }
|
||||
CSO_PUBLISH_SHELL="$(cygpath -aw /usr/bin/bash.exe)"
|
||||
else
|
||||
case "${BASH:-}" in /*) CSO_PUBLISH_SHELL=$BASH;; *) echo 'CSO publication requires an absolute Bash executable.' >&2;exit 1;;esac
|
||||
fi
|
||||
exec "$CSO_STAGE_LOCKER" "$CSO_BUILD_ROOT/bin" "$CSO_PUBLISH_SHELL" "$CSO_BUILD_ROOT/scripts/build-cso.sh" __publish_locked "$CSO_STAGE"
|
||||
@@ -6,6 +6,14 @@ cd "$ROOT"
|
||||
|
||||
BUN_CMD="${BUN_CMD:-bun}"
|
||||
BUN_CMD_WAS_COPIED=0
|
||||
BUILD_STAMP="$ROOT/browse/dist/.build-complete"
|
||||
BUILD_STAMP_TMP="$BUILD_STAMP.tmp.$$"
|
||||
|
||||
# Setup trusts this stamp as proof that the selected multi-binary build completed.
|
||||
# Ordinary/direct builds include CSO and remain strict. Setup may explicitly omit
|
||||
# CSO after its host capability probe, while still publishing the general build.
|
||||
# Invalidate before touching output so an interrupted build cannot hide staleness.
|
||||
rm -f "$BUILD_STAMP" "$BUILD_STAMP_TMP"
|
||||
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*|Windows_NT)
|
||||
@@ -29,6 +37,15 @@ esac
|
||||
"$BUN_CMD" build --compile design/src/cli.ts --outfile design/dist/design
|
||||
"$BUN_CMD" build --compile make-pdf/src/cli.ts --outfile make-pdf/dist/pdf
|
||||
"$BUN_CMD" build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover
|
||||
if [ "${GSTACK_SETUP_RUNNING:-0}" = "1" ] && [ "${GSTACK_SETUP_SKIP_CSO_BUILD:-0}" = "1" ]; then
|
||||
# Setup removes these before invoking us too. Repeat here so the setup-private
|
||||
# escape hatch can never publish a completion stamp beside stale trusted code.
|
||||
rm -f bin/gstack-cso-launcher bin/gstack-cso-launcher.exe \
|
||||
bin/gstack-cso-core bin/gstack-cso-core.exe bin/gstack-cso-watchdog \
|
||||
bin/.gstack-cso-generation bin/.gstack-cso-generation.lock
|
||||
else
|
||||
BUN_CMD="$BUN_CMD" bash scripts/build-cso.sh
|
||||
fi
|
||||
bash browse/scripts/build-node-server.sh
|
||||
bash scripts/write-version-files.sh browse/dist/.version design/dist/.version make-pdf/dist/.version
|
||||
chmod +x browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover
|
||||
@@ -36,3 +53,8 @@ rm -f .*.bun-build
|
||||
if [ "$BUN_CMD_WAS_COPIED" -eq 1 ]; then
|
||||
rm -rf "$ROOT/.tmp-bun-bin"
|
||||
fi
|
||||
|
||||
# Publish last and on the same filesystem. A failure or interruption before the
|
||||
# rename leaves the canonical stamp absent, which makes setup rebuild everything.
|
||||
printf 'complete\n' > "$BUILD_STAMP_TMP"
|
||||
mv -f "$BUILD_STAMP_TMP" "$BUILD_STAMP"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Bind cryptographically verified `gh attestation verify --format json` output to reviewed statements. */
|
||||
import * as fs from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { canonical, sha256 } from '../lib/cso/contracts';
|
||||
|
||||
const HASH=/^[a-f0-9]{64}$/,MAX_BYTES=4*1024*1024;
|
||||
|
||||
export function verifiedStatementSetDigest(value:unknown,predicateType:string,subjectSha256:string):string{
|
||||
if(!Array.isArray(value)||!value.length||typeof predicateType!=='string'||!/^https:\/\/[A-Za-z0-9./_-]+$/.test(predicateType)||!HASH.test(subjectSha256))throw new Error('INVALID_VERIFIED_ATTESTATION_SET');
|
||||
const statements:string[]=[];
|
||||
for(const item of value){
|
||||
if(!item||typeof item!=='object'||Array.isArray(item))throw new Error('INVALID_VERIFIED_ATTESTATION_SET');
|
||||
const result=(item as any).verificationResult,statement=result?.statement;
|
||||
if(!statement||typeof statement!=='object'||Array.isArray(statement)||statement.predicateType!==predicateType||!Array.isArray(statement.subject)||
|
||||
!statement.subject.some((subject:any)=>subject&&typeof subject==='object'&&subject.digest?.sha256===subjectSha256))throw new Error('VERIFIED_ATTESTATION_IDENTITY_MISMATCH');
|
||||
statements.push(canonical(statement));
|
||||
}
|
||||
statements.sort();return`sha256:${sha256(canonical(statements))}`;
|
||||
}
|
||||
|
||||
if(import.meta.main){
|
||||
try{
|
||||
const args=process.argv.slice(2),command=args.shift(),file=args.shift(),predicate=args.shift(),subject=args.shift();
|
||||
if(command!=='digest'||!file||!predicate||!subject||args.length)throw new Error('Usage: cso-attestation-evidence digest VERIFIED.json PREDICATE SUBJECT_SHA256');
|
||||
const path=resolve(file),stat=fs.lstatSync(path);if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||stat.size<=0||stat.size>MAX_BYTES)throw new Error('UNSAFE_VERIFIED_ATTESTATION_FILE');
|
||||
process.stdout.write(verifiedStatementSetDigest(JSON.parse(fs.readFileSync(path,'utf8')),predicate,subject)+'\n');
|
||||
}catch(error){process.stderr.write((error instanceof Error?error.message:'ATTESTATION_EVIDENCE_ERROR')+'\n');process.exitCode=1;}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Generic producer runner. Compile this file before moving it to a clean producer host. */
|
||||
import * as fs from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { readBoundedStable } from '../lib/cso/bounded-file';
|
||||
import { CsoError } from '../lib/cso/contracts';
|
||||
import { executable, redact } from '../lib/cso/process';
|
||||
import { atomicWriteSync } from '../lib/fs-atomic';
|
||||
import { resolveClaudeCommand } from '../lib/claude-bin';
|
||||
import runtimeCatalog from '../lib/cso/runtime-catalog.json';
|
||||
import scannerCatalog from '../lib/cso/scanner-images/catalog.json';
|
||||
import { ClaudeAdapter } from '../test/helpers/providers/claude';
|
||||
import { GptAdapter } from '../test/helpers/providers/gpt';
|
||||
import { GeminiAdapter, prepareGeminiProducerState, removeGeminiProducerState } from '../test/helpers/providers/gemini';
|
||||
import { PRICING } from '../test/helpers/pricing';
|
||||
import type { ProviderAdapter, RunOpts, RunResult } from '../test/helpers/providers/types';
|
||||
import {
|
||||
producerInputHash,
|
||||
producerArtifactInventoryHash,
|
||||
producerInstallationIdentityHash,
|
||||
producerProviderIdentityHash,
|
||||
producerReceiptHash,
|
||||
sha256,
|
||||
type ProducerCell,
|
||||
type ProducerHost,
|
||||
type ProducerInput,
|
||||
type ProducerArtifactInventory,
|
||||
type ProducerInstallationIdentity,
|
||||
type ProducerProviderIdentity,
|
||||
type ProducerReceipt,
|
||||
type ProducerSourceEntry,
|
||||
} from './cso-eval-protocol';
|
||||
|
||||
const HEX = /^[a-f0-9]{64}$/;
|
||||
const GENERATION_MANIFEST_BYTES = 65;
|
||||
const INPUT_LIMIT = 4 * 1024 * 1024;
|
||||
const OUTPUT_LIMIT = 32 * 1024 * 1024;
|
||||
const ARTIFACT_FILE_LIMIT = 32 * 1024 * 1024;
|
||||
const ARTIFACT_TOTAL_LIMIT = 128 * 1024 * 1024;
|
||||
const ARTIFACT_COUNT_LIMIT = 4096;
|
||||
|
||||
function inside(parent: string, child: string): boolean {
|
||||
const path = relative(parent, child);
|
||||
return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path));
|
||||
}
|
||||
|
||||
function regularFile(path: string): fs.Stats {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
return stat;
|
||||
}
|
||||
|
||||
function safeDirectory(path: string): void {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(path) !== path) throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
|
||||
}
|
||||
|
||||
function assertIsolatedLayout(controlPath: string, receiptPath: string): { jobRoot: string; sourceRoot: string } {
|
||||
const jobRoot = dirname(controlPath), producerRoot = dirname(jobRoot), sourceRoot = join(jobRoot, 'source');
|
||||
safeDirectory(producerRoot); safeDirectory(jobRoot); safeDirectory(sourceRoot);
|
||||
if (basename(jobRoot) !== 'job' || basename(controlPath) !== 'producer-input.json') throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
|
||||
if (JSON.stringify(fs.readdirSync(producerRoot).sort()) !== JSON.stringify(['job'])) throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
|
||||
if (JSON.stringify(fs.readdirSync(jobRoot).sort()) !== JSON.stringify(['producer-input.json', 'source'])) throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
|
||||
if (inside(producerRoot, receiptPath)) throw new Error('NON_ISOLATED_PRODUCER_LAYOUT');
|
||||
return { jobRoot, sourceRoot };
|
||||
}
|
||||
|
||||
function assertReceiptDestination(path: string): void {
|
||||
const parent = dirname(path), stat = fs.lstatSync(parent);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(parent) !== parent || fs.existsSync(path)) throw new Error('UNSAFE_RECEIPT_DESTINATION');
|
||||
}
|
||||
|
||||
function walk(root: string, directory = root): string[] {
|
||||
const output: string[] = [];
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (directory === root && entry.name === '.git') {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink() || fs.realpathSync(join(root, '.git')) !== join(root, '.git')) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
continue;
|
||||
}
|
||||
if (entry.isSymbolicLink()) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
const full = join(directory, entry.name);
|
||||
if (entry.isDirectory()) output.push(...walk(root, full));
|
||||
else if (entry.isFile()) output.push(relative(root, full).split(sep).join('/'));
|
||||
else throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
}
|
||||
return output.sort();
|
||||
}
|
||||
|
||||
function validateSource(root: string, entries: ProducerSourceEntry[], expectedHash: string): void {
|
||||
const seen = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry.path !== 'string' || !entry.path || entry.path.includes('\\') || entry.path.startsWith('/') || entry.path.split('/').some(part => !part || part === '.' || part === '..') || seen.has(entry.path) || !HEX.test(entry.sha256) || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0) throw new Error('INVALID_PRODUCER_INPUT');
|
||||
seen.add(entry.path);
|
||||
}
|
||||
const actualPaths = walk(root);
|
||||
const expectedPaths = entries.map(entry => entry.path).sort();
|
||||
if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
const hashed: Array<[string, string]> = [];
|
||||
for (const entry of [...entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
|
||||
const path = resolve(root, ...entry.path.split('/'));
|
||||
if (!inside(root, path)) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
const stat = regularFile(path);
|
||||
const contents = readBoundedStable(path, 2 * 1024 * 1024, 'Producer source file');
|
||||
if (stat.size !== entry.bytes || contents.byteLength !== entry.bytes || sha256(contents) !== entry.sha256) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
hashed.push([entry.path, entry.sha256]);
|
||||
}
|
||||
if (sha256(JSON.stringify(hashed)) !== expectedHash) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
}
|
||||
|
||||
/** Seal the one-cell source copy before a provider starts. Production producers are macOS/Linux only. */
|
||||
export function sealProducerSource(root: string): void {
|
||||
if (process.platform === 'win32') return;
|
||||
const directories: string[] = [];
|
||||
const files: string[] = [];
|
||||
const visit = (directory: string): void => {
|
||||
const directoryStat = fs.lstatSync(directory);
|
||||
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
directories.push(directory);
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const full = join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
if (entry.isDirectory()) visit(full);
|
||||
else if (entry.isFile()) files.push(full);
|
||||
else throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
for (const file of files) fs.chmodSync(file, 0o444);
|
||||
// Children first so traversal stays available while modes are changed.
|
||||
for (const directory of directories.reverse()) fs.chmodSync(directory, 0o555);
|
||||
assertProducerSourceSealed(root);
|
||||
}
|
||||
|
||||
export function assertProducerSourceSealed(root: string): void {
|
||||
if (process.platform === 'win32') return;
|
||||
const visit = (directory: string): void => {
|
||||
const directoryStat = fs.lstatSync(directory);
|
||||
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || (directoryStat.mode & 0o777) !== 0o555) throw new Error('PRODUCER_CHANGED_SOURCE_MODE');
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const full = join(directory, entry.name), stat = fs.lstatSync(full);
|
||||
if (entry.isSymbolicLink()) throw new Error('PRODUCER_CHANGED_SOURCE_MODE');
|
||||
if (entry.isDirectory()) visit(full);
|
||||
else if (!entry.isFile() || (stat.mode & 0o777) !== 0o444) throw new Error('PRODUCER_CHANGED_SOURCE_MODE');
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
}
|
||||
|
||||
export function inventoryProducerArtifacts(helperHome: string): ProducerArtifactInventory {
|
||||
const empty = (): ProducerArtifactInventory => {
|
||||
const base = { schemaVersion: 1 as const, root: 'security/cso' as const, entries: [], totalBytes: 0 };
|
||||
return { ...base, identityHash: producerArtifactInventoryHash(base) };
|
||||
};
|
||||
const security = join(helperHome, 'security'), artifactRoot = join(security, 'cso');
|
||||
for (const directory of [security, artifactRoot]) {
|
||||
let stat: fs.Stats;
|
||||
try { stat = fs.lstatSync(directory); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return empty(); throw error; }
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(directory) !== directory) throw new Error('INVALID_PRODUCER_ARTIFACTS');
|
||||
}
|
||||
const entries: ProducerSourceEntry[] = [];
|
||||
let totalBytes = 0, visited = 0;
|
||||
const visit = (directory: string, depth: number): void => {
|
||||
if (depth > 32 || ++visited > ARTIFACT_COUNT_LIMIT * 2) throw new Error('INVALID_PRODUCER_ARTIFACTS');
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const full = join(directory, entry.name);
|
||||
if (++visited > ARTIFACT_COUNT_LIMIT * 2) throw new Error('INVALID_PRODUCER_ARTIFACTS');
|
||||
if (entry.isSymbolicLink()) throw new Error('INVALID_PRODUCER_ARTIFACTS');
|
||||
if (entry.isDirectory()) { visit(full, depth + 1); continue; }
|
||||
if (!entry.isFile() || entries.length >= ARTIFACT_COUNT_LIMIT) throw new Error('INVALID_PRODUCER_ARTIFACTS');
|
||||
const relativePath = relative(artifactRoot, full).split(sep).join('/');
|
||||
if (!relativePath || relativePath.length > 1024 || !/^[A-Za-z0-9._/-]+$/.test(relativePath) || relativePath.split('/').some(part => !part || part === '.' || part === '..') || redact(relativePath) !== relativePath) {
|
||||
throw new CsoError('REDACTION_FAILED', 'Producer artifact path withheld');
|
||||
}
|
||||
const contents = readBoundedStable(full, ARTIFACT_FILE_LIMIT, 'Producer artifact');
|
||||
totalBytes += contents.byteLength;
|
||||
if (totalBytes > ARTIFACT_TOTAL_LIMIT) throw new Error('PRODUCER_ARTIFACTS_TOO_LARGE');
|
||||
entries.push({ path: relativePath, sha256: sha256(contents), bytes: contents.byteLength });
|
||||
}
|
||||
};
|
||||
visit(artifactRoot, 0);
|
||||
entries.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const base = { schemaVersion: 1 as const, root: 'security/cso' as const, entries, totalBytes };
|
||||
return { ...base, identityHash: producerArtifactInventoryHash(base) };
|
||||
}
|
||||
|
||||
function repositoryIdentity(root: string): string {
|
||||
const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
||||
const env = { PATH: process.env.PATH ?? '', LANG: 'C', LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: nullPath, GIT_ATTR_NOSYSTEM: '1', GIT_OPTIONAL_LOCKS: '0' };
|
||||
const git = executable('git');
|
||||
const config = readBoundedStable(join(root, '.git', 'config'), 1024 * 1024, 'Producer Git configuration').toString('utf8');
|
||||
if (/^\s*\[\s*include(?:if)?(?=[\s."\]])/im.test(config)) throw new Error('INVALID_PRODUCER_SOURCE');
|
||||
const common = ['-c', 'core.fsmonitor=false', '-c', 'core.untrackedCache=false', '-c', `core.hooksPath=${nullPath}`, '-c', `core.attributesFile=${nullPath}`, '-c', `core.excludesFile=${nullPath}`, '-c', 'core.pager=cat', '-C', root];
|
||||
const read = (args: string[]) => execFileSync(git, [...common, ...args], { env, encoding: 'utf8', timeout: 10_000, maxBuffer: 1024 * 1024 });
|
||||
return sha256(JSON.stringify({ config: sha256(config), head: read(['rev-parse', '--verify', 'HEAD']).trim(), branch: read(['symbolic-ref', '--short', 'HEAD']).trim(), status: read(['status', '--porcelain=v2', '--untracked-files=all']) }));
|
||||
}
|
||||
|
||||
function validateCell(cell: ProducerCell): void {
|
||||
if (!cell || !HEX.test(cell.id) || !cell.caseId || !['node', 'bun', 'python', 'rails'].includes(cell.stack) || !['vulnerable', 'fixed'].includes(cell.variant) || !['v2', 'v3'].includes(cell.version) || !['daily', 'comprehensive'].includes(cell.mode) || ![1, 2, 3].includes(cell.repetition) || !cell.model || !['claude', 'codex', 'gemini'].includes(cell.host) || !Number.isInteger(cell.budgetSeconds) || cell.budgetSeconds <= 60 || cell.budgetSeconds > 3600 || !HEX.test(cell.sourceHash) || !HEX.test(cell.skillHash)) throw new Error('INVALID_PRODUCER_INPUT');
|
||||
}
|
||||
|
||||
export interface ProducerHelperBinding {
|
||||
producer: string;
|
||||
launcher: string;
|
||||
core: string;
|
||||
watchdog: string;
|
||||
generation: string;
|
||||
}
|
||||
|
||||
function boundExecutable(path: string): void {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || fs.realpathSync(path) !== path ||
|
||||
(process.platform !== 'win32' && ((stat.mode & 0o111) === 0 || (stat.mode & 0o6000) !== 0))) throw new Error('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
|
||||
function readGenerationManifest(path: string): string {
|
||||
const bytes = readBoundedStable(path, GENERATION_MANIFEST_BYTES, 'Producer helper generation manifest');
|
||||
const value = bytes.toString('utf8');
|
||||
if (bytes.byteLength !== GENERATION_MANIFEST_BYTES || !/^[a-f0-9]{64}\n$/.test(value)) {
|
||||
throw new Error('INVALID_PRODUCER_GENERATION');
|
||||
}
|
||||
return value.slice(0, -1);
|
||||
}
|
||||
|
||||
function boundGenerationManifest(path: string): void {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || fs.realpathSync(path) !== path ||
|
||||
stat.size !== GENERATION_MANIFEST_BYTES || (process.platform !== 'win32' && ((stat.mode & 0o444) === 0 || (stat.mode & 0o6111) !== 0))) {
|
||||
throw new Error('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
readGenerationManifest(path);
|
||||
}
|
||||
|
||||
function writableByProducer(path: string): boolean {
|
||||
try { fs.accessSync(path, fs.constants.W_OK); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/** Production bundles must remain immutable to the unprivileged producing agent. */
|
||||
export function validateProductionProducerInstallation(binding: ProducerHelperBinding, producerExecutable = process.execPath): void {
|
||||
if (typeof process.getuid === 'function' && process.getuid() === 0) throw new Error('ROOT_PRODUCER_UNSUPPORTED');
|
||||
if (producerExecutable !== binding.producer || !isAbsolute(producerExecutable) || resolve(producerExecutable) !== producerExecutable || dirname(producerExecutable) !== dirname(binding.launcher)) {
|
||||
throw new Error('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
const executables = [producerExecutable, binding.launcher, binding.core, binding.watchdog];
|
||||
for (const path of executables) boundExecutable(path);
|
||||
boundGenerationManifest(binding.generation);
|
||||
const files = [...executables, binding.generation];
|
||||
|
||||
const directories: string[] = [];
|
||||
for (let current = dirname(producerExecutable);;) {
|
||||
directories.push(current);
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
for (const path of directories) {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync(path) !== path) throw new Error('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
if ([...files, ...directories].some(writableByProducer)) throw new Error('WRITABLE_PRODUCER_INSTALLATION');
|
||||
if (process.platform !== 'win32') {
|
||||
for (const path of [...files, ...directories]) {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (stat.uid !== 0 || (stat.mode & 0o022) !== 0) throw new Error('UNTRUSTED_PRODUCER_INSTALLATION');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveProducerHelperBinding(
|
||||
sourceRoot: string,
|
||||
stateRoot: string,
|
||||
testLauncherPath?: string,
|
||||
): ProducerHelperBinding {
|
||||
if (process.platform === 'win32' && testLauncherPath === undefined) throw new Error('UNSUPPORTED_PRODUCER_PLATFORM');
|
||||
const executableSuffix = process.platform === 'win32' ? '.exe' : '';
|
||||
const launcher = testLauncherPath ?? join(dirname(process.execPath), `gstack-cso-launcher${executableSuffix}`);
|
||||
if (!isAbsolute(launcher) || resolve(launcher) !== launcher || basename(launcher) !== `gstack-cso-launcher${executableSuffix}`) {
|
||||
throw new Error('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
const directory = dirname(launcher);
|
||||
if (inside(sourceRoot, directory) || inside(directory, sourceRoot) || inside(stateRoot, directory) || inside(directory, stateRoot)) {
|
||||
throw new Error('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
const binding = {
|
||||
producer: testLauncherPath === undefined ? process.execPath : join(dirname(launcher), `cso-eval-producer${executableSuffix}`),
|
||||
launcher,
|
||||
core: join(directory, `gstack-cso-core${executableSuffix}`),
|
||||
watchdog: join(directory, `gstack-cso-watchdog${executableSuffix}`),
|
||||
generation: join(directory, '.gstack-cso-generation'),
|
||||
};
|
||||
try {
|
||||
boundExecutable(binding.producer);
|
||||
boundExecutable(binding.launcher);
|
||||
boundExecutable(binding.core);
|
||||
boundExecutable(binding.watchdog);
|
||||
boundGenerationManifest(binding.generation);
|
||||
} catch {
|
||||
throw new Error('INVALID_PRODUCER_HELPER');
|
||||
}
|
||||
// The explicit path exists solely for source-level unit tests. The production
|
||||
// CLI has no override and enforces an unprivileged, nonwritable installation.
|
||||
if (testLauncherPath === undefined) validateProductionProducerInstallation(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
const PRODUCER_ARTIFACT_LIMIT = 512 * 1024 * 1024;
|
||||
|
||||
function artifactIdentity(path: string): { sha256: string; bytes: number } {
|
||||
const named = fs.lstatSync(path);
|
||||
if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size <= 0 || named.size > PRODUCER_ARTIFACT_LIMIT) {
|
||||
throw new Error('INVALID_PRODUCER_INSTALLATION');
|
||||
}
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
||||
const opened = fs.fstatSync(descriptor);
|
||||
if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== named.dev || opened.ino !== named.ino || opened.mode !== named.mode || opened.size !== named.size) {
|
||||
throw new Error('PRODUCER_INSTALLATION_RACE');
|
||||
}
|
||||
const hash = createHash('sha256'), buffer = Buffer.allocUnsafe(1024 * 1024);
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
|
||||
if (count === 0) break;
|
||||
bytes += count;
|
||||
if (bytes > PRODUCER_ARTIFACT_LIMIT) throw new Error('INVALID_PRODUCER_INSTALLATION');
|
||||
hash.update(buffer.subarray(0, count));
|
||||
}
|
||||
const after = fs.fstatSync(descriptor), current = fs.lstatSync(path);
|
||||
if (bytes !== opened.size || !current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || current.dev !== opened.dev || current.ino !== opened.ino || current.mode !== opened.mode || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) {
|
||||
throw new Error('PRODUCER_INSTALLATION_RACE');
|
||||
}
|
||||
return { sha256: hash.digest('hex'), bytes };
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function generationIdentity(path: string, core: { sha256: string; bytes: number }): ProducerInstallationIdentity['generation'] {
|
||||
const manifest = artifactIdentity(path);
|
||||
const coreSha256 = readGenerationManifest(path);
|
||||
if (manifest.bytes !== GENERATION_MANIFEST_BYTES || manifest.sha256 !== sha256(`${coreSha256}\n`)) {
|
||||
throw new Error('PRODUCER_INSTALLATION_RACE');
|
||||
}
|
||||
if (coreSha256 !== core.sha256) throw new Error('PRODUCER_HELPER_GENERATION_MISMATCH');
|
||||
return { coreSha256, manifest };
|
||||
}
|
||||
|
||||
export function producerInstallationIdentity(binding: ProducerHelperBinding): ProducerInstallationIdentity {
|
||||
const core = artifactIdentity(binding.core);
|
||||
const withoutHash: Omit<ProducerInstallationIdentity, 'identityHash'> = {
|
||||
schemaVersion: 1,
|
||||
producer: artifactIdentity(binding.producer),
|
||||
launcher: artifactIdentity(binding.launcher),
|
||||
core,
|
||||
watchdog: artifactIdentity(binding.watchdog),
|
||||
generation: generationIdentity(binding.generation, core),
|
||||
embeddedCatalogs: {
|
||||
runtimeRevision: runtimeCatalog.revision,
|
||||
runtimeBuildRevision: runtimeCatalog.buildRevision,
|
||||
runtimeSha256: sha256(JSON.stringify(runtimeCatalog)),
|
||||
scannerRevision: scannerCatalog.revision,
|
||||
scannerSha256: sha256(JSON.stringify(scannerCatalog)),
|
||||
},
|
||||
};
|
||||
return { ...withoutHash, identityHash: producerInstallationIdentityHash(withoutHash) };
|
||||
}
|
||||
|
||||
export const PRODUCER_PROVIDER_POLICY: Record<ProducerHost, { family: ProducerProviderIdentity['family']; policyRevision: string; version: string }> = {
|
||||
claude: { family: 'claude', policyRevision: 'claude-2.1.263-cso-v1', version: '2.1.263 (Claude Code)' },
|
||||
codex: { family: 'gpt', policyRevision: 'codex-0.153.4-cso-v3-generation', version: 'codex-cli 0.153.4' },
|
||||
gemini: { family: 'gemini', policyRevision: 'gemini-0.59.0-cso-v1', version: '0.59.0' },
|
||||
};
|
||||
|
||||
function validateProviderIdentity(identity: ProducerProviderIdentity, family: ProducerProviderIdentity['family']): ProducerProviderIdentity {
|
||||
if (!identity || identity.schemaVersion !== 1 || identity.family !== family || !identity.policyRevision || !identity.version ||
|
||||
!Array.isArray(identity.argsPrefix) || identity.argsPrefix.some(value => typeof value !== 'string' || value.includes('\0')) ||
|
||||
!identity.executable || !HEX.test(identity.executable.sha256) || !Number.isSafeInteger(identity.executable.bytes) || identity.executable.bytes <= 0 ||
|
||||
!HEX.test(identity.identityHash)) throw new Error('INVALID_PRODUCER_PROVIDER_IDENTITY');
|
||||
const { identityHash, ...withoutHash } = identity;
|
||||
if (producerProviderIdentityHash(withoutHash) !== identityHash) throw new Error('INVALID_PRODUCER_PROVIDER_IDENTITY');
|
||||
return identity;
|
||||
}
|
||||
|
||||
export interface ProducerProviderResolution {
|
||||
identity: ProducerProviderIdentity;
|
||||
command: { executable: string; argsPrefix: string[] };
|
||||
}
|
||||
|
||||
export function resolveProducerProviderIdentity(host: ProducerHost, stateRoot: string): ProducerProviderResolution {
|
||||
const policy = PRODUCER_PROVIDER_POLICY[host];
|
||||
const resolved = host === 'claude'
|
||||
? resolveClaudeCommand()
|
||||
: { command: executable(host === 'codex' ? 'codex' : 'gemini'), argsPrefix: [] as string[] };
|
||||
if (!resolved) throw new Error('PRODUCER_UNAVAILABLE: provider CLI not found');
|
||||
const command = fs.realpathSync(resolved.command);
|
||||
const versionOutput = execFileSync(resolved.command, [...resolved.argsPrefix, '--version'], {
|
||||
cwd: dirname(command),
|
||||
env: { PATH: process.env.PATH ?? '', HOME: stateRoot, LANG: 'C', LC_ALL: 'C', TZ: 'UTC' },
|
||||
encoding: 'utf8', timeout: 10_000, maxBuffer: 64 * 1024,
|
||||
}).trim();
|
||||
if (policy.version !== versionOutput || redact(versionOutput) !== versionOutput) throw new Error('UNQUALIFIED_PRODUCER_PROVIDER_VERSION');
|
||||
const withoutHash: Omit<ProducerProviderIdentity, 'identityHash'> = {
|
||||
schemaVersion: 1,
|
||||
family: policy.family,
|
||||
policyRevision: policy.policyRevision,
|
||||
executable: artifactIdentity(command),
|
||||
argsPrefix: [...resolved.argsPrefix],
|
||||
version: versionOutput,
|
||||
};
|
||||
return {
|
||||
identity: { ...withoutHash, identityHash: producerProviderIdentityHash(withoutHash) },
|
||||
command: { executable: command, argsPrefix: [...resolved.argsPrefix] },
|
||||
};
|
||||
}
|
||||
|
||||
function promptFor(cell: ProducerCell, skill: string, sourceRoot: string, helper: ProducerHelperBinding): string {
|
||||
const invocation = `/cso${cell.mode === 'comprehensive' ? ' --comprehensive' : ''} --budget ${cell.budgetSeconds}`;
|
||||
const sourceAccess = cell.host === 'codex'
|
||||
? 'The Codex permission profile grants read-only access to exactly that immutable snapshot so the trusted helper can read it. Use only the trusted helper to inspect or act on source; direct provider file or shell access is outside this evaluation policy.'
|
||||
: 'The application snapshot is available to the producing agent only through the trusted helper.';
|
||||
const sourceLocation = `The neutral current working directory contains no application source. The application repository at ${sourceRoot} is your entire source scope. ${sourceAccess}`;
|
||||
return `You are the producing agent in a matched security evaluation. ${sourceLocation} The only trusted gstack-cso for this run is the absolute launcher path ${JSON.stringify(helper.launcher)}. Interpret every \`gstack-cso\` command in the skill as that exact path; do not discover or invoke another helper through PATH. Do not inspect parent directories, process metadata, evaluation control files, sibling jobs, alternative source variants, expected labels, or evaluator assertions. Follow the exact CSO skill instructions below and perform this invocation: ${invocation}\n\nDo not ask questions. Leave the application branch unchanged. End with the normal CSO report. Producer statements are claims that an independent evaluator will challenge; never infer private assertions or call a result tested without the helper evidence required by the skill.\n\n<exact-cso-skill sha256="${cell.skillHash}">\n${skill}\n</exact-cso-skill>\n`;
|
||||
}
|
||||
|
||||
export function adapterFor(host: ProducerHost): ProviderAdapter {
|
||||
return host === 'claude' ? new ClaudeAdapter() : host === 'codex' ? new GptAdapter() : new GeminiAdapter();
|
||||
}
|
||||
|
||||
function writeExclusiveAtomic(path: string, value: unknown): void {
|
||||
const parent = dirname(path);
|
||||
const stat = fs.lstatSync(parent);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('UNSAFE_RECEIPT_DESTINATION');
|
||||
try { atomicWriteSync(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, noReplace: true }); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('RECEIPT_EXISTS'); throw error; }
|
||||
}
|
||||
|
||||
/** Redact both channels and both concatenation orders before a receipt can bind them. */
|
||||
export function sanitizeProducerRun(run: RunResult): RunResult {
|
||||
if (typeof run.output !== 'string' || (run.error && typeof run.error.reason !== 'string')) {
|
||||
throw new CsoError('REDACTION_FAILED', 'Producer output withheld because it was not valid text');
|
||||
}
|
||||
const reason = run.error?.reason ?? '';
|
||||
try {
|
||||
const views = [run.output, reason, run.output + reason, reason + run.output];
|
||||
if (views.some(value => redact(value) !== value)) {
|
||||
return {
|
||||
...run,
|
||||
output: '[sensitive producer output redacted]',
|
||||
error: { code: run.error?.code ?? 'unknown', reason: 'Sensitive producer output or error withheld' },
|
||||
};
|
||||
}
|
||||
return run;
|
||||
} catch {
|
||||
throw new CsoError('REDACTION_FAILED', 'Producer output withheld because redaction could not safely inspect it');
|
||||
}
|
||||
}
|
||||
|
||||
export function producerFailureMessage(error: unknown): string {
|
||||
if (error instanceof CsoError && error.code === 'REDACTION_FAILED') return 'REDACTION_FAILED: producer payload withheld';
|
||||
const message = error instanceof Error ? error.message : 'CSO producer failed';
|
||||
try { return redact(message); }
|
||||
catch { return 'REDACTION_FAILED: producer error withheld'; }
|
||||
}
|
||||
|
||||
export async function runProducerCell(inputPath: string, receiptPath: string, options: {
|
||||
adapter?: ProviderAdapter;
|
||||
paidExecutionAuthorized: boolean;
|
||||
/** Unit tests run this source file through Bun; production always uses the adjacent compiled bundle. */
|
||||
testHelperLauncherPath?: string;
|
||||
/** Unit tests never execute a provider CLI; production always measures the reviewed binary. */
|
||||
testProviderIdentity?: ProducerProviderIdentity;
|
||||
/** Test-only exact command paired with testProviderIdentity. */
|
||||
testProviderCommand?: { executable: string; argsPrefix: string[] };
|
||||
}): Promise<ProducerReceipt> {
|
||||
if (!options.paidExecutionAuthorized) throw new Error('PAID_EXECUTION_NOT_AUTHORIZED');
|
||||
const controlPath = resolve(inputPath);
|
||||
const receipt = resolve(receiptPath);
|
||||
const { jobRoot, sourceRoot } = assertIsolatedLayout(controlPath, receipt);
|
||||
assertReceiptDestination(receipt);
|
||||
const bytes = readBoundedStable(controlPath, INPUT_LIMIT, 'Producer input');
|
||||
let input: ProducerInput;
|
||||
try { input = JSON.parse(bytes.toString('utf8')); } catch { throw new Error('INVALID_PRODUCER_INPUT'); }
|
||||
if (input.schemaVersion !== 1 || typeof input.skill !== 'string' || Buffer.byteLength(input.skill) > INPUT_LIMIT || !Array.isArray(input.source)) throw new Error('INVALID_PRODUCER_INPUT');
|
||||
validateCell(input.cell);
|
||||
if (basename(receipt) !== `${input.cell.id}.json`) throw new Error('UNMATCHED_RECEIPT_DESTINATION');
|
||||
if (sha256(input.skill) !== input.cell.skillHash) throw new Error('INVALID_PRODUCER_SKILL');
|
||||
validateSource(sourceRoot, input.source, input.cell.sourceHash);
|
||||
sealProducerSource(sourceRoot);
|
||||
validateSource(sourceRoot, input.source, input.cell.sourceHash);
|
||||
const originalRepositoryIdentity = repositoryIdentity(sourceRoot);
|
||||
const stateRoot = join(jobRoot, 'state');
|
||||
const helperHome = join(stateRoot, 'cso-home');
|
||||
const helper = resolveProducerHelperBinding(sourceRoot, stateRoot, options.testHelperLauncherPath);
|
||||
const installationIdentity = producerInstallationIdentity(helper);
|
||||
const adapter = options.adapter ?? adapterFor(input.cell.host);
|
||||
if ((input.cell.host === 'codex' ? 'gpt' : input.cell.host) !== adapter.family) throw new Error('UNMATCHED_PRODUCER_ADAPTER');
|
||||
fs.mkdirSync(stateRoot, { recursive: false, mode: 0o700 });
|
||||
fs.mkdirSync(helperHome, { recursive: false, mode: 0o700 });
|
||||
const provider = options.testProviderIdentity
|
||||
? {
|
||||
identity: validateProviderIdentity(options.testProviderIdentity, adapter.family),
|
||||
command: options.testProviderCommand ?? { executable: process.execPath, argsPrefix: [] },
|
||||
}
|
||||
: resolveProducerProviderIdentity(input.cell.host, stateRoot);
|
||||
const providerIdentity = provider.identity;
|
||||
const runOptions = {
|
||||
prompt: promptFor(input.cell, input.skill, sourceRoot, helper),
|
||||
workdir: stateRoot,
|
||||
timeoutMs: input.cell.budgetSeconds * 1000,
|
||||
model: input.cell.model,
|
||||
csoProducer: {
|
||||
stateDirectory: stateRoot,
|
||||
sourceDirectory: sourceRoot,
|
||||
helperLauncher: helper.launcher,
|
||||
helperGeneration: helper.generation,
|
||||
providerCommand: provider.command,
|
||||
},
|
||||
} satisfies RunOpts;
|
||||
const availability = await adapter.available(runOptions);
|
||||
if (!availability.ok) throw new Error(`PRODUCER_UNAVAILABLE: ${availability.reason ?? input.cell.host}`);
|
||||
const inputHash = producerInputHash(input);
|
||||
|
||||
// Load the opaque metadata into memory, then remove it before starting the
|
||||
// agent. A clean producer host exposes only source + installed product bits.
|
||||
fs.unlinkSync(controlPath);
|
||||
const previousHome = process.env.GSTACK_HOME;
|
||||
const previousSessionKind = process.env.GSTACK_SESSION_KIND;
|
||||
const previousHeadless = process.env.GSTACK_HEADLESS;
|
||||
process.env.GSTACK_HOME = helperHome;
|
||||
process.env.GSTACK_SESSION_KIND = 'spawned';
|
||||
process.env.GSTACK_HEADLESS = '1';
|
||||
const startedAt = new Date().toISOString();
|
||||
let run: RunResult;
|
||||
try {
|
||||
if (input.cell.host === 'gemini') prepareGeminiProducerState(stateRoot, helper.launcher);
|
||||
run = await adapter.run(runOptions);
|
||||
} finally {
|
||||
try {
|
||||
if (input.cell.host === 'gemini') removeGeminiProducerState(stateRoot);
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.GSTACK_HOME; else process.env.GSTACK_HOME = previousHome;
|
||||
if (previousSessionKind === undefined) delete process.env.GSTACK_SESSION_KIND; else process.env.GSTACK_SESSION_KIND = previousSessionKind;
|
||||
if (previousHeadless === undefined) delete process.env.GSTACK_HEADLESS; else process.env.GSTACK_HEADLESS = previousHeadless;
|
||||
}
|
||||
}
|
||||
const finishedAt = new Date().toISOString();
|
||||
const installationAfter = producerInstallationIdentity(helper);
|
||||
if (installationAfter.identityHash !== installationIdentity.identityHash) {
|
||||
throw new Error('PRODUCER_HELPER_GENERATION_CHANGED');
|
||||
}
|
||||
const providerAfter = artifactIdentity(provider.command.executable);
|
||||
if (providerAfter.sha256 !== providerIdentity.executable.sha256 || providerAfter.bytes !== providerIdentity.executable.bytes) {
|
||||
throw new Error('PRODUCER_PROVIDER_INSTALLATION_RACE');
|
||||
}
|
||||
assertProducerSourceSealed(sourceRoot);
|
||||
validateSource(sourceRoot, input.source, input.cell.sourceHash);
|
||||
if (repositoryIdentity(sourceRoot) !== originalRepositoryIdentity) throw new Error('PRODUCER_CHANGED_SOURCE');
|
||||
const artifacts = inventoryProducerArtifacts(helperHome);
|
||||
run = sanitizeProducerRun(run);
|
||||
if(!run.error&&!run.output.trim())run={...run,error:{code:'unknown',reason:'empty output from provider CLI (exit 0)'}};
|
||||
if (Buffer.byteLength(run.output) > OUTPUT_LIMIT) throw new Error('PRODUCER_OUTPUT_TOO_LARGE');
|
||||
const tokensReported = run.tokens.input > 0 || run.tokens.output > 0 || (run.tokens.cached ?? 0) > 0;
|
||||
const estimatedCostUSD = tokensReported && PRICING[run.modelUsed] ? adapter.estimateCost(run.tokens, run.modelUsed) : null;
|
||||
const withoutHash: Omit<ProducerReceipt, 'receiptHash'> = {
|
||||
schemaVersion: 1,
|
||||
cell: input.cell,
|
||||
inputHash,
|
||||
installationIdentity,
|
||||
providerIdentity,
|
||||
artifacts,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
status: run.error ? 'failed' : 'succeeded',
|
||||
requestedModel: input.cell.model,
|
||||
modelUsed: run.modelUsed,
|
||||
modelIdentitySource: run.modelUsed === input.cell.model ? 'requested_pin' : 'provider_reported',
|
||||
durationMs: run.durationMs,
|
||||
firstUsefulResultMs: null,
|
||||
toolCalls: run.toolCalls,
|
||||
output: run.output,
|
||||
outputHash: sha256(run.output),
|
||||
usage: {
|
||||
inputTokens: tokensReported ? run.tokens.input : null,
|
||||
outputTokens: tokensReported ? run.tokens.output : null,
|
||||
cachedTokens: tokensReported && run.tokens.cached !== undefined ? run.tokens.cached : null,
|
||||
estimatedCostUSD,
|
||||
},
|
||||
...(run.error ? { error: run.error } : {}),
|
||||
};
|
||||
const result = { ...withoutHash, receiptHash: producerReceiptHash(withoutHash) };
|
||||
writeExclusiveAtomic(receipt, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function cli(args: string[]): Promise<void> {
|
||||
if (args.length !== 4 || args[0] !== 'run' || args[3] !== '--execute-paid') throw new Error('Usage: cso-eval-producer run <consumable-input.json> <new-receipt.json> --execute-paid');
|
||||
if (process.env.CSO_EVAL_PAID !== '1') throw new Error('PAID_EXECUTION_NOT_AUTHORIZED: also set CSO_EVAL_PAID=1 on the isolated producer host');
|
||||
const receipt = await runProducerCell(args[1], args[2], { paidExecutionAuthorized: true });
|
||||
console.log(JSON.stringify({ cellId: receipt.cell.id, status: receipt.status, durationMs: receipt.durationMs, receiptHash: receipt.receiptHash }));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
cli(process.argv.slice(2)).catch(error => { console.error(producerFailureMessage(error)); process.exitCode = 1; });
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export type ProducerHost = 'claude' | 'codex' | 'gemini';
|
||||
export type ProducerVersion = 'v2' | 'v3';
|
||||
export type ProducerMode = 'daily' | 'comprehensive';
|
||||
export type ProducerStack = 'node' | 'bun' | 'python' | 'rails';
|
||||
export type ProducerVariant = 'vulnerable' | 'fixed';
|
||||
|
||||
export interface ProducerCell {
|
||||
id: string;
|
||||
caseId: string;
|
||||
stack: ProducerStack;
|
||||
variant: ProducerVariant;
|
||||
version: ProducerVersion;
|
||||
mode: ProducerMode;
|
||||
repetition: 1 | 2 | 3;
|
||||
model: string;
|
||||
host: ProducerHost;
|
||||
budgetSeconds: number;
|
||||
sourceHash: string;
|
||||
skillHash: string;
|
||||
}
|
||||
|
||||
export interface ProducerSourceEntry {
|
||||
path: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* This control file is consumed before a producing agent starts. It must never
|
||||
* be placed inside the application repository or retained on the producer.
|
||||
*/
|
||||
export interface ProducerInput {
|
||||
schemaVersion: 1;
|
||||
cell: ProducerCell;
|
||||
/** Exact canonical portable payload: root SKILL.md plus its manifest-listed sections. */
|
||||
skill: string;
|
||||
source: ProducerSourceEntry[];
|
||||
}
|
||||
|
||||
export interface ProducerArtifactIdentity {
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface ProducerInstallationIdentity {
|
||||
schemaVersion: 1;
|
||||
producer: ProducerArtifactIdentity;
|
||||
launcher: ProducerArtifactIdentity;
|
||||
core: ProducerArtifactIdentity;
|
||||
watchdog: ProducerArtifactIdentity;
|
||||
/** The adjacent manifest is exactly `<coreSha256>\n`; bind both its bytes and declaration. */
|
||||
generation: {
|
||||
coreSha256: string;
|
||||
manifest: ProducerArtifactIdentity;
|
||||
};
|
||||
embeddedCatalogs: {
|
||||
runtimeRevision: string;
|
||||
runtimeBuildRevision: string;
|
||||
runtimeSha256: string;
|
||||
scannerRevision: string;
|
||||
scannerSha256: string;
|
||||
};
|
||||
identityHash: string;
|
||||
}
|
||||
|
||||
export interface ProducerProviderIdentity {
|
||||
schemaVersion: 1;
|
||||
family: 'claude' | 'gpt' | 'gemini';
|
||||
policyRevision: string;
|
||||
executable: ProducerArtifactIdentity;
|
||||
argsPrefix: string[];
|
||||
version: string;
|
||||
identityHash: string;
|
||||
}
|
||||
|
||||
export interface ProducerArtifactInventory {
|
||||
schemaVersion: 1;
|
||||
root: 'security/cso';
|
||||
/** Sorted safe paths relative to root within the retained helper home. */
|
||||
entries: ProducerSourceEntry[];
|
||||
totalBytes: number;
|
||||
identityHash: string;
|
||||
}
|
||||
|
||||
export interface ProducerReceipt {
|
||||
schemaVersion: 1;
|
||||
cell: ProducerCell;
|
||||
inputHash: string;
|
||||
installationIdentity: ProducerInstallationIdentity;
|
||||
providerIdentity: ProducerProviderIdentity;
|
||||
artifacts: ProducerArtifactInventory;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
status: 'succeeded' | 'failed';
|
||||
requestedModel: string;
|
||||
modelUsed: string;
|
||||
/** Provider-reported when it differs; otherwise the exact CLI model pin. */
|
||||
modelIdentitySource: 'provider_reported' | 'requested_pin';
|
||||
durationMs: number;
|
||||
firstUsefulResultMs: null;
|
||||
toolCalls: number;
|
||||
output: string;
|
||||
outputHash: string;
|
||||
usage: {
|
||||
/** CLI/provider-reported tokens. Null means the adapter did not report usage. */
|
||||
inputTokens: number | null;
|
||||
outputTokens: number | null;
|
||||
cachedTokens: number | null;
|
||||
/** Pricing-table estimate, not a host-billed amount. */
|
||||
estimatedCostUSD: number | null;
|
||||
};
|
||||
error?: { code: string; reason: string };
|
||||
receiptHash: string;
|
||||
}
|
||||
|
||||
/** Compact trusted index; raw output remains in the separately retained receipt. */
|
||||
export type ProducerReceiptIndex = Omit<ProducerReceipt, 'output' | 'error'> & {
|
||||
error?: { code: string };
|
||||
};
|
||||
|
||||
export const sha256 = (value: string | Buffer): string => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
export function producerInputHash(input: ProducerInput): string {
|
||||
return sha256(JSON.stringify(input));
|
||||
}
|
||||
|
||||
export function producerReceiptHash(receipt: Omit<ProducerReceipt, 'receiptHash'>): string {
|
||||
return sha256(JSON.stringify(receipt));
|
||||
}
|
||||
|
||||
export function producerInstallationIdentityHash(identity: Omit<ProducerInstallationIdentity, 'identityHash'>): string {
|
||||
if (!identity.generation || !identity.generation.manifest || identity.generation.coreSha256 !== identity.core.sha256 ||
|
||||
identity.generation.manifest.bytes !== 65 ||
|
||||
identity.generation.manifest.sha256 !== sha256(`${identity.generation.coreSha256}\n`)) {
|
||||
throw new Error('INVALID_PRODUCER_GENERATION_IDENTITY');
|
||||
}
|
||||
return sha256(JSON.stringify(identity));
|
||||
}
|
||||
|
||||
export function producerProviderIdentityHash(identity: Omit<ProducerProviderIdentity, 'identityHash'>): string {
|
||||
return sha256(JSON.stringify(identity));
|
||||
}
|
||||
|
||||
export function producerArtifactInventoryHash(inventory: Omit<ProducerArtifactInventory, 'identityHash'>): string {
|
||||
return sha256(JSON.stringify(inventory));
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Trusted corpus preparation, producer receipt collection, and objective result accounting. */
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { readBoundedStable } from '../lib/cso/bounded-file';
|
||||
import { executable } from '../lib/cso/process';
|
||||
import { atomicWriteSync } from '../lib/fs-atomic';
|
||||
import { loadCorpusManifest, materializeCase, sourceFiles, STACKS, type CorpusManifest, type EvalStack, type EvalVariant } from '../test/fixtures/cso-eval/materialize';
|
||||
import {
|
||||
producerArtifactInventoryHash,
|
||||
producerInputHash,
|
||||
producerInstallationIdentityHash,
|
||||
producerProviderIdentityHash,
|
||||
producerReceiptHash,
|
||||
sha256,
|
||||
type ProducerCell,
|
||||
type ProducerArtifactInventory,
|
||||
type ProducerHost,
|
||||
type ProducerInput,
|
||||
type ProducerInstallationIdentity,
|
||||
type ProducerProviderIdentity,
|
||||
type ProducerReceipt,
|
||||
type ProducerReceiptIndex,
|
||||
type ProducerSourceEntry,
|
||||
} from './cso-eval-protocol';
|
||||
|
||||
export type EvalVersion = 'v2' | 'v3';
|
||||
export type EvalMode = 'daily' | 'comprehensive';
|
||||
export type Outcome = 'passed' | 'failed' | 'blocked' | 'not_attempted';
|
||||
export interface EvalCell extends ProducerCell {
|
||||
id: string; caseId: string; stack: EvalStack; variant: EvalVariant; version: EvalVersion; mode: EvalMode;
|
||||
repetition: 1 | 2 | 3; model: string; host: ProducerHost; budgetSeconds: number; sourceHash: string; skillHash: string;
|
||||
}
|
||||
export interface EvalMatrix {
|
||||
schemaVersion: 1; corpusVersion: string; corpusHash: string; model: string; host: ProducerHost;
|
||||
budgets: { daily: number; comprehensive: number }; skillHashes: { v2: string; v3: string }; repetitions: 3; cells: EvalCell[];
|
||||
}
|
||||
export interface PortableSkillFile {
|
||||
path: string;
|
||||
contents: string;
|
||||
}
|
||||
export interface PortableSkillFileIdentity {
|
||||
path: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
}
|
||||
export interface PortableSkillPayloadIdentity {
|
||||
schemaVersion: 1;
|
||||
version: EvalVersion;
|
||||
files: PortableSkillFileIdentity[];
|
||||
}
|
||||
export interface EvalFinding {
|
||||
id: string; evidence: 'supported' | 'hypothesis' | 'legacy_review'; claimedTested: boolean;
|
||||
/** Written only by the independent evaluator after reviewing the reported trace. */
|
||||
judgment: 'correct' | 'incorrect' | 'unadjudicated'; matchedCaseId?: string;
|
||||
/**
|
||||
* Written only by the independent evaluator. A producer's tested claim is
|
||||
* valid only when this finding, rather than merely another finding in the
|
||||
* same corpus cell, owns the trusted repair and recheck observations.
|
||||
*/
|
||||
trustedVerification?: {
|
||||
repair: Outcome;
|
||||
repairEvidenceHash?: string;
|
||||
recheck: Outcome;
|
||||
recheckEvidenceHash?: string;
|
||||
};
|
||||
}
|
||||
export interface EvalResult {
|
||||
cellId: string; sourceHash: string; skillHash: string; model: string; host: string; budgetSeconds: number;
|
||||
reportPresent: boolean; reportComplete: boolean; findings: EvalFinding[];
|
||||
setup: Outcome; reproduction: Outcome; repair: Outcome; recheck: Outcome;
|
||||
/** Hash of trusted private observations; never a producer's claimed verification status. */
|
||||
oracleEvidenceHash?: string;
|
||||
oracleVersion?: string;
|
||||
heldOutAssertionsPassed: boolean;
|
||||
currentSourceHash?: string;
|
||||
recheckEvidenceHash?: string;
|
||||
freshRecheck: boolean;
|
||||
latencyMs: number;
|
||||
firstUsefulResultMs: number | null;
|
||||
/** Required by the release scorer; omitted only by isolated accounting tests. */
|
||||
producerReceiptHash?: string;
|
||||
usage?: { source: 'host'; tokens?: number; costUSD?: number };
|
||||
prerequisite?: string;
|
||||
}
|
||||
export interface Rate { numerator: number; denominator: number; value: number | null }
|
||||
export interface ReleaseQualification {
|
||||
containment: Record<string, 'passed' | 'failed' | 'not_run'>;
|
||||
}
|
||||
export interface PreparedEvalSchedule {
|
||||
schemaVersion: 1;
|
||||
matrixHash: string;
|
||||
scheduledCells: number;
|
||||
preparedCells: number;
|
||||
jobs: Array<{ cellId: string; relativePath: string; inputHash: string }>;
|
||||
}
|
||||
export interface ProducerGroupSummary {
|
||||
version: EvalVersion;
|
||||
mode: EvalMode;
|
||||
scheduled: number;
|
||||
submitted: number;
|
||||
missing: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
latency: { samples: number; denominator: number; p95Ms: number | null };
|
||||
firstUsefulResult: { samples: 0; denominator: number; p95Ms: null };
|
||||
modelTokens: { measured: number; denominator: number; total: number | null };
|
||||
estimatedCost: { measured: number; denominator: number; totalUSD: number | null; source: 'pricing-table-estimate' };
|
||||
}
|
||||
export interface ProducerBatch {
|
||||
schemaVersion: 1;
|
||||
matrixHash: string;
|
||||
scheduleHash: string;
|
||||
receipts: ProducerReceiptIndex[];
|
||||
summary: {
|
||||
scheduled: number;
|
||||
prepared: number;
|
||||
submitted: number;
|
||||
missing: number;
|
||||
matchedPairsExpected: number;
|
||||
matchedPairsSubmitted: number;
|
||||
modelMismatches: Array<{ pair: string; v2: string; v3: string }>;
|
||||
groups: ProducerGroupSummary[];
|
||||
note: string;
|
||||
};
|
||||
batchHash: string;
|
||||
}
|
||||
export const REQUIRED_CONTAINMENT = [
|
||||
'hostile-startup-configuration', 'environment-canary', 'remote-docker-context', 'ipv4-egress', 'ipv6-egress', 'dns-egress',
|
||||
'archive-traversal-and-poisoning', 'symlink-traversal', 'split-output-secrets', 'concurrent-admission', 'expired-snapshot-replay',
|
||||
'watchdog-survival', 'failed-report-writes', 'held-out-oracle-visibility',
|
||||
] as const;
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
const HEX = /^[a-f0-9]{64}$/;
|
||||
const rate = (numerator: number, denominator: number): Rate => ({ numerator, denominator, value: denominator ? numerator / denominator : null });
|
||||
const validNumber = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
const PORTABLE_PAYLOAD_HEADER = '<!-- GSTACK_CSO_EVAL_PAYLOAD ';
|
||||
const PORTABLE_PAYLOAD_HEADER_END = ' -->\n';
|
||||
const PORTABLE_FILE_END = '\n<<<END_GSTACK_CSO_EVAL_FILE>>>\n';
|
||||
const PORTABLE_FILE_LIMIT = 2 * 1024 * 1024;
|
||||
const PORTABLE_PAYLOAD_LIMIT = 4 * 1024 * 1024;
|
||||
|
||||
function portablePayloadPreamble(version: EvalVersion): string {
|
||||
return `# Complete portable CSO evaluation instructions (${version})\n\nThis package is the sole CSO instruction input for this evaluation cell. Apply the embedded SKILL.md and every embedded manifest-listed section. Resolve any SKILL.md reference to cso/sections/<file> or sections/<file> from the matching embedded file below. Do not read an installed, user-home, repository, or other-version CSO skill or section. The file bodies are exact generated bytes; package markers are transport metadata.\n\n`;
|
||||
}
|
||||
|
||||
function portableFileOpen(identity: PortableSkillFileIdentity): string {
|
||||
return `<<<GSTACK_CSO_EVAL_FILE ${JSON.stringify(identity)}>>>\n`;
|
||||
}
|
||||
|
||||
function parsePortableManifest(contents: string): { files: string[] } {
|
||||
let manifest: any;
|
||||
try { manifest = JSON.parse(contents); } catch { throw new Error('INVALID_CSO_EVAL_PAYLOAD_MANIFEST'); }
|
||||
if (!manifest || manifest.skill !== 'cso' || manifest.version !== 1 || !Array.isArray(manifest.sections) || manifest.sections.length === 0) throw new Error('INVALID_CSO_EVAL_PAYLOAD_MANIFEST');
|
||||
const files: string[] = [];
|
||||
const ids = new Set<string>();
|
||||
for (const section of manifest.sections) {
|
||||
if (!section || typeof section.id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(section.id) || ids.has(section.id) || typeof section.file !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*\.md$/.test(section.file) || files.includes(section.file) || typeof section.title !== 'string' || !section.title.trim() || typeof section.trigger !== 'string' || !section.trigger.trim()) throw new Error('INVALID_CSO_EVAL_PAYLOAD_MANIFEST');
|
||||
ids.add(section.id); files.push(section.file);
|
||||
}
|
||||
return { files };
|
||||
}
|
||||
|
||||
function validatePortableSkillFiles(version: EvalVersion, files: PortableSkillFile[]): PortableSkillFile[] {
|
||||
if (!['v2', 'v3'].includes(version) || !Array.isArray(files)) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
const byPath = new Map<string, string>();
|
||||
for (const file of files) {
|
||||
if (!file || typeof file.path !== 'string' || typeof file.contents !== 'string' || byPath.has(file.path) || Buffer.byteLength(file.contents) > PORTABLE_FILE_LIMIT || file.contents.includes(PORTABLE_FILE_END.trim())) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
byPath.set(file.path, file.contents);
|
||||
}
|
||||
const root = byPath.get('SKILL.md'), manifest = byPath.get('sections/manifest.json');
|
||||
if (root === undefined || manifest === undefined) throw new Error('INCOMPLETE_CSO_EVAL_PAYLOAD');
|
||||
const frontmatter = root.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1];
|
||||
const versionMatch = frontmatter?.match(/^version:\s*([0-9]+)(?:\.[0-9]+){0,2}\s*$/m);
|
||||
if (!versionMatch || Number(versionMatch[1]) !== Number(version.slice(1))) throw new Error('CSO_EVAL_PAYLOAD_VERSION_MISMATCH');
|
||||
const sectionFiles = parsePortableManifest(manifest).files;
|
||||
const expectedPaths = ['SKILL.md', 'sections/manifest.json', ...sectionFiles.map(file => `sections/${file}`)];
|
||||
if (byPath.size !== expectedPaths.length || expectedPaths.some(path => !byPath.has(path))) throw new Error('INCOMPLETE_CSO_EVAL_PAYLOAD');
|
||||
return expectedPaths.map(path => ({ path, contents: byPath.get(path)! }));
|
||||
}
|
||||
|
||||
/** Render the only portable instruction format accepted by matrix preparation. */
|
||||
export function createPortableSkillPayload(version: EvalVersion, inputFiles: PortableSkillFile[]): string {
|
||||
const files = validatePortableSkillFiles(version, inputFiles);
|
||||
const identities = files.map(file => ({ path: file.path, sha256: sha256(file.contents), bytes: Buffer.byteLength(file.contents) }));
|
||||
const identity: PortableSkillPayloadIdentity = { schemaVersion: 1, version, files: identities };
|
||||
let payload = `${PORTABLE_PAYLOAD_HEADER}${JSON.stringify(identity)}${PORTABLE_PAYLOAD_HEADER_END}${portablePayloadPreamble(version)}`;
|
||||
for (let index = 0; index < files.length; index++) payload += `${portableFileOpen(identities[index])}${files[index].contents}${PORTABLE_FILE_END}`;
|
||||
if (Buffer.byteLength(payload) > PORTABLE_PAYLOAD_LIMIT) throw new Error('CSO_EVAL_PAYLOAD_TOO_LARGE');
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Validate canonical serialization and return the identities bound by its hash. */
|
||||
export function validatePortableSkillPayload(payload: string, expectedVersion?: EvalVersion): PortableSkillPayloadIdentity {
|
||||
if (typeof payload !== 'string' || Buffer.byteLength(payload) > PORTABLE_PAYLOAD_LIMIT || !payload.startsWith(PORTABLE_PAYLOAD_HEADER)) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
const headerEnd = payload.indexOf(PORTABLE_PAYLOAD_HEADER_END, PORTABLE_PAYLOAD_HEADER.length);
|
||||
if (headerEnd < 0) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
let identity: PortableSkillPayloadIdentity;
|
||||
try { identity = JSON.parse(payload.slice(PORTABLE_PAYLOAD_HEADER.length, headerEnd)); } catch { throw new Error('INVALID_CSO_EVAL_PAYLOAD'); }
|
||||
if (identity?.schemaVersion !== 1 || !['v2', 'v3'].includes(identity.version) || (expectedVersion && identity.version !== expectedVersion) || !Array.isArray(identity.files)) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
let cursor = headerEnd + PORTABLE_PAYLOAD_HEADER_END.length;
|
||||
const preamble = portablePayloadPreamble(identity.version);
|
||||
if (payload.slice(cursor, cursor + preamble.length) !== preamble) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
cursor += preamble.length;
|
||||
const files: PortableSkillFile[] = [];
|
||||
for (const file of identity.files) {
|
||||
if (!file || typeof file.path !== 'string' || !HEX.test(file.sha256) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > PORTABLE_FILE_LIMIT) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
const open = portableFileOpen(file);
|
||||
if (payload.slice(cursor, cursor + open.length) !== open) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
cursor += open.length;
|
||||
const end = payload.indexOf(PORTABLE_FILE_END, cursor);
|
||||
if (end < 0) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
const contents = payload.slice(cursor, end);
|
||||
if (Buffer.byteLength(contents) !== file.bytes || sha256(contents) !== file.sha256) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
files.push({ path: file.path, contents }); cursor = end + PORTABLE_FILE_END.length;
|
||||
}
|
||||
if (cursor !== payload.length || createPortableSkillPayload(identity.version, files) !== payload) throw new Error('INVALID_CSO_EVAL_PAYLOAD');
|
||||
return identity;
|
||||
}
|
||||
|
||||
/** Read one generated CSO skill tree and package every manifest-listed section. */
|
||||
export function loadPortableSkillPayload(version: EvalVersion, skillDirectory: string): string {
|
||||
const root = resolve(skillDirectory), stat = lstatSync(root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(root) !== root) throw new Error('UNSAFE_CSO_EVAL_SKILL_DIRECTORY');
|
||||
const read = (relativePath: string) => {
|
||||
const bytes = readBoundedStable(join(root, ...relativePath.split('/')), PORTABLE_FILE_LIMIT, `CSO ${version} ${relativePath}`);
|
||||
const contents = bytes.toString('utf8');
|
||||
if (!Buffer.from(contents).equals(bytes)) throw new Error('INVALID_CSO_EVAL_PAYLOAD_ENCODING');
|
||||
return contents;
|
||||
};
|
||||
const rootSkill = read('SKILL.md'), manifest = read('sections/manifest.json');
|
||||
const sectionFiles = parsePortableManifest(manifest).files;
|
||||
const sectionsRoot = join(root, 'sections'), sectionStat = lstatSync(sectionsRoot);
|
||||
if (!sectionStat.isDirectory() || sectionStat.isSymbolicLink() || realpathSync(sectionsRoot) !== sectionsRoot) throw new Error('UNSAFE_CSO_EVAL_SKILL_DIRECTORY');
|
||||
const listed = new Set(sectionFiles);
|
||||
const inventory = () => readdirSync(sectionsRoot, { withFileTypes: true }).filter(entry => entry.name.endsWith('.md')).map(entry => {
|
||||
if (!entry.isFile() || entry.isSymbolicLink() || !listed.has(entry.name)) throw new Error('UNLISTED_CSO_EVAL_SECTION');
|
||||
return entry.name;
|
||||
}).sort();
|
||||
const firstInventory = inventory();
|
||||
const files = [
|
||||
{ path: 'SKILL.md', contents: rootSkill },
|
||||
{ path: 'sections/manifest.json', contents: manifest },
|
||||
...sectionFiles.map(file => ({ path: `sections/${file}`, contents: read(`sections/${file}`) })),
|
||||
];
|
||||
if (JSON.stringify(inventory()) !== JSON.stringify(firstInventory) || files.some(file => read(file.path) !== file.contents)) throw new Error('CSO_EVAL_SKILL_CHANGED_DURING_PACKAGING');
|
||||
return createPortableSkillPayload(version, files);
|
||||
}
|
||||
|
||||
function matrixHash(matrix: EvalMatrix): string { return digest(JSON.stringify(matrix)); }
|
||||
|
||||
function safeWriteNew(path: string, value: unknown): void {
|
||||
const target = resolve(path), parent = dirname(target), stat = lstatSync(parent);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(parent) !== parent) throw new Error('UNSAFE_EVAL_DESTINATION');
|
||||
atomicWriteSync(target, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, noReplace: true });
|
||||
}
|
||||
|
||||
function readJsonBounded(path: string, max: number, label: string): any {
|
||||
try { return JSON.parse(readBoundedStable(resolve(path), max, label).toString('utf8')); }
|
||||
catch (error) { if (error instanceof SyntaxError) throw new Error(`INVALID_${label.toUpperCase().replaceAll(' ', '_')}`); throw error; }
|
||||
}
|
||||
|
||||
function initializeFixtureRepository(path: string): void {
|
||||
const nullPath = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
||||
const env = {
|
||||
PATH: process.env.PATH ?? '', LANG: 'C', LC_ALL: 'C',
|
||||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: nullPath, GIT_ATTR_NOSYSTEM: '1',
|
||||
GIT_AUTHOR_NAME: 'CSO Eval', GIT_AUTHOR_EMAIL: 'cso-eval@invalid',
|
||||
GIT_COMMITTER_NAME: 'CSO Eval', GIT_COMMITTER_EMAIL: 'cso-eval@invalid',
|
||||
};
|
||||
const git = executable('git');
|
||||
execFileSync(git, ['init', '--quiet', '--initial-branch=main'], { cwd: path, env, stdio: 'ignore' });
|
||||
const safe = ['-c', `core.hooksPath=${nullPath}`, '-c', `core.attributesFile=${nullPath}`, '-c', `core.excludesFile=${nullPath}`];
|
||||
execFileSync(git, [...safe, 'add', '--all'], { cwd: path, env, stdio: 'ignore' });
|
||||
execFileSync(git, [...safe, 'commit', '--quiet', '-m', 'immutable evaluation fixture'], { cwd: path, env, stdio: 'ignore' });
|
||||
}
|
||||
|
||||
function sourceEntries(caseId: string, variant: EvalVariant): ProducerSourceEntry[] {
|
||||
return Object.entries(sourceFiles(caseId, variant)).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([path, contents]) => ({ path, sha256: sha256(contents), bytes: Buffer.byteLength(contents) }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare independent one-cell jobs. The returned schedule is trusted
|
||||
* evaluator metadata; transfer one job at a time and never give the schedule
|
||||
* or sibling jobs to the producing agent.
|
||||
*/
|
||||
export function prepareEvalJobs(matrix: EvalMatrix, payloads: { v2: string; v3: string }, destination: string, selectedCellIds?: string[]): PreparedEvalSchedule {
|
||||
validateMatrix(matrix);
|
||||
validatePortableSkillPayload(payloads.v2, 'v2'); validatePortableSkillPayload(payloads.v3, 'v3');
|
||||
if (sha256(payloads.v2) !== matrix.skillHashes.v2 || sha256(payloads.v3) !== matrix.skillHashes.v3) throw new Error('EVAL_SKILL_HASH_MISMATCH');
|
||||
const root = resolve(destination);
|
||||
if (existsSync(root)) throw new Error('EVAL_DESTINATION_EXISTS');
|
||||
const parent = dirname(root);
|
||||
if (realpathSync(parent) !== parent || !lstatSync(parent).isDirectory()) throw new Error('UNSAFE_EVAL_DESTINATION');
|
||||
const selected = selectedCellIds === undefined ? matrix.cells : selectedCellIds.map(id => {
|
||||
const cell = matrix.cells.find(item => item.id === id);
|
||||
if (!cell) throw new Error('UNKNOWN_EVAL_CELL');
|
||||
return cell;
|
||||
});
|
||||
if (new Set(selected.map(cell => cell.id)).size !== selected.length) throw new Error('DUPLICATE_EVAL_CELL');
|
||||
mkdirSync(root, { mode: 0o700 });
|
||||
const jobsRoot = join(root, 'jobs'); mkdirSync(jobsRoot, { mode: 0o700 });
|
||||
const jobs: PreparedEvalSchedule['jobs'] = [];
|
||||
for (const cell of selected) {
|
||||
const jobRoot = join(jobsRoot, cell.id); mkdirSync(jobRoot, { mode: 0o700 });
|
||||
const source = materializeCase(cell.caseId, cell.variant, join(jobRoot, 'source'));
|
||||
if (source.sourceHash !== cell.sourceHash) throw new Error('CORPUS_INTEGRITY_MISMATCH');
|
||||
initializeFixtureRepository(source.path);
|
||||
const input: ProducerInput = { schemaVersion: 1, cell, skill: payloads[cell.version], source: sourceEntries(cell.caseId, cell.variant) };
|
||||
const inputHash = producerInputHash(input);
|
||||
safeWriteNew(join(jobRoot, 'producer-input.json'), input);
|
||||
jobs.push({ cellId: cell.id, relativePath: `jobs/${cell.id}`, inputHash });
|
||||
}
|
||||
const schedule: PreparedEvalSchedule = { schemaVersion: 1, matrixHash: matrixHash(matrix), scheduledCells: matrix.cells.length, preparedCells: jobs.length, jobs };
|
||||
safeWriteNew(join(root, 'schedule.json'), schedule);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
function validateSchedule(matrix: EvalMatrix, schedule: PreparedEvalSchedule): void {
|
||||
if (schedule?.schemaVersion !== 1 || schedule.matrixHash !== matrixHash(matrix) || schedule.scheduledCells !== matrix.cells.length || !Array.isArray(schedule.jobs) || schedule.preparedCells !== schedule.jobs.length) throw new Error('INVALID_EVAL_SCHEDULE');
|
||||
const known = new Set(matrix.cells.map(cell => cell.id));
|
||||
const seen = new Set<string>();
|
||||
for (const job of schedule.jobs) {
|
||||
if (!job || !known.has(job.cellId) || seen.has(job.cellId) || job.relativePath !== `jobs/${job.cellId}` || !HEX.test(job.inputHash)) throw new Error('INVALID_EVAL_SCHEDULE');
|
||||
seen.add(job.cellId);
|
||||
}
|
||||
}
|
||||
|
||||
function validateReceipt(receipt: ProducerReceipt, cell: EvalCell, inputHash: string): void {
|
||||
if (receipt?.schemaVersion !== 1 || JSON.stringify(receipt.cell) !== JSON.stringify(cell) || receipt.inputHash !== inputHash || receipt.requestedModel !== cell.model || !['succeeded', 'failed'].includes(receipt.status) || typeof receipt.modelUsed !== 'string' || !receipt.modelUsed || !['provider_reported', 'requested_pin'].includes(receipt.modelIdentitySource) || (receipt.modelIdentitySource === 'requested_pin' && receipt.modelUsed !== receipt.requestedModel) || (receipt.modelIdentitySource === 'provider_reported' && receipt.modelUsed === receipt.requestedModel) || !validNumber(receipt.durationMs) || receipt.firstUsefulResultMs !== null || !Number.isSafeInteger(receipt.toolCalls) || receipt.toolCalls < 0 || typeof receipt.output !== 'string' || receipt.outputHash !== sha256(receipt.output) || !HEX.test(receipt.receiptHash)) throw new Error('INVALID_PRODUCER_RECEIPT');
|
||||
const { receiptHash, ...withoutHash } = receipt;
|
||||
if (producerReceiptHash(withoutHash) !== receiptHash) throw new Error('INVALID_PRODUCER_RECEIPT');
|
||||
const started = Date.parse(receipt.startedAt), finished = Date.parse(receipt.finishedAt);
|
||||
if (!Number.isFinite(started) || !Number.isFinite(finished) || finished < started) throw new Error('INVALID_PRODUCER_RECEIPT');
|
||||
if (!receipt.usage || !['inputTokens', 'outputTokens', 'cachedTokens', 'estimatedCostUSD'].every(field => receipt.usage[field as keyof typeof receipt.usage] === null || validNumber(receipt.usage[field as keyof typeof receipt.usage]))) throw new Error('INVALID_PRODUCER_RECEIPT');
|
||||
const hasError = !!receipt.error;
|
||||
if ((receipt.status === 'failed') !== hasError || (hasError && (!receipt.error!.code || typeof receipt.error!.reason !== 'string'))) throw new Error('INVALID_PRODUCER_RECEIPT');
|
||||
validateInstallationIdentity(receipt.installationIdentity, 'INVALID_PRODUCER_RECEIPT');
|
||||
validateProviderIdentity(receipt.providerIdentity, cell.host === 'codex' ? 'gpt' : cell.host, 'INVALID_PRODUCER_RECEIPT');
|
||||
validateArtifactInventory(receipt.artifacts, 'INVALID_PRODUCER_RECEIPT');
|
||||
}
|
||||
|
||||
function validateInstallationIdentity(identity: ProducerInstallationIdentity, error: string): void {
|
||||
const artifact = (value: any) => value && HEX.test(value.sha256) && Number.isSafeInteger(value.bytes) && value.bytes > 0;
|
||||
if (!identity || identity.schemaVersion !== 1 || !artifact(identity.producer) || !artifact(identity.launcher) || !artifact(identity.core) || !artifact(identity.watchdog) ||
|
||||
!identity.embeddedCatalogs || !identity.embeddedCatalogs.runtimeRevision || !identity.embeddedCatalogs.runtimeBuildRevision || !HEX.test(identity.embeddedCatalogs.runtimeSha256) ||
|
||||
!identity.embeddedCatalogs.scannerRevision || !HEX.test(identity.embeddedCatalogs.scannerSha256) || !HEX.test(identity.identityHash)) throw new Error(error);
|
||||
const { identityHash, ...withoutHash } = identity;
|
||||
if (producerInstallationIdentityHash(withoutHash) !== identityHash) throw new Error(error);
|
||||
}
|
||||
|
||||
function validateProviderIdentity(identity: ProducerProviderIdentity, family: 'claude' | 'gpt' | 'gemini', error: string): void {
|
||||
if (!identity || identity.schemaVersion !== 1 || identity.family !== family || !identity.policyRevision || !identity.version ||
|
||||
!identity.executable || !HEX.test(identity.executable.sha256) || !Number.isSafeInteger(identity.executable.bytes) || identity.executable.bytes <= 0 ||
|
||||
!Array.isArray(identity.argsPrefix) || identity.argsPrefix.some(value => typeof value !== 'string' || value.includes('\0')) || !HEX.test(identity.identityHash)) throw new Error(error);
|
||||
const { identityHash, ...withoutHash } = identity;
|
||||
if (producerProviderIdentityHash(withoutHash) !== identityHash) throw new Error(error);
|
||||
}
|
||||
|
||||
function validateArtifactInventory(inventory: ProducerArtifactInventory, error: string): void {
|
||||
if (!inventory || inventory.schemaVersion !== 1 || inventory.root !== 'security/cso' || !Array.isArray(inventory.entries) || inventory.entries.length > 4096 ||
|
||||
!Number.isSafeInteger(inventory.totalBytes) || inventory.totalBytes < 0 || inventory.totalBytes > 128 * 1024 * 1024 || !HEX.test(inventory.identityHash)) throw new Error(error);
|
||||
let previous = '', total = 0;
|
||||
for (const entry of inventory.entries) {
|
||||
if (!entry || typeof entry.path !== 'string' || entry.path.length > 1024 || !/^[A-Za-z0-9._/-]+$/.test(entry.path) || entry.path.split('/').some(part => !part || part === '.' || part === '..') ||
|
||||
entry.path <= previous || !HEX.test(entry.sha256) || !Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || entry.bytes > 32 * 1024 * 1024) throw new Error(error);
|
||||
previous = entry.path; total += entry.bytes;
|
||||
}
|
||||
if (total !== inventory.totalBytes) throw new Error(error);
|
||||
const { identityHash, ...withoutHash } = inventory;
|
||||
if (producerArtifactInventoryHash(withoutHash) !== identityHash) throw new Error(error);
|
||||
}
|
||||
|
||||
function validateReceiptIndex(receipt: ProducerReceiptIndex, cell: EvalCell): void {
|
||||
if (!receipt || receipt.schemaVersion !== 1 || JSON.stringify(receipt.cell) !== JSON.stringify(cell) || !HEX.test(receipt.inputHash) || receipt.requestedModel !== cell.model || !receipt.modelUsed || !['provider_reported', 'requested_pin'].includes(receipt.modelIdentitySource) || (receipt.modelIdentitySource === 'requested_pin' && receipt.modelUsed !== receipt.requestedModel) || (receipt.modelIdentitySource === 'provider_reported' && receipt.modelUsed === receipt.requestedModel) || !['succeeded', 'failed'].includes(receipt.status) || !HEX.test(receipt.outputHash) || !HEX.test(receipt.receiptHash) || !validNumber(receipt.durationMs) || receipt.firstUsefulResultMs !== null || !Number.isSafeInteger(receipt.toolCalls) || receipt.toolCalls < 0 || !receipt.usage) throw new Error('INVALID_PRODUCER_BATCH');
|
||||
const started = Date.parse(receipt.startedAt), finished = Date.parse(receipt.finishedAt);
|
||||
if (!Number.isFinite(started) || !Number.isFinite(finished) || finished < started || !['inputTokens', 'outputTokens', 'cachedTokens', 'estimatedCostUSD'].every(field => receipt.usage[field as keyof typeof receipt.usage] === null || validNumber(receipt.usage[field as keyof typeof receipt.usage])) || ((receipt.status === 'failed') !== !!receipt.error) || (receipt.error && !receipt.error.code)) throw new Error('INVALID_PRODUCER_BATCH');
|
||||
validateInstallationIdentity(receipt.installationIdentity, 'INVALID_PRODUCER_BATCH');
|
||||
validateProviderIdentity(receipt.providerIdentity, cell.host === 'codex' ? 'gpt' : cell.host, 'INVALID_PRODUCER_BATCH');
|
||||
validateArtifactInventory(receipt.artifacts, 'INVALID_PRODUCER_BATCH');
|
||||
}
|
||||
|
||||
const matchedPairKey = (cell: EvalCell): string => [cell.caseId, cell.variant, cell.mode, cell.repetition, cell.model, cell.host, cell.budgetSeconds, cell.sourceHash].join('|');
|
||||
|
||||
export function collectProducerReceipts(matrix: EvalMatrix, schedule: PreparedEvalSchedule, receipts: ProducerReceipt[]): ProducerBatch {
|
||||
validateMatrix(matrix); validateSchedule(matrix, schedule);
|
||||
if (!Array.isArray(receipts)) throw new Error('INVALID_PRODUCER_RECEIPTS');
|
||||
const cells = new Map(matrix.cells.map(cell => [cell.id, cell]));
|
||||
const jobs = new Map(schedule.jobs.map(job => [job.cellId, job]));
|
||||
const seen = new Set<string>();
|
||||
for (const receipt of receipts) {
|
||||
const cell = cells.get(receipt?.cell?.id), job = jobs.get(receipt?.cell?.id);
|
||||
if (!cell || !job || seen.has(cell.id)) throw new Error('UNKNOWN_OR_DUPLICATE_PRODUCER_RECEIPT');
|
||||
validateReceipt(receipt, cell, job.inputHash); seen.add(cell.id);
|
||||
}
|
||||
if (new Set(receipts.map(receipt => receipt.installationIdentity.identityHash)).size > 1) throw new Error('UNMATCHED_PRODUCER_INSTALLATIONS');
|
||||
if (new Set(receipts.map(receipt => receipt.providerIdentity.identityHash)).size > 1) throw new Error('UNMATCHED_PRODUCER_PROVIDERS');
|
||||
const pairs = new Map<string, Partial<Record<EvalVersion, ProducerReceipt>>>();
|
||||
for (const receipt of receipts) {
|
||||
const key = matchedPairKey(receipt.cell as EvalCell);
|
||||
const pair = pairs.get(key) ?? {}; pair[receipt.cell.version] = receipt; pairs.set(key, pair);
|
||||
}
|
||||
const completePairs = [...pairs.entries()].filter(([, pair]) => pair.v2 && pair.v3) as Array<[string, { v2: ProducerReceipt; v3: ProducerReceipt }]>;
|
||||
const modelMismatches = completePairs.filter(([, pair]) => pair.v2.modelUsed !== pair.v3.modelUsed).map(([pair, value]) => ({ pair: digest(pair), v2: value.v2.modelUsed, v3: value.v3.modelUsed }));
|
||||
if (modelMismatches.length) throw new Error(`UNMATCHED_EFFECTIVE_MODELS: ${modelMismatches.length} matched v2/v3 pair(s) used different normalized model identities`);
|
||||
const groups: ProducerGroupSummary[] = [];
|
||||
for (const version of ['v2', 'v3'] as const) for (const mode of ['daily', 'comprehensive'] as const) {
|
||||
const expected = matrix.cells.filter(cell => cell.version === version && cell.mode === mode);
|
||||
const submitted = receipts.filter(receipt => receipt.cell.version === version && receipt.cell.mode === mode);
|
||||
const tokens = submitted.flatMap(receipt => receipt.usage.inputTokens === null || receipt.usage.outputTokens === null ? [] : [receipt.usage.inputTokens + receipt.usage.outputTokens]);
|
||||
const costs = submitted.flatMap(receipt => receipt.usage.estimatedCostUSD === null ? [] : [receipt.usage.estimatedCostUSD]);
|
||||
groups.push({ version, mode, scheduled: expected.length, submitted: submitted.length, missing: expected.length - submitted.length, succeeded: submitted.filter(receipt => receipt.status === 'succeeded').length, failed: submitted.filter(receipt => receipt.status === 'failed').length,
|
||||
latency: { samples: submitted.length, denominator: expected.length, p95Ms: quantile(submitted.map(receipt => receipt.durationMs), 0.95) },
|
||||
firstUsefulResult: { samples: 0, denominator: expected.length, p95Ms: null },
|
||||
modelTokens: { measured: tokens.length, denominator: expected.length, total: tokens.length ? tokens.reduce((sum, value) => sum + value, 0) : null },
|
||||
estimatedCost: { measured: costs.length, denominator: expected.length, totalUSD: costs.length ? costs.reduce((sum, value) => sum + value, 0) : null, source: 'pricing-table-estimate' },
|
||||
});
|
||||
}
|
||||
const indexes: ProducerReceiptIndex[] = receipts.map(({ output: _output, error, ...receipt }) => ({ ...receipt, ...(error ? { error: { code: error.code } } : {}) }));
|
||||
const base = { schemaVersion: 1 as const, matrixHash: matrixHash(matrix), scheduleHash: digest(JSON.stringify(schedule)), receipts: indexes.sort((left, right) => left.cell.id.localeCompare(right.cell.id)), summary: {
|
||||
scheduled: matrix.cells.length, prepared: schedule.preparedCells, submitted: receipts.length, missing: matrix.cells.length - receipts.length,
|
||||
matchedPairsExpected: matrix.cells.length / 2, matchedPairsSubmitted: completePairs.length, modelMismatches, groups,
|
||||
note: 'Costs are pricing-table estimates. First-useful timing is unmeasured because the reused provider adapters return completed runs. Trusted findings and runtime outcomes require separate oracle adjudication.',
|
||||
} };
|
||||
return { ...base, batchHash: digest(JSON.stringify(base)) };
|
||||
}
|
||||
|
||||
export function createEvalMatrix(options: { model: string; host: ProducerHost; skillHashes: { v2: string; v3: string }; budgets?: { daily: number; comprehensive: number } }, corpus: CorpusManifest = loadCorpusManifest()): EvalMatrix {
|
||||
if (!options.model?.trim() || !['claude', 'codex', 'gemini'].includes(options.host) || !HEX.test(options.skillHashes.v2) || !HEX.test(options.skillHashes.v3) || options.skillHashes.v2 === options.skillHashes.v3) throw new Error('INVALID_MATCHED_EVAL_INPUT');
|
||||
const budgets = options.budgets ?? { daily: 600, comprehensive: 1800 };
|
||||
if (![budgets.daily, budgets.comprehensive].every(value => Number.isInteger(value) && value > 60 && value <= 3600)) throw new Error('INVALID_EVAL_BUDGET');
|
||||
const cells: EvalCell[] = [];
|
||||
for (const fixture of corpus.cases) for (const variant of ['vulnerable', 'fixed'] as const) for (const mode of ['daily', 'comprehensive'] as const) for (const repetition of [1, 2, 3] as const) for (const version of ['v2', 'v3'] as const) {
|
||||
const cell = { caseId: fixture.id, stack: fixture.stack, variant, version, mode, repetition, model: options.model, host: options.host, budgetSeconds: budgets[mode], sourceHash: fixture.filesHash[variant], skillHash: options.skillHashes[version] };
|
||||
cells.push({ id: digest(JSON.stringify(cell)), ...cell });
|
||||
}
|
||||
return { schemaVersion: 1, corpusVersion: corpus.version, corpusHash: digest(JSON.stringify(corpus)), model: options.model, host: options.host, budgets, skillHashes: options.skillHashes, repetitions: 3, cells };
|
||||
}
|
||||
|
||||
export function validateMatrix(matrix: EvalMatrix, corpus: CorpusManifest = loadCorpusManifest()): void {
|
||||
const expected = createEvalMatrix({ model: matrix.model, host: matrix.host, skillHashes: matrix.skillHashes, budgets: matrix.budgets }, corpus);
|
||||
if (JSON.stringify(matrix) !== JSON.stringify(expected)) throw new Error('UNMATCHED_OR_INCOMPLETE_EVAL_MATRIX');
|
||||
}
|
||||
function validateResult(result: EvalResult, cell: EvalCell, corpus: CorpusManifest): void {
|
||||
if (result.sourceHash !== cell.sourceHash || result.skillHash !== cell.skillHash || result.model !== cell.model || result.host !== cell.host || result.budgetSeconds !== cell.budgetSeconds) throw new Error('UNMATCHED_EVAL_RESULT');
|
||||
for (const field of ['setup', 'reproduction', 'repair', 'recheck'] as const) if (!['passed', 'failed', 'blocked', 'not_attempted'].includes(result[field])) throw new Error('INVALID_EVAL_OUTCOME');
|
||||
for (const field of ['reportPresent', 'reportComplete', 'heldOutAssertionsPassed', 'freshRecheck'] as const) if (typeof result[field] !== 'boolean') throw new Error('INVALID_EVAL_RESULT');
|
||||
if (!Array.isArray(result.findings) || !validNumber(result.latencyMs) || (result.firstUsefulResultMs !== null && (!validNumber(result.firstUsefulResultMs) || result.firstUsefulResultMs > result.latencyMs))) throw new Error('INVALID_EVAL_RESULT');
|
||||
if (result.reportComplete && !result.reportPresent) throw new Error('MISSING_EVAL_REPORT');
|
||||
if (!result.reportPresent && result.findings.length) throw new Error('FINDINGS_WITHOUT_REPORT');
|
||||
if (result.setup === 'blocked' && !result.prerequisite?.trim()) throw new Error('MISSING_SETUP_PREREQUISITE');
|
||||
if (result.oracleEvidenceHash !== undefined && (!HEX.test(result.oracleEvidenceHash) || result.oracleVersion !== corpus.version)) throw new Error('INVALID_ORACLE_PROVENANCE');
|
||||
if (result.currentSourceHash !== undefined && !HEX.test(result.currentSourceHash)) throw new Error('INVALID_RECHECK_SOURCE');
|
||||
if (result.recheckEvidenceHash !== undefined && !HEX.test(result.recheckEvidenceHash)) throw new Error('INVALID_RECHECK_EVIDENCE');
|
||||
if (result.usage && (result.usage.source !== 'host' || (result.usage.tokens !== undefined && !validNumber(result.usage.tokens)) || (result.usage.costUSD !== undefined && !validNumber(result.usage.costUSD)))) throw new Error('INVALID_EVAL_USAGE');
|
||||
if (result.producerReceiptHash !== undefined && !HEX.test(result.producerReceiptHash)) throw new Error('INVALID_PRODUCER_RECEIPT_PROVENANCE');
|
||||
const ids = new Set<string>(), repairEvidence = new Set<string>(), recheckEvidence = new Set<string>();
|
||||
for (const finding of result.findings) {
|
||||
if (!finding.id || ids.has(finding.id) || !['supported', 'hypothesis', 'legacy_review'].includes(finding.evidence) || !['correct', 'incorrect', 'unadjudicated'].includes(finding.judgment) || typeof finding.claimedTested !== 'boolean') throw new Error('INVALID_FINDING_JUDGMENT');
|
||||
ids.add(finding.id);
|
||||
if (finding.judgment === 'correct' && (cell.variant !== 'vulnerable' || finding.matchedCaseId !== cell.caseId)) throw new Error('INVALID_ORACLE_MATCH');
|
||||
if (finding.trustedVerification !== undefined) {
|
||||
const verification = finding.trustedVerification as Record<string, unknown>, allowed = new Set(['repair', 'repairEvidenceHash', 'recheck', 'recheckEvidenceHash']);
|
||||
if (!verification || typeof verification !== 'object' || Array.isArray(verification) || Object.keys(verification).some(key => !allowed.has(key)) ||
|
||||
!['passed', 'failed', 'blocked', 'not_attempted'].includes(String(verification.repair)) ||
|
||||
!['passed', 'failed', 'blocked', 'not_attempted'].includes(String(verification.recheck)) || !finding.claimedTested || cell.version !== 'v3' || cell.mode !== 'comprehensive')
|
||||
throw new Error('INVALID_TRUSTED_FINDING_VERIFICATION');
|
||||
const repairHash = verification.repairEvidenceHash, recheckHash = verification.recheckEvidenceHash;
|
||||
if (!(repairHash === undefined || (typeof repairHash === 'string' && HEX.test(repairHash))) ||
|
||||
!(recheckHash === undefined || (typeof recheckHash === 'string' && HEX.test(recheckHash))) ||
|
||||
(verification.repair === 'passed') !== (repairHash !== undefined) ||
|
||||
(verification.recheck === 'passed') !== (recheckHash !== undefined) ||
|
||||
(verification.recheck === 'passed' && verification.repair !== 'passed'))
|
||||
throw new Error('INVALID_TRUSTED_FINDING_VERIFICATION');
|
||||
if (typeof repairHash === 'string') {
|
||||
if (repairEvidence.has(repairHash)) throw new Error('DUPLICATE_TRUSTED_REPAIR_BINDING');
|
||||
repairEvidence.add(repairHash);
|
||||
}
|
||||
if (typeof recheckHash === 'string') {
|
||||
if (recheckEvidence.has(recheckHash)) throw new Error('DUPLICATE_TRUSTED_RECHECK_BINDING');
|
||||
recheckEvidence.add(recheckHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cell.mode === 'daily' && [result.setup, result.reproduction, result.repair, result.recheck].some(value => value !== 'not_attempted')) throw new Error('DAILY_EVAL_EXECUTED_APPLICATION');
|
||||
}
|
||||
function quantile(values: number[], fraction: number): number | null {
|
||||
if (!values.length) return null;
|
||||
return [...values].sort((left, right) => left - right)[Math.ceil(values.length * fraction) - 1];
|
||||
}
|
||||
function measuredRate(cells: EvalCell[], results: Map<string, EvalResult>, field: 'setup' | 'reproduction' | 'repair' | 'recheck', corpus: CorpusManifest): Rate {
|
||||
return rate(cells.filter(cell => {
|
||||
const result = results.get(cell.id);
|
||||
if (!result?.reportPresent || !result.reportComplete || result[field] !== 'passed') return false;
|
||||
if (field === 'setup') return true;
|
||||
if (!result.oracleEvidenceHash || result.oracleVersion !== corpus.version || result.setup !== 'passed') return false;
|
||||
if (field === 'reproduction') return true;
|
||||
if (result.reproduction !== 'passed' || result.repair !== 'passed' || !result.heldOutAssertionsPassed) return false;
|
||||
if (cell.version === 'v3' && !result.findings.some(finding => trustedFindingVerification(cell, result, finding, 'repair', corpus))) return false;
|
||||
if (field === 'repair') return true;
|
||||
// Correct alternative patches need not match the reference fix byte-for-byte.
|
||||
// Closure needs a fresh source and a separate trusted current-source observation.
|
||||
return result.freshRecheck && !!result.currentSourceHash && result.currentSourceHash !== cell.sourceHash &&
|
||||
!!result.recheckEvidenceHash && result.recheckEvidenceHash !== result.oracleEvidenceHash &&
|
||||
(cell.version !== 'v3' || result.findings.some(finding => trustedFindingVerification(cell, result, finding, 'recheck', corpus)));
|
||||
}).length, cells.length);
|
||||
}
|
||||
|
||||
function trustedFindingVerification(cell: EvalCell, result: EvalResult, finding: EvalFinding, stage: 'repair' | 'recheck', corpus: CorpusManifest): boolean {
|
||||
const verification = finding.trustedVerification;
|
||||
if (!result.reportPresent || !result.reportComplete || cell.version !== 'v3' || cell.mode !== 'comprehensive' || cell.variant !== 'vulnerable' || finding.evidence !== 'supported' ||
|
||||
finding.judgment !== 'correct' || finding.matchedCaseId !== cell.caseId || !finding.claimedTested || !verification ||
|
||||
result.setup !== 'passed' || result.reproduction !== 'passed' || result.repair !== 'passed' || !result.heldOutAssertionsPassed ||
|
||||
verification.repair !== 'passed' || verification.repairEvidenceHash !== result.oracleEvidenceHash || result.oracleVersion !== corpus.version)
|
||||
return false;
|
||||
if (stage === 'repair') return true;
|
||||
return result.recheck === 'passed' && verification.recheck === 'passed' && verification.recheckEvidenceHash === result.recheckEvidenceHash &&
|
||||
result.freshRecheck && !!result.currentSourceHash && result.currentSourceHash !== cell.sourceHash &&
|
||||
!!result.recheckEvidenceHash && result.recheckEvidenceHash !== result.oracleEvidenceHash;
|
||||
}
|
||||
|
||||
export function scoreEval(matrix: EvalMatrix, observations: EvalResult[], qualification: ReleaseQualification = { containment: {} }, corpus: CorpusManifest = loadCorpusManifest()) {
|
||||
validateMatrix(matrix, corpus);
|
||||
if (!Array.isArray(observations)) throw new Error('INVALID_EVAL_RESULTS');
|
||||
const cells = new Map(matrix.cells.map(cell => [cell.id, cell]));
|
||||
const results = new Map<string, EvalResult>();
|
||||
for (const result of observations) {
|
||||
const cell = cells.get(result.cellId);
|
||||
if (!cell || results.has(result.cellId)) throw new Error('UNKNOWN_OR_DUPLICATE_EVAL_RESULT');
|
||||
validateResult(result, cell, corpus); results.set(result.cellId, result);
|
||||
}
|
||||
const eligibleFinding = (cell: EvalCell, finding: EvalFinding) => finding.evidence === 'supported' || (cell.version === 'v2' && finding.evidence === 'legacy_review');
|
||||
const found = (cell: EvalCell) => {
|
||||
const result = results.get(cell.id);
|
||||
return result?.reportPresent === true && result.reportComplete === true &&
|
||||
result.findings.some(finding => eligibleFinding(cell, finding) && finding.judgment === 'correct');
|
||||
};
|
||||
const groups: any[] = [];
|
||||
for (const version of ['v2', 'v3'] as const) for (const mode of ['daily', 'comprehensive'] as const) {
|
||||
const selected = matrix.cells.filter(cell => cell.version === version && cell.mode === mode);
|
||||
const completed = selected.map(cell => results.get(cell.id)).filter((result): result is EvalResult => !!result);
|
||||
const expected = selected.filter(cell => cell.variant === 'vulnerable');
|
||||
const highCritical = expected.filter(cell => ['critical','high'].includes(corpus.cases.find(fixture => fixture.id === cell.caseId)!.severity));
|
||||
let correct = 0, reported = 0, unadjudicated = 0;
|
||||
for (const cell of selected) {
|
||||
const result = results.get(cell.id);
|
||||
const findings = result?.reportPresent && result.reportComplete ? result.findings.filter(finding => finding.evidence !== 'hypothesis') : [];
|
||||
reported += findings.length;
|
||||
unadjudicated += findings.filter(finding => finding.judgment === 'unadjudicated').length;
|
||||
correct += Number(findings.some(finding => eligibleFinding(cell, finding) && finding.judgment === 'correct')); // Duplicate reports do not increase true positives.
|
||||
}
|
||||
const firstUseful = completed.flatMap(result => result.firstUsefulResultMs === null ? [] : [result.firstUsefulResultMs]);
|
||||
const costs = completed.flatMap(result => result.usage?.costUSD === undefined ? [] : [result.usage.costUSD]);
|
||||
const tokens = completed.flatMap(result => result.usage?.tokens === undefined ? [] : [result.usage.tokens]);
|
||||
const falseTested = selected.reduce((count, cell) => {
|
||||
const result = results.get(cell.id); if (!result) return count;
|
||||
return count + result.findings.filter(finding => finding.claimedTested && !trustedFindingVerification(cell, result, finding, 'repair', corpus)).length;
|
||||
}, 0);
|
||||
groups.push({ version, mode, cells: selected.length, submitted: completed.length, missing: selected.length - completed.length,
|
||||
reports: rate(completed.filter(result => result.reportPresent && result.reportComplete).length, selected.length), precision: rate(correct, reported), recall: rate(expected.filter(found).length, expected.length), highCriticalRecall: rate(highCritical.filter(found).length, highCritical.length),
|
||||
unadjudicated, falseTested, setup: mode === 'comprehensive' ? measuredRate(selected, results, 'setup', corpus) : null,
|
||||
reproduction: mode === 'comprehensive' ? measuredRate(expected, results, 'reproduction', corpus) : null,
|
||||
repair: mode === 'comprehensive' ? measuredRate(expected, results, 'repair', corpus) : null,
|
||||
recheck: mode === 'comprehensive' ? measuredRate(expected, results, 'recheck', corpus) : null,
|
||||
setupBlocked: completed.filter(result => result.setup === 'blocked').length,
|
||||
firstUsefulResult: { samples: firstUseful.length, denominator: selected.length, medianMs: quantile(firstUseful, 0.5), p95Ms: quantile(firstUseful, 0.95) },
|
||||
latency: { samples: completed.length, p95Ms: quantile(completed.map(result => result.latencyMs), 0.95) },
|
||||
cost: { measured: costs.length, denominator: selected.length, totalUSD: costs.length ? costs.reduce((sum, cost) => sum + cost, 0) : null },
|
||||
modelTokens: { measured: tokens.length, denominator: selected.length, total: tokens.length ? tokens.reduce((sum, count) => sum + count, 0) : null },
|
||||
});
|
||||
}
|
||||
const daily = groups.find(group => group.version === 'v3' && group.mode === 'daily');
|
||||
const comprehensive = groups.find(group => group.version === 'v3' && group.mode === 'comprehensive');
|
||||
const baseline = groups.find(group => group.version === 'v2' && group.mode === 'comprehensive');
|
||||
const perStack = Object.fromEntries(STACKS.map(stack => {
|
||||
const eligible = matrix.cells.filter(cell => cell.version === 'v3' && cell.mode === 'comprehensive' && cell.variant === 'vulnerable' && cell.stack === stack);
|
||||
// A repaired oracle case demonstrates the full find-to-repair workflow only
|
||||
// when the producer also reported the matching supported finding.
|
||||
const successful = new Set(eligible.filter(cell => {
|
||||
const result = results.get(cell.id);
|
||||
return result?.findings.some(finding => trustedFindingVerification(cell, result, finding, 'repair', corpus));
|
||||
}).map(cell => cell.caseId));
|
||||
return [stack, { correctHeldOutRepairs: successful.size, denominator: new Set(eligible.map(cell => cell.caseId)).size }];
|
||||
}));
|
||||
const assessedAll = observations.length === matrix.cells.length && observations.every(result => result.reportPresent && result.reportComplete) && groups.every(group => !group.unadjudicated);
|
||||
const gate = (condition: boolean | null, hasData: boolean): 'pass' | 'fail' | 'unmeasured' => !hasData || condition === null ? 'unmeasured' : condition ? 'pass' : 'fail';
|
||||
const containmentValues = REQUIRED_CONTAINMENT.map(name => qualification.containment?.[name]);
|
||||
if (containmentValues.some(value => value !== undefined && !['passed', 'failed', 'not_run'].includes(value))) throw new Error('INVALID_CONTAINMENT_RESULT');
|
||||
const gates = {
|
||||
matchedCompleteMatrix: gate(assessedAll, observations.length > 0),
|
||||
mandatoryReports: gate(groups.every(group => group.reports.numerator === group.reports.denominator), observations.length === matrix.cells.length),
|
||||
dailyPrecision95: gate(daily.precision.value === null ? null : daily.precision.value >= 0.95, daily.submitted === daily.cells && !daily.unadjudicated),
|
||||
comprehensiveHighCriticalRecall80: gate(comprehensive.highCriticalRecall.value >= 0.8, comprehensive.submitted === comprehensive.cells && !comprehensive.unadjudicated),
|
||||
noHighCriticalRecallRegression: gate(comprehensive.highCriticalRecall.value >= baseline.highCriticalRecall.value, comprehensive.submitted === comprehensive.cells && baseline.submitted === baseline.cells && !comprehensive.unadjudicated && !baseline.unadjudicated),
|
||||
allCoreColdStarts: gate(comprehensive.setup.numerator === comprehensive.setup.denominator, comprehensive.submitted === comprehensive.cells),
|
||||
zeroFalselyTestedRepairs: gate(comprehensive.falseTested === 0 && daily.falseTested === 0, comprehensive.submitted === comprehensive.cells && daily.submitted === daily.cells),
|
||||
heldOutRepairEachStack: gate(Object.values(perStack).every(value => value.correctHeldOutRepairs >= 1), comprehensive.submitted === comprehensive.cells),
|
||||
containmentAndCanaries: containmentValues.includes('failed') ? 'fail' as const : containmentValues.every(value => value === 'passed') ? 'pass' as const : 'unmeasured' as const,
|
||||
};
|
||||
return { schemaVersion: 1, corpusVersion: corpus.version, model: matrix.model, host: matrix.host,
|
||||
status: Object.values(gates).every(value => value === 'pass') ? 'qualified' : observations.length ? 'partial' : 'unmeasured', groups, perStack, gates,
|
||||
notes: ['Missing and setup-blocked supported scenarios remain in recall and workflow denominators.', 'Costs and tokens come only from host-reported usage; wall-clock budgets are not model-spend caps.', 'Oracle judgments and qualification receipts must come from the trusted evaluator, never producing agents.'] };
|
||||
}
|
||||
|
||||
/** Release scoring path: every trusted judgment must bind to a collected producer receipt. */
|
||||
export function scoreCollectedEval(matrix: EvalMatrix, batch: ProducerBatch, observations: EvalResult[], qualification: ReleaseQualification = { containment: {} }, corpus: CorpusManifest = loadCorpusManifest()) {
|
||||
validateMatrix(matrix, corpus);
|
||||
if (!Array.isArray(observations)) throw new Error('INVALID_EVAL_RESULTS');
|
||||
if (!batch || batch.schemaVersion !== 1 || batch.matrixHash !== matrixHash(matrix) || !HEX.test(batch.scheduleHash) || !HEX.test(batch.batchHash)) throw new Error('INVALID_PRODUCER_BATCH');
|
||||
const { batchHash, ...withoutHash } = batch;
|
||||
if (digest(JSON.stringify(withoutHash)) !== batchHash || !Array.isArray(batch.receipts) || batch.receipts.length !== matrix.cells.length || batch.summary.scheduled !== matrix.cells.length || batch.summary.prepared !== matrix.cells.length || batch.summary.submitted !== matrix.cells.length || batch.summary.missing !== 0 || batch.summary.matchedPairsExpected !== matrix.cells.length / 2 || batch.summary.matchedPairsSubmitted !== matrix.cells.length / 2 || batch.summary.modelMismatches.length !== 0) throw new Error('INCOMPLETE_PRODUCER_BATCH');
|
||||
const cells = new Map(matrix.cells.map(cell => [cell.id, cell]));
|
||||
const receipts = new Map<string, ProducerReceiptIndex>();
|
||||
for (const receipt of batch.receipts) {
|
||||
const cell = cells.get(receipt?.cell?.id);
|
||||
if (!cell || receipts.has(cell.id)) throw new Error('INVALID_PRODUCER_BATCH');
|
||||
validateReceiptIndex(receipt, cell); receipts.set(cell.id, receipt);
|
||||
}
|
||||
if (new Set(batch.receipts.map(receipt => receipt.installationIdentity.identityHash)).size !== 1) throw new Error('UNMATCHED_PRODUCER_INSTALLATIONS');
|
||||
if (new Set(batch.receipts.map(receipt => receipt.providerIdentity.identityHash)).size !== 1) throw new Error('UNMATCHED_PRODUCER_PROVIDERS');
|
||||
const actualModels = new Map<string, Partial<Record<EvalVersion, string>>>();
|
||||
for (const receipt of batch.receipts) {
|
||||
const key = matchedPairKey(receipt.cell as EvalCell), pair = actualModels.get(key) ?? {};
|
||||
pair[receipt.cell.version] = receipt.modelUsed; actualModels.set(key, pair);
|
||||
}
|
||||
if (actualModels.size !== matrix.cells.length / 2 || [...actualModels.values()].some(pair => !pair.v2 || !pair.v3 || pair.v2 !== pair.v3)) throw new Error('UNMATCHED_EFFECTIVE_MODELS');
|
||||
for (const result of observations) {
|
||||
const receipt = receipts.get(result.cellId);
|
||||
if (!receipt || result.producerReceiptHash !== receipt.receiptHash) throw new Error('UNBOUND_EVAL_RESULT');
|
||||
}
|
||||
const score = scoreEval(matrix, observations, qualification, corpus);
|
||||
return { ...score, producerBatchHash: batch.batchHash };
|
||||
}
|
||||
|
||||
function cli(args: string[]): void {
|
||||
const [command, ...rest] = args;
|
||||
if (command === 'payload') {
|
||||
if (rest.length !== 6 || rest[0] !== '--version' || !['v2', 'v3'].includes(rest[1]) || rest[2] !== '--skill-dir' || rest[4] !== '--output') throw new Error('Usage: cso-eval payload --version <v2|v3> --skill-dir <directory> --output <new-file>');
|
||||
const payload = loadPortableSkillPayload(rest[1] as EvalVersion, rest[3]);
|
||||
const target = resolve(rest[5]), parent = dirname(target), stat = lstatSync(parent);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(parent) !== parent) throw new Error('UNSAFE_EVAL_DESTINATION');
|
||||
atomicWriteSync(target, payload, { mode: 0o600, noReplace: true });
|
||||
console.log(JSON.stringify({ version: rest[1], files: validatePortableSkillPayload(payload).files.length, sha256: sha256(payload), output: target, paidCalls: 0 })); return;
|
||||
}
|
||||
if (command === 'materialize') {
|
||||
if (rest.length !== 3 || !['vulnerable', 'fixed'].includes(rest[1])) throw new Error('Usage: cso-eval materialize <case-id> <vulnerable|fixed> <new-directory>');
|
||||
console.log(JSON.stringify(materializeCase(rest[0], rest[1] as EvalVariant, rest[2]), null, 2)); return;
|
||||
}
|
||||
if (command === 'matrix') {
|
||||
const values: Record<string, string> = {};
|
||||
for (let index = 0; index < rest.length; index += 2) {
|
||||
if (!['--model', '--host', '--v2-payload', '--v3-payload', '--output'].includes(rest[index]) || !rest[index + 1] || values[rest[index]]) throw new Error('Usage: cso-eval matrix --model <id> --host <claude|codex|gemini> --v2-payload <file> --v3-payload <file> --output <new-file>');
|
||||
values[rest[index]] = rest[index + 1];
|
||||
}
|
||||
if (Object.keys(values).length !== 5) throw new Error('All matrix flags are required; exact portable skill payloads and model identity must be pinned.');
|
||||
const payloads = { v2: readBoundedStable(resolve(values['--v2-payload']), PORTABLE_PAYLOAD_LIMIT, 'v2 portable skill payload').toString('utf8'), v3: readBoundedStable(resolve(values['--v3-payload']), PORTABLE_PAYLOAD_LIMIT, 'v3 portable skill payload').toString('utf8') };
|
||||
validatePortableSkillPayload(payloads.v2, 'v2'); validatePortableSkillPayload(payloads.v3, 'v3');
|
||||
const matrix = createEvalMatrix({ model: values['--model'], host: values['--host'] as ProducerHost, skillHashes: { v2: digest(payloads.v2), v3: digest(payloads.v3) } });
|
||||
safeWriteNew(values['--output'], matrix); console.log(JSON.stringify({ cells: matrix.cells.length, output: values['--output'], paidCalls: 0 })); return;
|
||||
}
|
||||
if (command === 'prepare') {
|
||||
if (rest.length !== 7 || rest[1] !== '--v2-payload' || rest[3] !== '--v3-payload' || rest[5] !== '--output') throw new Error('Usage: cso-eval prepare <matrix.json> --v2-payload <file> --v3-payload <file> --output <new-directory>');
|
||||
const matrix = readJsonBounded(rest[0], 16 * 1024 * 1024, 'evaluation matrix') as EvalMatrix;
|
||||
const schedule = prepareEvalJobs(matrix, { v2: readBoundedStable(resolve(rest[2]), PORTABLE_PAYLOAD_LIMIT, 'v2 portable skill payload').toString('utf8'), v3: readBoundedStable(resolve(rest[4]), PORTABLE_PAYLOAD_LIMIT, 'v3 portable skill payload').toString('utf8') }, rest[6]);
|
||||
console.log(JSON.stringify({ scheduled: schedule.scheduledCells, prepared: schedule.preparedCells, output: resolve(rest[6]), paidCalls: 0 })); return;
|
||||
}
|
||||
if (command === 'collect') {
|
||||
if (rest.length !== 4) throw new Error('Usage: cso-eval collect <matrix.json> <schedule.json> <receipts-directory> <new-batch.json>');
|
||||
const matrix = readJsonBounded(rest[0], 16 * 1024 * 1024, 'evaluation matrix') as EvalMatrix;
|
||||
const schedule = readJsonBounded(rest[1], 16 * 1024 * 1024, 'evaluation schedule') as PreparedEvalSchedule;
|
||||
const receiptRoot = resolve(rest[2]), stat = lstatSync(receiptRoot);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || realpathSync(receiptRoot) !== receiptRoot) throw new Error('UNSAFE_RECEIPT_DIRECTORY');
|
||||
const receipts: ProducerReceipt[] = [];
|
||||
for (const entry of readdirSync(receiptRoot, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || entry.isSymbolicLink() || !/^[a-f0-9]{64}\.json$/.test(entry.name)) throw new Error('UNSAFE_RECEIPT_DIRECTORY');
|
||||
receipts.push(readJsonBounded(join(receiptRoot, entry.name), 40 * 1024 * 1024, 'producer receipt'));
|
||||
}
|
||||
const batch = collectProducerReceipts(matrix, schedule, receipts);
|
||||
safeWriteNew(resolve(rest[3]), batch);
|
||||
console.log(JSON.stringify({ output: resolve(rest[3]), submitted: batch.summary.submitted, missing: batch.summary.missing, modelMismatches: batch.summary.modelMismatches.length, paidCalls: 0 })); return;
|
||||
}
|
||||
if (command === 'score') {
|
||||
if (rest.length < 3 || rest.length > 4) throw new Error('Usage: cso-eval score <matrix.json> <producer-batch.json> <trusted-results.json> [qualification.json]');
|
||||
console.log(JSON.stringify(scoreCollectedEval(readJsonBounded(rest[0], 16 * 1024 * 1024, 'evaluation matrix'), readJsonBounded(rest[1], 16 * 1024 * 1024, 'producer batch'), readJsonBounded(rest[2], 64 * 1024 * 1024, 'trusted results'), rest[3] ? readJsonBounded(rest[3], 4 * 1024 * 1024, 'qualification') : undefined), null, 2)); return;
|
||||
}
|
||||
throw new Error('Usage: cso-eval <payload|matrix|prepare|materialize|collect|score>');
|
||||
}
|
||||
if (import.meta.main) { try { cli(process.argv.slice(2)); } catch (error) { console.error(error instanceof Error ? error.message : 'CSO evaluation failed'); process.exitCode = 1; } }
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Trusted CI input validation. Qualification evidence is handled separately. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export const CSO_RUNTIME_PLATFORMS = ['linux/amd64', 'linux/arm64'] as const;
|
||||
export const CSO_RUNTIME_STACKS = ['node', 'bun', 'python', 'rails', 'postgresql'] as const;
|
||||
export type CsoRuntimeBuildPlatform = typeof CSO_RUNTIME_PLATFORMS[number];
|
||||
export type CsoRuntimeBuildStack = typeof CSO_RUNTIME_STACKS[number];
|
||||
|
||||
const IMAGE = /^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/;
|
||||
const TAGGED_IMAGE = /^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9][a-z0-9._/-]*:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
const REVISION = /^[a-z0-9][a-z0-9._-]{0,100}$/;
|
||||
const REQUIRED_VERSIONS: Record<CsoRuntimeBuildStack, string[]> = {
|
||||
node: ['node', 'npm', 'cso-preparation'],
|
||||
// oven/bun exposes a Bun-backed `node` fallback, not a real Node release.
|
||||
// Recording a Node version here would make package engine admission unsound.
|
||||
bun: ['bun', 'cso-preparation'],
|
||||
python: ['python', 'uv', 'cso-preparation'],
|
||||
rails: ['ruby', 'bundler', 'cso-preparation'],
|
||||
postgresql: ['postgresql'],
|
||||
};
|
||||
|
||||
export interface ImageBuildRow {
|
||||
inputRevision: string;
|
||||
profileId: string;
|
||||
runtimeId: string;
|
||||
stack: CsoRuntimeBuildStack;
|
||||
platform: CsoRuntimeBuildPlatform;
|
||||
arch: 'amd64' | 'arm64';
|
||||
runner: 'ubuntu-24.04' | 'ubuntu-24.04-arm';
|
||||
baseSource: string;
|
||||
baseIndexImage: string;
|
||||
baseImage: string;
|
||||
uvSource: string;
|
||||
uvIndexImage: string;
|
||||
uvImage: string;
|
||||
sbomGeneratorSource: string;
|
||||
sbomGeneratorIndexImage: string;
|
||||
sbomGeneratorImage: string;
|
||||
versions: Record<string, string>;
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function repositoryOfPinned(image: string): string {
|
||||
return image.slice(0, image.lastIndexOf('@'));
|
||||
}
|
||||
|
||||
function repositoryOfTag(image: string): string {
|
||||
const slash = image.lastIndexOf('/');
|
||||
const colon = image.lastIndexOf(':');
|
||||
if (colon <= slash) throw new Error('INVALID_SOURCE_REFERENCE');
|
||||
return image.slice(0, colon);
|
||||
}
|
||||
|
||||
function assertPinnedFamily(source: unknown, indexImage: unknown, images: unknown, label: string): Record<CsoRuntimeBuildPlatform, string> {
|
||||
if (typeof source !== 'string' || !TAGGED_IMAGE.test(source)) throw new Error(`INVALID_${label}_SOURCE`);
|
||||
if (typeof indexImage !== 'string' || !IMAGE.test(indexImage)) throw new Error(`UNPINNED_${label}_INDEX`);
|
||||
if (!record(images)) throw new Error(`MISSING_${label}_IMAGES`);
|
||||
const repository = repositoryOfTag(source);
|
||||
if (repositoryOfPinned(indexImage) !== repository) throw new Error(`MISMATCHED_${label}_REPOSITORY`);
|
||||
const pinned = {} as Record<CsoRuntimeBuildPlatform, string>;
|
||||
for (const platform of CSO_RUNTIME_PLATFORMS) {
|
||||
const image = images[platform];
|
||||
if (typeof image !== 'string' || !IMAGE.test(image)) throw new Error(`UNPINNED_${label}_IMAGE: ${platform}`);
|
||||
if (repositoryOfPinned(image) !== repository) throw new Error(`MISMATCHED_${label}_REPOSITORY`);
|
||||
pinned[platform] = image;
|
||||
}
|
||||
if (pinned['linux/amd64'] === pinned['linux/arm64']) throw new Error(`DUPLICATE_${label}_PLATFORM_MANIFEST`);
|
||||
return pinned;
|
||||
}
|
||||
|
||||
/** Every declared stack must have both native platform manifests. */
|
||||
export function imageBuildMatrix(input: unknown): { include: ImageBuildRow[] } {
|
||||
if (!record(input)) throw new Error('INVALID_BUILD_INPUTS');
|
||||
if (input.schemaVersion !== 1 || input.helperAbi !== 3) throw new Error('INCOMPATIBLE_BUILD_INPUTS');
|
||||
if (input.state !== 'reviewed') throw new Error('MISSING_REVIEWED_BUILD_INPUTS: review pinned images and exact tool versions before staging publication.');
|
||||
if (typeof input.revision !== 'string' || !REVISION.test(input.revision)) throw new Error('INVALID_BUILD_INPUT_REVISION');
|
||||
if (typeof input.reviewedAt !== 'string' || !Number.isFinite(Date.parse(input.reviewedAt))) throw new Error('MISSING_BUILD_INPUT_REVIEW');
|
||||
if (typeof input.reviewMethod !== 'string' || input.reviewMethod.length < 40 || input.reviewMethod.length > 500) throw new Error('MISSING_BUILD_INPUT_REVIEW');
|
||||
if (!record(input.sbomGenerator)) throw new Error('UNPINNED_SBOM_GENERATOR');
|
||||
const sbomImages = assertPinnedFamily(
|
||||
input.sbomGenerator.source,
|
||||
input.sbomGenerator.indexImage,
|
||||
input.sbomGenerator.images,
|
||||
'SBOM_GENERATOR',
|
||||
);
|
||||
if (!Array.isArray(input.profiles) || input.profiles.length !== CSO_RUNTIME_STACKS.length) throw new Error('INCOMPLETE_STACK_MATRIX');
|
||||
|
||||
const rows: ImageBuildRow[] = [];
|
||||
const seenStacks = new Set<string>();
|
||||
const seenProfileIds = new Set<string>();
|
||||
for (const raw of input.profiles) {
|
||||
if (!record(raw)) throw new Error('INVALID_PROFILE');
|
||||
const { id, stack, source, indexImage, baseImages, versions } = raw;
|
||||
if (typeof id !== 'string' || !REVISION.test(id) || seenProfileIds.has(id)) throw new Error('INVALID_PROFILE_ID');
|
||||
seenProfileIds.add(id);
|
||||
if (typeof stack !== 'string' || !CSO_RUNTIME_STACKS.includes(stack as CsoRuntimeBuildStack) || seenStacks.has(stack)) throw new Error('INVALID_STACK');
|
||||
seenStacks.add(stack);
|
||||
const typedStack = stack as CsoRuntimeBuildStack;
|
||||
const bases = assertPinnedFamily(source, indexImage, baseImages, 'BASE');
|
||||
if (!record(versions)) throw new Error('MISSING_TOOL_VERSIONS');
|
||||
const exactVersion = (value: unknown) => typeof value === 'string' &&
|
||||
(typedStack === 'postgresql'
|
||||
? /^\d+\.\d+(?:\.\d+)?(?:[.+_-][A-Za-z0-9.-]+)?$/
|
||||
: /^\d+\.\d+\.\d+(?:[.+_-][A-Za-z0-9.-]+)?$/).test(value);
|
||||
const expectedKeys = [...REQUIRED_VERSIONS[typedStack]].sort();
|
||||
const suppliedKeys = Object.keys(versions).sort();
|
||||
if (expectedKeys.join(',') !== suppliedKeys.join(',') || expectedKeys.some(key => !exactVersion(versions[key]))) throw new Error('UNPINNED_TOOL_VERSION');
|
||||
if (typedStack !== 'postgresql' && versions['cso-preparation'] !== '1.0.0') throw new Error('INCOMPATIBLE_PREPARATION_HELPER');
|
||||
|
||||
let uvImages = {} as Record<CsoRuntimeBuildPlatform, string>;
|
||||
if (typedStack === 'python') uvImages = assertPinnedFamily(raw.uvSource, raw.uvIndexImage, raw.uvImages, 'UV');
|
||||
else if (raw.uvSource !== undefined || raw.uvIndexImage !== undefined || raw.uvImages !== undefined) throw new Error('UNEXPECTED_UV_IMAGE');
|
||||
|
||||
for (const platform of CSO_RUNTIME_PLATFORMS) {
|
||||
const arch = platform === 'linux/amd64' ? 'amd64' : 'arm64';
|
||||
rows.push({
|
||||
inputRevision: input.revision,
|
||||
profileId: id,
|
||||
runtimeId: `${id}-${arch}`,
|
||||
stack: typedStack,
|
||||
platform,
|
||||
arch,
|
||||
runner: arch === 'amd64' ? 'ubuntu-24.04' : 'ubuntu-24.04-arm',
|
||||
baseSource: source as string,
|
||||
baseIndexImage: indexImage as string,
|
||||
baseImage: bases[platform],
|
||||
uvSource: typedStack === 'python' ? raw.uvSource as string : '',
|
||||
uvIndexImage: typedStack === 'python' ? raw.uvIndexImage as string : '',
|
||||
uvImage: typedStack === 'python' ? uvImages[platform] : '',
|
||||
sbomGeneratorSource: input.sbomGenerator.source as string,
|
||||
sbomGeneratorIndexImage: input.sbomGenerator.indexImage as string,
|
||||
sbomGeneratorImage: sbomImages[platform],
|
||||
versions: versions as Record<string, string>,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { include: rows };
|
||||
}
|
||||
|
||||
export function committedImageBuildMatrix(): { include: ImageBuildRow[] } {
|
||||
const file = resolve(import.meta.dir, '../lib/cso/images/build-inputs.json');
|
||||
return imageBuildMatrix(JSON.parse(readFileSync(file, 'utf8')));
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
if (process.argv.length !== 2) throw new Error('No arguments accepted; build inputs come from the reviewed repository file.');
|
||||
process.stdout.write(JSON.stringify(committedImageBuildMatrix()) + '\n');
|
||||
} catch (error) {
|
||||
process.stderr.write((error instanceof Error ? error.message : 'INVALID_BUILD_INPUTS') + '\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Release-only proof that a CSO image is public and anonymously pullable. */
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
||||
const PLATFORM = /^linux\/(amd64|arm64)$/;
|
||||
const RUNTIME = /^(node|bun|python|rails|postgresql)-(amd64|arm64)$/;
|
||||
const SCANNER = /^(gitleaks|osv|semgrep|zizmor|trivy|schemathesis)-(amd64|arm64)$/;
|
||||
const MAX_HTTP_BYTES = 64 * 1024;
|
||||
const MAX_DOCKER_OUTPUT = 64 * 1024;
|
||||
const DOCKER_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface PublicGhcrTarget {
|
||||
image: string;
|
||||
owner: string;
|
||||
repository: string;
|
||||
packageName: string;
|
||||
digest: string;
|
||||
platform: 'linux/amd64' | 'linux/arm64';
|
||||
}
|
||||
|
||||
export interface PublicImageProof {
|
||||
schemaVersion: 1;
|
||||
image: string;
|
||||
platform: 'linux/amd64' | 'linux/arm64';
|
||||
packageName: string;
|
||||
packageVisibility: 'public';
|
||||
packageApiUrl: string;
|
||||
anonymousPull: 'passed';
|
||||
dockerConfig: 'isolated-empty-auths';
|
||||
verifiedAt: string;
|
||||
}
|
||||
|
||||
interface CommandResult { exitCode: number; stdout: string; stderr: string }
|
||||
export interface PublicGhcrDependencies {
|
||||
fetch: typeof globalThis.fetch;
|
||||
githubToken: string;
|
||||
dockerPath: string;
|
||||
runDocker: (args: string[], env: Record<string, string>) => Promise<CommandResult>;
|
||||
now: () => string;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function fail(message: string): never { throw new Error(message); }
|
||||
|
||||
export function parsePublicGhcrTarget(image: string, platform: string, githubRepository: string): PublicGhcrTarget {
|
||||
if (!REPOSITORY.test(githubRepository)) fail('INVALID_GITHUB_REPOSITORY');
|
||||
const platformMatch = PLATFORM.exec(platform);
|
||||
if (!platformMatch) fail('INVALID_PUBLIC_IMAGE_PLATFORM');
|
||||
const normalizedRepository = githubRepository.toLowerCase();
|
||||
const prefix = `ghcr.io/${normalizedRepository}/`;
|
||||
if (!image.startsWith(prefix)) fail('PUBLIC_IMAGE_REPOSITORY_MISMATCH');
|
||||
const at = image.lastIndexOf('@');
|
||||
if (at < prefix.length || image.indexOf('@') !== at) fail('PUBLIC_IMAGE_MUST_BE_DIGEST_PINNED');
|
||||
const suffix = image.slice(prefix.length, at), digest = image.slice(at + 1);
|
||||
if (!DIGEST.test(digest)) fail('PUBLIC_IMAGE_MUST_BE_DIGEST_PINNED');
|
||||
const [namespace, name, extra] = suffix.split('/');
|
||||
if (extra || !name || !['cso-staging', 'cso-scanners'].includes(namespace)) fail('INVALID_PUBLIC_IMAGE_PACKAGE');
|
||||
const nameMatch = namespace === 'cso-staging' ? RUNTIME.exec(name) : SCANNER.exec(name);
|
||||
if (!nameMatch || nameMatch[2] !== platformMatch[1]) fail('PUBLIC_IMAGE_PLATFORM_MISMATCH');
|
||||
const [owner, repository] = normalizedRepository.split('/');
|
||||
return {
|
||||
image,
|
||||
owner,
|
||||
repository,
|
||||
packageName: `${repository}/${namespace}/${name}`,
|
||||
digest,
|
||||
platform: platform as PublicGhcrTarget['platform'],
|
||||
};
|
||||
}
|
||||
|
||||
async function responseJson(response: Response, label: string): Promise<Record<string, unknown>> {
|
||||
const body = await response.text();
|
||||
if (body.length > MAX_HTTP_BYTES) fail(`${label}_RESPONSE_TOO_LARGE`);
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(body); } catch { fail(`${label}_INVALID_RESPONSE`); }
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) fail(`${label}_INVALID_RESPONSE`);
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function publicHeaders(githubToken: string): Record<string, string> {
|
||||
return {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'gstack-cso-public-image-verifier',
|
||||
};
|
||||
}
|
||||
|
||||
export function assertPublicPackageMetadata(value: Record<string, unknown>, target: PublicGhcrTarget): void {
|
||||
const owner = value.owner;
|
||||
if (value.name !== target.packageName || value.package_type !== 'container' || value.visibility !== 'public' ||
|
||||
!owner || typeof owner !== 'object' || Array.isArray(owner) ||
|
||||
typeof (owner as Record<string, unknown>).login !== 'string' ||
|
||||
((owner as Record<string, unknown>).login as string).toLowerCase() !== target.owner) {
|
||||
fail('GHCR_PACKAGE_IS_NOT_PUBLIC');
|
||||
}
|
||||
}
|
||||
|
||||
async function publicPackageApi(target: PublicGhcrTarget, deps: PublicGhcrDependencies): Promise<string> {
|
||||
const ownerUrl = `https://api.github.com/users/${encodeURIComponent(target.owner)}`;
|
||||
const ownerResponse = await deps.fetch(ownerUrl, { headers: publicHeaders(deps.githubToken), redirect: 'error', signal: AbortSignal.timeout(15_000) });
|
||||
if (ownerResponse.status !== 200) fail(`PUBLIC_OWNER_LOOKUP_FAILED: HTTP ${ownerResponse.status}`);
|
||||
const owner = await responseJson(ownerResponse, 'PUBLIC_OWNER_LOOKUP');
|
||||
if (typeof owner.login !== 'string' || owner.login.toLowerCase() !== target.owner || !['User', 'Organization'].includes(String(owner.type))) {
|
||||
fail('PUBLIC_OWNER_LOOKUP_IDENTITY_MISMATCH');
|
||||
}
|
||||
const collection = owner.type === 'Organization' ? 'orgs' : 'users';
|
||||
const packageUrl = `https://api.github.com/${collection}/${encodeURIComponent(target.owner)}/packages/container/${encodeURIComponent(target.packageName)}`;
|
||||
let lastStatus = 0;
|
||||
for (let attempt = 1; attempt <= 6; attempt++) {
|
||||
const response = await deps.fetch(packageUrl, { headers: publicHeaders(deps.githubToken), redirect: 'error', signal: AbortSignal.timeout(15_000) });
|
||||
lastStatus = response.status;
|
||||
if (response.status === 200) {
|
||||
assertPublicPackageMetadata(await responseJson(response, 'PUBLIC_PACKAGE_LOOKUP'), target);
|
||||
return packageUrl;
|
||||
}
|
||||
await response.body?.cancel();
|
||||
if (attempt < 6 && [404, 429, 500, 502, 503, 504].includes(response.status)) await deps.sleep(attempt * 2_000);
|
||||
else break;
|
||||
}
|
||||
fail(`GHCR_PACKAGE_IS_NOT_PUBLIC: authenticated GitHub Packages metadata lookup returned HTTP ${lastStatus}`);
|
||||
}
|
||||
|
||||
function cleanDockerEnvironment(configRoot: string, dockerHost: string): Record<string, string> {
|
||||
return {
|
||||
HOME: configRoot,
|
||||
DOCKER_CONFIG: configRoot,
|
||||
DOCKER_HOST: dockerHost,
|
||||
LANG: 'C.UTF-8',
|
||||
LC_ALL: 'C.UTF-8',
|
||||
PATH: '/usr/local/bin:/usr/bin:/bin',
|
||||
TMPDIR: configRoot,
|
||||
};
|
||||
}
|
||||
|
||||
async function defaultRunDocker(dockerPath: string, args: string[], env: Record<string, string>): Promise<CommandResult> {
|
||||
const process = Bun.spawn([dockerPath, ...args], { env, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' });
|
||||
const timeout = setTimeout(() => process.kill(), DOCKER_TIMEOUT_MS);
|
||||
const boundedText = async (stream: ReadableStream<Uint8Array>): Promise<string> => {
|
||||
const reader = stream.getReader(), chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_DOCKER_OUTPUT) {
|
||||
process.kill();
|
||||
await reader.cancel();
|
||||
fail('ANONYMOUS_DOCKER_OUTPUT_LIMIT');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks.map(chunk => Buffer.from(chunk))).toString('utf8');
|
||||
} finally { reader.releaseLock(); }
|
||||
};
|
||||
try {
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
boundedText(process.stdout),
|
||||
boundedText(process.stderr),
|
||||
process.exited,
|
||||
]);
|
||||
return { exitCode, stdout, stderr };
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
|
||||
function dockerError(label: string, result: CommandResult): never {
|
||||
const detail = result.stderr.trim().slice(0, 2048);
|
||||
fail(`${label}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
export async function verifyPublicGhcrImage(
|
||||
options: { image: string; platform: string; githubRepository: string; dockerHost?: string; removeAfter?: boolean },
|
||||
dependencies?: Partial<PublicGhcrDependencies>,
|
||||
): Promise<PublicImageProof> {
|
||||
const target = parsePublicGhcrTarget(options.image, options.platform, options.githubRepository);
|
||||
const dockerHost = options.dockerHost ?? 'unix:///var/run/docker.sock';
|
||||
if (dockerHost !== 'unix:///var/run/docker.sock') fail('UNTRUSTED_PUBLIC_IMAGE_DOCKER_HOST');
|
||||
const dockerPath = dependencies?.dockerPath ?? Bun.which('docker') ?? '';
|
||||
if (!path.isAbsolute(dockerPath)) fail('DOCKER_UNAVAILABLE');
|
||||
const githubToken = dependencies?.githubToken ?? process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? '';
|
||||
if (!githubToken || githubToken.length > 2048 || /[\r\n]/.test(githubToken)) fail('GITHUB_PACKAGE_METADATA_TOKEN_REQUIRED');
|
||||
const deps: PublicGhcrDependencies = {
|
||||
fetch: dependencies?.fetch ?? globalThis.fetch,
|
||||
githubToken,
|
||||
dockerPath,
|
||||
runDocker: dependencies?.runDocker ?? ((args, env) => defaultRunDocker(dockerPath, args, env)),
|
||||
now: dependencies?.now ?? (() => new Date().toISOString()),
|
||||
sleep: dependencies?.sleep ?? (ms => Bun.sleep(ms)),
|
||||
};
|
||||
const packageApiUrl = await publicPackageApi(target, deps);
|
||||
const configRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-cso-anonymous-docker-'));
|
||||
fs.chmodSync(configRoot, 0o700);
|
||||
fs.writeFileSync(path.join(configRoot, 'config.json'), '{"auths":{}}\n', { flag: 'wx', mode: 0o600 });
|
||||
const env = cleanDockerEnvironment(configRoot, dockerHost);
|
||||
let pulled = false;
|
||||
try {
|
||||
const pull = await deps.runDocker([
|
||||
'--config', configRoot, '--host', dockerHost, 'image', 'pull', '--quiet', '--platform', target.platform, target.image,
|
||||
], env);
|
||||
if (pull.exitCode !== 0) dockerError('ANONYMOUS_IMAGE_PULL_FAILED', pull);
|
||||
pulled = true;
|
||||
const inspect = await deps.runDocker([
|
||||
'--config', configRoot, '--host', dockerHost, 'image', 'inspect', target.image, '--format', '{{json .RepoDigests}}',
|
||||
], env);
|
||||
if (inspect.exitCode !== 0) dockerError('ANONYMOUS_IMAGE_INSPECT_FAILED', inspect);
|
||||
let repoDigests: unknown;
|
||||
try { repoDigests = JSON.parse(inspect.stdout.trim()); } catch { fail('ANONYMOUS_IMAGE_INSPECT_INVALID'); }
|
||||
if (!Array.isArray(repoDigests) || !repoDigests.includes(target.image)) fail('ANONYMOUS_IMAGE_DIGEST_MISMATCH');
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
image: target.image,
|
||||
platform: target.platform,
|
||||
packageName: target.packageName,
|
||||
packageVisibility: 'public',
|
||||
packageApiUrl,
|
||||
anonymousPull: 'passed',
|
||||
dockerConfig: 'isolated-empty-auths',
|
||||
verifiedAt: deps.now(),
|
||||
};
|
||||
} finally {
|
||||
if (pulled && options.removeAfter) {
|
||||
const remove = await deps.runDocker([
|
||||
'--config', configRoot, '--host', dockerHost, 'image', 'rm', '--force', target.image,
|
||||
], env);
|
||||
if (remove.exitCode !== 0) dockerError('ANONYMOUS_IMAGE_CLEANUP_FAILED', remove);
|
||||
}
|
||||
fs.rmSync(configRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function option(args: string[], name: string): string | undefined {
|
||||
const index = args.indexOf(name);
|
||||
if (index < 0) return undefined;
|
||||
const value = args[index + 1];
|
||||
if (!value || value.startsWith('--')) fail(`MISSING_${name.slice(2).toUpperCase().replaceAll('-', '_')}`);
|
||||
args.splice(index, 2);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
const args = process.argv.slice(2), command = args.shift();
|
||||
const image = option(args, '--image'), platform = option(args, '--platform'), repository = option(args, '--repository');
|
||||
const output = option(args, '--output'), dockerHost = option(args, '--docker-host');
|
||||
const removeAt = args.indexOf('--remove-after'), removeAfter = removeAt >= 0;
|
||||
if (removeAfter) args.splice(removeAt, 1);
|
||||
if (command !== 'verify' || !image || !platform || !repository || !output || args.length) {
|
||||
fail('Usage: cso-public-ghcr.ts verify --image IMAGE@sha256:DIGEST --platform linux/ARCH --repository OWNER/REPO --output PROOF.json [--docker-host unix:///var/run/docker.sock] [--remove-after]');
|
||||
}
|
||||
const proof = await verifyPublicGhcrImage({ image, platform, githubRepository: repository, dockerHost, removeAfter });
|
||||
fs.writeFileSync(path.resolve(output), JSON.stringify(proof, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||
process.stdout.write(`ANONYMOUS PULL VERIFIED ${proof.image}\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write((error instanceof Error ? error.message : 'PUBLIC_IMAGE_VERIFICATION_FAILED') + '\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Generate a reviewable catalog candidate from authenticated qualification statements. */
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { canonical, sha256 as sha256Hex } from '../lib/cso/contracts';
|
||||
import committedCatalog from '../lib/cso/runtime-catalog.json';
|
||||
import buildInputs from '../lib/cso/images/build-inputs.json';
|
||||
import {
|
||||
CSO_HELPER_ABI,
|
||||
type QualifiedRuntime,
|
||||
type RuntimeCatalog,
|
||||
type RuntimeQualification,
|
||||
validateRuntimeCatalog,
|
||||
} from '../lib/cso/runtime-catalog';
|
||||
import { imageBuildMatrix, type ImageBuildRow } from './cso-image-matrix';
|
||||
|
||||
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
||||
const WORKFLOW = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/[0-9]+$/;
|
||||
const OUTPUT_IMAGE = /^ghcr\.io\/garrytan\/gstack\/cso-staging\/(node|bun|python|rails|postgresql)-(amd64|arm64)@sha256:[a-f0-9]{64}$/;
|
||||
const MAX_EVIDENCE_FILES = 10;
|
||||
const MAX_EVIDENCE_BYTES = 1024 * 1024;
|
||||
|
||||
export interface RuntimeQualificationStatement {
|
||||
schemaVersion: 1;
|
||||
helperAbi: 3;
|
||||
state: 'qualified';
|
||||
buildRevision: string;
|
||||
runtimeId: string;
|
||||
stack: ImageBuildRow['stack'];
|
||||
platform: ImageBuildRow['platform'];
|
||||
image: string;
|
||||
versions: Record<string, string>;
|
||||
sourceCommit: string;
|
||||
workflow: string;
|
||||
qualifiedAt: string;
|
||||
sbomDigest: string;
|
||||
provenanceDigest: string;
|
||||
checks: Record<string, true>;
|
||||
}
|
||||
|
||||
function sha256(value: string | Buffer): string {
|
||||
return `sha256:${sha256Hex(value)}`;
|
||||
}
|
||||
|
||||
function versionsKey(value: Record<string, string>): string {
|
||||
return canonical(value);
|
||||
}
|
||||
|
||||
function requiredChecks(stack: ImageBuildRow['stack']): string[] {
|
||||
if (stack === 'postgresql') return [
|
||||
'containmentPassed', 'coldStartPassed', 'multiDatabasePassed', 'readinessPassed',
|
||||
'secretCanaryPassed', 'watchdogCleanupPassed',
|
||||
].sort();
|
||||
const common = [
|
||||
'accuracyGatesPassed', 'acquisitionPublicOnlyPassed', 'coldStartPassed', 'containmentPassed',
|
||||
'heldOutRepairPassed', 'offlineLifecyclePassed', 'positiveNegativeAssertionsPassed',
|
||||
'secretCanaryPassed', 'watchdogCleanupPassed',
|
||||
];
|
||||
return stack === 'rails'
|
||||
? [...common, 'nativeExtensionsPassed', 'railsPostgresqlPassed', 'railsSqlitePassed'].sort()
|
||||
: common.sort();
|
||||
}
|
||||
|
||||
function qualification(statement: RuntimeQualificationStatement): RuntimeQualification {
|
||||
if (statement.stack === 'postgresql') return {
|
||||
kind: 'postgresql',
|
||||
sourceCommit: statement.sourceCommit,
|
||||
workflow: statement.workflow,
|
||||
sbomDigest: statement.sbomDigest,
|
||||
provenanceDigest: statement.provenanceDigest,
|
||||
verifiedProvenance: true,
|
||||
containmentPassed: true,
|
||||
coldStartPassed: true,
|
||||
multiDatabasePassed: true,
|
||||
readinessPassed: true,
|
||||
};
|
||||
return {
|
||||
kind: 'application',
|
||||
sourceCommit: statement.sourceCommit,
|
||||
workflow: statement.workflow,
|
||||
sbomDigest: statement.sbomDigest,
|
||||
provenanceDigest: statement.provenanceDigest,
|
||||
verifiedProvenance: true,
|
||||
containmentPassed: true,
|
||||
coldStartPassed: true,
|
||||
positiveNegativeAssertionsPassed: true,
|
||||
heldOutRepairPassed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function validateStatement(raw: unknown, row: ImageBuildRow): RuntimeQualificationStatement {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('INVALID_QUALIFICATION_STATEMENT');
|
||||
const statement = raw as RuntimeQualificationStatement;
|
||||
const expectedFields = [
|
||||
'buildRevision', 'checks', 'helperAbi', 'image', 'platform', 'provenanceDigest',
|
||||
'qualifiedAt', 'runtimeId', 'sbomDigest', 'schemaVersion', 'sourceCommit', 'stack',
|
||||
'state', 'versions', 'workflow',
|
||||
];
|
||||
if (canonical(Object.keys(statement).sort()) !== canonical(expectedFields.sort())) throw new Error('INVALID_QUALIFICATION_STATEMENT_FIELDS');
|
||||
if (statement.schemaVersion !== 1 || statement.helperAbi !== CSO_HELPER_ABI || statement.state !== 'qualified') throw new Error('UNQUALIFIED_RUNTIME_EVIDENCE');
|
||||
if (statement.buildRevision !== row.inputRevision || statement.runtimeId !== row.runtimeId ||
|
||||
statement.stack !== row.stack || statement.platform !== row.platform ||
|
||||
versionsKey(statement.versions) !== versionsKey(row.versions)) throw new Error(`QUALIFICATION_BUILD_MISMATCH: ${row.runtimeId}`);
|
||||
if (!OUTPUT_IMAGE.test(statement.image) || !statement.image.includes(`/${row.stack}-${row.arch}@`)) throw new Error(`INVALID_QUALIFIED_IMAGE: ${row.runtimeId}`);
|
||||
if (!/^[a-f0-9]{40}$/.test(statement.sourceCommit) || !WORKFLOW.test(statement.workflow) ||
|
||||
!Number.isFinite(Date.parse(statement.qualifiedAt)) || !DIGEST.test(statement.sbomDigest) ||
|
||||
!DIGEST.test(statement.provenanceDigest)) throw new Error(`MISSING_QUALIFICATION_PROVENANCE: ${row.runtimeId}`);
|
||||
if (!statement.checks || typeof statement.checks !== 'object' || Array.isArray(statement.checks)) throw new Error(`MISSING_RELEASE_GATES: ${row.runtimeId}`);
|
||||
const expected = requiredChecks(row.stack);
|
||||
const actual = Object.keys(statement.checks).sort();
|
||||
if (canonical(actual) !== canonical(expected) || actual.some(key => statement.checks[key] !== true)) throw new Error(`MISSING_RELEASE_GATES: ${row.runtimeId}`);
|
||||
return statement;
|
||||
}
|
||||
|
||||
export function catalogPromotionCandidate(
|
||||
currentValue: unknown,
|
||||
inputValue: unknown,
|
||||
evidenceValues: unknown[],
|
||||
): RuntimeCatalog {
|
||||
validateRuntimeCatalog(currentValue);
|
||||
const current = currentValue as RuntimeCatalog;
|
||||
const rows = imageBuildMatrix(inputValue).include;
|
||||
if (!current.profiles || current.buildRevision !== rows[0].inputRevision) throw new Error('CATALOG_BUILD_REVISION_MISMATCH');
|
||||
const profiles = new Map(current.profiles.map(profile => [profile.id, profile]));
|
||||
if (profiles.size !== rows.length) throw new Error('CATALOG_PROFILE_MATRIX_MISMATCH');
|
||||
for (const row of rows) {
|
||||
const profile = profiles.get(row.runtimeId);
|
||||
if (!profile || profile.stack !== row.stack || profile.platform !== row.platform ||
|
||||
versionsKey(profile.versions) !== versionsKey(row.versions)) throw new Error(`CATALOG_PROFILE_MATRIX_MISMATCH: ${row.runtimeId}`);
|
||||
}
|
||||
if (evidenceValues.length !== rows.length) throw new Error('INCOMPLETE_QUALIFICATION_MATRIX');
|
||||
const byId = new Map<string, unknown>();
|
||||
for (const value of evidenceValues) {
|
||||
const id = (value as any)?.runtimeId;
|
||||
if (typeof id !== 'string' || byId.has(id)) throw new Error('DUPLICATE_QUALIFICATION_STATEMENT');
|
||||
byId.set(id, value);
|
||||
}
|
||||
const statements = rows.map(row => {
|
||||
if (!byId.has(row.runtimeId)) throw new Error(`MISSING_QUALIFICATION_STATEMENT: ${row.runtimeId}`);
|
||||
return validateStatement(byId.get(row.runtimeId), row);
|
||||
});
|
||||
const sourceCommits = new Set(statements.map(item => item.sourceCommit));
|
||||
const workflows = new Set(statements.map(item => item.workflow));
|
||||
if (sourceCommits.size !== 1 || workflows.size !== 1) throw new Error('SPLIT_QUALIFICATION_RUN');
|
||||
const sourceCommit = statements[0].sourceCommit;
|
||||
const workflow = statements[0].workflow;
|
||||
const runId = workflow.slice(workflow.lastIndexOf('/') + 1);
|
||||
const runtimes: QualifiedRuntime[] = statements.map(statement => ({
|
||||
id: statement.runtimeId,
|
||||
stack: statement.stack,
|
||||
platform: statement.platform,
|
||||
state: 'qualified',
|
||||
image: statement.image,
|
||||
entrypoint: '/opt/cso/entrypoint',
|
||||
helperAbi: CSO_HELPER_ABI,
|
||||
versions: statement.versions,
|
||||
policyVersion: 'cso-isolation-v1',
|
||||
qualifiedAt: statement.qualifiedAt,
|
||||
qualification: qualification(statement),
|
||||
}));
|
||||
const qualificationEvidenceDigest = sha256(canonical(statements));
|
||||
const evidenceDigest = sha256(canonical(runtimes));
|
||||
const candidate: RuntimeCatalog = {
|
||||
schemaVersion: 1,
|
||||
revision: `cso-v3-${runId}-${sourceCommit.slice(0, 12)}`,
|
||||
previousRevision: current.revision,
|
||||
helperAbi: CSO_HELPER_ABI,
|
||||
buildRevision: current.buildRevision,
|
||||
profiles: current.profiles,
|
||||
promotion: { sourceCommit, workflow, evidenceDigest, qualificationEvidenceDigest },
|
||||
runtimes,
|
||||
};
|
||||
validateRuntimeCatalog(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** A promotion may only replace the exact catalog revision it was generated from. */
|
||||
export function validateRuntimeCatalogTransition(currentValue: unknown, proposedValue: unknown): void {
|
||||
validateRuntimeCatalog(currentValue);
|
||||
validateRuntimeCatalog(proposedValue);
|
||||
const current = currentValue as RuntimeCatalog;
|
||||
const proposed = proposedValue as RuntimeCatalog;
|
||||
if (!proposed.promotion || proposed.runtimes.length === 0) throw new Error('UNQUALIFIED_RUNTIME_CATALOG_PROMOTION');
|
||||
if (proposed.revision === current.revision || proposed.previousRevision !== current.revision) {
|
||||
throw new Error('RUNTIME_CATALOG_BASE_REVISION_MISMATCH');
|
||||
}
|
||||
if (proposed.buildRevision !== current.buildRevision ||
|
||||
canonical(proposed.profiles) !== canonical(current.profiles)) {
|
||||
throw new Error('RUNTIME_CATALOG_PROFILE_TRANSITION_MISMATCH');
|
||||
}
|
||||
}
|
||||
|
||||
function collectEvidence(root: string): unknown[] {
|
||||
const absolute = path.resolve(root);
|
||||
const rootStat = fs.lstatSync(absolute);
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('UNSAFE_EVIDENCE_ROOT');
|
||||
const files: string[] = [];
|
||||
const visit = (directory: string, depth: number): void => {
|
||||
if (depth > 4) throw new Error('EVIDENCE_TREE_TOO_DEEP');
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) throw new Error('UNSAFE_EVIDENCE_ENTRY');
|
||||
if (entry.isDirectory()) visit(file, depth + 1);
|
||||
else if (entry.name === 'qualified-runtime.json') files.push(file);
|
||||
if (files.length > MAX_EVIDENCE_FILES) throw new Error('TOO_MANY_QUALIFICATION_STATEMENTS');
|
||||
}
|
||||
};
|
||||
visit(absolute, 0);
|
||||
return files.sort().map(file => {
|
||||
const before = fs.lstatSync(file);
|
||||
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > MAX_EVIDENCE_BYTES) throw new Error('UNSAFE_QUALIFICATION_STATEMENT');
|
||||
const body = fs.readFileSync(file, 'utf8');
|
||||
const after = fs.lstatSync(file);
|
||||
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size ||
|
||||
before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error('QUALIFICATION_EVIDENCE_RACE');
|
||||
try { return JSON.parse(body); } catch { throw new Error('INVALID_QUALIFICATION_STATEMENT'); }
|
||||
});
|
||||
}
|
||||
|
||||
function readCatalogArtifact(file: string): unknown {
|
||||
const absolute = path.resolve(file);
|
||||
const before = fs.lstatSync(absolute);
|
||||
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size <= 0 || before.size > MAX_EVIDENCE_BYTES) {
|
||||
throw new Error('UNSAFE_RUNTIME_CATALOG_ARTIFACT');
|
||||
}
|
||||
const body = fs.readFileSync(absolute, 'utf8');
|
||||
const after = fs.lstatSync(absolute);
|
||||
if (before.dev !== after.dev || before.ino !== after.ino || before.mode !== after.mode ||
|
||||
before.nlink !== after.nlink || before.size !== after.size || before.mtimeMs !== after.mtimeMs ||
|
||||
before.ctimeMs !== after.ctimeMs) throw new Error('RUNTIME_CATALOG_ARTIFACT_RACE');
|
||||
try { return JSON.parse(body); } catch { throw new Error('INVALID_RUNTIME_CATALOG_ARTIFACT'); }
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === 'validate-transition') {
|
||||
if (args.length !== 3) throw new Error('Usage: cso-runtime-promotion.ts validate-transition <current-catalog> <proposed-catalog>');
|
||||
validateRuntimeCatalogTransition(readCatalogArtifact(args[1]), readCatalogArtifact(args[2]));
|
||||
process.stdout.write('VALID TRANSITION\n');
|
||||
} else {
|
||||
if (args.length !== 4 || args[0] !== '--evidence-root' || args[2] !== '--output') {
|
||||
throw new Error('Usage: cso-runtime-promotion.ts --evidence-root <directory> --output <new-file>');
|
||||
}
|
||||
const candidate = catalogPromotionCandidate(committedCatalog, buildInputs, collectEvidence(args[1]));
|
||||
const output = path.resolve(args[3]);
|
||||
fs.writeFileSync(output, JSON.stringify(candidate, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||
process.stdout.write(`${output}\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
process.stderr.write((error instanceof Error ? error.message : 'RUNTIME_PROMOTION_FAILED') + '\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Assemble and validate a complete source-controlled scanner catalog proposal. */
|
||||
import * as fs from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { basename, join, relative, resolve, sep } from 'node:path';
|
||||
import { canonical, sha256 } from '../lib/cso/contracts';
|
||||
import { QualifiedScanner, ScannerCatalog, scannerVersionHash, validateQualifiedScanner, validateScannerCatalog } from '../lib/cso/scanner-catalog';
|
||||
import { SCANNER_IDS, scannerPlans } from '../lib/cso/scanners';
|
||||
|
||||
const PLATFORMS = ['linux/amd64', 'linux/arm64'] as const;
|
||||
const REVISION = /^[a-z0-9][a-z0-9._-]{0,100}$/;
|
||||
const WORKFLOW = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/actions\/runs\/[0-9]+$/;
|
||||
const MAX_ARTIFACT = 1_048_576;
|
||||
const MAX_TREE_BYTES = 10 * 1024 * 1024 * 1024;
|
||||
const MAX_TREE_ENTRIES = 100_000;
|
||||
|
||||
function sameFile(left: fs.Stats, right: fs.Stats): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink && left.size === right.size;
|
||||
}
|
||||
function hashRegularFile(path: string, expected: fs.Stats): string {
|
||||
const hash = createHash('sha256'), fd = fs.openSync(path, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
||||
try {
|
||||
const before = fs.fstatSync(fd);
|
||||
if (!before.isFile() || before.nlink !== 1 || !sameFile(expected, before)) throw new Error('UNSAFE_SCANNER_ASSET');
|
||||
const buffer = Buffer.alloc(1024 * 1024); let count = 0, bytes = 0;
|
||||
while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) {
|
||||
bytes += count;
|
||||
if (bytes > expected.size || bytes > MAX_TREE_BYTES) throw new Error('SCANNER_ASSET_LIMIT');
|
||||
hash.update(buffer.subarray(0, count));
|
||||
}
|
||||
const after = fs.fstatSync(fd), pathname = fs.lstatSync(path);
|
||||
if (bytes !== expected.size || !sameFile(before, after) || !sameFile(after, pathname)) throw new Error('UNSAFE_SCANNER_ASSET');
|
||||
return hash.digest('hex');
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
function sameStrings(left: string[], right: string[]): boolean {
|
||||
return canonical([...left].sort()) === canonical([...right].sort());
|
||||
}
|
||||
function exactKeys(value: Record<string, unknown>, allowed: string[], label: string): void {
|
||||
if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error(`UNEXPECTED_${label.toUpperCase()}_FIELD`);
|
||||
}
|
||||
function strictProfile(value: unknown): QualifiedScanner {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('INVALID_SCANNER_FRAGMENT');
|
||||
const profile = value as QualifiedScanner & Record<string, unknown>;
|
||||
exactKeys(profile, ['id', 'scanner', 'state', 'platform', 'image', 'entrypoint', 'executable', 'version', 'versionOutputSha256', 'helperAbi', 'isolationPolicyHash', 'capabilities', 'assets', 'qualifiedAt', 'qualification'], 'scanner_profile');
|
||||
if (!profile.qualification || typeof profile.qualification !== 'object' || Array.isArray(profile.qualification)) throw new Error('INVALID_SCANNER_QUALIFICATION');
|
||||
exactKeys(profile.qualification as unknown as Record<string, unknown>, ['sourceCommit', 'workflow', 'sbomDigest', 'provenanceDigest', 'verifiedProvenance', 'containmentPassed', 'adapterContractPassed', 'offlineAssetsPassed'], 'scanner_qualification');
|
||||
if (profile.assets !== undefined) {
|
||||
if (!profile.assets || typeof profile.assets !== 'object' || Array.isArray(profile.assets)) throw new Error('INVALID_SCANNER_ASSETS');
|
||||
exactKeys(profile.assets as unknown as Record<string, unknown>, ['semgrepRules', 'advisoryDatabase'], 'scanner_assets');
|
||||
for (const [name, asset] of Object.entries(profile.assets)) {
|
||||
if (!asset || typeof asset !== 'object' || Array.isArray(asset)) throw new Error('INVALID_SCANNER_ASSETS');
|
||||
exactKeys(asset as unknown as Record<string, unknown>, name === 'semgrepRules' ? ['path', 'sha256'] : ['path', 'contentSha256', 'updatedAt', 'ecosystems'], 'scanner_asset');
|
||||
}
|
||||
}
|
||||
validateQualifiedScanner(profile);
|
||||
const required = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [profile.scanner] })[0].requiredFeatures;
|
||||
if (!sameStrings(profile.capabilities, required)) throw new Error(`CAPABILITY_CONTRACT_MISMATCH: ${profile.scanner}`);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function scannerCatalogProposal(current: ScannerCatalog, fragments: unknown[], revision: string, expected: { sourceCommit: string; workflow: string; imagePrefix: string }): ScannerCatalog {
|
||||
validateScannerCatalog(current);
|
||||
if (!REVISION.test(revision) || revision === current.revision) throw new Error('INVALID_SCANNER_CATALOG_REVISION');
|
||||
if (!expected || !/^[a-f0-9]{40}$/.test(expected.sourceCommit)) throw new Error('INVALID_EXPECTED_SOURCE_COMMIT');
|
||||
if (!WORKFLOW.test(expected.workflow) || !expected.workflow.startsWith('https://github.com/garrytan/gstack/actions/runs/')) throw new Error('INVALID_EXPECTED_WORKFLOW');
|
||||
if (expected.imagePrefix !== 'ghcr.io/garrytan/gstack/cso-scanners/') throw new Error('INVALID_EXPECTED_IMAGE_PREFIX');
|
||||
const scanners = fragments.map(strictProfile), identities = new Set<string>();
|
||||
if (scanners.length !== SCANNER_IDS.length * PLATFORMS.length) throw new Error('INCOMPLETE_SCANNER_CATALOG_MATRIX');
|
||||
for (const profile of scanners) {
|
||||
const identity = `${profile.scanner}:${profile.platform}`;
|
||||
if (identities.has(identity)) throw new Error(`DUPLICATE_SCANNER_CATALOG_PROFILE: ${identity}`);
|
||||
identities.add(identity);
|
||||
if (profile.qualification.sourceCommit !== expected.sourceCommit) throw new Error(`SOURCE_COMMIT_MISMATCH: ${identity}`);
|
||||
if (profile.qualification.workflow !== expected.workflow) throw new Error(`WORKFLOW_IDENTITY_MISMATCH: ${identity}`);
|
||||
if (!profile.image.startsWith(expected.imagePrefix)) throw new Error(`IMAGE_REPOSITORY_MISMATCH: ${identity}`);
|
||||
}
|
||||
for (const scanner of SCANNER_IDS) for (const platform of PLATFORMS) if (!identities.has(`${scanner}:${platform}`)) throw new Error(`MISSING_SCANNER_CATALOG_PROFILE: ${scanner}:${platform}`);
|
||||
scanners.sort((a, b) => a.scanner.localeCompare(b.scanner) || a.platform.localeCompare(b.platform));
|
||||
const proposal: ScannerCatalog = {
|
||||
schemaVersion: 1,
|
||||
revision,
|
||||
previousRevision: current.revision,
|
||||
helperAbi: 3,
|
||||
promotion: { sourceCommit: expected.sourceCommit, workflow: expected.workflow, evidenceDigest: `sha256:${sha256(canonical(scanners))}` },
|
||||
scanners,
|
||||
};
|
||||
validateScannerCatalog(proposal);
|
||||
return proposal;
|
||||
}
|
||||
|
||||
/** Promotion is a compare-and-swap against the catalog revision reviewed by qualification. */
|
||||
export function validateScannerCatalogTransition(current: ScannerCatalog, proposed: ScannerCatalog): void {
|
||||
validateScannerCatalog(current); validateScannerCatalog(proposed);
|
||||
if (proposed.revision === current.revision || proposed.previousRevision !== current.revision)
|
||||
throw new Error('SCANNER_CATALOG_BASE_REVISION_MISMATCH');
|
||||
}
|
||||
|
||||
function readJson(path: string): unknown {
|
||||
const stat = fs.lstatSync(path);
|
||||
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > MAX_ARTIFACT) throw new Error(`UNSAFE_CATALOG_ARTIFACT: ${basename(path)}`);
|
||||
return JSON.parse(fs.readFileSync(path, 'utf8'));
|
||||
}
|
||||
function fragments(directory: string): unknown[] {
|
||||
const root = fs.realpathSync(directory), values: unknown[] = [];
|
||||
for (const name of fs.readdirSync(root).sort()) {
|
||||
if (!/^[a-z0-9._-]+\.json$/.test(name)) throw new Error(`UNSAFE_FRAGMENT_NAME: ${name}`);
|
||||
values.push(readJson(join(root, name)));
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** A file hashes as its bytes; a directory hashes a canonical regular-file inventory. */
|
||||
export function scannerAssetHash(path: string): string {
|
||||
const supplied = fs.lstatSync(path);
|
||||
if (supplied.isSymbolicLink()) throw new Error('UNSAFE_SCANNER_ASSET');
|
||||
const root = fs.realpathSync(path), initial = fs.lstatSync(root);
|
||||
if (initial.isFile()) {
|
||||
if (initial.nlink !== 1 || initial.size > MAX_TREE_BYTES) throw new Error('UNSAFE_SCANNER_ASSET');
|
||||
return hashRegularFile(root, initial);
|
||||
}
|
||||
if (!initial.isDirectory()) throw new Error('UNSAFE_SCANNER_ASSET');
|
||||
const entries: Array<[string, number, string]> = [], pending = [root]; let bytes = 0, objects = 0;
|
||||
while (pending.length) {
|
||||
const directory = pending.pop()!;
|
||||
for (const item of fs.readdirSync(directory).sort()) {
|
||||
const full = join(directory, item), stat = fs.lstatSync(full), rel = relative(root, full).split(sep).join('/');
|
||||
if (!rel || rel.startsWith('../') || stat.isSymbolicLink() || ++objects > MAX_TREE_ENTRIES) throw new Error('UNSAFE_SCANNER_ASSET');
|
||||
if (stat.isDirectory()) { pending.push(full); continue; }
|
||||
if (!stat.isFile() || stat.nlink !== 1) throw new Error('UNSAFE_SCANNER_ASSET');
|
||||
bytes += stat.size;
|
||||
if (bytes > MAX_TREE_BYTES) throw new Error('SCANNER_ASSET_LIMIT');
|
||||
entries.push([rel, stat.size, hashRegularFile(full, stat)]);
|
||||
}
|
||||
}
|
||||
if (!entries.length) throw new Error('EMPTY_SCANNER_ASSET');
|
||||
entries.sort(([left], [right]) => left.localeCompare(right));
|
||||
return sha256(canonical(entries));
|
||||
}
|
||||
|
||||
function option(args: string[], name: string): string | undefined {
|
||||
const at = args.indexOf(name); if (at < 0) return undefined;
|
||||
const value = args[at + 1]; if (!value || value.startsWith('--')) throw new Error(`MISSING_${name.slice(2).toUpperCase().replaceAll('-', '_')}`);
|
||||
args.splice(at, 2); return value;
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
const args = process.argv.slice(2), command = args.shift();
|
||||
if (command === 'validate') {
|
||||
const path = args.shift(); if (!path || args.length) throw new Error('Usage: validate CATALOG.json');
|
||||
const value = readJson(resolve(path)); validateScannerCatalog(value); process.stdout.write('VALID\n');
|
||||
} else if (command === 'validate-transition') {
|
||||
const current = args.shift(), proposed = args.shift(); if (!current || !proposed || args.length) throw new Error('Usage: validate-transition CURRENT.json PROPOSED.json');
|
||||
validateScannerCatalogTransition(readJson(resolve(current)) as ScannerCatalog, readJson(resolve(proposed)) as ScannerCatalog); process.stdout.write('VALID TRANSITION\n');
|
||||
} else if (command === 'assemble') {
|
||||
const directory = args.shift(), currentPath = args.shift(), output = args.shift(), revision = option(args, '--revision'), sourceCommit = option(args, '--source-commit'), workflow = option(args, '--workflow'), imagePrefix = option(args, '--image-prefix');
|
||||
if (!directory || !currentPath || !output || !revision || !sourceCommit || !workflow || !imagePrefix || args.length) throw new Error('Usage: assemble FRAGMENTS CURRENT OUTPUT --revision ID --source-commit SHA --workflow URL --image-prefix PREFIX');
|
||||
const proposal = scannerCatalogProposal(readJson(resolve(currentPath)) as ScannerCatalog, fragments(resolve(directory)), revision, { sourceCommit, workflow, imagePrefix });
|
||||
fs.writeFileSync(resolve(output), JSON.stringify(proposal, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
||||
process.stdout.write(`${proposal.scanners.length} QUALIFIED PROFILES\n`);
|
||||
} else if (command === 'hash-asset') {
|
||||
const path = args.shift(); if (!path || args.length) throw new Error('Usage: hash-asset PATH'); process.stdout.write(scannerAssetHash(resolve(path)) + '\n');
|
||||
} else if (command === 'version-hash') {
|
||||
const stdout = args.shift(), stderr = args.shift(); if (!stdout || !stderr || args.length) throw new Error('Usage: version-hash STDOUT STDERR');
|
||||
const read = (path: string) => { const stat = fs.lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 8192) throw new Error('UNSAFE_VERSION_OUTPUT'); return fs.readFileSync(path, 'utf8'); };
|
||||
process.stdout.write(scannerVersionHash(read(resolve(stdout)), read(resolve(stderr))) + '\n');
|
||||
} else throw new Error('Usage: cso-scanner-catalog <validate|validate-transition|assemble|hash-asset|version-hash> ...');
|
||||
} catch (error) {
|
||||
process.stderr.write((error instanceof Error ? error.message : 'SCANNER_CATALOG_ERROR') + '\n'); process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Validate reviewed scanner inputs before any image is built or published. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { SCANNER_IDS, ScannerId, scannerPlans } from '../lib/cso/scanners';
|
||||
|
||||
const PLATFORMS = ['linux/amd64', 'linux/arm64'] as const;
|
||||
const IMAGE = /^(?:[a-z0-9.-]+(?::[0-9]+)?\/)?[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/;
|
||||
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
||||
const HASH = /^[a-f0-9]{64}$/;
|
||||
const VERSION = /^[0-9][A-Za-z0-9.+_-]{0,100}$/;
|
||||
const EXECUTABLE = /^\/(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]+$/;
|
||||
const REPOSITORY = /^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
||||
const ID = /^[A-Za-z0-9][A-Za-z0-9._+/-]{0,100}$/;
|
||||
const SIGNER_WORKFLOW = /^(?:github\.com\/)?[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/\.github\/workflows\/[A-Za-z0-9_.\/-]+\.ya?ml$/;
|
||||
|
||||
type Platform = typeof PLATFORMS[number];
|
||||
type AssetDeclaration = {
|
||||
semgrepRules?: { path: string; sha256: string };
|
||||
advisoryDatabase?: { path: string; contentSha256: string; updatedAt: string; ecosystems: string[] };
|
||||
};
|
||||
export type ReviewedAttestedImage = {
|
||||
image: string;
|
||||
repository: string;
|
||||
sourceCommit: string;
|
||||
release: string;
|
||||
signerWorkflow: string;
|
||||
signerDigest: string;
|
||||
provenanceStatementDigest: string;
|
||||
sbomStatementDigest: string;
|
||||
};
|
||||
export interface ScannerBuildRow {
|
||||
scanner: ScannerId;
|
||||
platform: Platform;
|
||||
arch: 'amd64' | 'arm64';
|
||||
runner: 'ubuntu-24.04' | 'ubuntu-24.04-arm';
|
||||
baseImage: string;
|
||||
scannerExecutable: string;
|
||||
version: string;
|
||||
capabilities: string[];
|
||||
assets: AssetDeclaration | null;
|
||||
applicationExecutable: string;
|
||||
sbomGenerator: ReviewedAttestedImage;
|
||||
baseAttestation: ReviewedAttestedImage;
|
||||
}
|
||||
|
||||
function object(value: unknown, code: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(code);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function exact(value: Record<string, unknown>, allowed: string[], code: string): void {
|
||||
if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error(code);
|
||||
}
|
||||
function safePath(value: unknown, prefix: string, code: string): string {
|
||||
if (typeof value !== 'string' || !EXECUTABLE.test(value) || !value.startsWith(prefix) || value.split('/').includes('..')) throw new Error(code);
|
||||
return value;
|
||||
}
|
||||
function digest(value: unknown, code: string): string {
|
||||
if (typeof value !== 'string' || !DIGEST.test(value)) throw new Error(code);
|
||||
return value;
|
||||
}
|
||||
function attestedImage(value:unknown,code:string):ReviewedAttestedImage{
|
||||
const raw=object(value,code);exact(raw,['image','repository','sourceCommit','release','signerWorkflow','signerDigest','provenanceStatementDigest','sbomStatementDigest'],code);
|
||||
if(typeof raw.image!=='string'||!IMAGE.test(raw.image)||typeof raw.repository!=='string'||!REPOSITORY.test(raw.repository)||
|
||||
typeof raw.sourceCommit!=='string'||!/^[a-f0-9]{40}$/.test(raw.sourceCommit)||typeof raw.release!=='string'||!ID.test(raw.release)||
|
||||
typeof raw.signerWorkflow!=='string'||!SIGNER_WORKFLOW.test(raw.signerWorkflow)||typeof raw.signerDigest!=='string'||!/^[a-f0-9]{40}$/.test(raw.signerDigest))throw new Error(code);
|
||||
return{image:raw.image,repository:raw.repository,sourceCommit:raw.sourceCommit,release:raw.release,signerWorkflow:raw.signerWorkflow,signerDigest:raw.signerDigest,
|
||||
provenanceStatementDigest:digest(raw.provenanceStatementDigest,code),sbomStatementDigest:digest(raw.sbomStatementDigest,code)};
|
||||
}
|
||||
function assets(value: unknown, scanner: ScannerId): AssetDeclaration | null {
|
||||
if (value === undefined || value === null) {
|
||||
if (['semgrep', 'osv', 'trivy'].includes(scanner)) throw new Error(`MISSING_OFFLINE_ASSET: ${scanner}`);
|
||||
return null;
|
||||
}
|
||||
const raw = object(value, 'INVALID_SCANNER_ASSET'); exact(raw, ['semgrepRules', 'advisoryDatabase'], 'INVALID_SCANNER_ASSET');
|
||||
if (scanner === 'semgrep') {
|
||||
const rules = object(raw.semgrepRules, 'MISSING_SEMGREP_RULES'); exact(rules, ['path', 'sha256'], 'INVALID_SEMGREP_RULES');
|
||||
if (raw.advisoryDatabase !== undefined) throw new Error('INVALID_SEMGREP_RULES');
|
||||
const path = safePath(rules.path, '/policy/catalog/', 'INVALID_SEMGREP_RULES');
|
||||
if (typeof rules.sha256 !== 'string' || !HASH.test(rules.sha256)) throw new Error('INVALID_SEMGREP_RULES');
|
||||
return { semgrepRules: { path, sha256: rules.sha256 } };
|
||||
}
|
||||
if (scanner === 'osv' || scanner === 'trivy') {
|
||||
const db = object(raw.advisoryDatabase, 'MISSING_ADVISORY_DATABASE'); exact(db, ['path', 'contentSha256', 'updatedAt', 'ecosystems'], 'INVALID_ADVISORY_DATABASE');
|
||||
if (raw.semgrepRules !== undefined) throw new Error('INVALID_ADVISORY_DATABASE');
|
||||
const path = safePath(db.path, '/opt/cso/scanner-data/', 'INVALID_ADVISORY_DATABASE');
|
||||
if (typeof db.contentSha256 !== 'string' || !HASH.test(db.contentSha256) || typeof db.updatedAt !== 'string' || !Number.isFinite(Date.parse(db.updatedAt)) || !Array.isArray(db.ecosystems) || !db.ecosystems.length || new Set(db.ecosystems).size !== db.ecosystems.length || db.ecosystems.some(item => typeof item !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9.+_-]{0,63}$/.test(item))) throw new Error('INVALID_ADVISORY_DATABASE');
|
||||
return { advisoryDatabase: { path, contentSha256: db.contentSha256, updatedAt: new Date(db.updatedAt).toISOString(), ecosystems: (db.ecosystems as string[]).slice().sort() } };
|
||||
}
|
||||
throw new Error(`UNEXPECTED_OFFLINE_ASSET: ${scanner}`);
|
||||
}
|
||||
|
||||
/** Every scanner must have both native platforms; partial matrices cannot publish. */
|
||||
export function scannerBuildMatrix(input: unknown): { include: ScannerBuildRow[] } {
|
||||
const data = object(input, 'INVALID_SCANNER_BUILD_INPUTS');
|
||||
exact(data, ['schemaVersion', 'helperAbi', 'state', 'sbomGenerator', 'profiles', 'instructions'], 'INVALID_SCANNER_BUILD_INPUTS');
|
||||
if (data.schemaVersion !== 1 || data.helperAbi !== 3) throw new Error('INCOMPATIBLE_SCANNER_BUILD_INPUTS');
|
||||
if (data.state !== 'reviewed') throw new Error('MISSING_REVIEWED_SCANNER_INPUTS: review immutable images, assets, versions, SBOMs, and provenance before staging.');
|
||||
const sbomGenerator=attestedImage(data.sbomGenerator,'UNVERIFIED_SBOM_GENERATOR');
|
||||
if (!Array.isArray(data.profiles) || data.profiles.length !== SCANNER_IDS.length) throw new Error('INCOMPLETE_SCANNER_MATRIX');
|
||||
const rows: ScannerBuildRow[] = [], seen = new Set<ScannerId>();
|
||||
for (const value of data.profiles) {
|
||||
const profile = object(value, 'INVALID_SCANNER_PROFILE');
|
||||
exact(profile, ['scanner', 'version', 'baseImages', 'executable', 'assets', 'applicationExecutable'], 'INVALID_SCANNER_PROFILE');
|
||||
const scanner = profile.scanner as ScannerId;
|
||||
if (!SCANNER_IDS.includes(scanner) || seen.has(scanner)) throw new Error('INVALID_OR_DUPLICATE_SCANNER');
|
||||
seen.add(scanner);
|
||||
if (typeof profile.version !== 'string' || !VERSION.test(profile.version)) throw new Error(`UNPINNED_SCANNER_VERSION: ${scanner}`);
|
||||
if (typeof profile.executable !== 'string' || !EXECUTABLE.test(profile.executable)) throw new Error(`INVALID_SCANNER_EXECUTABLE: ${scanner}`);
|
||||
const baseImages = object(profile.baseImages, `MISSING_SCANNER_IMAGES: ${scanner}`);
|
||||
exact(baseImages, [...PLATFORMS], `INVALID_SCANNER_IMAGES: ${scanner}`);
|
||||
const declaredAssets = assets(profile.assets, scanner);
|
||||
const applicationExecutable = scanner === 'schemathesis' ? safePath(profile.applicationExecutable, '/', 'MISSING_SCHEMATHESIS_FIXTURE_RUNTIME') : '';
|
||||
if (scanner !== 'schemathesis' && profile.applicationExecutable !== undefined) throw new Error(`UNEXPECTED_APPLICATION_EXECUTABLE: ${scanner}`);
|
||||
const capabilities = scannerPlans({ snapshotRoot: '/source', offline: true, selected: [scanner] })[0].requiredFeatures.slice().sort();
|
||||
for (const platform of PLATFORMS) {
|
||||
const baseAttestation=attestedImage(baseImages[platform],`INVALID_UPSTREAM_EVIDENCE: ${scanner} ${platform}`),baseImage=baseAttestation.image;
|
||||
const arch = platform === 'linux/amd64' ? 'amd64' : 'arm64';
|
||||
rows.push({ scanner, platform, arch, runner: arch === 'amd64' ? 'ubuntu-24.04' : 'ubuntu-24.04-arm', baseImage, scannerExecutable: profile.executable, version: profile.version, capabilities, assets: declaredAssets, applicationExecutable, sbomGenerator, baseAttestation });
|
||||
}
|
||||
}
|
||||
return { include: rows };
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
if (process.argv.length !== 2) throw new Error('No arguments accepted; scanner inputs come from the reviewed repository file.');
|
||||
const file = resolve(import.meta.dir, '../lib/cso/scanner-images/build-inputs.json');
|
||||
process.stdout.write(JSON.stringify(scannerBuildMatrix(JSON.parse(readFileSync(file, 'utf8')))) + '\n');
|
||||
} catch (error) {
|
||||
process.stderr.write((error instanceof Error ? error.message : 'INVALID_SCANNER_BUILD_INPUTS') + '\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bun
|
||||
/** Native trusted-CI verification for reviewed OCI build inputs. */
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { committedImageBuildMatrix, type ImageBuildRow } from './cso-image-matrix';
|
||||
|
||||
const MAX_OUTPUT = 1024 * 1024;
|
||||
const TIMEOUT = 120_000;
|
||||
|
||||
interface VersionProbe {
|
||||
image: string;
|
||||
executable: string;
|
||||
args: string[];
|
||||
version: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function assertVersionOutput(name: string, expected: string, output: string): void {
|
||||
if (Buffer.byteLength(output) > 64 * 1024) throw new Error(`VERSION_OUTPUT_TOO_LARGE: ${name}`);
|
||||
const first = output.match(/(?<!\d)(\d+\.\d+(?:\.\d+)?)(?![\d.])/);
|
||||
if (!first || first[1] !== expected) throw new Error(`RUNTIME_VERSION_MISMATCH: ${name} expected ${expected}`);
|
||||
}
|
||||
|
||||
export function probesForBuildRow(row: ImageBuildRow): VersionProbe[] {
|
||||
const base = row.baseImage;
|
||||
switch (row.stack) {
|
||||
case 'node':
|
||||
return [
|
||||
{ image: base, executable: '/usr/local/bin/node', args: ['--version'], version: row.versions.node, name: 'node' },
|
||||
{ image: base, executable: '/usr/local/bin/npm', args: ['--version'], version: row.versions.npm, name: 'npm' },
|
||||
];
|
||||
case 'bun':
|
||||
return [{ image: base, executable: '/usr/local/bin/bun', args: ['--version'], version: row.versions.bun, name: 'bun' }];
|
||||
case 'python':
|
||||
return [
|
||||
{ image: base, executable: '/usr/local/bin/python', args: ['--version'], version: row.versions.python, name: 'python' },
|
||||
{ image: row.uvImage, executable: '/uv', args: ['--version'], version: row.versions.uv, name: 'uv' },
|
||||
];
|
||||
case 'rails':
|
||||
return [
|
||||
{ image: base, executable: '/usr/local/bin/ruby', args: ['--version'], version: row.versions.ruby, name: 'ruby' },
|
||||
{ image: base, executable: '/usr/local/bin/bundle', args: ['--version'], version: row.versions.bundler, name: 'bundler' },
|
||||
];
|
||||
case 'postgresql':
|
||||
return [{ image: base, executable: '/usr/lib/postgresql/17/bin/postgres', args: ['--version'], version: row.versions.postgresql, name: 'postgresql' }];
|
||||
}
|
||||
}
|
||||
|
||||
function command(docker: string, args: string[], timeout = TIMEOUT): string {
|
||||
const env: Record<string, string> = { PATH: process.env.PATH ?? '/usr/bin:/bin' };
|
||||
if (process.env.HOME) env.HOME = process.env.HOME;
|
||||
if (process.env.DOCKER_CONFIG) env.DOCKER_CONFIG = process.env.DOCKER_CONFIG;
|
||||
const result = spawnSync(docker, args, { encoding: 'utf8', timeout, maxBuffer: MAX_OUTPUT, env });
|
||||
if (result.error || result.status !== 0) {
|
||||
const message = [result.error?.message, result.stderr, result.stdout].filter(Boolean).join('\n').slice(0, 4096);
|
||||
throw new Error(`DOCKER_INPUT_VERIFICATION_FAILED: ${args[0]} ${message}`);
|
||||
}
|
||||
if (Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr) > MAX_OUTPUT) throw new Error('DOCKER_INPUT_VERIFICATION_OUTPUT_TOO_LARGE');
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function inspectManifest(docker: string, image: string): any {
|
||||
const raw = command(docker, ['buildx', 'imagetools', 'inspect', image, '--format', '{{json .Manifest}}']);
|
||||
try { return JSON.parse(raw); } catch { throw new Error(`INVALID_OCI_MANIFEST: ${image}`); }
|
||||
}
|
||||
|
||||
function inspectImage(docker: string, image: string): any {
|
||||
const raw = command(docker, ['buildx', 'imagetools', 'inspect', image, '--format', '{{json .Image}}']);
|
||||
try { return JSON.parse(raw); } catch { throw new Error(`INVALID_OCI_IMAGE_CONFIG: ${image}`); }
|
||||
}
|
||||
|
||||
function digestOf(image: string): string {
|
||||
return image.slice(image.lastIndexOf('@') + 1);
|
||||
}
|
||||
|
||||
function assertSourceIndex(docker: string, source: string, indexImage: string, platformImage: string, row: ImageBuildRow): void {
|
||||
const sourceManifest = inspectManifest(docker, source);
|
||||
if (sourceManifest?.digest !== digestOf(indexImage)) throw new Error(`SOURCE_TAG_MOVED: ${source}`);
|
||||
const indexManifest = inspectManifest(docker, indexImage);
|
||||
const platformDigest = digestOf(platformImage);
|
||||
const match = indexManifest?.manifests?.find((manifest: any) =>
|
||||
manifest?.digest === platformDigest && manifest?.platform?.os === 'linux' && manifest?.platform?.architecture === row.arch);
|
||||
if (!match) throw new Error(`PLATFORM_MANIFEST_NOT_IN_INDEX: ${platformImage}`);
|
||||
const config = inspectImage(docker, platformImage);
|
||||
if (config?.os !== 'linux' || config?.architecture !== row.arch) throw new Error(`PLATFORM_CONFIG_MISMATCH: ${platformImage}`);
|
||||
}
|
||||
|
||||
export function verifyRuntimeBuildRow(row: ImageBuildRow, docker = Bun.which('docker')): void {
|
||||
if (!docker || !docker.startsWith('/')) throw new Error('TRUSTED_DOCKER_NOT_FOUND');
|
||||
command(docker, ['--host', 'unix:///var/run/docker.sock', 'info'], 30_000);
|
||||
assertSourceIndex(docker, row.baseSource, row.baseIndexImage, row.baseImage, row);
|
||||
assertSourceIndex(docker, row.sbomGeneratorSource, row.sbomGeneratorIndexImage, row.sbomGeneratorImage, row);
|
||||
if (row.stack === 'python') assertSourceIndex(docker, row.uvSource, row.uvIndexImage, row.uvImage, row);
|
||||
const images = [...new Set(probesForBuildRow(row).map(probe => probe.image))];
|
||||
for (const image of images) command(docker, ['--host', 'unix:///var/run/docker.sock', 'pull', '--platform', row.platform, image], 5 * 60_000);
|
||||
for (const probe of probesForBuildRow(row)) {
|
||||
const output = command(docker, [
|
||||
'--host', 'unix:///var/run/docker.sock', 'run', '--rm', '--pull', 'never', '--network', 'none',
|
||||
'--entrypoint', probe.executable, probe.image, ...probe.args,
|
||||
], 30_000);
|
||||
assertVersionOutput(probe.name, probe.version, output);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
try {
|
||||
if (process.argv.length !== 2) throw new Error('No arguments accepted; the selected row comes from committed inputs and trusted CI environment.');
|
||||
const stack = process.env.CSO_STACK;
|
||||
const platform = process.env.CSO_PLATFORM;
|
||||
const rows = committedImageBuildMatrix().include.filter(row => row.stack === stack && row.platform === platform);
|
||||
if (rows.length !== 1) throw new Error('INVALID_RUNTIME_BUILD_ROW');
|
||||
verifyRuntimeBuildRow(rows[0]);
|
||||
process.stdout.write(`verified ${rows[0].runtimeId} base manifests and versions\n`);
|
||||
} catch (error) {
|
||||
process.stderr.write((error instanceof Error ? error.message : 'RUNTIME_BASE_VERIFICATION_FAILED') + '\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,11 @@ import { discoverTemplates, discoverSkillFiles } from './discover-skills';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { ALL_HOST_CONFIGS, getExternalHosts, getHostConfig } from '../hosts/index';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const ROOT_REALPATH = fs.realpathSync(ROOT);
|
||||
const CLAUDE_SKIPS = new Set(getHostConfig('claude').generation.skipSkills ?? []);
|
||||
|
||||
function isRepoRootSymlink(candidateDir: string): boolean {
|
||||
try {
|
||||
@@ -75,6 +77,11 @@ for (const { tmpl, output } of TEMPLATES) {
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(outPath)) {
|
||||
const skillDir = output === 'SKILL.md' ? '' : output.split('/')[0];
|
||||
if (CLAUDE_SKIPS.has(skillDir)) {
|
||||
console.log(` - ${output.padEnd(30)} — intentionally omitted for Claude Code`);
|
||||
continue;
|
||||
}
|
||||
hasErrors = true;
|
||||
console.log(` \u274c ${output.padEnd(30)} — generated file missing! Run: bun run gen:skill-docs`);
|
||||
continue;
|
||||
@@ -92,8 +99,6 @@ for (const file of SKILL_FILES) {
|
||||
|
||||
// ─── External Host Skills (config-driven) ───────────────────
|
||||
|
||||
import { getExternalHosts, getHostConfig } from '../hosts/index';
|
||||
|
||||
for (const hostConfig of getExternalHosts()) {
|
||||
const hostDir = path.join(ROOT, hostConfig.hostSubdir, 'skills');
|
||||
if (fs.existsSync(hostDir)) {
|
||||
@@ -132,8 +137,6 @@ for (const hostConfig of getExternalHosts()) {
|
||||
|
||||
// ─── Freshness (config-driven) ──────────────────────────────
|
||||
|
||||
import { ALL_HOST_CONFIGS } from '../hosts/index';
|
||||
|
||||
for (const hostConfig of ALL_HOST_CONFIGS) {
|
||||
const hostFlag = hostConfig.name === 'claude' ? '' : ` --host ${hostConfig.name}`;
|
||||
console.log(`\n Freshness (${hostConfig.displayName}):`);
|
||||
|
||||
@@ -252,6 +252,34 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }>
|
||||
file: 'browse/test/security-audit-r2.test.ts',
|
||||
reason: 'symlink-attack fixtures (evil-link) need Developer Mode CI runners lack; expect(toThrow) fires unhandled on Windows',
|
||||
},
|
||||
// CSO comprehensive execution is qualified only for Linux containers behind
|
||||
// the POSIX watchdog and Unix-domain registry broker. Keep the portable
|
||||
// static/parser contracts in the Windows lane while leaving these exact
|
||||
// containment suites to the Linux and macOS gates.
|
||||
{
|
||||
file: 'test/cso-preparation-adversarial.test.ts',
|
||||
reason: 'exercises POSIX prepared-tree and archive-cache containment for qualified Linux Docker execution, which Windows does not admit',
|
||||
},
|
||||
{
|
||||
file: 'test/cso-preparation-container.test.ts',
|
||||
reason: 'asserts POSIX permission and symlink semantics for inert exports consumed by qualified Linux Docker execution',
|
||||
},
|
||||
{
|
||||
file: 'test/cso-preparation-executor.test.ts',
|
||||
reason: 'executes the Linux Docker acquisition path and its Unix-domain registry broker; comprehensive execution is unavailable on Windows',
|
||||
},
|
||||
{
|
||||
file: 'test/cso-verification-cleanup.test.ts',
|
||||
reason: 'spawns the POSIX detached watchdog used by contained repair verification, which Windows intentionally leaves unavailable',
|
||||
},
|
||||
{
|
||||
file: 'test/cso-witness.test.ts',
|
||||
reason: 'tests the contained repair witness with POSIX private-directory and compiled-helper assumptions; comprehensive execution is unavailable on Windows',
|
||||
},
|
||||
{
|
||||
file: 'test/cso-scanner-cli.test.ts',
|
||||
reason: 'drives the prebuilt POSIX CSO launcher with /usr/bin/git and a POSIX-only PATH; native Windows launcher behavior is covered by the dedicated cso-windows-launcher gate',
|
||||
},
|
||||
];
|
||||
|
||||
// Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for
|
||||
|
||||
Reference in New Issue
Block a user