diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e86139a..6ca6d51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,15 @@ jobs: shell: pwsh run: ./skills/scripts/verify-routing-coherence.ps1 + - name: Bootstrap supply-chain regression + shell: pwsh + run: ./skills/scripts/test-bootstrap-supply-chain.ps1 + + - name: Bootstrap supply-chain regression (Windows PowerShell 5.1) + if: runner.os == 'Windows' + shell: powershell + run: ./skills/scripts/test-bootstrap-supply-chain.ps1 + - name: Smoke (verify + parse + quick route) shell: pwsh run: ./skills/scripts/smoke.ps1 @@ -72,6 +81,7 @@ jobs: run: | set -euo pipefail bash skills/scripts/test-routing.sh + bash skills/scripts/test-bootstrap-manifest.sh scratch="$(mktemp -d)" trap 'rm -rf "$scratch"' EXIT diff --git a/.gitignore b/.gitignore index 8073f7e..7254c4a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ Desktop.ini *.tmp *.bak *.log +implementation-notes.md # User-specific config .claude/ diff --git a/kali/scripts/bootstrap-manifest.json b/kali/scripts/bootstrap-manifest.json index 226774b..85d94f8 100644 --- a/kali/scripts/bootstrap-manifest.json +++ b/kali/scripts/bootstrap-manifest.json @@ -23,6 +23,16 @@ "metasploitmcp — Metasploit MCP Server (apt install metasploitmcp, port 8085)", "hexstrike-ai — 150+ 安全工具 MCP 自动化 (apt install hexstrike-ai)" ], + "bootstrapDependencies": { + "pipx": { + "package": "pipx==1.16.5", + "version": "1.16.5" + }, + "pnpm": { + "package": "pnpm@10.24.0", + "version": "10.24.0" + } + }, "capabilities": [ { "name": "jadx", @@ -156,6 +166,7 @@ "servicePort": 23816, "docsUrl": "https://github.com/Mouseww/anything-analyzer", "canAutoInstall": true, + "pinnedCommit": "0ed4791688e5186da051c85eb7ddfe4639e14fd2", "verificationMode": "service-or-registration" }, { diff --git a/kali/scripts/bootstrap-reverse.sh b/kali/scripts/bootstrap-reverse.sh index c8cf1f6..b9ff643 100644 --- a/kali/scripts/bootstrap-reverse.sh +++ b/kali/scripts/bootstrap-reverse.sh @@ -22,6 +22,7 @@ CAPABILITIES=() START_SERVICES=false SKIP_REFRESH=false MANUAL_REQUIRED=false +FAILED=false LAST_CAPABILITY_MANUAL=false for arg in "$@"; do @@ -131,13 +132,24 @@ install_git_commit() { local install_dir="$3" if [[ -d "$install_dir/.git" ]]; then - local current - current=$(git -C "$install_dir" rev-parse HEAD 2>/dev/null || true) + local current status + if ! current=$(git -C "$install_dir" rev-parse HEAD 2>/dev/null); then + log_err "无法解析现有 checkout HEAD: $install_dir" + return 1 + fi if [[ "$current" != "$commit" ]]; then log_err "Existing checkout is not at pinned commit $commit: $install_dir" log_err "Move it aside explicitly, then retry; bootstrap will not overwrite local changes." return 1 fi + if ! status=$(git -C "$install_dir" status --porcelain --untracked-files=all); then + log_err "无法检查 checkout 状态: $install_dir" + return 1 + fi + if [[ -n "$status" ]]; then + log_err "现有 checkout 含本地修改,拒绝执行: $install_dir" + return 1 + fi return 0 fi if [[ -e "$install_dir" ]]; then @@ -145,15 +157,33 @@ install_git_commit() { return 1 fi - mkdir -p "$(dirname "$install_dir")" - git init -q "$install_dir" - git -C "$install_dir" remote add origin "$repo" - git -C "$install_dir" fetch --depth 1 origin "$commit" - git -C "$install_dir" checkout -q --detach FETCH_HEAD - local resolved - resolved=$(git -C "$install_dir" rev-parse HEAD) + local parent stage resolved status + parent=$(dirname "$install_dir") + mkdir -p "$parent" + stage=$(mktemp -d "$parent/.reverse-bootstrap-XXXXXX") || return 1 + if ! git init -q "$stage" || + ! git -C "$stage" remote add origin "$repo" || + ! git -C "$stage" fetch --depth 1 origin "$commit" || + ! git -C "$stage" checkout -q --detach FETCH_HEAD; then + rm -rf "$stage" + return 1 + fi + if ! resolved=$(git -C "$stage" rev-parse HEAD); then + rm -rf "$stage" + return 1 + fi if [[ "$resolved" != "$commit" ]]; then log_err "Pinned checkout verification failed (expected $commit, got $resolved)" + rm -rf "$stage" + return 1 + fi + if ! status=$(git -C "$stage" status --porcelain --untracked-files=all) || [[ -n "$status" ]]; then + log_err "Staged checkout is not clean: $stage" + rm -rf "$stage" + return 1 + fi + if ! mv -T "$stage" "$install_dir"; then + rm -rf "$stage" return 1 fi } @@ -330,6 +360,13 @@ manifest_field() { '.capabilities[] | select(.name == $name) | .[$field] // empty' "$KALI_MANIFEST" } +manifest_dependency() { + local name="$1" + local field="$2" + jq -er --arg name "$name" --arg field "$field" \ + '.bootstrapDependencies[$name][$field] // empty' "$KALI_MANIFEST" +} + install_manifest_release() { local capability="$1" local repo asset_regex install_dir release_tag asset_sha256 @@ -624,23 +661,30 @@ EOF # ─── 服务启动 ────────────────────────────────────────────────────────────────────── start_anything_analyzer() { + local repo_dir="$HOME/tools/anything-analyzer" + local repo commit + repo=$(manifest_field anything-analyzer repoUrl) + commit=$(manifest_field anything-analyzer pinnedCommit) + install_git_commit "$repo" "$commit" "$repo_dir" || return 1 + if test_tcp_port 23816 2>/dev/null; then log_ok "anything-analyzer 已在运行 (port 23816)" return 0 fi - local repo_dir="$HOME/tools/anything-analyzer" - - if [[ ! -d "$repo_dir" ]]; then - log_info "克隆 anything-analyzer ..." - git clone https://github.com/Mouseww/anything-analyzer.git "$repo_dir" + local pnpm_package pnpm_version current_pnpm_version='' + pnpm_package=$(manifest_dependency pnpm package) || return 1 + pnpm_version=$(manifest_dependency pnpm version) || return 1 + if command -v pnpm &>/dev/null; then + current_pnpm_version=$(pnpm --version 2>/dev/null | head -n1 | tr -d '[:space:]') + fi + if [[ "$current_pnpm_version" != "$pnpm_version" ]]; then + npm install -g "$pnpm_package" || return 1 fi - if ! command -v pnpm &>/dev/null; then - npm install -g pnpm - fi - - (cd "$repo_dir" && pnpm install && nohup pnpm dev > /tmp/anything-analyzer.log 2>&1 &) + (cd "$repo_dir" && pnpm install --frozen-lockfile) || return 1 + install_git_commit "$repo" "$commit" "$repo_dir" || return 1 + (cd "$repo_dir" && nohup pnpm dev > /tmp/anything-analyzer.log 2>&1 &) log_info "等待 anything-analyzer 启动 (port 23816) ..." if wait_for_port 23816 120; then @@ -681,6 +725,7 @@ for cap in "${CAPABILITIES[@]}"; do fi else RESULTS+=("{\"name\":\"$cap\",\"status\":\"failed\"}") + FAILED=true fi done @@ -691,7 +736,9 @@ if [[ "$SKIP_REFRESH" != "true" ]]; then fi final_exit_code=0 -if [[ "$MANUAL_REQUIRED" == "true" ]]; then +if [[ "$FAILED" == "true" ]]; then + final_exit_code=1 +elif [[ "$MANUAL_REQUIRED" == "true" ]]; then final_exit_code=2 fi diff --git a/skills/scripts/bootstrap-manifest.json b/skills/scripts/bootstrap-manifest.json index 6d7b8e5..b693856 100644 --- a/skills/scripts/bootstrap-manifest.json +++ b/skills/scripts/bootstrap-manifest.json @@ -1,4 +1,14 @@ { + "bootstrapDependencies": { + "pipx": { + "package": "pipx==1.16.5", + "version": "1.16.5" + }, + "pnpm": { + "package": "pnpm@10.24.0", + "version": "10.24.0" + } + }, "capabilities": [ { "name": "jadx", @@ -127,6 +137,7 @@ "servicePort": 23816, "docsUrl": "https://github.com/Mouseww/anything-analyzer", "canAutoInstall": true, + "pinnedCommit": "0ed4791688e5186da051c85eb7ddfe4639e14fd2", "verificationMode": "service-or-registration" }, { diff --git a/skills/scripts/bootstrap-reverse.ps1 b/skills/scripts/bootstrap-reverse.ps1 index 32e0b60..0c12862 100644 --- a/skills/scripts/bootstrap-reverse.ps1 +++ b/skills/scripts/bootstrap-reverse.ps1 @@ -23,6 +23,18 @@ $ErrorActionPreference = 'Stop' $OutputEncoding = [System.Text.UTF8Encoding]::new($false) . (Join-Path $PSScriptRoot 'lib\ToolDiscovery.ps1') +. (Join-Path $PSScriptRoot 'lib\BootstrapSupplyChain.ps1') + +function Get-BootstrapDependency { + param([Parameter(Mandatory = $true)][string]$Name) + + $manifest = Get-Content -LiteralPath (Get-ReverseBootstrapManifestPath) -Raw -Encoding UTF8 | ConvertFrom-Json + $dependency = $manifest.bootstrapDependencies.PSObject.Properties[$Name].Value + if ($null -eq $dependency -or [string]::IsNullOrWhiteSpace([string]$dependency.package) -or [string]::IsNullOrWhiteSpace([string]$dependency.version)) { + throw "bootstrapDependencies.$Name must define package and version." + } + return $dependency +} $Capability = @( foreach ($item in @($Capability)) { @@ -153,20 +165,6 @@ function Ensure-JavaRuntime { } } -function Ensure-Pnpm { - Ensure-NodeRuntime - if (-not (Get-NodeCommandPath -Name 'pnpm')) { - $npm = Get-NodeCommandPath -Name 'npm' - if ([string]::IsNullOrWhiteSpace($npm)) { - throw 'npm is not available after Node.js installation.' - } - & $npm install -g pnpm - if ($LASTEXITCODE -ne 0) { - throw 'Failed to install pnpm globally.' - } - } -} - function Get-AnythingAnalyzerUserDataPaths { $candidates = @( (Join-Path $env:APPDATA 'anything-analyzer'), @@ -341,34 +339,11 @@ function Set-AnythingAnalyzerPnpmBuildApprovals { function Approve-AnythingAnalyzerBuildScripts { param( - [Parameter(Mandatory = $true)][string]$RepoDir, - [Parameter(Mandatory = $true)][string]$PnpmPath + [Parameter(Mandatory = $true)][string]$RepoDir ) $buildPackages = @('electron', 'esbuild', 'better-sqlite3') - Push-Location $RepoDir - try { - $approveExitCode = 1 - try { - $approveOutput = & $PnpmPath approve-builds --all 2>&1 - $approveExitCode = $LASTEXITCODE - } - catch { - $approveOutput = $_.Exception.Message - $approveExitCode = 1 - } - - if ($approveExitCode -eq 0) { - return - } - - Write-Warning 'pnpm approve-builds --all is unavailable or failed; writing pnpm-workspace.yaml build approvals directly.' - } - finally { - Pop-Location - } - Set-AnythingAnalyzerPnpmBuildApprovals -RepoDir $RepoDir -Packages $buildPackages } @@ -777,6 +752,13 @@ function Start-AnythingAnalyzerService { $AuthToken = Ensure-AnythingAnalyzerMcpConfig -Port ([int]$Definition.servicePort) } + $repoDir = [string]$Definition.installDir + $checkoutDefinition = [pscustomobject]@{ + repo = [string]$Definition.repoUrl + pinnedCommit = [string]$Definition.pinnedCommit + } + Ensure-GitCloneInstall -Definition $checkoutDefinition -TargetPath $repoDir | Out-Null + if (Test-ReverseTcpPort -Port ([int]$Definition.servicePort)) { return } @@ -793,65 +775,13 @@ if (Test-ReverseIsWindows) { } } - $repoDir = @($Definition.startupDirCandidates) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 - if ([string]::IsNullOrWhiteSpace($repoDir)) { - $installDir = $Definition.installDir - $gh = Get-FirstCommandPath -Names @('gh') - $git = Get-FirstCommandPath -Names @('git') - if ($gh) { - & $gh repo clone 'Mouseww/anything-analyzer' $installDir - } - elseif ($git) { - & $git clone $Definition.repoUrl $installDir - } - else { - throw 'Cannot clone anything-analyzer because neither gh nor git is available.' - } - if ($LASTEXITCODE -ne 0) { - throw 'Failed to clone anything-analyzer.' - } - $repoDir = $installDir - } - $pnpm = Get-NodeCommandPath -Name 'pnpm' if ([string]::IsNullOrWhiteSpace($pnpm)) { throw 'pnpm is not available after installation.' } - - Push-Location $repoDir - try { - Approve-AnythingAnalyzerBuildScripts -RepoDir $repoDir -PnpmPath $pnpm - - if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $repoDir -PnpmPath $pnpm)) { - $nodeModules = Join-Path $repoDir 'node_modules' - if (Test-Path -LiteralPath $nodeModules) { - Remove-Item -LiteralPath $nodeModules -Recurse -Force - } - } - - & $pnpm install - if ($LASTEXITCODE -ne 0) { - if (-not [string]::IsNullOrWhiteSpace($vsBuildToolsError)) { - throw "pnpm install failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $vsBuildToolsError" - } - throw 'pnpm install failed for anything-analyzer.' - } - - & $pnpm rebuild electron esbuild better-sqlite3 - if ($LASTEXITCODE -ne 0) { - if (-not [string]::IsNullOrWhiteSpace($vsBuildToolsError)) { - throw "pnpm rebuild failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $vsBuildToolsError" - } - throw 'pnpm rebuild failed for anything-analyzer.' - } - - if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $repoDir -PnpmPath $pnpm)) { - throw 'Electron is still not healthy after reinstall/rebuild.' - } - } - finally { - Pop-Location - } + $git = Get-FirstCommandPath -Names @('git') + Invoke-AnythingAnalyzerPinnedInstall -RepoDir $repoDir -PnpmPath $pnpm -GitPath $git ` + -PinnedCommit ([string]$Definition.pinnedCommit) -VsBuildToolsError $vsBuildToolsError $stdoutLog = Join-Path $repoDir 'anything-analyzer-dev.log' $stderrLog = Join-Path $repoDir 'anything-analyzer-dev.err.log' @@ -892,55 +822,6 @@ function Ensure-AndroidPlatformTools { return (Resolve-ReverseToolSpec -Name 'adb') } -function Ensure-GitCloneInstall { - param( - [Parameter(Mandatory = $true)]$Definition, - [Parameter(Mandatory = $true)][string]$TargetPath - ) - - $pinnedCommit = if ($Definition.PSObject.Properties['pinnedCommit']) { [string]$Definition.pinnedCommit } else { '' } - $git = Get-FirstCommandPath -Names @('git') - if ([string]::IsNullOrWhiteSpace($git)) { - throw "Cannot clone $($Definition.repo) because git is not available." - } - - if ((Test-Path -LiteralPath $TargetPath -PathType Container) -and (Test-Path -LiteralPath (Join-Path $TargetPath '.git'))) { - if (-not [string]::IsNullOrWhiteSpace($pinnedCommit)) { - $currentCommit = (& $git -C $TargetPath rev-parse HEAD).Trim() - if ($LASTEXITCODE -ne 0 -or $currentCommit -ne $pinnedCommit) { - throw "Existing checkout is not at pinned commit $pinnedCommit. Move it aside explicitly, then retry: $TargetPath" - } - } - return $true - } - - if (Test-Path -LiteralPath $TargetPath) { - $backupPath = "$TargetPath.bak-$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))" - Move-Item -LiteralPath $TargetPath -Destination $backupPath -Force - } - - Ensure-DownloadDirectory -Path (Split-Path -Path $TargetPath -Parent) - - if ([string]::IsNullOrWhiteSpace($pinnedCommit)) { - & $git clone --depth 1 $Definition.repo $TargetPath - if ($LASTEXITCODE -ne 0) { - throw "git clone failed for $($Definition.repo)" - } - } - else { - & $git init --quiet $TargetPath - & $git -C $TargetPath remote add origin $Definition.repo - & $git -C $TargetPath fetch --depth 1 origin $pinnedCommit - & $git -C $TargetPath checkout --quiet --detach FETCH_HEAD - $resolvedCommit = (& $git -C $TargetPath rev-parse HEAD).Trim() - if ($LASTEXITCODE -ne 0 -or $resolvedCommit -ne $pinnedCommit) { - throw "Pinned checkout verification failed for $($Definition.repo): expected $pinnedCommit, got $resolvedCommit" - } - } - - return $true -} - function Ensure-Capability { param([Parameter(Mandatory = $true)][string]$Name) @@ -961,7 +842,7 @@ function Ensure-Capability { } $existingState = Get-ReverseCapabilityState -Name $Name - if ($existingState -and -not $definition.PSObject.Properties['mcpNames']) { + if ($existingState -and -not $definition.PSObject.Properties['mcpNames'] -and $definition.bootstrapKind -ne 'git-clone') { $toolSpec = $null try { $toolSpec = Resolve-ReverseToolSpec -Name $Name @@ -1172,6 +1053,12 @@ function Expand-CapabilityDependencies { return $ordered } +function Test-BootstrapResultsSucceeded { + param([Parameter(Mandatory = $true)][object[]]$Results) + + return (@($Results | Where-Object { $_.status -eq 'failed' }).Count -eq 0) +} + $expandedCapabilities = Expand-CapabilityDependencies -Names $Capability $results = @() @@ -1231,3 +1118,6 @@ if (-not $SkipRefresh) { } $results | ConvertTo-Json -Depth 5 +if (-not (Test-BootstrapResultsSucceeded -Results $results)) { + exit 1 +} diff --git a/skills/scripts/bootstrap-reverse.sh b/skills/scripts/bootstrap-reverse.sh index 57ca7aa..fae4b01 100644 --- a/skills/scripts/bootstrap-reverse.sh +++ b/skills/scripts/bootstrap-reverse.sh @@ -27,6 +27,7 @@ if [[ -z "$TOOLS_ROOT" || "$TOOLS_ROOT" == "/" || "$TOOLS_ROOT" == "$HOME" ]]; t exit 2 fi MCP_CONFIG_PATH="${CLAUDE_MCP_CONFIG:-$HOME/.claude/mcp.json}" +MANIFEST_PATH="$SCRIPT_DIR/bootstrap-manifest.json" UNAME_S="$(uname -s 2>/dev/null || echo unknown)" case "$UNAME_S" in @@ -39,6 +40,7 @@ START_SERVICES=false SKIP_REFRESH=false LIST_ONLY=false MANUAL_REQUIRED=false +FAILED=false LAST_CAPABILITY_MANUAL=false CAPABILITIES=() @@ -67,6 +69,39 @@ json_escape() { ensure_dir() { mkdir -p "$1"; } +manifest_field() { + local capability="$1" + local field="$2" + python3 - "$MANIFEST_PATH" "$capability" "$field" <<'PY' +import json, pathlib, sys +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')) +for capability in manifest.get('capabilities', []): + if capability.get('name') == sys.argv[2]: + value = capability.get(sys.argv[3]) + if value is None or value == '': + raise SystemExit(1) + if isinstance(value, (dict, list)): + print(json.dumps(value, separators=(',', ':'))) + else: + print(value) + raise SystemExit(0) +raise SystemExit(1) +PY +} + +manifest_dependency() { + local name="$1" + local field="$2" + python3 - "$MANIFEST_PATH" "$name" "$field" <<'PY' +import json, pathlib, sys +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')) +value = manifest.get('bootstrapDependencies', {}).get(sys.argv[2], {}).get(sys.argv[3]) +if value is None or value == '': + raise SystemExit(1) +print(value) +PY +} + safe_remove_install_dir() { local target="$1" local tmp_target="${2:-}" @@ -107,7 +142,19 @@ sudo_cmd() { } is_kali() { - [[ -f /etc/os-release ]] && grep -qi '^ID=.*kali' /etc/os-release + [[ -f /etc/os-release ]] || return 1 + local line + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + ID_LIKE=*|id_like=*) continue ;; + ID=*|id=*) + case "$line" in + *[Kk][Aa][Ll][Ii]*) return 0 ;; + esac + ;; + esac + done < /etc/os-release + return 1 } platform_doc() { @@ -189,6 +236,22 @@ install_brew_cask() { } ensure_python_runtime() { + ensure_python_interpreter || return 1 + local pipx_package pipx_version current_version + pipx_package=$(manifest_dependency pipx package) || return 1 + pipx_version=$(manifest_dependency pipx version) || return 1 + current_version="" + if has_cmd pipx; then + current_version=$(pipx --version 2>/dev/null | head -n1 | tr -d '[:space:]') + fi + if [[ "$current_version" != "$pipx_version" ]]; then + python3 -m pip install --user --upgrade "$pipx_package" || return 1 + fi + python3 -m pipx ensurepath >/dev/null 2>&1 || true + export PATH="$HOME/.local/bin:$PATH" +} + +ensure_python_interpreter() { if ! has_cmd python3; then case "$PLATFORM" in macos) install_brew python ;; @@ -196,18 +259,7 @@ ensure_python_runtime() { *) log_err "Install Python 3 manually. See $(platform_doc)"; return 1 ;; esac fi - if ! has_cmd pipx; then - case "$PLATFORM" in - macos) - python3 -m pip install --user pipx || install_brew pipx - ;; - linux) - install_apt pipx || python3 -m pip install --user pipx - ;; - esac - fi - python3 -m pipx ensurepath >/dev/null 2>&1 || true - export PATH="$HOME/.local/bin:$PATH" + has_cmd python3 || { log_err "Python 3 installation completed without a usable python3 command."; return 1; } } ensure_node_runtime() { @@ -229,10 +281,17 @@ ensure_java_runtime() { } ensure_pnpm() { - ensure_node_runtime - if has_cmd pnpm; then return 0; fi - if has_cmd corepack; then corepack enable || true; fi - if ! has_cmd pnpm; then npm install -g pnpm; fi + ensure_node_runtime || return 1 + local package version current_version + package=$(manifest_dependency pnpm package) || return 1 + version=$(manifest_dependency pnpm version) || return 1 + current_version="" + if has_cmd pnpm; then + current_version=$(pnpm --version 2>/dev/null | head -n1 | tr -d '[:space:]') + fi + if [[ "$current_version" != "$version" ]]; then + npm install -g "$package" || return 1 + fi } # Args: repo regex [release_tag] @@ -351,6 +410,86 @@ install_github_release() { log_ok "installed $repo to $dest" } +install_git_commit() { + local repo="$1" + local commit="$2" + local install_dir="$3" + + git_checkout_is_clean() { + local checkout="$1" + local status + if ! status=$(git -C "$checkout" status --porcelain --untracked-files=all); then + log_err "Cannot inspect checkout state: $checkout" + return 1 + fi + if [[ -n "$status" ]]; then + log_err "Existing checkout has local changes; refusing to execute it: $checkout" + return 1 + fi + } + + cleanup_git_stage() { + local stage="$1" + local parent="$2" + case "$stage" in + "$parent"/.reverse-bootstrap-*) rm -rf "$stage" ;; + *) log_err "Refusing to clean unexpected staging path: $stage" ;; + esac + } + + if [[ -d "$install_dir/.git" ]]; then + local current + if ! current=$(git -C "$install_dir" rev-parse HEAD 2>/dev/null); then + log_err "Cannot resolve existing checkout HEAD: $install_dir" + return 1 + fi + if [[ "$current" != "$commit" ]]; then + log_err "Existing checkout is not at pinned commit $commit: $install_dir" + log_err "Move it aside explicitly, then retry; bootstrap will not overwrite local changes." + return 1 + fi + git_checkout_is_clean "$install_dir" || return 1 + return 0 + fi + if [[ -e "$install_dir" ]]; then + log_err "Install path exists but is not a git checkout: $install_dir" + return 1 + fi + + local parent stage resolved + parent=$(dirname "$install_dir") + ensure_dir "$parent" + stage=$(mktemp -d "$parent/.reverse-bootstrap-XXXXXX") || return 1 + if ! git init --quiet "$stage" || + ! git -C "$stage" remote add origin "$repo" || + ! git -C "$stage" fetch --depth 1 origin "$commit" || + ! git -C "$stage" checkout --quiet --detach FETCH_HEAD; then + cleanup_git_stage "$stage" "$parent" + return 1 + fi + if ! resolved=$(git -C "$stage" rev-parse HEAD); then + cleanup_git_stage "$stage" "$parent" + return 1 + fi + if [[ "$resolved" != "$commit" ]]; then + log_err "Pinned checkout verification failed for $repo: expected $commit, got $resolved" + cleanup_git_stage "$stage" "$parent" + return 1 + fi + if ! git_checkout_is_clean "$stage"; then + cleanup_git_stage "$stage" "$parent" + return 1 + fi + if ! python3 - "$stage" "$install_dir" <<'PY' +import os, sys +os.rename(sys.argv[1], sys.argv[2]) +PY + then + cleanup_git_stage "$stage" "$parent" + return 1 + fi +} + write_mcp_server() { local name="$1" local json_payload="$2" @@ -444,12 +583,14 @@ ensure_jeb_pro() { ensure_jadx() { if has_cmd jadx; then log_ok "jadx ready: $(cmd_path jadx)"; return 0; fi ensure_java_runtime - local tag="v1.5.6" - local sha="545ea2be9c242511bc145755cf4bda2485ade42966e096f8b4d3da2a230e8974" - local re='^jadx-1\.5\.6\.zip$' + local repo re tag sha + repo=$(manifest_field jadx repo) || return 1 + re=$(manifest_field jadx assetRegex) || return 1 + tag=$(manifest_field jadx releaseTag) || return 1 + sha=$(manifest_field jadx assetSha256) || return 1 case "$PLATFORM" in - macos) install_brew jadx || install_github_release skylot/jadx "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;; - linux) install_github_release skylot/jadx "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;; + macos) install_brew jadx || install_github_release "$repo" "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;; + linux) install_github_release "$repo" "$re" "$TOOLS_ROOT/jadx" "$tag" "$sha" ;; esac } @@ -462,10 +603,12 @@ ensure_apktool() { if install_apt apktool; then return 0; fi ensure_dir "$TOOLS_ROOT/apktool" local meta url digest jar wrapper - local tag="v3.0.2" - local sha="eee4669a704a14e0623407e6701b0b91887e61e1e4049cb7a82833e14ae8b5fd" - local re='^apktool_3\.0\.2\.jar$' - meta=$(latest_github_asset_meta iBotPeaches/Apktool "$re" "$tag") + local repo tag sha re + repo=$(manifest_field apktool repo) || return 1 + tag=$(manifest_field apktool releaseTag) || return 1 + sha=$(manifest_field apktool assetSha256) || return 1 + re=$(manifest_field apktool assetRegex) || return 1 + meta=$(latest_github_asset_meta "$repo" "$re" "$tag") url=$(printf '%s' "$meta" | cut -f1) digest=$(printf '%s' "$meta" | cut -f2) jar="$TOOLS_ROOT/apktool/apktool.jar" @@ -480,45 +623,70 @@ ensure_apktool() { } ensure_frida_tools() { - ensure_python_runtime + ensure_python_runtime || return 1 if has_cmd frida && has_cmd frida-ps; then log_ok "frida-tools ready"; return 0; fi - pipx install frida-tools || pipx upgrade frida-tools + local package + package=$(manifest_field frida pipPackage) || return 1 + pipx install --force "$package" || return 1 export PATH="$HOME/.local/bin:$PATH" } ensure_idalib_mcp() { - ensure_python_runtime + ensure_python_runtime || return 1 if has_cmd ida-pro-mcp; then log_ok "ida-pro-mcp ready: $(cmd_path ida-pro-mcp)"; return 0; fi - pipx install 'git+https://github.com/mrexodia/ida-pro-mcp.git' || pipx upgrade ida-pro-mcp + local source + source=$(manifest_field idalib-mcp pipSource) || return 1 + pipx install --force "$source" || return 1 export PATH="$HOME/.local/bin:$PATH" log_warn "Post-install: run 'ida-pro-mcp --install', choose Streamable HTTP + Global, then restart IDA Pro." } ensure_jshookmcp() { - ensure_node_runtime - write_mcp_server "jshook" '{"command":"npx","args":["-y","@jshookmcp/jshook@0.3.4"],"env":{"JSHOOK_BASE_PROFILE":"search"}}' + ensure_node_runtime || return 1 + local package + package=$(manifest_field jshookmcp npmPackage) || return 1 + write_mcp_server "jshook" "$(python3 - "$package" <<'PY' +import json, sys +print(json.dumps({'command':'npx','args':['-y',sys.argv[1]],'env':{'JSHOOK_BASE_PROFILE':'search'}})) +PY +)" } ensure_reqable_mcp() { - ensure_node_runtime - write_mcp_server "reqable-mcp" '{"command":"npx","args":["-y","reqable-mcp-server@1.0.1"]}' + ensure_node_runtime || return 1 + local package + package=$(manifest_field reqable-mcp npmPackage) || return 1 + write_mcp_server "reqable-mcp" "$(python3 - "$package" <<'PY' +import json, sys +print(json.dumps({'command':'npx','args':['-y',sys.argv[1]]})) +PY +)" log_warn "Reqable MCP requires the separately installed Reqable desktop application and its local API." } ensure_anything_analyzer() { - ensure_node_runtime - ensure_pnpm local dir="$TOOLS_ROOT/anything-analyzer" - if [[ ! -d "$dir/.git" ]]; then - if ! has_cmd git; then - case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac - fi - rm -rf "$dir" - git clone https://github.com/Mouseww/anything-analyzer "$dir" + local repo commit + repo=$(manifest_field anything-analyzer repoUrl) || return 1 + commit=$(manifest_field anything-analyzer pinnedCommit) || return 1 + if ! has_cmd git; then + case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac fi + install_git_commit "$repo" "$commit" "$dir" || return 1 + ensure_node_runtime || return 1 + ensure_pnpm || return 1 write_mcp_server "anything-analyzer" '{"url":"http://localhost:23816/mcp"}' if $START_SERVICES; then - (cd "$dir" && pnpm install && nohup pnpm dev >/tmp/anything-analyzer.log 2>&1 &) + (cd "$dir" && pnpm install --frozen-lockfile) || return 1 + install_git_commit "$repo" "$commit" "$dir" || return 1 + ( + cd "$dir" || exit 1 + if has_cmd nohup; then + nohup pnpm dev >/tmp/anything-analyzer.log 2>&1 & + else + pnpm dev >/tmp/anything-analyzer.log 2>&1 & + fi + ) if wait_for_port 23816 120; then if test_mcp_http 23816; then log_ok "anything-analyzer MCP server ready on port 23816 (HTTP verified)" @@ -572,16 +740,21 @@ ensure_adb() { } ensure_agent_browser() { - ensure_node_runtime + ensure_node_runtime || return 1 if has_cmd agent-browser; then log_ok "agent-browser ready"; return 0; fi - npm install -g agent-browser + local package + package=$(manifest_field agent-browser npmPackage) || return 1 + npm install -g "$package" || return 1 if has_cmd npx; then npx playwright install chromium || true; fi local setup="$SKILL_ROOT/browser-automation/scripts/setup.sh" if [[ -x "$setup" ]]; then "$setup" --skip-browser-install || true; fi } ensure_ghidra_mcp() { - ensure_java_runtime + ensure_java_runtime || return 1 + local repo regex + repo=$(manifest_field ghidra-mcp repo) || return 1 + regex=$(manifest_field ghidra-mcp assetRegex) || return 1 case "$PLATFORM" in macos) if ! has_cmd ghidraRun && [[ ! -d /Applications/Ghidra.app ]]; then @@ -590,7 +763,7 @@ ensure_ghidra_mcp() { ;; linux) if ! has_cmd ghidraRun; then - install_github_release NationalSecurityAgency/ghidra '^ghidra_.*_PUBLIC_.*\.zip$' "$TOOLS_ROOT/ghidra" || \ + install_github_release "$repo" "$regex" "$TOOLS_ROOT/ghidra" || \ manual_required ghidra-mcp "Install Ghidra from GitHub release or Flatpak, then configure ghidra-mcp if used." fi ;; @@ -600,15 +773,26 @@ ensure_ghidra_mcp() { ensure_seclists() { local dir="$TOOLS_ROOT/SecLists" - if [[ -d "$dir/.git" || -d /usr/share/seclists ]]; then log_ok "SecLists ready"; return 0; fi + if [[ -d /usr/share/seclists ]]; then log_ok "SecLists ready"; return 0; fi if ! has_cmd git; then case "$PLATFORM" in macos) install_brew git ;; linux) install_apt git ;; esac; fi - git clone https://github.com/danielmiessler/SecLists "$dir" + local repo commit + repo=$(manifest_field seclists repo) || return 1 + commit=$(manifest_field seclists pinnedCommit) || return 1 + install_git_commit "$repo" "$commit" "$dir" || return 1 } ensure_proxycat() { - ensure_python_runtime + ensure_python_runtime || return 1 if has_cmd proxycat; then log_ok "proxycat ready"; return 0; fi - pipx install git+https://github.com/honmashironeko/ProxyCat.git || manual_required proxycat "Clone/install ProxyCat manually; verify command 'proxycat'." + local repo commit + repo=$(manifest_field proxycat repo) || return 1 + commit=$(manifest_field proxycat pinnedCommit) || return 1 + pipx install "git+${repo}@${commit}" || { + manual_required proxycat "Clone/install ProxyCat manually; verify command 'proxycat'." + LAST_CAPABILITY_MANUAL=true + MANUAL_REQUIRED=true + return 0 + } } ensure_burpsuite_mcp() { @@ -649,7 +833,10 @@ ensure_pentestswarm() { if ! has_cmd go; then case "$PLATFORM" in macos) install_brew go ;; linux) install_apt golang-go ;; esac fi - if go install github.com/Armur-Ai/Pentest-Swarm-AI/cmd/pentestswarm@v0.1.0; then + local go_package docker_image + go_package=$(manifest_field pentestswarm goPackage) || return 1 + docker_image=$(manifest_field pentestswarm dockerImage) || return 1 + if go install "$go_package"; then local go_bin go_bin="$(go env GOBIN 2>/dev/null || true)" if [[ -z "$go_bin" ]]; then @@ -663,8 +850,12 @@ ensure_pentestswarm() { log_warn "pentestswarm installed but no executable was found in GOBIN/GOPATH; trying Docker fallback" fi if has_cmd docker; then - write_mcp_server "pentestswarm" '{"command":"docker","args":["run","--rm","-i","ghcr.io/armur-ai/pentestswarm:v0.1.0","mcp","serve"]}' - log_warn "pentestswarm Go install failed or produced no runnable binary; registered Docker fallback ghcr.io/armur-ai/pentestswarm:v0.1.0" + write_mcp_server "pentestswarm" "$(python3 - "$docker_image" <<'PY' +import json, sys +print(json.dumps({'command':'docker','args':['run','--rm','-i',sys.argv[1],'mcp','serve']})) +PY +)" + log_warn "pentestswarm Go install failed or produced no runnable binary; registered Docker fallback $docker_image" else manual_required pentestswarm "Install Go 1.24+ or Docker, then install Pentest-Swarm-AI and ensure pentestswarm is on PATH." fi @@ -687,9 +878,11 @@ ensure_yara() { } ensure_pwntools() { - ensure_python_runtime + ensure_python_runtime || return 1 if python3 -c "import pwn" 2>/dev/null; then log_ok "pwntools ready"; return 0; fi - pipx install pwntools || python3 -m pip install --user pwntools + local package + package=$(manifest_field pwntools pipPackage) || return 1 + pipx install "$package" || python3 -m pip install --user "$package" || return 1 } status_json_line() { @@ -768,6 +961,11 @@ done < <(expand_capabilities "${CAPABILITIES[@]}") log_info "platform=$PLATFORM doc=$(platform_doc) tools_root=$TOOLS_ROOT" +if ! ensure_python_interpreter; then + log_err "Python 3 is required to read bootstrap-manifest.json; no capability was executed." + exit 1 +fi + for cap in "${EXPANDED[@]}"; do log_info "ensure $cap" LAST_CAPABILITY_MANUAL=false @@ -779,6 +977,7 @@ for cap in "${EXPANDED[@]}"; do fi else status_json_line "$cap" "failed" "see $(platform_doc)" >> "$RESULTS_FILE" + FAILED=true fi done @@ -787,7 +986,9 @@ if ! $SKIP_REFRESH; then fi FINAL_EXIT_CODE=0 -if $MANUAL_REQUIRED; then +if $FAILED; then + FINAL_EXIT_CODE=1 +elif $MANUAL_REQUIRED; then FINAL_EXIT_CODE=2 fi diff --git a/skills/scripts/lib/BootstrapSupplyChain.ps1 b/skills/scripts/lib/BootstrapSupplyChain.ps1 new file mode 100644 index 0000000..36dbd27 --- /dev/null +++ b/skills/scripts/lib/BootstrapSupplyChain.ps1 @@ -0,0 +1,176 @@ +function Ensure-Pnpm { + Ensure-NodeRuntime + $dependency = Get-BootstrapDependency -Name 'pnpm' + $pnpm = Get-NodeCommandPath -Name 'pnpm' + $currentVersion = '' + if ($pnpm) { + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $versionOutput = @(& $pnpm --version 2>$null) + $versionExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + $versionLine = $versionOutput | Select-Object -First 1 + if ($versionExitCode -eq 0 -and $null -ne $versionLine) { + $currentVersion = ([string]$versionLine).Trim() + } + } + if ($currentVersion -ne [string]$dependency.version) { + $npm = Get-NodeCommandPath -Name 'npm' + if ([string]::IsNullOrWhiteSpace($npm)) { + throw 'npm is not available after Node.js installation.' + } + & $npm install -g ([string]$dependency.package) + if ($LASTEXITCODE -ne 0) { + throw "Failed to install pinned pnpm dependency $($dependency.package)." + } + } +} + +function Assert-GitCheckoutState { + param( + [Parameter(Mandatory = $true)][string]$GitPath, + [Parameter(Mandatory = $true)][string]$CheckoutPath, + [Parameter(Mandatory = $true)][string]$PinnedCommit + ) + + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $resolvedOutput = @(& $GitPath -C $CheckoutPath rev-parse HEAD 2>$null) + $resolveExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + $resolvedLine = $resolvedOutput | Select-Object -First 1 + $resolvedCommit = if ($null -eq $resolvedLine) { '' } else { ([string]$resolvedLine).Trim() } + if ($resolveExitCode -ne 0 -or $resolvedCommit -ne $PinnedCommit) { + throw "Checkout verification failed: expected $PinnedCommit, got $resolvedCommit ($CheckoutPath)" + } + try { + $ErrorActionPreference = 'Continue' + $status = @(& $GitPath -C $CheckoutPath status --porcelain --untracked-files=all 2>$null) + $statusExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($statusExitCode -ne 0) { + throw "Cannot inspect checkout state: $CheckoutPath" + } + if ($status.Count -gt 0) { + throw "Checkout has local changes; refusing to execute it: $CheckoutPath" + } +} + +function Move-BootstrapDirectory { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination + ) + [IO.Directory]::Move($Source, $Destination) +} + +function Ensure-GitCloneInstall { + param( + [Parameter(Mandatory = $true)]$Definition, + [Parameter(Mandatory = $true)][string]$TargetPath + ) + + $git = Get-FirstCommandPath -Names @('git') + if ([string]::IsNullOrWhiteSpace($git)) { + throw 'git is required for git-clone bootstrap definitions.' + } + + $pinnedCommit = if ($Definition.PSObject.Properties['pinnedCommit']) { [string]$Definition.pinnedCommit } else { '' } + if ([string]::IsNullOrWhiteSpace($pinnedCommit)) { + throw "Git capability $($Definition.repo) must define pinnedCommit." + } + + if ((Test-Path -LiteralPath $TargetPath -PathType Container) -and (Test-Path -LiteralPath (Join-Path $TargetPath '.git'))) { + Assert-GitCheckoutState -GitPath $git -CheckoutPath $TargetPath -PinnedCommit $pinnedCommit + return $true + } + if (Test-Path -LiteralPath $TargetPath) { + throw "Install path exists but is not a git checkout: $TargetPath" + } + + $parent = Split-Path -Path $TargetPath -Parent + Ensure-DownloadDirectory -Path $parent + $stagePath = Join-Path $parent ('.reverse-bootstrap-{0}' -f [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $stagePath | Out-Null + try { + & $git init --quiet $stagePath + if ($LASTEXITCODE -ne 0) { throw 'git init failed' } + & $git -C $stagePath remote add origin $Definition.repo + if ($LASTEXITCODE -ne 0) { throw 'git remote add failed' } + & $git -C $stagePath fetch --depth 1 origin $pinnedCommit + if ($LASTEXITCODE -ne 0) { throw 'git fetch failed' } + & $git -C $stagePath checkout --quiet --detach FETCH_HEAD + if ($LASTEXITCODE -ne 0) { throw 'git checkout failed' } + Assert-GitCheckoutState -GitPath $git -CheckoutPath $stagePath -PinnedCommit $pinnedCommit + Move-BootstrapDirectory -Source $stagePath -Destination $TargetPath + if (-not (Test-Path -LiteralPath (Join-Path $TargetPath '.git') -PathType Container)) { + throw "Failed to promote staged checkout to $TargetPath" + } + } + finally { + if (Test-Path -LiteralPath $stagePath) { + Remove-Item -LiteralPath $stagePath -Recurse -Force + } + } + + return $true +} + +function Invoke-AnythingAnalyzerPinnedInstall { + param( + [Parameter(Mandatory = $true)][string]$RepoDir, + [Parameter(Mandatory = $true)][string]$PnpmPath, + [Parameter(Mandatory = $true)][string]$GitPath, + [Parameter(Mandatory = $true)][string]$PinnedCommit, + [string]$VsBuildToolsError = '' + ) + + $workspacePath = Join-Path $RepoDir 'pnpm-workspace.yaml' + $workspaceExisted = Test-Path -LiteralPath $workspacePath -PathType Leaf + $workspaceBytes = if ($workspaceExisted) { [IO.File]::ReadAllBytes($workspacePath) } else { $null } + + Push-Location $RepoDir + try { + Approve-AnythingAnalyzerBuildScripts -RepoDir $RepoDir + if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $RepoDir -PnpmPath $PnpmPath)) { + $nodeModules = Join-Path $RepoDir 'node_modules' + Remove-Item -LiteralPath $nodeModules -Recurse -Force -ErrorAction SilentlyContinue + } + + & $PnpmPath install --frozen-lockfile + if ($LASTEXITCODE -ne 0) { + if ($VsBuildToolsError) { throw "pnpm install failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $VsBuildToolsError" } + throw 'pnpm install failed for anything-analyzer.' + } + & $PnpmPath rebuild electron esbuild better-sqlite3 + if ($LASTEXITCODE -ne 0) { + if ($VsBuildToolsError) { throw "pnpm rebuild failed for anything-analyzer. Visual Studio Build Tools auto-install also failed earlier: $VsBuildToolsError" } + throw 'pnpm rebuild failed for anything-analyzer.' + } + if (-not (Test-AnythingAnalyzerElectronHealthy -RepoDir $RepoDir -PnpmPath $PnpmPath)) { + throw 'anything-analyzer Electron dependency is still unhealthy after pnpm rebuild.' + } + } + finally { + Pop-Location + if ($workspaceExisted) { + [IO.File]::WriteAllBytes($workspacePath, $workspaceBytes) + } + elseif (Test-Path -LiteralPath $workspacePath) { + Remove-Item -LiteralPath $workspacePath -Force + } + } + + Assert-GitCheckoutState -GitPath $GitPath -CheckoutPath $RepoDir -PinnedCommit $PinnedCommit +} diff --git a/skills/scripts/test-bootstrap-manifest.sh b/skills/scripts/test-bootstrap-manifest.sh new file mode 100644 index 0000000..080e43a --- /dev/null +++ b/skills/scripts/test-bootstrap-manifest.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BOOTSTRAP="$SCRIPT_DIR/bootstrap-reverse.sh" +KALI_BOOTSTRAP="$SCRIPT_DIR/../../kali/scripts/bootstrap-reverse.sh" +MANIFEST="$SCRIPT_DIR/bootstrap-manifest.json" +REAL_PYTHON="$(command -v python3)" +SCRATCH="$(mktemp -d /tmp/reverse-bootstrap-test-XXXXXX)" +trap 'rm -rf "$SCRATCH"' EXIT +STUB_BIN="$SCRATCH/bin" +CALL_LOG="$SCRATCH/calls.log" +mkdir -p "$STUB_BIN" "$SCRATCH/home" "$SCRATCH/tools" + +cat > "$STUB_BIN/command-stub" <<'STUB' +#!/usr/bin/env bash +name="$(basename "$0")" +{ printf '%s' "$name"; for arg in "$@"; do printf '|%s' "$arg"; done; printf '\n'; } >> "$CALL_LOG" +if [[ "$name" == sudo || "$name" == nohup ]]; then + next="${1:-}"; shift || true + exec "$(dirname "$0")/$next" "$@" +fi +case "$name:${1:-}" in + pipx:--version) printf '%s\n' "${STUB_PIPX_VERSION:-0}" ;; + pnpm:--version) printf '%s\n' "${STUB_PNPM_VERSION:-0}" ;; + brew:install) + if [[ "${2:-}" == python ]]; then + ln -sf "$STUB_PYTHON_SOURCE" "$STUB_ACTIVE_BIN/python3" + fi + ;; + apt-get:install) + pkg="${3:-${2:-}}" + if [[ "$pkg" == python3 ]]; then + ln -sf "$STUB_PYTHON_SOURCE" "$STUB_ACTIVE_BIN/python3" + fi + ;; + git:init) + target="${!#}"; mkdir -p "$target/.git"; printf '%s\n' unpinned-head > "$target/.stub-head" + ;; + git:-C) + case "${3:-}" in + fetch) + [[ "${STUB_FAIL_FETCH:-0}" != 1 ]] || exit 1 + printf '%s\n' "${7:-}" > "$2/.stub-fetch" + ;; + checkout) cp "$2/.stub-fetch" "$2/.stub-head" ;; + rev-parse) cat "$2/.stub-head" ;; + status) [[ ! -e "$2/.stub-dirty" ]] || printf '%s\n' '?? .npmrc' ;; + esac + ;; + nc:-z) + [[ "${STUB_NC_PREOCCUPIED:-0}" != 1 ]] || exit 0 + count=0; [[ ! -f "$STUB_NC_STATE" ]] || count="$(cat "$STUB_NC_STATE")" + printf '%s\n' "$((count + 1))" > "$STUB_NC_STATE" + (( count > 0 )) && exit 0 || exit 1 + ;; +esac +[[ "${STUB_FAIL_COMMAND:-}" != "$name" ]] +STUB +chmod +x "$STUB_BIN/command-stub" +for name in git node npm npx pipx pnpm sleep nc apt-get sudo nohup; do ln -s command-stub "$STUB_BIN/$name"; done +ln -s command-stub "$STUB_BIN/brew" + +cat > "$STUB_BIN/python3" <> "\$CALL_LOG" +if [[ "\${1:-}" == '-m' && "\${2:-}" == pip ]]; then [[ "\${STUB_FAIL_PIP_INSTALL:-0}" != 1 ]]; exit; fi +if [[ "\${1:-}" == '-m' && "\${2:-}" == pipx ]]; then exit 0; fi +if [[ "\${1:-}" == '-c' && "\${2:-}" == 'import pwn' ]]; then exit 1; fi +if [[ "\${1:-}" == '-' && "\${2:-}" == 23816 ]]; then exit 0; fi +exec "$REAL_PYTHON" "\$@" +STUB +chmod +x "$STUB_BIN/python3" + +json_value() { + "$REAL_PYTHON" - "$MANIFEST" "$1" "$2" <<'PY' +import json, pathlib, sys +d=json.loads(pathlib.Path(sys.argv[1]).read_text()) +if sys.argv[2] == 'dependency': v=d['bootstrapDependencies'][sys.argv[3]]['package'] +else: v=next(x for x in d['capabilities'] if x['name']==sys.argv[2])[sys.argv[3]] +print(v) +PY +} + +run_generic() { + env PATH="$STUB_BIN:/usr/bin:/bin" HOME="$SCRATCH/home" CALL_LOG="$CALL_LOG" \ + STUB_ACTIVE_BIN="$STUB_BIN" STUB_PYTHON_SOURCE="$STUB_BIN/python3" \ + STUB_PIPX_VERSION="${STUB_PIPX_VERSION:-}" STUB_PNPM_VERSION="${STUB_PNPM_VERSION:-}" \ + STUB_FAIL_PIP_INSTALL="${STUB_FAIL_PIP_INSTALL:-0}" STUB_FAIL_FETCH="${STUB_FAIL_FETCH:-0}" \ + REVERSE_SKILL_TOOLS_DIR="${TEST_TOOLS_ROOT:-$SCRATCH/tools}" \ + CLAUDE_MCP_CONFIG="$SCRATCH/home/mcp.json" bash "$BOOTSTRAP" "$@" +} +run_kali() { + rm -f "$SCRATCH/nc-count" + env PATH="$STUB_BIN:/opt/homebrew/bin:/usr/bin:/bin" HOME="$SCRATCH/home" \ + CALL_LOG="$CALL_LOG" STUB_NC_STATE="$SCRATCH/nc-count" STUB_PNPM_VERSION="${STUB_PNPM_VERSION:-}" \ + STUB_FAIL_FETCH="${STUB_FAIL_FETCH:-0}" STUB_NC_PREOCCUPIED="${STUB_NC_PREOCCUPIED:-0}" bash "$KALI_BOOTSTRAP" "$@" +} +expect_line() { grep -Fqx "$1" "$CALL_LOG" || { echo "missing argv: $1" >&2; cat "$CALL_LOG" >&2; return 1; }; } +expect_fragment() { grep -Fq "$1" "$CALL_LOG" || { echo "missing argv fragment: $1" >&2; cat "$CALL_LOG" >&2; return 1; }; } +rejects_without_pnpm() { + local runner="$1"; shift + : > "$CALL_LOG"; set +e; "$runner" "$@" >/dev/null 2>&1; local rc=$?; set -e + [[ $rc -ne 0 ]] && ! grep -Eq '^pnpm\|(install|dev)' "$CALL_LOG" +} + +pipx_package=$(json_value dependency pipx) +pnpm_package=$(json_value dependency pnpm) +anything_repo=$(json_value anything-analyzer repoUrl) +anything_pin=$(json_value anything-analyzer pinnedCommit) + +# The manifest parser is bootstrapped before a Node-only sink, without installing pipx. +NO_PYTHON_BIN="$SCRATCH/no-python-bin" +PARSER_FIXTURE="$SCRATCH/parser-bootstrap" +mkdir -p "$NO_PYTHON_BIN" +for name in git node npm npx pipx pnpm sleep nc brew apt-get sudo nohup; do ln -s "$STUB_BIN/command-stub" "$NO_PYTHON_BIN/$name"; done +for tool in bash uname dirname mktemp rm head tr basename mkdir cat ln; do ln -s "$(command -v "$tool")" "$NO_PYTHON_BIN/$tool"; done +mkdir -p "$PARSER_FIXTURE" +cp "$BOOTSTRAP" "$PARSER_FIXTURE/bootstrap-reverse.sh" +cp "$MANIFEST" "$PARSER_FIXTURE/bootstrap-manifest.json" +: > "$CALL_LOG" +if ! env PATH="$NO_PYTHON_BIN" HOME="$SCRATCH/home" CALL_LOG="$CALL_LOG" \ + STUB_ACTIVE_BIN="$NO_PYTHON_BIN" STUB_PYTHON_SOURCE="$STUB_BIN/python3" \ + REVERSE_SKILL_TOOLS_DIR="$SCRATCH/tools" CLAUDE_MCP_CONFIG="$SCRATCH/home/mcp.json" \ + bash "$PARSER_FIXTURE/bootstrap-reverse.sh" agent-browser --skip-refresh \ + >"$SCRATCH/parser-out.log" 2>&1; then + echo "parser-bootstrap failed:" >&2 + cat "$SCRATCH/parser-out.log" >&2 + exit 1 +fi +if [[ "$(uname -s)" == Darwin ]]; then + expect_line 'brew|install|python' +else + expect_line 'apt-get|install|-y|python3' +fi +expect_line "npm|install|-g|$(json_value agent-browser npmPackage)" +! grep -Fq '|pip|install|' "$CALL_LOG" + +# A required empty manifest field fails before any package-manager sink. +BROKEN_DIR="$SCRATCH/broken-bootstrap" +mkdir -p "$BROKEN_DIR" +cp "$BOOTSTRAP" "$BROKEN_DIR/bootstrap-reverse.sh" +"$REAL_PYTHON" - "$MANIFEST" "$BROKEN_DIR/bootstrap-manifest.json" <<'PY' +import json, pathlib, sys +data = json.loads(pathlib.Path(sys.argv[1]).read_text()) +next(x for x in data['capabilities'] if x['name'] == 'agent-browser')['npmPackage'] = '' +pathlib.Path(sys.argv[2]).write_text(json.dumps(data)) +PY +: > "$CALL_LOG" +set +e +env PATH="$STUB_BIN:/usr/bin:/bin" HOME="$SCRATCH/home" CALL_LOG="$CALL_LOG" \ + STUB_ACTIVE_BIN="$STUB_BIN" STUB_PYTHON_SOURCE="$STUB_BIN/python3" \ + REVERSE_SKILL_TOOLS_DIR="$SCRATCH/tools" CLAUDE_MCP_CONFIG="$SCRATCH/home/mcp.json" \ + bash "$BROKEN_DIR/bootstrap-reverse.sh" agent-browser --skip-refresh >/dev/null 2>&1 +broken_rc=$? +set -e +[[ $broken_rc -ne 0 ]] +! grep -Eq '^npm\|install\|-g(\||$)' "$CALL_LOG" + +# Table: each generic package-manager sink receives its canonical manifest value. +while IFS='|' read -r capability field expected; do + : > "$CALL_LOG" + STUB_PIPX_VERSION=1.16.5 run_generic "$capability" --skip-refresh >/dev/null + expect_line "$expected" +done < "$CALL_LOG" +STUB_FAIL_PIP_INSTALL=1 run_generic frida --skip-refresh >/dev/null 2>&1 && exit 1 || true +expect_line "python3|-m|pip|install|--user|--upgrade|$pipx_package" +[[ $(grep -c '|pip|install|' "$CALL_LOG") -eq 1 ]] +! grep -Eq '^pipx\|(install|upgrade)' "$CALL_LOG" + +# Generic Anything Analyzer: staged checkout, pinned pnpm, frozen install, clean recheck, then dev. +: > "$CALL_LOG" +STUB_PIPX_VERSION=1.16.5 STUB_PNPM_VERSION=0 run_generic anything-analyzer --start-services --skip-refresh >/dev/null +anything_dir="$SCRATCH/tools/anything-analyzer" +expect_line "npm|install|-g|$pnpm_package" +expect_line 'pnpm|install|--frozen-lockfile' +expect_line 'pnpm|dev' +expect_fragment "remote|add|origin|$anything_repo" +expect_fragment "fetch|--depth|1|origin|$anything_pin" +[[ -d "$anything_dir/.git" ]] +[[ $(grep -c '|status|--porcelain|--untracked-files=all' "$CALL_LOG") -ge 2 ]] + +# Dirty sources never reach install/dev. +touch "$anything_dir/.stub-dirty" +rejects_without_pnpm run_generic anything-analyzer --start-services --skip-refresh +rm "$anything_dir/.stub-dirty" + +# Failed fetch leaves no final checkout or staging poison; a retry can succeed. +retry_root="$SCRATCH/retry-tools" +TEST_TOOLS_ROOT="$retry_root" STUB_FAIL_FETCH=1 rejects_without_pnpm run_generic anything-analyzer --start-services --skip-refresh +[[ ! -e "$retry_root/anything-analyzer" ]] +[[ -z "$(find "$retry_root" -maxdepth 1 -name '.reverse-bootstrap-*' -print -quit)" ]] +: > "$CALL_LOG" +TEST_TOOLS_ROOT="$retry_root" STUB_PNPM_VERSION=10.24.0 run_generic anything-analyzer --start-services --skip-refresh >/dev/null +[[ -d "$retry_root/anything-analyzer/.git" ]] + +# Kali exercises the same source-before-execution boundary where associative arrays are supported. +if (( BASH_VERSINFO[0] >= 4 )); then + kali_dir="$SCRATCH/home/tools/anything-analyzer" + rm -rf "$kali_dir" + : > "$CALL_LOG" + set +e + STUB_PNPM_VERSION=0 run_kali anything-analyzer --start-services --skip-refresh >/dev/null 2>&1 + set -e + expect_line "npm|install|-g|$pnpm_package" + expect_line 'pnpm|install|--frozen-lockfile' + [[ $(grep -c '|status|--porcelain|--untracked-files=all' "$CALL_LOG") -ge 2 ]] + touch "$kali_dir/.stub-dirty" + rejects_without_pnpm run_kali anything-analyzer --start-services --skip-refresh + STUB_NC_PREOCCUPIED=1 rejects_without_pnpm run_kali anything-analyzer --start-services --skip-refresh + expect_fragment '|status|--porcelain|--untracked-files=all' + + rm -rf "$kali_dir" + : > "$CALL_LOG" + STUB_FAIL_FETCH=1 rejects_without_pnpm run_kali anything-analyzer --start-services --skip-refresh + [[ ! -e "$kali_dir" ]] + [[ -z "$(find "${kali_dir%/*}" -maxdepth 1 -name '.reverse-bootstrap-*' -print -quit)" ]] + set +e + STUB_PNPM_VERSION=10.24.0 run_kali anything-analyzer --start-services --skip-refresh >/dev/null 2>&1 + set -e + [[ -d "$kali_dir/.git" ]] + expect_line 'pnpm|install|--frozen-lockfile' +fi + +echo 'bootstrap manifest source regression passed' diff --git a/skills/scripts/test-bootstrap-supply-chain.ps1 b/skills/scripts/test-bootstrap-supply-chain.ps1 new file mode 100644 index 0000000..e8062de --- /dev/null +++ b/skills/scripts/test-bootstrap-supply-chain.ps1 @@ -0,0 +1,192 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$scratch = Join-Path ([IO.Path]::GetTempPath()) ('reverse-bootstrap-ps-' + [Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $scratch | Out-Null + +function Ensure-DownloadDirectory { param([string]$Path) New-Item -ItemType Directory -Path $Path -Force | Out-Null } +function Get-FirstCommandPath { param([string[]]$Names) return (Get-Command $Names[0]).Source } +function Ensure-NodeRuntime {} +function Get-NodeCommandPath { param([string]$Name) $command = Get-Command $Name -ErrorAction SilentlyContinue; if ($command) { return $command.Source } } +function Get-BootstrapDependency { return [pscustomobject]@{ package = 'pnpm@10.24.0'; version = '10.24.0' } } +function Approve-AnythingAnalyzerBuildScripts { param([string]$RepoDir) Set-Content (Join-Path $RepoDir 'pnpm-workspace.yaml') 'generated' } +function Test-AnythingAnalyzerElectronHealthy { return $true } + +. (Join-Path $PSScriptRoot 'lib/BootstrapSupplyChain.ps1') + +function Assert-True { param([bool]$Condition, [string]$Message) if (-not $Condition) { throw $Message } } +function Invoke-Git { param([string[]]$Arguments) & git @Arguments; if ($LASTEXITCODE -ne 0) { throw "git failed: $Arguments" } } +function Write-UnixExecutable { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Content + ) + $normalized = ($Content -replace "`r`n", "`n") -replace "`r", "`n" + if (-not $normalized.EndsWith("`n")) { $normalized += "`n" } + [IO.File]::WriteAllText($Path, $normalized, [Text.UTF8Encoding]::new($false)) + & chmod +x $Path +} + +try { + $source = Join-Path $scratch 'source' + New-Item -ItemType Directory -Path $source | Out-Null + Invoke-Git -Arguments @('-C', $source, 'init', '--quiet') + Invoke-Git -Arguments @('-C', $source, 'config', 'user.email', 'test@example.invalid') + Invoke-Git -Arguments @('-C', $source, 'config', 'user.name', 'test') + Set-Content (Join-Path $source 'package.json') '{}' + Invoke-Git -Arguments @('-C', $source, 'add', 'package.json') + Invoke-Git -Arguments @('-C', $source, 'commit', '--quiet', '-m', 'fixture') + $pin = (& git -C $source rev-parse HEAD).Trim() + $definition = [pscustomobject]@{ repo = $source; pinnedCommit = $pin } + + $target = Join-Path $scratch 'installed' + Ensure-GitCloneInstall -Definition $definition -TargetPath $target | Out-Null + Assert-True ((& git -C $target rev-parse HEAD).Trim() -eq $pin) 'pinned checkout was not promoted' + Set-Content (Join-Path $target 'package.json') '{"dirty":true}' + try { Ensure-GitCloneInstall -Definition $definition -TargetPath $target | Out-Null; throw 'dirty checkout accepted' } catch { Assert-True ($_.Exception.Message -match 'local changes') 'dirty rejection reason changed' } + + $failedTarget = Join-Path $scratch 'failed' + $badDefinition = [pscustomobject]@{ repo = (Join-Path $scratch 'missing'); pinnedCommit = $pin } + $failedFetchRejected = $false + try { Ensure-GitCloneInstall -Definition $badDefinition -TargetPath $failedTarget | Out-Null } catch { $failedFetchRejected = $true } + Assert-True $failedFetchRejected 'failed fetch accepted' + Assert-True (-not (Test-Path $failedTarget)) 'failed fetch poisoned final path' + Assert-True (@(Get-ChildItem $scratch -Filter '.reverse-bootstrap-*').Count -eq 0) 'failed fetch left staging path' + + $raceTarget = Join-Path $scratch 'race' + $raceStage = Join-Path $scratch '.reverse-bootstrap-race' + New-Item -ItemType Directory -Path $raceTarget, $raceStage | Out-Null + Set-Content (Join-Path $raceTarget 'owner.txt') owner + $raceRejected = $false + try { Move-BootstrapDirectory -Source $raceStage -Destination $raceTarget } catch { $raceRejected = $true } + Assert-True $raceRejected 'promotion race accepted' + Assert-True ((Get-Content (Join-Path $raceTarget 'owner.txt')) -eq 'owner') 'promotion race modified concurrent target' + Remove-Item -LiteralPath $raceStage -Recurse -Force + + $bin = Join-Path $scratch 'bin' + New-Item -ItemType Directory -Path $bin | Out-Null + $env:PATH = "$bin$([IO.Path]::PathSeparator)$env:PATH" + $env:BOOTSTRAP_PS_LOG = Join-Path $scratch 'commands.log' + $isWindowsHost = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT + $stub = Join-Path $bin ($(if ($isWindowsHost) { 'npm.cmd' } else { 'npm' })) + if ($isWindowsHost) { + Set-Content $stub @' +@echo off +echo npm^|%*>>"%BOOTSTRAP_PS_LOG%" +'@ + } + else { + Write-UnixExecutable -Path $stub -Content @' +#!/bin/sh +printf "npm|%s\n" "$*" >> "$BOOTSTRAP_PS_LOG" +'@ + } + $pnpm = Join-Path $bin ($(if ($isWindowsHost) { 'pnpm.cmd' } else { 'pnpm' })) + if ($isWindowsHost) { Set-Content $pnpm "@echo off`r`necho 0" } + else { Write-UnixExecutable -Path $pnpm -Content "#!/bin/sh`necho 0" } + Ensure-Pnpm + Assert-True ((Get-Content $env:BOOTSTRAP_PS_LOG) -match 'npm\|install -g pnpm@10.24.0') 'pnpm install was not pinned' + + Invoke-Git -Arguments @('-C', $target, 'checkout', '--quiet', '--', 'package.json') + if ($isWindowsHost) { + Set-Content $pnpm @' +@echo off +if "%1"=="--version" (echo 10.24.0) else (echo pnpm^|%*>>"%BOOTSTRAP_PS_LOG%") +'@ + } + else { + Write-UnixExecutable -Path $pnpm -Content @' +#!/bin/sh +[ "$1" = --version ] && { echo 10.24.0; exit; } +printf "pnpm|%s\n" "$*" >> "$BOOTSTRAP_PS_LOG" +'@ + } + $commandLogBefore = Get-Content -LiteralPath $env:BOOTSTRAP_PS_LOG -Raw + Ensure-Pnpm + $commandLogAfter = Get-Content -LiteralPath $env:BOOTSTRAP_PS_LOG -Raw + Assert-True ($commandLogAfter -eq $commandLogBefore) 'matching pnpm version triggered reinstall' + function Approve-AnythingAnalyzerBuildScripts { param([string]$RepoDir) Set-Content (Join-Path $RepoDir 'pnpm-workspace.yaml') 'generated'; Set-Content (Join-Path $RepoDir 'package.json') '{"mutated":true}' } + $dirtyRejected = $false + try { Invoke-AnythingAnalyzerPinnedInstall -RepoDir $target -PnpmPath $pnpm -GitPath (Get-Command git).Source -PinnedCommit $pin } catch { $dirtyRejected = $_.Exception.Message -match 'local changes' } + Assert-True $dirtyRejected 'post-install dirty checkout accepted or rejection reason changed' + Assert-True (-not (Test-Path (Join-Path $target 'pnpm-workspace.yaml'))) 'generated workspace file was not removed' + + Invoke-Git -Arguments @('-C', $target, 'config', 'user.email', 'test@example.invalid') + Invoke-Git -Arguments @('-C', $target, 'config', 'user.name', 'test') + Invoke-Git -Arguments @('-C', $target, 'commit', '--allow-empty', '--quiet', '-m', 'wrong checkout') + $wrongCommitRejected = $false + try { Ensure-GitCloneInstall -Definition $definition -TargetPath $target | Out-Null } catch { $wrongCommitRejected = $_.Exception.Message -match 'expected' } + Assert-True $wrongCommitRejected 'clean wrong-commit checkout accepted' + + $publicProfile = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) + $publicTools = Join-Path $publicProfile 'Tools' + $publicTarget = Join-Path $publicTools 'SecLists' + if (Test-Path -LiteralPath $publicTarget) { + Write-Host 'SKIP: public bootstrap exit regression (existing SecLists checkout)' + } + else { + $createdPublicTools = -not (Test-Path -LiteralPath $publicTools) + try { + New-Item -ItemType Directory -Path $publicTarget -Force | Out-Null + Invoke-Git -Arguments @('-C', $publicTarget, 'init', '--quiet') + Invoke-Git -Arguments @('-C', $publicTarget, 'config', 'user.email', 'test@example.invalid') + Invoke-Git -Arguments @('-C', $publicTarget, 'config', 'user.name', 'test') + Set-Content (Join-Path $publicTarget 'fixture.txt') 'wrong checkout' + Invoke-Git -Arguments @('-C', $publicTarget, 'add', 'fixture.txt') + Invoke-Git -Arguments @('-C', $publicTarget, 'commit', '--quiet', '-m', 'fixture') + + $powerShellHost = if ($PSVersionTable.PSEdition -eq 'Desktop') { Join-Path $PSHOME 'powershell.exe' } else { Join-Path $PSHOME 'pwsh' } + $childOutput = @(& $powerShellHost -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'bootstrap-reverse.ps1') -Capability seclists -SkipRefresh) + $childExitCode = $LASTEXITCODE + $global:LASTEXITCODE = 0 + $childResult = ($childOutput -join [Environment]::NewLine) | ConvertFrom-Json + Assert-True ($childExitCode -ne 0) 'failed public bootstrap exited successfully' + Assert-True ($childResult.status -eq 'failed') 'failed public bootstrap did not report failed status' + Assert-True ($childResult.error -match 'Checkout verification failed') 'failed public bootstrap did not report checkout verification' + } + finally { + Remove-Item -LiteralPath $publicTarget -Recurse -Force -ErrorAction SilentlyContinue + if ($createdPublicTools -and (Test-Path -LiteralPath $publicTools) -and (@(Get-ChildItem -LiteralPath $publicTools -Force).Count -eq 0)) { + Remove-Item -LiteralPath $publicTools -Force -ErrorAction SilentlyContinue + } + } + } + + . (Join-Path $PSScriptRoot 'bootstrap-reverse.ps1') -Capability '__test_missing__' -SkipRefresh | Out-Null + $script:gitCloneDefinition = [pscustomobject]@{ + name = 'test-git-clone' + bootstrapKind = 'git-clone' + canAutoInstall = $true + installDir = (Join-Path $scratch 'test-git-clone') + } + $script:gitCloneVerifierCalled = $false + function Get-ReverseBootstrapDefinition { param([string]$Name) return $script:gitCloneDefinition } + function Get-ReverseCapabilityState { param([string]$Name) return [pscustomobject]@{ Ready = $true } } + function Resolve-ReverseToolSpec { param([string]$Name) return [pscustomobject]@{ Available = $true } } + function Ensure-GitCloneInstall { + param($Definition, [string]$TargetPath) + $script:gitCloneVerifierCalled = $true + return [pscustomobject]@{ Verified = $true } + } + $gitCloneResult = Ensure-Capability -Name 'test-git-clone' + Assert-True $script:gitCloneVerifierCalled 'available git-clone capability skipped checkout verification' + Assert-True $gitCloneResult.Verified 'git-clone capability did not return checkout verification result' + + $script:serviceCheckoutVerifierCalled = $false + function Ensure-GitCloneInstall { + param($Definition, [string]$TargetPath) + $script:serviceCheckoutVerifierCalled = $true + } + function Test-ReverseTcpPort { param([int]$Port) return $true } + Start-AnythingAnalyzerService -Definition ([pscustomobject]@{ + installDir = (Join-Path $scratch 'anything-analyzer') + repoUrl = $source + pinnedCommit = $pin + servicePort = 23816 + }) -AuthToken 'test-token' + Assert-True $script:serviceCheckoutVerifierCalled 'running Anything Analyzer service skipped checkout verification' + + Write-Host 'PowerShell bootstrap supply-chain regression passed' +} +finally { + Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/skills/scripts/verify-routing-coherence.ps1 b/skills/scripts/verify-routing-coherence.ps1 index 54469fa..607a3ab 100644 --- a/skills/scripts/verify-routing-coherence.ps1 +++ b/skills/scripts/verify-routing-coherence.ps1 @@ -389,11 +389,23 @@ if (Test-Path -LiteralPath $kaliManifest) { # --- supply-chain pin gate: auto-install download sources MUST be pinned --- # 统一判定:pinnedVersion / pinnedCommit / pinPolicy 三选一; # github-release-* 额外接受 assetSha256 / preferApiDigest(GitHub 官方发布资产哈希)。 +# local-http-mcp 只有在不获取外部源码时才可免 pin。 $pinKinds = @('pip-package', 'npm-mcp', 'npm-global', 'go-install', 'git-clone') foreach ($mf in @($skillsManifest, $kaliManifest)) { if (-not (Test-Path -LiteralPath $mf)) { continue } $mn = Split-Path $mf -Leaf $mc = Get-Content -LiteralPath $mf -Raw -Encoding UTF8 | ConvertFrom-Json + foreach ($dependencyProperty in @($mc.bootstrapDependencies.PSObject.Properties)) { + $dependency = $dependencyProperty.Value + $expectedSuffix = '(?:==|@)' + [regex]::Escape([string]$dependency.version) + '$' + if ([string]::IsNullOrWhiteSpace([string]$dependency.package) -or + [string]::IsNullOrWhiteSpace([string]$dependency.version) -or + [string]$dependency.package -notmatch $expectedSuffix) { + Bad "unpinned bootstrap dependency: $($dependencyProperty.Name) in $mn" + } else { + Ok "pinned bootstrap dependency $($dependencyProperty.Name) in $mn" + } + } foreach ($cap in $mc.capabilities) { if (-not $cap.canAutoInstall) { continue } $hasPin = ($cap.pinnedVersion -or $cap.pinnedCommit -or $cap.pinPolicy) @@ -401,7 +413,10 @@ foreach ($mf in @($skillsManifest, $kaliManifest)) { 'github-release-zip' { $hasPin = $hasPin -or $cap.assetSha256 -or $cap.preferApiDigest } 'github-release-jar-wrapper' { $hasPin = $hasPin -or $cap.assetSha256 } 'github-release-tar' { $hasPin = $hasPin -or $cap.assetSha256 -or $cap.preferApiDigest } - 'local-http-mcp' { $hasPin = $true } # 本地服务,不下载 + 'local-http-mcp' { + $fetchesExternalSource = $cap.repoUrl -or $cap.repo + $hasPin = (-not $fetchesExternalSource) -or $cap.pinnedCommit -or $cap.pinnedVersion + } 'winget-package' { $hasPin = $hasPin } # winget-latest 属于 pinPolicy 'apt-package' { $hasPin = $true } # 发行版仓库自带(Kali 侧) 'docker-image' { $hasPin = $true } # fallback 通道