From 1aaa9b3fe156c4c6a48afae584dd268d61a54fa2 Mon Sep 17 00:00:00 2001 From: Synvoya Date: Thu, 20 Aug 2026 14:49:53 +1000 Subject: [PATCH] release: prepare CodeInspectus v2.6.0 Publish the curated public projection for consent-driven engine setup, managed integrity verification, platform compatibility guards, direct Supabase Edge analysis, and updated release documentation. Signed-off-by: Synvoya --- CHANGELOG.md | 25 ++ NOTICE | 7 +- README.md | 42 +- THIRD-PARTY-NOTICES.md | 15 +- detection-db/osv-pub/snapshot.json | 4 +- docs/CI-POLICY.md | 2 +- docs/CLI-REFERENCE.md | 15 +- docs/TYPESCRIPT-SDK.md | 2 +- docs/context/verify.md | 2 +- docs/examples/codeinspectus-security.yml | 11 +- engines.lock.json | 36 +- evals/run-evals.ts | 50 +-- package-lock.json | 4 +- package.json | 2 +- scripts/sdk-consumer-e2e.mjs | 2 +- scripts/smoke-stdio.mjs | 19 +- server.json | 4 +- src/ai-checks/supabase-edge-auth.test.ts | 8 + src/ai-checks/supabase-edge-auth.ts | 7 + src/bundle/index.test.ts | 2 +- src/ci-workflow.test.ts | 4 +- src/cli.test.ts | 5 +- src/cli.ts | 4 +- src/config.ts | 6 +- src/engines/lockfile.test.ts | 23 + src/engines/lockfile.ts | 30 ++ src/engines/opengrep.test.ts | 25 ++ src/engines/opengrep.ts | 17 +- src/engines/resolve.ts | 12 +- src/engines/signature.ts | 51 ++- src/index.ts | 12 +- src/install.ts | 61 ++- src/provenance.test.ts | 2 +- src/provenance.ts | 2 +- src/release-contract.test.ts | 5 +- src/schemas.ts | 48 +++ src/sdk/index.ts | 2 +- src/server.ts | 50 ++- src/setup.test.ts | 228 ++++++++++ src/setup.ts | 517 +++++++++++++++++++++++ 40 files changed, 1243 insertions(+), 120 deletions(-) create mode 100644 src/engines/lockfile.test.ts create mode 100644 src/engines/opengrep.test.ts create mode 100644 src/setup.test.ts create mode 100644 src/setup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb6583..97868d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to CodeInspectus are documented here. Versioning follows [Semantic Versioning](https://semver.org). AI-code detections and compliance mappings are AI-drafted and practitioner-reviewed — see the honesty notes in the [README](README.md). +## [2.6.0] — 2026-08-20 + +### Added +- Added consent-driven first-use setup for nontechnical users. Interactive `npx codeinspectus` + and `codeinspectus setup` explain each external engine's coverage, license, current state, and + platform-specific download size before offering install-all or component selection. +- Added `codeinspectus_setup` for MCP clients. Planning is offline; installation requires an + explicit `confirm_downloads=true`, writes only beneath `~/.codeinspectus`, and never changes the + scanned repository. Declined choices persist locally and can be cleared with `setup --reset`. +- Added automatic, approval-gated Cosign 3.1.2 bootstrapping for Opengrep/Trivy verification. + Platform binaries are immutable-SHA pinned from a Sigstore-verified upstream checksum manifest, + removing the separate Cosign prerequisite while preserving fail-closed publisher verification. + +### Changed +- Added exact release-asset byte sizes for every supported Opengrep, Gitleaks, Trivy, and Cosign + platform. Trivy database storage is deliberately labelled as an estimate because upstream DB + transfer and extracted size change independently of CodeInspectus releases. +- Direct file scans no longer mark the project-level Supabase Edge analyzer as an unreadable + directory. The aggregate native engine is `5.20.0`; the 94-rule catalog is unchanged. +- Existing healthy binaries and databases remain no-download no-ops. Scans remain offline, npm + installation has no executable-downloading lifecycle script, and manual `repair-engines` remains + available as an advanced compatibility path. +- Package, MCP server, CLI, SDK API, MCP Registry manifest, and reference CI install versions are + synchronized at `2.6.0`. Export/storage schema versions remain `2.0.0`. + ## [2.5.0] — 2026-08-13 ### Licensing diff --git a/NOTICE b/NOTICE index e0ca099..fadcdc8 100644 --- a/NOTICE +++ b/NOTICE @@ -4,6 +4,7 @@ Copyright 2026 Synvoya This product includes software developed by Synvoya. CodeInspectus interoperates with separately licensed third-party security -engines. Those engines are downloaded from their upstream release sources and -are not included in the CodeInspectus npm package. See THIRD-PARTY-NOTICES.md -for engine and runtime-dependency attribution. +engines and the Cosign verification helper. Those executables are downloaded +from their upstream release sources after approval and are not included in the +CodeInspectus npm package. See THIRD-PARTY-NOTICES.md for engine, verifier, data, +and runtime-dependency attribution. diff --git a/README.md b/README.md index d95a60d..c14d72e 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,11 @@ IDs** (29 JavaScript/TypeScript, 6 Flutter/Dart, 4 Android, 4 iOS, 4 React Nativ 3 Firebase configuration, 2 GitHub Actions workflow, and 2 JavaScript baseline SAST rules), 18 Opengrep-owned SAST rules, and 4 custom Gitleaks rules. All 20 Opengrep YAML rules remain physically active: the two native-owned rules reconcile exact results and fall back to Opengrep on mismatch or -native unavailability. Opengrep, Gitleaks, and Trivy remain installed and additive. +native unavailability. Opengrep, Gitleaks, and Trivy are optional, managed, additive engines. -> CodeInspectus downloads the official, **SHA-pinned** engine binaries only when you run the -> explicit engine-repair command, stores them outside the npm package, and calls +> CodeInspectus explains each engine's coverage, license, platform-specific size, and required +> action before asking permission. After approval it downloads the official, **SHA-pinned** +> engine binaries, stores them outside the npm package, and calls > them as local subprocesses. It does **not** fork them. ## Why CodeInspectus? @@ -90,19 +91,29 @@ before shipping. ## Install -**Prerequisites:** **Node.js ≥22**. Node 24 LTS is recommended. Also install -[**cosign**](https://github.com/sigstore/cosign) -on your `PATH` when Opengrep or Trivy binaries need installation. Signature verification is -**fail-closed**: those binaries are never installed without publisher verification. **Gitleaks** -verifies by checksum and needs no cosign; a DB-only Trivy refresh reuses the already SHA-verified -local Trivy binary. +**Prerequisite:** **Node.js ≥22**. Node 24 LTS is recommended. No separate engine or Cosign +installation is required. CodeInspectus can bootstrap a SHA-pinned Cosign verifier inside +`~/.codeinspectus/` after approval; signature verification remains **fail-closed**. +On Linux, the current upstream Opengrep assets require glibc. Alpine/musl remains supported for +native CodeInspectus rules, Gitleaks, and Trivy, but setup marks Opengrep unavailable before any +download and reports aggregate scan coverage as partial when it is selected. ```bash -# Register once per machine with your agent (see "Client registration"), then: -npx codeinspectus repair-engines +# Interactive: inspect coverage, licenses, and sizes; then approve all or choose components. +npx codeinspectus setup + +# Automation after an operator has reviewed the plan: +npx codeinspectus setup --status +npx codeinspectus setup --all +npx codeinspectus setup --select opengrep,gitleaks ``` -`repair-engines` first checks local state without network access. It downloads only missing, +On a terminal, a first bare `npx codeinspectus` run opens this guided setup. MCP clients continue +to start over piped stdio and expose `codeinspectus_setup`: agents must request a plan, show it, +ask permission, then call install with `confirm_downloads=true`. Declined choices are saved so +users are not repeatedly prompted; `setup --reset` clears them. + +Setup first checks local state without network access. It downloads only missing, mismatched, or newly pinned binaries, verifies them against the immutable lockfile shipped in the npm package, and atomically installs them under `~/.codeinspectus/`. It refreshes the offline Trivy vulnerability DB only when it is missing, lacks rescan provenance, or is more than seven @@ -111,9 +122,9 @@ perform zero network I/O.** Every scan and `codeinspectus_list_rules` response includes structured `engine_setup` state: `ready`, `repair_required`, `db_refresh_recommended`, or `unsupported_platform`. MCP agents are -instructed to explain non-ready state and obtain approval before running the repair command—there -is no silent npm `postinstall` download. The older `install-engines` command remains supported as -a compatibility alias and explicitly refreshes the Trivy DB. +instructed to explain non-ready state and obtain approval through `codeinspectus_setup`. There is +no silent npm `postinstall` download. `repair-engines` remains available for advanced/manual use; +the older `install-engines` command remains a compatibility alias. If a Trivy DB was installed before 0.3.2, scan output tells your agent that CVE rescan tracking is not yet enabled. The agent should run `npx codeinspectus repair-engines` @@ -281,6 +292,7 @@ useful when you want the same policy persisted explicitly in a repository. | Tool | Purpose | |------|---------| | `codeinspectus_scan` | Full local scan of a path (engines + AI checks). Returns CWE-keyed findings, detected technologies, exact native-pack and Pub dependency coverage, remediations, framework tags, and three-state repository evidence for supported runtime controls. | +| `codeinspectus_setup` | Offline setup plan, saved decline choices, or approval-gated verified downloads to `~/.codeinspectus`; never writes to the target repository. | | `codeinspectus_rescan` | Re-scan after fixes; diffs vs a prior scan → resolved / remaining / introduced, with fresh technology and pack coverage. | | `codeinspectus_compliance_report` | Per-framework **code-level control coverage** (not certification). | | `codeinspectus_explain_finding` | Deep explanation + full remediation for one finding. | diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 0083c71..15cecad 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -1,6 +1,6 @@ # Third-party notices -This document records third-party software and data used by CodeInspectus 2.5.0. CodeInspectus +This document records third-party software and data used by CodeInspectus 2.6.0. CodeInspectus itself is licensed under Apache-2.0; third-party components retain their own licenses. ## Separately downloaded security engines @@ -10,6 +10,19 @@ The following executables are **not included in the CodeInspectus npm package**. publisher/checksum evidence and exact binary SHA-256 from `engines.lock.json`, and stores them in a per-user managed directory. CodeInspectus is not affiliated with or endorsed by these projects. +### Cosign 3.1.2 (verification helper) + +- Project: +- Exact source: +- License: Apache License 2.0 +- License text: +- Copyright: The Sigstore Authors + +Guided setup downloads Cosign only when Opengrep or Trivy needs publisher-signature verification +and no usable system copy exists. Its platform binary SHA-256 is pinned from the upstream checksum +manifest after that manifest's Sigstore bundle verified successfully. Cosign is stored separately +under `~/.codeinspectus/bin`; it is not included in the npm package. + ### Opengrep 1.23.0 - Project: diff --git a/detection-db/osv-pub/snapshot.json b/detection-db/osv-pub/snapshot.json index 1f52be0..9f0b820 100644 --- a/detection-db/osv-pub/snapshot.json +++ b/detection-db/osv-pub/snapshot.json @@ -2,8 +2,8 @@ "schema_version": "1.0.0", "snapshot": { "ecosystem": "Pub", - "snapshot_version": "2026-08-13.28cf740b203f", - "checked_at": "2026-08-13T03:28:38.176Z", + "snapshot_version": "2026-08-20.28cf740b203f", + "checked_at": "2026-08-20T04:30:10.006Z", "latest_record_modified": "2026-07-08T06:53:12.190101301Z", "source_index_url": "https://storage.googleapis.com/osv-vulnerabilities/Pub/modified_id.csv", "source_index_sha256": "sha256:28cf740b203f097a1707dbe015e73ba76e78653c02d2b5d89af98992a3bd061a", diff --git a/docs/CI-POLICY.md b/docs/CI-POLICY.md index 5516a97..fb50e18 100644 --- a/docs/CI-POLICY.md +++ b/docs/CI-POLICY.md @@ -38,7 +38,7 @@ written before the policy exit is returned, including exits 1 and 2, so CI can r The shipped [copyable workflow](examples/codeinspectus-security.yml) separates the explicit, network-permitted install/engine-repair steps from the offline scan step. Every third-party action is -pinned to a full commit SHA. It installs the exact `codeinspectus@2.5.0` release under +pinned to a full commit SHA. It installs the exact `codeinspectus@2.6.0` release under `$RUNNER_TEMP` with npm lifecycle scripts disabled; it does not run the target repository's package install, lifecycle scripts, build, tests, or other code. The job uses least privilege (`contents: read`, `security-events: write`), checks out with `persist-credentials: false`, does not reference repository diff --git a/docs/CLI-REFERENCE.md b/docs/CLI-REFERENCE.md index f4f393b..fcee940 100644 --- a/docs/CLI-REFERENCE.md +++ b/docs/CLI-REFERENCE.md @@ -92,6 +92,11 @@ destination lookup, issue submission, or network request. `issue submit` is inte ## Engine lifecycle and metadata ```bash +codeinspectus setup +codeinspectus setup --status +codeinspectus setup --all +codeinspectus setup --select opengrep,gitleaks,trivy +codeinspectus setup --reset codeinspectus repair-engines [ENGINE] codeinspectus install-engines [ENGINE] codeinspectus verify-engines @@ -100,8 +105,16 @@ codeinspectus --version codeinspectus --help ``` +Bare `codeinspectus` starts guided setup only when stdin and stdout are interactive terminals; +piped stdio still starts the MCP server. `setup` shows exact pinned binary asset sizes and a clearly +labelled Trivy database disk estimate before approval. `--all` and `--select` are explicit +noninteractive approvals. Saved choices prevent repeat prompting; `--reset` removes them. + `install-engines` remains a compatibility alias. `pin-engines` is maintainer-only. Normal scans -never repair or download engines implicitly. +never repair or download engines implicitly. No npm lifecycle script downloads executables. +The pinned Linux Opengrep release assets require glibc. On Alpine/musl, setup blocks Opengrep +before download while leaving native rules, Gitleaks, and Trivy available; scans report the missing +Opengrep surface as partial coverage. ## Exit status diff --git a/docs/TYPESCRIPT-SDK.md b/docs/TYPESCRIPT-SDK.md index dff9746..f4c9aab 100644 --- a/docs/TYPESCRIPT-SDK.md +++ b/docs/TYPESCRIPT-SDK.md @@ -36,7 +36,7 @@ The RC exports explicit contract names: - `RepositoryHistoryManifestV1` - `IssuePayloadV1`, `IssueAdapter`, `DestinationVisibility` -`SDK_API_VERSION` is `2.5.0`. `SDK_COMPATIBILITY` records the exact schema versions understood +`SDK_API_VERSION` is `2.6.0`. `SDK_COMPATIBILITY` records the exact schema versions understood by typed helpers. The V2 SDK accepts those schema versions and fails closed on another version; additive optional fields within a compatible schema do not break consumers. Removing or changing a required field, exit meaning or command semantic requires a new contract version and SDK major. diff --git a/docs/context/verify.md b/docs/context/verify.md index 4f7e2ad..a5ba64e 100644 --- a/docs/context/verify.md +++ b/docs/context/verify.md @@ -111,7 +111,7 @@ Expected: all focused tests pass; manifest `1.19.0` owns exactly 72 native rule JavaScript/TypeScript, 6 Flutter/Dart, 4 Android, 4 iOS, 4 React Native, 2 Expo, 10 Python AI/API, 1 Go AI, 1 Java AI, 1 C# AI, 1 PHP AI, 1 Rust AI, 1 Ruby AI, 3 Firebase configuration, 2 GitHub Actions workflow, and 2 JavaScript baseline SAST) across sixteen packs and 45 analyzers; -the aggregate native engine is `5.19.0`. The JavaScript/TypeScript pack is `1.9.0`; the Flutter +the aggregate native engine is `5.20.0`. The JavaScript/TypeScript pack is `1.9.0`; the Flutter pack is `1.1.0`. A detected Flutter project runs the six Flutter analyzers, a plain Dart package reports that pack as `not_applicable`, Android/iOS project evidence activates only its matching platform pack, and scanner-filter exclusion reports installed packs as `not_run` diff --git a/docs/examples/codeinspectus-security.yml b/docs/examples/codeinspectus-security.yml index 56e64b8..ef70c89 100644 --- a/docs/examples/codeinspectus-security.yml +++ b/docs/examples/codeinspectus-security.yml @@ -32,12 +32,9 @@ jobs: with: node-version: 24 - - name: Install cosign for fail-closed engine verification - uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - # Network-permitted preparation. Install the exact released scanner outside the target. # --ignore-scripts prevents npm lifecycle execution; no target package/build code runs. - - name: Install CodeInspectus 2.5.0 outside the repository + - name: Install CodeInspectus 2.6.0 outside the repository shell: bash run: | npm install \ @@ -45,10 +42,10 @@ jobs: --no-save \ --no-package-lock \ --prefix "$RUNNER_TEMP/codeinspectus-cli" \ - codeinspectus@2.5.0 + codeinspectus@2.6.0 - - name: Explicitly repair verified engines and the offline vulnerability database - run: node "$RUNNER_TEMP/codeinspectus-cli/node_modules/codeinspectus/dist/index.js" repair-engines --refresh-db + - name: Explicitly approve and install verified engines and the offline vulnerability database + run: node "$RUNNER_TEMP/codeinspectus-cli/node_modules/codeinspectus/dist/index.js" setup --all - name: Verify installed engine hashes before scanning run: node "$RUNNER_TEMP/codeinspectus-cli/node_modules/codeinspectus/dist/index.js" verify-engines diff --git a/engines.lock.json b/engines.lock.json index 3b546c2..f32b268 100644 --- a/engines.lock.json +++ b/engines.lock.json @@ -1,13 +1,30 @@ { "_comment": "SHA256 pins for engine binaries. MANDATORY (PRD §0.2): every binary's SHA256 is verified before execution. Trivy was supply-chain-compromised twice in early 2026 — never trust a mutable tag. Maintainers populate sha256 via `codeinspectus pin-engines` (`--all-platforms`, `--platform `, or `--pin-only`) and commit the reviewed lockfile. User-facing `repair-engines` verifies downloads against these immutable shipped pins and never rewrites this file. The flow downloads from the verified release URL, checks the publisher signature (cosign for opengrep, checksums for gitleaks, checksums+sigstore for trivy), then verifies the EXTRACTED binary's SHA256. A null sha256 means the binary is NOT pinned and CodeInspectus will REFUSE to execute it on that platform.", "schema_version": 1, - "generated_at": "2026-06-20T23:32:58.571Z", + "generated_at": "2026-08-20T02:54:34.000Z", "sigstore_identities": { "_comment": "Cosign keyless signer identities, resolved from the actual release certificates 2026-06-21 and confirmed with `cosign verify-blob` (Verified OK). Signature verification is MANDATORY and fail-closed for opengrep + trivy.", "issuer": "https://token.actions.githubusercontent.com", "trivy": "https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v0.71.2", "opengrep": "https://github.com/opengrep/opengrep/.github/workflows/rolling-release.yml@refs/heads/main" }, + "verifiers": { + "cosign": { + "version": "3.1.2", + "repo": "sigstore/cosign", + "release_base": "https://github.com/sigstore/cosign/releases/download/v3.1.2", + "signature": "checksums+sigstore", + "checksums_asset": "cosign_checksums.txt", + "_security_note": "Bootstrap SHA pins were taken from cosign_checksums.txt after its Sigstore bundle verified OK for keyless@projectsigstore.iam.gserviceaccount.com on 2026-08-20.", + "platforms": { + "darwin-arm64": { "asset": "cosign-darwin-arm64", "archive": "raw", "binary": "cosign", "sha256": "dec1c3f802320b19c2fbcf2dc7bcfb3f258e1c181a046c23a1a074bdf932f10a", "download_size_bytes": 139584002, "provenance": { "method": "cosign", "verified": true, "at": "2026-08-20T02:54:34.000Z", "identity": "keyless@projectsigstore.iam.gserviceaccount.com", "issuer": "https://accounts.google.com" } }, + "darwin-x64": { "asset": "cosign-darwin-amd64", "archive": "raw", "binary": "cosign", "sha256": "acd180f8b015be25240ca33abee8a1e564eb65cdf1a3cee4725456d2dceb7da6", "download_size_bytes": 148576536, "provenance": { "method": "cosign", "verified": true, "at": "2026-08-20T02:54:34.000Z", "identity": "keyless@projectsigstore.iam.gserviceaccount.com", "issuer": "https://accounts.google.com" } }, + "linux-x64": { "asset": "cosign-linux-amd64", "archive": "raw", "binary": "cosign", "sha256": "f7622ed3cf22e55e1ae6377c080979ff77a22da9981c11df222a2e444991e7cf", "download_size_bytes": 141150460, "provenance": { "method": "cosign", "verified": true, "at": "2026-08-20T02:54:34.000Z", "identity": "keyless@projectsigstore.iam.gserviceaccount.com", "issuer": "https://accounts.google.com" } }, + "linux-arm64": { "asset": "cosign-linux-arm64", "archive": "raw", "binary": "cosign", "sha256": "90e7ae0b5dfd60f20816b52c012addf7fc055ebcc7bea4ce81c428ca8518c302", "download_size_bytes": 132737437, "provenance": { "method": "cosign", "verified": true, "at": "2026-08-20T02:54:34.000Z", "identity": "keyless@projectsigstore.iam.gserviceaccount.com", "issuer": "https://accounts.google.com" } }, + "win32-x64": { "asset": "cosign-windows-amd64.exe", "archive": "raw", "binary": "cosign", "sha256": "fe4d621d7ae5e900ee62089837c00f996ae9acb82027d573d1d157b6ee875cb2", "download_size_bytes": 198762712, "provenance": { "method": "cosign", "verified": true, "at": "2026-08-20T02:54:34.000Z", "identity": "keyless@projectsigstore.iam.gserviceaccount.com", "issuer": "https://accounts.google.com" } } + } + } + }, "engines": { "opengrep": { "version": "1.23.0", @@ -21,6 +38,7 @@ "archive": "raw", "binary": "opengrep", "sha256": "945739e56fec4aab28da296811f3473a0ff733af3dc2371d150b071430265c5f", + "download_size_bytes": 43236128, "provenance": { "method": "cosign", "verified": true, @@ -34,6 +52,7 @@ "archive": "raw", "binary": "opengrep", "sha256": "2fa99169e34e9f233fd7412d5cdc167114ba752a2510da3ce322b272c15ab755", + "download_size_bytes": 43843472, "provenance": { "method": "cosign", "verified": true, @@ -47,6 +66,8 @@ "archive": "raw", "binary": "opengrep", "sha256": "1f06548af379ab6080698a609612890ffad2d92dc2172f1e97d38d48096d5ef8", + "runtime": { "libc": "glibc" }, + "download_size_bytes": 41612596, "provenance": { "method": "cosign", "verified": true, @@ -60,6 +81,8 @@ "archive": "raw", "binary": "opengrep", "sha256": "ddf4935b138a2e825e6860529df1fb031524f7a2da8933ab7b2a16e5939c5178", + "runtime": { "libc": "glibc" }, + "download_size_bytes": 43242485, "provenance": { "method": "cosign", "verified": true, @@ -73,6 +96,7 @@ "archive": "raw", "binary": "opengrep", "sha256": "7cbe02d9cc3fb812adce8720fc46515bb6a954a4ceeb3856d3a099d783749aae", + "download_size_bytes": 48520704, "provenance": { "method": "cosign", "verified": true, @@ -95,6 +119,7 @@ "archive": "tar.gz", "binary": "gitleaks", "sha256": "ba52fb1bfabbcde42f032afad3d6e0b19dff8ed105229a16e7caa338bbc0e84f", + "download_size_bytes": 7897593, "provenance": { "method": "checksums", "verified": true, @@ -106,6 +131,7 @@ "archive": "tar.gz", "binary": "gitleaks", "sha256": "cee01fea7173f1b779dff188e1c26ecbcb4027d394acc573b23aaf0be260e291", + "download_size_bytes": 8359235, "provenance": { "method": "checksums", "verified": true, @@ -117,6 +143,7 @@ "archive": "tar.gz", "binary": "gitleaks", "sha256": "88f91962aa2f93ac6ab281d553b9e125f5197bbbce38f9f2437f7299c32e5509", + "download_size_bytes": 8230402, "provenance": { "method": "checksums", "verified": true, @@ -128,6 +155,7 @@ "archive": "tar.gz", "binary": "gitleaks", "sha256": "00e91bbe655bd7c47753e8cfe61cb76ea1a5d7e7702fe161ee40102b46b3823b", + "download_size_bytes": 7601421, "provenance": { "method": "checksums", "verified": true, @@ -139,6 +167,7 @@ "archive": "zip", "binary": "gitleaks", "sha256": "17157e2ee8b76fc8b1d8bee607a250e34b8a8023c8bc81822d4b5ee4d78fcb7c", + "download_size_bytes": 8438883, "provenance": { "method": "checksums", "verified": true, @@ -160,6 +189,7 @@ "archive": "tar.gz", "binary": "trivy", "sha256": "7d8bf18455702bdacb585e4041591c7019c39cc5fe14beacd1485e89d619127d", + "download_size_bytes": 47498236, "provenance": { "method": "cosign", "verified": true, @@ -173,6 +203,7 @@ "archive": "tar.gz", "binary": "trivy", "sha256": "f83c71055d30baa3bf0c74a5659a8d38d5ac0781c9a65a6841c5696f8b66dfd6", + "download_size_bytes": 51331511, "provenance": { "method": "cosign", "verified": true, @@ -186,6 +217,7 @@ "archive": "tar.gz", "binary": "trivy", "sha256": "46d2e9f20fc45d131d2c590497d3b598afe6be67a58ce105113e43f1328779bf", + "download_size_bytes": 50304278, "provenance": { "method": "cosign", "verified": true, @@ -199,6 +231,7 @@ "archive": "tar.gz", "binary": "trivy", "sha256": "5dcf6209995ed375f39fe38bed250ac292fe111077d39f864ff270788846c88b", + "download_size_bytes": 45242800, "provenance": { "method": "cosign", "verified": true, @@ -212,6 +245,7 @@ "archive": "zip", "binary": "trivy", "sha256": "b4e76b9c0bc74bd70f2f1269e86c055f9f57a4f25307bbe0847ec43332ad2ef6", + "download_size_bytes": 51536693, "provenance": { "method": "cosign", "verified": true, diff --git a/evals/run-evals.ts b/evals/run-evals.ts index 08c48b8..b1db8c9 100644 --- a/evals/run-evals.ts +++ b/evals/run-evals.ts @@ -680,8 +680,8 @@ function assertFlutterExecutionEnvelope( ); assert(aiEngines.length === 1, `${label}: expected exactly one AI engine run record`); assert( - aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected codeinspectus-ai@5.19.0 to run`, + aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected codeinspectus-ai@5.20.0 to run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -822,8 +822,8 @@ function assertMobileExecutionEnvelope( (engine: any) => engine.engine === "codeinspectus-ai", ); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -972,8 +972,8 @@ function assertReactNativeExpoExecutionEnvelope( (engine: any) => engine.engine === "codeinspectus-ai", ); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1101,8 +1101,8 @@ function assertPythonAiApiExecutionEnvelope( (engine: any) => engine.engine === "codeinspectus-ai", ); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1200,8 +1200,8 @@ function assertGoAiExecutionEnvelope( (engine: any) => engine.engine === "codeinspectus-ai", ); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1302,8 +1302,8 @@ function assertJavaAiExecutionEnvelope( (engine: any) => engine.engine === "codeinspectus-ai", ); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1396,8 +1396,8 @@ function assertCsharpAiExecutionEnvelope( if (!expectEngineDetails) return; const aiEngines = (result.engine_details ?? []).filter((engine: any) => engine.engine === "codeinspectus-ai"); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1484,8 +1484,8 @@ function assertPhpAiExecutionEnvelope( if (!expectEngineDetails) return; const aiEngines = (result.engine_details ?? []).filter((engine: any) => engine.engine === "codeinspectus-ai"); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1572,8 +1572,8 @@ function assertRustAiExecutionEnvelope( if (!expectEngineDetails) return; const aiEngines = (result.engine_details ?? []).filter((engine: any) => engine.engine === "codeinspectus-ai"); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1660,8 +1660,8 @@ function assertRubyAiExecutionEnvelope( if (!expectEngineDetails) return; const aiEngines = (result.engine_details ?? []).filter((engine: any) => engine.engine === "codeinspectus-ai"); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1748,8 +1748,8 @@ function assertFirebaseExecutionEnvelope( if (!expectEngineDetails) return; const aiEngines = (result.engine_details ?? []).filter((engine: any) => engine.engine === "codeinspectus-ai"); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -1831,8 +1831,8 @@ function assertGithubActionsExecutionEnvelope( if (!expectEngineDetails) return; const aiEngines = (result.engine_details ?? []).filter((engine: any) => engine.engine === "codeinspectus-ai"); assert( - aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.19.0", - `${label}: expected exactly one codeinspectus-ai@5.19.0 run`, + aiEngines.length === 1 && aiEngines[0].ran === true && aiEngines[0].version === "5.20.0", + `${label}: expected exactly one codeinspectus-ai@5.20.0 run`, ); assert( (result.engine_details ?? []).every((engine: any) => engine.engine === "codeinspectus-ai"), @@ -2464,7 +2464,7 @@ async function main() { assert(boundaryFindings.every((x) => x.producer_components?.some((component: string) => component.startsWith("ai:"))), "API-boundary finding missing detector provenance"); const serialized = JSON.stringify(boundaryScan); assert(!serialized.includes("provider failure") && !serialized.includes("database unavailable"), "API-boundary output leaked planted internal detail"); - assert(boundaryScan.engine_details.some((x: any) => x.engine === "codeinspectus-ai" && x.version === "5.19.0"), "AI engine version was not bumped for expanded multi-pack coverage"); + assert(boundaryScan.engine_details.some((x: any) => x.engine === "codeinspectus-ai" && x.version === "5.20.0"), "AI engine version was not bumped for expanded multi-pack coverage"); }, }, { diff --git a/package-lock.json b/package-lock.json index c492ebf..60d2eb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeinspectus", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeinspectus", - "version": "2.5.0", + "version": "2.6.0", "license": "Apache-2.0", "dependencies": { "@lezer/python": "1.1.19", diff --git a/package.json b/package.json index 1da1d6a..7276779 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeinspectus", - "version": "2.5.0", + "version": "2.6.0", "mcpName": "io.github.Synvoya/codeinspectus", "description": "CodeInspectus, by Synvoya — a local-first security MCP server and CLI with CI policy, versioned exports, evidence bundles, history, baselines, and approval-gated agent workflows. Zero network egress at scan time.", "license": "Apache-2.0", diff --git a/scripts/sdk-consumer-e2e.mjs b/scripts/sdk-consumer-e2e.mjs index 5e3975b..5e288cd 100644 --- a/scripts/sdk-consumer-e2e.mjs +++ b/scripts/sdk-consumer-e2e.mjs @@ -23,7 +23,7 @@ try { const client = new CodeInspectusClient(); const result = await client.run(["--version"]); if (result.exitCode !== 0 || result.stdout.trim() !== ${JSON.stringify(sourceVersion)}) throw new Error("installed CLI invocation failed"); - if (SDK_API_VERSION !== "2.5.0" || SDK_COMPATIBILITY.export_schema !== "2.0.0") throw new Error("SDK compatibility metadata mismatch"); + if (SDK_API_VERSION !== "2.6.0" || SDK_COMPATIBILITY.export_schema !== "2.0.0") throw new Error("SDK compatibility metadata mismatch"); process.stdout.write(JSON.stringify({ sdk: SDK_API_VERSION, cli: result.stdout.trim(), exit: result.exitCode })); `); const runtime = execFileSync(process.execPath, [join(consumer, "consumer.mjs")], { cwd: consumer, encoding: "utf8" }); diff --git a/scripts/smoke-stdio.mjs b/scripts/smoke-stdio.mjs index f250973..3ecbfc6 100644 --- a/scripts/smoke-stdio.mjs +++ b/scripts/smoke-stdio.mjs @@ -84,6 +84,7 @@ async function waitFor(id, timeoutMs = 8000) { console.error("✓ tools/list:", tools.join(", ")); const expected = [ "codeinspectus_scan", + "codeinspectus_setup", "codeinspectus_rescan", "codeinspectus_compliance_report", "codeinspectus_explain_finding", @@ -98,13 +99,29 @@ async function waitFor(id, timeoutMs = 8000) { jsonrpc: "2.0", id: 3, method: "tools/call", + params: { name: "codeinspectus_setup", arguments: { action: "plan", components: ["gitleaks"] } }, + }); + const setup = await waitFor(3); + const setupPlan = setup.result?.structuredContent; + if (setupPlan?.outcome !== "planned" || setupPlan?.plan?.schema_version !== "1.0.0") { + throw new Error("setup plan returned no validated structuredContent"); + } + if (setupPlan.plan.components.filter((item) => item.selected).map((item) => item.id).join(",") !== "gitleaks") { + throw new Error("setup plan did not preserve the requested component selection"); + } + console.error("✓ codeinspectus_setup: offline component plan returned"); + + send({ + jsonrpc: "2.0", + id: 4, + method: "tools/call", params: { name: "codeinspectus_scan", arguments: { path: process.cwd() } }, }); // A full-project scan invokes all managed engines and can legitimately take // longer than the lightweight initialize/tools-list requests, especially on // cold CI runners. Keep the short default for protocol calls, but give the // scanner a bounded, engine-appropriate window. - const scan = await waitFor(3, 30_000); + const scan = await waitFor(4, 30_000); const sc = scan.result?.structuredContent; if (!sc || typeof sc.scan_id !== "string") throw new Error("scan returned no structuredContent"); if (!Array.isArray(sc.detected_technologies)) throw new Error("scan returned no detected_technologies array"); diff --git a/server.json b/server.json index 201a30d..c9b91c7 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.Synvoya/codeinspectus", "title": "CodeInspectus", "description": "Local-first MCP security scanner and CLI for AI-generated applications.", - "version": "2.5.0", + "version": "2.6.0", "websiteUrl": "https://codeinspectus.com", "repository": { "url": "https://github.com/Synvoya/codeinspectus", @@ -13,7 +13,7 @@ { "registryType": "npm", "identifier": "codeinspectus", - "version": "2.5.0", + "version": "2.6.0", "registryBaseUrl": "https://registry.npmjs.org", "transport": { "type": "stdio" } } diff --git a/src/ai-checks/supabase-edge-auth.test.ts b/src/ai-checks/supabase-edge-auth.test.ts index e13cc97..a4aa1d6 100644 --- a/src/ai-checks/supabase-edge-auth.test.ts +++ b/src/ai-checks/supabase-edge-auth.test.ts @@ -29,6 +29,14 @@ async function tempProject(): Promise { } describe("Supabase Edge deployment and handler-root authentication", () => { + test("treats a direct file target as outside project-level Edge deployment analysis", async () => { + const root = await tempProject(); + const file = join(root, "route.ts"); + await writeFile(file, "export const value = 1;\n"); + + await expect(runSupabaseEdgeAuthAnalysis(file)).resolves.toEqual({ findings: [], notes: [] }); + }); + test("reports only request-reachable privileged sinks without authentication", async () => { const findings = await runSupabaseEdgeAuthCheck(CORPUS); diff --git a/src/ai-checks/supabase-edge-auth.ts b/src/ai-checks/supabase-edge-auth.ts index f8ffffa..4fa5af9 100644 --- a/src/ai-checks/supabase-edge-auth.ts +++ b/src/ai-checks/supabase-edge-auth.ts @@ -186,6 +186,13 @@ async function loadProject(target: string, notes: BoundedNotes): Promise undefined); + if (targetInfo?.isFile()) return { root, files, skipped, incomplete }; + const walk = async (directory: string): Promise => { if (++directories > MAX_DIRECTORIES) { incomplete = true; diff --git a/src/bundle/index.test.ts b/src/bundle/index.test.ts index 0a72dae..ecb1554 100644 --- a/src/bundle/index.test.ts +++ b/src/bundle/index.test.ts @@ -52,7 +52,7 @@ describe("sealed scan bundles", () => { expect(verified.manifest).toMatchObject({ schema_version: "1.0.0", scan_id: scan().scan_id, detection_database: { version: "1.19.0", date: "2026-08-13" }, - native_engine: { name: "codeinspectus-ai", version: "5.19.0" }, + native_engine: { name: "codeinspectus-ai", version: "5.20.0" }, artifacts: expect.arrayContaining([expect.objectContaining({ path: "artifacts/scan-record.json", sha256: expect.stringMatching(/^[0-9a-f]{64}$/) })]), }); expect(verified.manifest.commodity_engines.every((engine) => engine.integrity_state === "verified" && /^[0-9a-f]{64}$/.test(engine.verified_sha256!))).toBe(true); diff --git a/src/ci-workflow.test.ts b/src/ci-workflow.test.ts index c7e3266..adc3f5e 100644 --- a/src/ci-workflow.test.ts +++ b/src/ci-workflow.test.ts @@ -19,14 +19,14 @@ describe("shipped GitHub Actions policy workflow", () => { expect(executable).not.toMatch(/pull_request_target|secrets\.|github\.token|GITHUB_TOKEN/); expect(executable).not.toMatch(/npm ci|npm run|npx /); expect(executable).toMatch(/--ignore-scripts/); - expect(executable).toMatch(/codeinspectus@2\.5\.0/); + expect(executable).toMatch(/codeinspectus@2\.6\.0/); expect(executable).toMatch(/\$RUNNER_TEMP\/codeinspectus-cli/); expect(source).toMatch(/persist-credentials:\s*false/); }); test("repairs before the offline scan, uploads evidence, then restores policy status", async () => { const source = await readFile(path, "utf8"); - const repair = source.indexOf("repair-engines --refresh-db"); + const repair = source.indexOf("setup --all"); const scan = source.indexOf("--fail-on-severity high"); const summary = source.indexOf("Add bounded policy summary"); const sarif = source.indexOf("github/codeql-action/upload-sarif@"); diff --git a/src/cli.test.ts b/src/cli.test.ts index e386b64..118ff6c 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -177,8 +177,9 @@ describe("CLI parser", () => { expect(() => parseCliCommand(argv as string[])).toThrow(expected as RegExp); }); - test("help documents MCP default and the first-class commands", () => { - expect(cliHelp()).toMatch(/Start the MCP server over stdio/); + test("help documents interactive setup, piped MCP, and the first-class commands", () => { + expect(cliHelp()).toMatch(/Guided setup in a terminal; MCP server over piped stdio/); + expect(cliHelp()).toMatch(/codeinspectus setup \[options\]/); expect(cliHelp()).toMatch(/codeinspectus scan /); expect(cliHelp()).toMatch(/codeinspectus scans /); expect(cliHelp()).toMatch(/codeinspectus bundle /); diff --git a/src/cli.ts b/src/cli.ts index e1ca69b..731aaea 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -269,7 +269,8 @@ export function cliHelp(command?: "scan" | "preflight" | "export"): string { "CodeInspectus, by Synvoya — local-first security MCP server and CLI.", "", "Usage:", - " codeinspectus Start the MCP server over stdio (default).", + " codeinspectus Guided setup in a terminal; MCP server over piped stdio.", + " codeinspectus setup [options] Review and approve external engine downloads.", " codeinspectus scan [options] Scan a local file or directory.", " codeinspectus preflight [options] Inspect readiness without scanning or writing.", " codeinspectus export --format [options] Export a stored canonical scan.", @@ -289,6 +290,7 @@ export function cliHelp(command?: "scan" | "preflight" | "export"): string { ...common, "", "Scans are offline. Preflight never downloads, repairs, authenticates, scans, or writes.", + "Setup options: --status, --all, --select opengrep,gitleaks,trivy, --reset.", "No account. No telemetry.", "", ].join("\n"); diff --git a/src/config.ts b/src/config.ts index 8dd002a..cce16df 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,7 +23,7 @@ import { dirname } from "node:path"; export const MCP_SPEC_VERSION = "2025-11-25"; export const SERVER_NAME = "codeinspectus"; -export const SERVER_VERSION = "2.5.0"; +export const SERVER_VERSION = "2.6.0"; // ── Bundled engine versions (SHA-pinned in engines.lock.json) ─────────────── export const ENGINE_VERSIONS = { @@ -37,13 +37,14 @@ export type EngineName = "opengrep" | "gitleaks" | "trivy"; // Aggregate CodeInspectus native-analyzer engine version. Individual detector packs // retain their own semantic versions so adding a pack does not imply unrelated rule changes. -export const CODEINSPECTUS_AI_VERSION = "5.19.0"; +export const CODEINSPECTUS_AI_VERSION = "5.20.0"; export const CODEINSPECTUS_PUB_VERSION = "1.0.0"; // ── Managed directories (per-machine, never per-repo) ─────────────────────── // PRD §12: an MCP server is installed once per machine, not per repo. export const MANAGED_ROOT = join(homedir(), ".codeinspectus"); export const MANAGED_BIN = join(MANAGED_ROOT, "bin"); +export const MANAGED_OPENGREP_CACHE = join(MANAGED_ROOT, "opengrep-cache"); export const MANAGED_TRIVY_CACHE = join(MANAGED_ROOT, "trivy-cache"); export const MANAGED_SCANS = join(MANAGED_ROOT, "scans"); export const MANAGED_TRIAGE = join(MANAGED_ROOT, "triage"); @@ -53,6 +54,7 @@ export const MANAGED_PROVENANCE = join(MANAGED_ROOT, "provenance"); export const MANAGED_TRIVY_DB_META = join(MANAGED_TRIVY_CACHE, "db", "metadata.json"); export const MANAGED_TRIVY_DB = join(MANAGED_TRIVY_CACHE, "db", "trivy.db"); export const MANAGED_TRIVY_DB_PROVENANCE = join(MANAGED_PROVENANCE, "trivy", "vulnerability-db.json"); +export const MANAGED_SETUP_PREFERENCES = join(MANAGED_ROOT, "setup-preferences.json"); // ── Package-root-relative asset resolution ────────────────────────────────── // At runtime the bundle lives at /dist/index.js; data/ and detection-db/ diff --git a/src/engines/lockfile.test.ts b/src/engines/lockfile.test.ts new file mode 100644 index 0000000..c062648 --- /dev/null +++ b/src/engines/lockfile.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "vitest"; + +import { platformRuntimeIssue, type PlatformEntry } from "./lockfile.js"; + +const entry: PlatformEntry = { + asset: "engine", + archive: "raw", + binary: "engine", + sha256: "0".repeat(64), + runtime: { libc: "glibc" }, +}; + +describe("engine runtime requirements", () => { + test("blocks glibc-only Linux assets on non-glibc and unknown runtimes", () => { + expect(platformRuntimeIssue(entry, "linux", "non-glibc")).toMatch(/requires glibc/i); + expect(platformRuntimeIssue(entry, "linux", "unknown")).toMatch(/could not be verified/i); + }); + + test("accepts a verified glibc runtime and ignores libc requirements off Linux", () => { + expect(platformRuntimeIssue(entry, "linux", "glibc")).toBeUndefined(); + expect(platformRuntimeIssue(entry, "darwin", "unknown")).toBeUndefined(); + }); +}); diff --git a/src/engines/lockfile.ts b/src/engines/lockfile.ts index e54531b..c673d4f 100644 --- a/src/engines/lockfile.ts +++ b/src/engines/lockfile.ts @@ -20,6 +20,10 @@ export interface PlatformEntry { archive: "raw" | "tar.gz" | "zip"; binary: string; sha256: string | null; + /** Runtime ABI required by this upstream asset, when narrower than the OS key. */ + runtime?: { libc: "glibc" }; + /** Exact upstream release-asset bytes, recorded during release pinning. */ + download_size_bytes?: number; provenance?: Provenance; _verify?: string; } @@ -39,6 +43,8 @@ export interface Lockfile { generated_at: string | null; sigstore_identities?: Record; engines: Record; + /** Bootstrap tools are not scanners, but use the same immutable release-pin shape. */ + verifiers?: { cosign: EngineLockEntry }; } /** Node platform/arch → lockfile platform key. */ @@ -48,6 +54,30 @@ export function platformKey(): string { return `${platform}-${arch}`; } +export type LinuxLibc = "glibc" | "non-glibc" | "unknown"; + +/** Detect the ABI of the running Node process without executing external tools. */ +export function linuxLibc(): LinuxLibc { + if (process.platform !== "linux") return "unknown"; + try { + const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined; + return report?.header?.glibcVersionRuntime ? "glibc" : "non-glibc"; + } catch { + return "unknown"; + } +} + +export function platformRuntimeIssue( + entry: PlatformEntry, + platform = process.platform, + libc = linuxLibc(), +): string | undefined { + if (platform !== "linux" || entry.runtime?.libc !== "glibc" || libc === "glibc") return undefined; + return libc === "non-glibc" + ? "The pinned upstream binary requires glibc, but this Linux runtime is non-glibc (for example Alpine/musl)." + : "The pinned upstream binary requires glibc, but this Linux runtime ABI could not be verified."; +} + export async function loadLockfile(): Promise { const raw = await readFile(ENGINES_LOCKFILE, "utf8"); return JSON.parse(raw) as Lockfile; diff --git a/src/engines/opengrep.test.ts b/src/engines/opengrep.test.ts new file mode 100644 index 0000000..ce4189c --- /dev/null +++ b/src/engines/opengrep.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "vitest"; +import { join } from "node:path"; + +import { MANAGED_OPENGREP_CACHE, OPENGREP_RULES_DIR } from "../config.js"; +import { buildOpengrepArgs, opengrepExecEnvironment } from "./opengrep.js"; + +describe("Opengrep offline invocation", () => { + test("disables version checks and routes all writable runtime paths outside the target", () => { + const target = "/repository"; + const temporary = "/managed-temporary"; + const args = buildOpengrepArgs(target, `${temporary}/result.sarif`); + const env = opengrepExecEnvironment(temporary); + + expect(args).toContain("--disable-version-check"); + expect(args).toContain(OPENGREP_RULES_DIR); + expect(args.at(-1)).toBe(target); + expect(env).toEqual({ + XDG_CACHE_HOME: MANAGED_OPENGREP_CACHE, + SEMGREP_LOG_FILE: join(temporary, "opengrep.log"), + SEMGREP_VERSION_CACHE_PATH: join(temporary, "opengrep-version-cache"), + OPENGREP_ENABLE_VERSION_CHECK: "0", + }); + expect(Object.values(env).some((value) => value.startsWith(target))).toBe(false); + }); +}); diff --git a/src/engines/opengrep.ts b/src/engines/opengrep.ts index 8f1a850..795beea 100644 --- a/src/engines/opengrep.ts +++ b/src/engines/opengrep.ts @@ -7,9 +7,9 @@ * or registry packs, which would require network and break zero-egress. */ -import { readFile, access } from "node:fs/promises"; +import { readFile, access, mkdir } from "node:fs/promises"; import { join } from "node:path"; -import { OPENGREP_RULES_DIR } from "../config.js"; +import { MANAGED_OPENGREP_CACHE, OPENGREP_RULES_DIR } from "../config.js"; import { resolveEngine, EngineUnavailableError } from "./resolve.js"; import { execBinary } from "./exec.js"; import { log } from "../logger.js"; @@ -38,9 +38,11 @@ export async function runOpengrep(target: string, tmpDir: string): Promise { + return { + XDG_CACHE_HOME: MANAGED_OPENGREP_CACHE, + SEMGREP_LOG_FILE: join(tmpDir, "opengrep.log"), + SEMGREP_VERSION_CACHE_PATH: join(tmpDir, "opengrep-version-cache"), + OPENGREP_ENABLE_VERSION_CHECK: "0", + }; +} + function engineNote( engine: "opengrep", version: string, diff --git a/src/engines/resolve.ts b/src/engines/resolve.ts index 7953efe..60ed023 100644 --- a/src/engines/resolve.ts +++ b/src/engines/resolve.ts @@ -16,7 +16,7 @@ import { constants as FS } from "node:fs"; import { join } from "node:path"; import { MANAGED_BIN, PKG_ROOT, type EngineName } from "../config.js"; import { sha256Hex } from "../util/hash.js"; -import { loadLockfile, getPlatformEntry, platformKey, type Lockfile } from "./lockfile.js"; +import { loadLockfile, getPlatformEntry, platformKey, platformRuntimeIssue, type Lockfile } from "./lockfile.js"; import { log } from "../logger.js"; export class EngineUnavailableError extends Error { @@ -125,6 +125,16 @@ export async function resolveEngine( ); } + const runtimeIssue = platformRuntimeIssue(entry); + if (runtimeIssue) { + throw new EngineUnavailableError( + engine, + "unsupported_platform", + `${engine} ${engineMeta.version} is unsupported on this runtime. ${runtimeIssue} ` + + "Use a glibc-based Linux distribution/container for this engine; other CodeInspectus engines and native rules remain available.", + ); + } + // Find the binary on disk. let found: string | undefined; for (const p of candidatePaths(engine)) { diff --git a/src/engines/signature.ts b/src/engines/signature.ts index f1da687..777357f 100644 --- a/src/engines/signature.ts +++ b/src/engines/signature.ts @@ -13,31 +13,58 @@ */ import { spawn } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { MANAGED_BIN } from "../config.js"; +import { loadLockfile, platformKey } from "./lockfile.js"; +import { sha256Hex } from "../util/hash.js"; export interface SigResult { ok: boolean; detail: string; } -export async function hasCosign(): Promise { +async function systemCosign(): Promise { return await new Promise((resolve) => { const c = spawn(process.platform === "win32" ? "where" : "which", ["cosign"], { - stdio: "ignore", + stdio: ["ignore", "pipe", "ignore"], }); - c.on("error", () => resolve(false)); - c.on("close", (code) => resolve(code === 0)); + let stdout = ""; + c.stdout.on("data", (data) => (stdout += data.toString())); + c.on("error", () => resolve(undefined)); + c.on("close", (code) => resolve(code === 0 ? stdout.split(/\r?\n/).find(Boolean) : undefined)); }); } -function runCosign(args: string[]): Promise<{ code: number | null; out: string }> { - return new Promise((resolve) => { - const c = spawn("cosign", args, { stdio: ["ignore", "pipe", "pipe"] }); +export async function resolveCosign(): Promise { + const managed = join(MANAGED_BIN, process.platform === "win32" ? "cosign.exe" : "cosign"); + if (await hasManagedCosign()) return managed; + return await systemCosign(); +} + +export async function hasManagedCosign(): Promise { + const managed = join(MANAGED_BIN, process.platform === "win32" ? "cosign.exe" : "cosign"); + if (!await access(managed).then(() => true).catch(() => false)) return false; + const expected = (await loadLockfile().catch(() => undefined))?.verifiers?.cosign.platforms[platformKey()]?.sha256; + if (!expected) return false; + const actual = sha256Hex(await readFile(managed).catch(() => Buffer.alloc(0))); + return actual.toLowerCase() === expected.toLowerCase(); +} + +export async function hasCosign(): Promise { + return Boolean(await resolveCosign()); +} + +async function runCosign(args: string[]): Promise<{ code: number | null; out: string }> { + const executable = await resolveCosign(); + if (!executable) return { code: null, out: "cosign is not installed" }; + return await new Promise((resolve) => { + const captured = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"] }); let out = ""; - c.stdout.on("data", (d) => (out += d.toString())); - c.stderr.on("data", (d) => (out += d.toString())); - c.on("error", (e) => resolve({ code: null, out: e.message })); - c.on("close", (code) => resolve({ code, out })); + captured.stdout.on("data", (d) => (out += d.toString())); + captured.stderr.on("data", (d) => (out += d.toString())); + captured.on("error", (e) => resolve({ code: null, out: e.message })); + captured.on("close", (code) => resolve({ code, out })); }); } diff --git a/src/index.ts b/src/index.ts index 4128e6d..faf10ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ /** * CodeInspectus entry point. * - * Default (no args): start the MCP server over stdio. + * Default (no args): guided setup on an interactive terminal; MCP over piped stdio. * `repair-engines`: explicitly fetch + verify only unhealthy engine/DB state. * `install-engines`: backward-compatible setup alias (explicit network step). * `pin-engines`: maintainer-only shipped lockfile generation. @@ -21,6 +21,11 @@ async function main(): Promise { const cmd = argv[0]; if (!cmd) { + if (process.stdin.isTTY && process.stdout.isTTY) { + const { runSetupCli } = await import("./setup.js"); + process.exitCode = await runSetupCli([], { automatic: true }); + return; + } const { startServer } = await import("./server.js"); await startServer(); return; @@ -40,6 +45,11 @@ async function main(): Promise { process.exitCode = await runCli(argv); return; } + case "setup": { + const { runSetupCli } = await import("./setup.js"); + process.exitCode = await runSetupCli(argv.slice(1)); + return; + } case "install-engines": { const { installEngines } = await import("./install.js"); await installEngines(argv.slice(1)); diff --git a/src/install.ts b/src/install.ts index 4c0f52f..c722330 100644 --- a/src/install.ts +++ b/src/install.ts @@ -49,11 +49,21 @@ import type { EngineSetupStatus } from "./types.js"; const ENGINE_ORDER: EngineName[] = ["opengrep", "gitleaks", "trivy"]; +export interface InstallIo { + stdout(text: string): void; + stderr(text: string): void; +} + +const DEFAULT_INSTALL_IO: InstallIo = { + stdout: (text) => process.stdout.write(text + "\n"), + stderr: (text) => process.stderr.write(text + "\n"), +}; + function out(s: string): void { - process.stdout.write(s + "\n"); + DEFAULT_INSTALL_IO.stdout(s); } function err(s: string): void { - process.stderr.write(s + "\n"); + DEFAULT_INSTALL_IO.stderr(s); } async function run(cmd: string, args: string[], cwd?: string): Promise<{ code: number | null; stderr: string }> { @@ -131,6 +141,7 @@ async function getVerifiedChecksums( cosignBin: boolean, staging: string, cache: Map>, + io: InstallIo, ): Promise> { const cached = cache.get(engine); if (cached) return cached; @@ -149,10 +160,10 @@ async function getVerifiedChecksums( await download(`${meta.release_base}/${meta.checksums_asset}.sigstore.json`, bundlePath); const r = await verifyBundle({ blob: checksumsPath, bundlePath, identity, issuer }); if (!r.ok) throw new Error(`${engine}: sigstore verification of checksums FAILED — ${r.detail} (fail-closed).`); - out(` ✓ ${engine}: cosign sigstore bundle verified over ${meta.checksums_asset} (identity: ${identity}).`); + io.stdout(` ✓ ${engine}: cosign sigstore bundle verified over ${meta.checksums_asset} (identity: ${identity}).`); await cacheArtifacts(engine, [checksumsPath, bundlePath]); } else { - out(` i ${engine}: checksums fetched (gitleaks publishes no cosign signature; checksum-only).`); + io.stdout(` i ${engine}: checksums fetched (gitleaks publishes no cosign signature; checksum-only).`); await cacheArtifacts(engine, [checksumsPath]); } const parsed = parseChecksums(await readFile(checksumsPath, "utf8")); @@ -215,6 +226,7 @@ async function pinEnginePlatform( checksumsCache: Map>, install: boolean, updateLockfile: boolean, + io: InstallIo, ): Promise { const meta = lock.engines[engine]; const entry = meta?.platforms[plat]; @@ -223,7 +235,7 @@ async function pinEnginePlatform( const platDir = join(staging, `${engine}-${plat}`); await mkdir(platDir, { recursive: true }); const url = `${meta.release_base}/${entry.asset}`; - out(`• ${engine} v${meta.version} [${plat}] — ${entry.asset}`); + io.stdout(`• ${engine} v${meta.version} [${plat}] — ${entry.asset}`); const archivePath = join(platDir, entry.asset); await download(url, archivePath); @@ -242,19 +254,19 @@ async function pinEnginePlatform( await download(`${url}.cert`, certPath); const r = await verifyCertSig({ blob: archivePath, certPath, sigPath, identity, issuer }); if (!r.ok) throw new Error(`${engine} [${plat}]: signature verification FAILED — ${r.detail} (fail-closed; not pinned).`); - out(` ✓ cosign signature verified.`); + io.stdout(` ✓ cosign signature verified.`); if (install) await cacheArtifacts(engine, [sigPath, certPath]); provenance = { method: "cosign", verified: true, at, identity, issuer }; } else { // gitleaks (checksums) / trivy (checksums+sigstore): verify checksum match. - const checksums = await getVerifiedChecksums(engine, meta, identities, cosignBin, staging, checksumsCache); + const checksums = await getVerifiedChecksums(engine, meta, identities, cosignBin, staging, checksumsCache, io); const expected = checksums.get(entry.asset); if (!expected) throw new Error(`${engine} [${plat}]: '${entry.asset}' not in checksums; cannot verify (fail-closed).`); const actualArchive = sha256Hex(await readFile(archivePath)); if (actualArchive.toLowerCase() !== expected) { throw new Error(`${engine} [${plat}]: archive checksum MISMATCH (expected ${expected}, got ${actualArchive}). Fail-closed.`); } - out(` ✓ archive checksum matches ${meta.signature === "checksums+sigstore" ? "signed " : ""}checksums.`); + io.stdout(` ✓ archive checksum matches ${meta.signature === "checksums+sigstore" ? "signed " : ""}checksums.`); provenance = meta.signature === "checksums+sigstore" ? { method: "cosign", verified: true, at, identity: identities[engine], issuer: identities.issuer } @@ -284,9 +296,9 @@ async function pinEnginePlatform( const dest = join(MANAGED_BIN, runName); await atomicInstallBinary(memberPath, dest); installed = true; - out(` ✓ installed ${dest}`); + io.stdout(` ✓ installed ${dest}`); } - out( + io.stdout( updateLockfile ? ` ✓ pinned sha256 ${sha}${install ? "" : " (cross-platform pin; not installed/run on this machine)"}` : ` ✓ matched immutable shipped sha256 ${sha}`, @@ -295,6 +307,7 @@ async function pinEnginePlatform( // Only the maintainer pinning command may mutate the packaged lockfile. if (updateLockfile) { entry.sha256 = sha; + entry.download_size_bytes = (await stat(archivePath)).size; entry.provenance = provenance; delete entry._verify; } @@ -338,11 +351,11 @@ async function replaceDirectory(staged: string, dest: string): Promise { } } -async function populateTrivyDb(): Promise { +async function populateTrivyDb(io: InstallIo): Promise { const trivyBin = join(MANAGED_BIN, process.platform === "win32" ? "trivy.exe" : "trivy"); const stagingCache = join(MANAGED_ROOT, `.trivy-cache-repair-${process.pid}-${Date.now()}`); const stagingDbDir = join(stagingCache, "db"); - out("• Trivy vuln DB — downloading offline snapshot (install-time only)…"); + io.stdout("• Trivy vuln DB — downloading offline snapshot (install-time only)…"); try { await mkdir(stagingCache, { recursive: true }); const r = await run(trivyBin, ["fs", "--download-db-only", "--cache-dir", stagingCache]); @@ -361,8 +374,8 @@ async function populateTrivyDb(): Promise { await rm(MANAGED_TRIVY_DB_PROVENANCE, { force: true }); await replaceDirectory(stagingDbDir, join(MANAGED_TRIVY_CACHE, "db")); await writeTrivyDbContentDigest(dbDigest); - out(` ✓ Trivy vulnerability DB content signature recorded (${dbDigest.slice(0, 23)}…).`); - out(` ✓ Trivy DB ready (downloaded ${meta.DownloadedAt ?? "?"}).`); + io.stdout(` ✓ Trivy vulnerability DB content signature recorded (${dbDigest.slice(0, 23)}…).`); + io.stdout(` ✓ Trivy DB ready (downloaded ${meta.DownloadedAt ?? "?"}).`); return meta.DownloadedAt; } finally { await rm(stagingCache, { recursive: true, force: true }).catch(() => {}); @@ -476,14 +489,14 @@ async function acquireRepairLock(): Promise<() => Promise> { } /** User-facing, incremental repair. Never mutates the packaged engines.lock.json. */ -export async function repairEngines(args: string[]): Promise { +export async function repairEngines(args: string[], io: InstallIo = DEFAULT_INSTALL_IO): Promise { const { selected, refreshDb } = parseRepairArgs(args); const fullScope = selected.length === ENGINE_ORDER.length; const firstStatus = await inspectEngineSetup(); let plan = planEngineRepair(firstStatus, selected, refreshDb); if (plan.blockers.length) throw new Error(plan.blockers.join("; ")); if (!plan.engines.length && !plan.refresh_trivy_db) { - out( + io.stdout( fullScope ? "✓ Engine setup ready. Shipped pins, managed binaries, and Trivy DB state need no repair." : "✓ Selected repair scope is healthy; no download needed. Unselected engine/DB state was not changed.", @@ -498,7 +511,7 @@ export async function repairEngines(args: string[]): Promise { plan = planEngineRepair(await inspectEngineSetup(), selected, refreshDb); if (plan.blockers.length) throw new Error(plan.blockers.join("; ")); if (!plan.engines.length && !plan.refresh_trivy_db) { - out( + io.stdout( fullScope ? "✓ Engine setup was repaired by another process; nothing to do." : "✓ Selected repair scope was repaired by another process; unselected state was not changed.", @@ -508,9 +521,9 @@ export async function repairEngines(args: string[]): Promise { const lock = await loadLockfile(); const current = platformKey(); - out("CodeInspectus repair-engines — explicit install-time network step."); - out(`Host platform: ${current} Managed dir: ${MANAGED_ROOT}`); - out(`Repair plan: engines ${plan.engines.join(", ") || "(none)"}; Trivy DB ${plan.refresh_trivy_db ? "refresh" : "unchanged"}.\n`); + io.stdout("CodeInspectus repair-engines — explicit install-time network step."); + io.stdout(`Host platform: ${current} Managed dir: ${MANAGED_ROOT}`); + io.stdout(`Repair plan: engines ${plan.engines.join(", ") || "(none)"}; Trivy DB ${plan.refresh_trivy_db ? "refresh" : "unchanged"}.\n`); await mkdir(MANAGED_BIN, { recursive: true }); await mkdir(MANAGED_TRIVY_CACHE, { recursive: true }); @@ -541,9 +554,10 @@ export async function repairEngines(args: string[]): Promise { checksumsCache, true, false, + io, ); } - if (plan.refresh_trivy_db) await populateTrivyDb(); + if (plan.refresh_trivy_db) await populateTrivyDb(io); } finally { await rm(staging, { recursive: true, force: true }).catch(() => {}); } @@ -556,7 +570,7 @@ export async function repairEngines(args: string[]): Promise { "Inspect `codeinspectus_list_rules` engine_setup details and retry.", ); } - out("\n✓ Engine repair complete. Packaged pins were not modified; scans remain offline."); + io.stdout("\n✓ Engine repair complete. Packaged pins were not modified; scans remain offline."); } finally { await releaseLock(); } @@ -637,6 +651,7 @@ export async function pinEngines(args: string[]): Promise { checksumsCache, install, true, + DEFAULT_INSTALL_IO, ); pinned.push(r); if (r.installed && engine === "trivy") currentInstalledTrivy = true; @@ -652,7 +667,7 @@ export async function pinEngines(args: string[]): Promise { await saveLockfile(lock); out("\n✓ engines.lock.json updated with verified SHA256 pins + provenance."); - if (currentInstalledTrivy) await populateTrivyDb(); + if (currentInstalledTrivy) await populateTrivyDb(DEFAULT_INSTALL_IO); await rm(staging, { recursive: true, force: true }).catch(() => {}); diff --git a/src/provenance.test.ts b/src/provenance.test.ts index 382f2a4..48da470 100644 --- a/src/provenance.test.ts +++ b/src/provenance.test.ts @@ -300,7 +300,7 @@ describe("component signatures", () => { }); expect(signature(JSON.stringify(compatibilityProjection))).toBe( - "sha256:279b51904bafd9e15650a3500e3bd8bac1024c544ef9cf677a10a13cf048e85c", + "sha256:ffdee4da7384ca0f18627b6cf579d77046fddf187bc345835b13c6917363a0da", ); expect(compatibilityProjection.every(([ruleId, components]) => ruleId === "ci-ai-edge-fn-no-auth" diff --git a/src/provenance.ts b/src/provenance.ts index 000f9eb..73b33c6 100644 --- a/src/provenance.ts +++ b/src/provenance.ts @@ -54,7 +54,7 @@ const COMPONENT_REVISIONS: Record = { [AI_INVOCATION_COMPONENT]: "1:all-analyzers-no-target-flags", "ai:client-secrets": "4:exact-supabase-secret-and-all-occurrence-bundle-state", "ai:supabase-rls-policy-state": "3:bounded-role-command-policy-composition-state", - "ai:supabase-edge-auth": "3:effective-config-and-handler-scoped-authn-authz", + "ai:supabase-edge-auth": "4:file-target-is-not-project-discovery-failure", "ai:prompt-injection": "2:prompt-sink-analysis-cwe-1427", "ai:unsafe-tool-execution": "1:proven-model-tool-arguments-to-node-shell", "ai:llm-dynamic-execution": "1:model-output-to-code-or-import-proven-shell", diff --git a/src/release-contract.test.ts b/src/release-contract.test.ts index c5979ee..6d5b236 100644 --- a/src/release-contract.test.ts +++ b/src/release-contract.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest"; import { SERVER_VERSION } from "./config.js"; import { SDK_API_VERSION, SDK_COMPATIBILITY } from "./sdk/index.js"; -const RELEASE_VERSION = "2.5.0"; +const RELEASE_VERSION = "2.6.0"; describe("V2 release source synchronization", () => { test("package, lockfile, server, CLI, and SDK versions agree", async () => { @@ -80,10 +80,11 @@ describe("V2 release source synchronization", () => { expect(changelog).toContain("## [2.0.0] — 2026-07-30"); expect(changelog).toContain("## [2.1.0] — 2026-08-02"); expect(changelog).toContain("## [2.5.0] — 2026-08-13"); + expect(changelog).toContain("## [2.6.0] — 2026-08-20"); expect(readme).toContain("CLI command reference"); expect(readme).toContain("reproduce-v2.1-case-study.mjs"); expect(changelog).toContain("CODEINSPECTUS_CASE_PACKAGE"); - for (const command of ["scan", "preflight", "export", "scans", "triage", "bundle", "bulk", "history", "issue"]) { + for (const command of ["setup", "scan", "preflight", "export", "scans", "triage", "bundle", "bulk", "history", "issue"]) { expect(cliReference).toMatch(new RegExp(`codeinspectus ${command}\\b`)); } expect(provenance).toContain("No Codex Security source code or documentation text was copied"); diff --git a/src/schemas.ts b/src/schemas.ts index f7befe6..2fa4a9c 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -380,6 +380,16 @@ export const listRulesInput = z.object({ engine: engineEnum.optional().describe("Filter to one engine's rules/detectors."), }); +export const setupComponentEnum = z.enum(["opengrep", "gitleaks", "trivy"]); +export const setupInput = z.object({ + action: z.enum(["plan", "install", "decline"]).default("plan") + .describe("Plan is offline/read-only. Install downloads only after confirm_downloads=true. Decline saves the choice."), + components: z.array(setupComponentEnum).optional() + .describe("Components to install or decline. Default: all three external engines."), + confirm_downloads: z.boolean().optional() + .describe("Must be true for an install that needs network downloads."), +}); + // ── Tool OUTPUT schemas ───────────────────────────────────────────────────── export const rescanResultSchema = z.object({ scan_id: z.string(), @@ -521,6 +531,43 @@ export const listRulesOutput = z.object({ note: z.string(), }); +export const setupComponentPlanSchema = z.object({ + id: setupComponentEnum, + name: z.string(), + version: z.string(), + state: z.string(), + action: z.enum(["none", "download", "redownload", "refresh_database", "blocked"]), + selected: z.boolean(), + download_size_bytes: z.number().int().nonnegative().optional(), + estimated_database_disk_bytes: z.number().int().nonnegative().optional(), + coverage: z.array(z.string()), + license: z.string(), + note: z.string().optional(), +}); + +export const setupPlanSchema = z.object({ + schema_version: z.literal("1.0.0"), + platform: z.string(), + native: z.object({ rule_count: z.number().int().nonnegative(), download_required: z.literal(false), coverage: z.string() }), + components: z.array(setupComponentPlanSchema), + verifier: z.object({ + name: z.literal("Cosign"), version: z.string(), required: z.boolean(), available: z.boolean(), + download_size_bytes: z.number().int().nonnegative().optional(), license: z.literal("Apache-2.0"), purpose: z.string(), + }), + preference_state: z.enum(["unconfigured", "configured", "invalid"]), + network_required: z.boolean(), + confirmation_required: z.boolean(), + exact_download_bytes: z.number().int().nonnegative(), + estimated_database_disk_bytes: z.number().int().nonnegative(), + warnings: z.array(z.string()), +}); + +export const setupOutput = z.object({ + outcome: z.enum(["planned", "installed", "declined"]), + message: z.string(), + plan: setupPlanSchema, +}); + // Inferred TS types for convenience. export type ScanInput = z.infer; export type RescanInput = z.infer; @@ -528,3 +575,4 @@ export type ComplianceReportInput = z.infer; export type ExplainFindingInput = z.infer; export type GenerateSbomInput = z.infer; export type ListRulesInput = z.infer; +export type SetupInput = z.infer; diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 0603d1f..8a791bc 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -10,7 +10,7 @@ import type { HistoryComparisonResult, HistoryListEntry, HistoryListResult, Hist import type { TriageAnnotation, TriageEvent, TriageState } from "../triage.js"; import type { Finding, ScannerKind, Severity } from "../types.js"; -export const SDK_API_VERSION = "2.5.0" as const; +export const SDK_API_VERSION = "2.6.0" as const; export const SDK_COMPATIBILITY = Object.freeze({ cli_major: 2, export_schema: "2.0.0", diff --git a/src/server.ts b/src/server.ts index 601d06e..c4a33cb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,5 @@ /** - * CodeInspectus MCP server — registers the six tools (PRD §11) over stdio. + * CodeInspectus MCP server — registers local security/reporting tools over stdio. * * All tools are read-only with respect to the user's files. Each returns both a * human-readable text block and validated structuredContent. Errors are returned @@ -20,18 +20,21 @@ import { explainFindingInput, generateSbomInput, listRulesInput, + setupInput, scanResultSchema, rescanResultSchema, complianceReportOutput, explainFindingOutput, sbomOutput, listRulesOutput, + setupOutput, type ScanInput, type RescanInput, type ComplianceReportInput, type ExplainFindingInput, type GenerateSbomInput, type ListRulesInput, + type SetupInput, } from "./schemas.js"; import { runScan } from "./scan.js"; @@ -41,6 +44,7 @@ import { explainFinding } from "./explain.js"; import { generateSbom } from "./sbom.js"; import { listRules } from "./rules.js"; import { summarizeScan, summarizeRescan } from "./summarize.js"; +import { buildSetupPlan, declineSetupComponents, formatSetupPlan, installSetupComponents, SETUP_COMPONENTS } from "./setup.js"; const READ_ONLY = { readOnlyHint: true, @@ -60,6 +64,13 @@ const MANAGED_WRITE = { openWorldHint: false, } as const; +const NETWORKED_MANAGED_WRITE = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, +} as const; + const SERVER_INSTRUCTIONS = "CodeInspectus reports; it never edits source. When asked to review security—or after making " + "security-relevant code changes—call codeinspectus_scan with an absolute path. Present findings " + @@ -70,8 +81,10 @@ const SERVER_INSTRUCTIONS = "a ran pack means its listed rules executed, not complete security coverage for that language. " + "Inspect engine_setup in scan/list-rules output. For repair_required, explain that engine coverage may be partial; " + "for db_refresh_recommended, explain the DB freshness/rescan-continuity limitation without calling current findings incomplete. " + - "Ask for approval, then run `npx codeinspectus repair-engines` in the user's terminal; never download " + - "engines silently or during a scan. " + + "When engine readiness is not yet known, call codeinspectus_setup with action=plan before the first scan. " + + "Explain each affected component, coverage, license and size, " + + "then ask for approval. Only after approval call action=install with confirm_downloads=true. Never download " + + "engines silently or during a scan. A terminal is not required for MCP setup. " + "For exposed secrets, advise rotation at the provider and keep values redacted. Treat " + "codeinspectus_compliance_report as code-level control coverage only, never certification or a " + "percent-compliant claim. codeinspectus_generate_sbom writes an artifact; the other tools do not " + @@ -113,6 +126,37 @@ export function createServer(): McpServer { }, ); + // ── codeinspectus_setup ──────────────────────────────────────────────────── + server.registerTool( + "codeinspectus_setup", + { + title: "Plan or install external security engines", + description: + "Inspect external-engine health and exact platform download sizes, save declined choices, or install selected " + + "Opengrep/Gitleaks/Trivy components after explicit confirmation. Plan is offline. Install writes only to " + + "~/.codeinspectus, verifies immutable pins/publisher provenance, and never modifies the target repository.", + inputSchema: setupInput.shape, + outputSchema: setupOutput.shape, + annotations: { title: "CodeInspectus Setup", ...NETWORKED_MANAGED_WRITE }, + }, + async (args: SetupInput): Promise => { + try { + const selection = args.components?.length ? args.components : [...SETUP_COMPONENTS]; + const result = args.action === "install" + ? await installSetupComponents(selection, args.confirm_downloads === true, { + io: { stdout: (text) => log.info(text), stderr: (text) => log.warn(text) }, + }) + : args.action === "decline" + ? await declineSetupComponents(selection) + : { outcome: "planned" as const, message: "Review the plan and ask for approval before installing.", plan: await buildSetupPlan({ selection }) }; + return ok(`${result.message}\n\n${formatSetupPlan(result.plan)}`, result as unknown as Record); + } catch (err) { + log.error("setup failed", err); + return fail(describeError("codeinspectus_setup failed", err)); + } + }, + ); + // ── codeinspectus_rescan ──────────────────────────────────────────────────── server.registerTool( "codeinspectus_rescan", diff --git a/src/setup.test.ts b/src/setup.test.ts new file mode 100644 index 0000000..c1d5601 --- /dev/null +++ b/src/setup.test.ts @@ -0,0 +1,228 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { loadLockfile, type Lockfile } from "./engines/lockfile.js"; +import { + bootstrapCosign, + buildSetupPlan, + declineSetupComponents, + formatSetupPlan, + installSetupComponents, + runSetupCli, + setupHelp, +} from "./setup.js"; +import { createHash } from "node:crypto"; +import type { EngineSetupStatus } from "./types.js"; + +let root: string; +let preferencesPath: string; +let lockfile: Lockfile; + +function status(states: Partial> = {}, db: EngineSetupStatus["trivy_db"]["state"] = "missing"): EngineSetupStatus { + const engines = (["opengrep", "gitleaks", "trivy"] as const).map((engine) => ({ + engine, + version: lockfile.engines[engine].version, + state: states[engine] ?? "ready", + })); + return { + state: engines.some((engine) => engine.state !== "ready") || db === "missing" ? "repair_required" : db === "ready" ? "ready" : "db_refresh_recommended", + platform: "darwin-arm64", + engines, + trivy_db: { state: db }, + network_required: true, + }; +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "codeinspectus-setup-test-")); + preferencesPath = join(root, "setup-preferences.json"); + lockfile = await loadLockfile(); +}); + +afterEach(async () => { + vi.unstubAllGlobals(); + await rm(root, { recursive: true, force: true }); +}); + +describe("consent-driven engine setup", () => { + test("documents every setup approval and preference option", () => { + expect(setupHelp()).toContain("--status"); + expect(setupHelp()).toContain("--all"); + expect(setupHelp()).toContain("--select opengrep,gitleaks,trivy"); + expect(setupHelp()).toContain("--reset"); + }); + test("reports native coverage, exact platform downloads, DB estimate, licenses, and verifier dependency", async () => { + const plan = await buildSetupPlan({ + status: status({ opengrep: "missing", gitleaks: "missing", trivy: "missing" }), + lockfile, + preferencesPath, + cosignAvailable: false, + }); + + expect(plan.native.rule_count).toBe(72); + expect(plan.native.download_required).toBe(false); + expect(plan.components.map((item) => [item.id, item.action, item.download_size_bytes])).toEqual([ + ["opengrep", "download", 43_236_128], + ["gitleaks", "download", 7_897_593], + ["trivy", "download", 47_498_236], + ]); + expect(plan.verifier).toMatchObject({ required: true, available: false, download_size_bytes: 139_584_002 }); + expect(plan.exact_download_bytes).toBe(238_215_959); + expect(plan.estimated_database_disk_bytes).toBeGreaterThan(1_000_000_000); + const rendered = formatSetupPlan(plan); + expect(rendered).toContain("72 rules, ready immediately"); + expect(rendered).toContain("License: GNU LGPL 2.1"); + expect(rendered).toContain("Required verifier: Cosign 3.1.2"); + expect(rendered).toContain("database download size changes upstream"); + }); + + test("persists declines so first-use planning does not repeatedly select missing components", async () => { + await declineSetupComponents(["opengrep"], preferencesPath); + const plan = await buildSetupPlan({ + status: status({ opengrep: "missing" }, "ready"), + lockfile, + preferencesPath, + cosignAvailable: true, + }); + + expect(plan.preference_state).toBe("configured"); + expect(plan.components.find((item) => item.id === "opengrep")?.selected).toBe(false); + expect(plan.network_required).toBe(false); + }); + + test("refuses networked installation without explicit confirmation", async () => { + const plan = await buildSetupPlan({ + status: status({ gitleaks: "missing" }, "ready"), + lockfile, + preferencesPath, + cosignAvailable: false, + }); + const repair = vi.fn(); + + await expect(installSetupComponents(["gitleaks"], false, { plan, preferencesPath, repair })).rejects.toThrow( + "Download confirmation is required", + ); + expect(repair).not.toHaveBeenCalled(); + await expect(readFile(preferencesPath, "utf8")).rejects.toThrow(); + }); + + test("installs only the approved selection and records the choice", async () => { + const plan = await buildSetupPlan({ + status: status({ gitleaks: "missing" }, "ready"), + lockfile, + preferencesPath, + cosignAvailable: false, + }); + const repair = vi.fn(async () => undefined); + const bootstrap = vi.fn(async () => undefined); + + await installSetupComponents(["gitleaks"], true, { plan, preferencesPath, repair, bootstrap }); + + expect(repair).toHaveBeenCalledWith(["gitleaks"], expect.any(Object)); + expect(bootstrap).not.toHaveBeenCalled(); + const saved = JSON.parse(await readFile(preferencesPath, "utf8")) as { choices: Record }; + expect(saved.choices).toEqual({ opengrep: "declined", gitleaks: "enabled", trivy: "declined" }); + }); + + test("bootstraps Cosign only after exact size and immutable SHA verification", async () => { + const bytes = Buffer.from("test-cosign-binary"); + const testLock = structuredClone(lockfile); + const entry = testLock.verifiers!.cosign.platforms["darwin-arm64"]!; + entry.download_size_bytes = bytes.byteLength; + entry.sha256 = createHash("sha256").update(bytes).digest("hex"); + testLock.verifiers!.cosign.release_base = "https://downloads.example/cosign"; + vi.stubGlobal("fetch", vi.fn(async () => new Response(bytes, { status: 200 }))); + const logs: string[] = []; + + await bootstrapCosign(testLock, "darwin-arm64", { stdout: (text) => logs.push(text), stderr: vi.fn() }, { + managedBin: join(root, "bin"), + managedReady: async () => false, + }); + + expect(await readFile(join(root, "bin", process.platform === "win32" ? "cosign.exe" : "cosign"))).toEqual(bytes); + expect(logs.join("\n")).toContain("can take several minutes"); + expect(logs.join("\n")).toContain("SHA-pinned Cosign 3.1.2"); + vi.unstubAllGlobals(); + }); + + test("fails closed on a Cosign bootstrap digest mismatch", async () => { + const testLock = structuredClone(lockfile); + const entry = testLock.verifiers!.cosign.platforms["darwin-arm64"]!; + entry.download_size_bytes = 3; + entry.sha256 = "0".repeat(64); + vi.stubGlobal("fetch", vi.fn(async () => new Response(Buffer.from("bad"), { status: 200 }))); + + await expect(bootstrapCosign(testLock, "darwin-arm64", { stdout: vi.fn(), stderr: vi.fn() }, { + managedBin: join(root, "bin"), + managedReady: async () => false, + })).rejects.toThrow("Cosign SHA256 mismatch"); + await expect(readFile(join(root, "bin", process.platform === "win32" ? "cosign.exe" : "cosign"))).rejects.toThrow(); + vi.unstubAllGlobals(); + }); + + test("noninteractive setup never downloads without --all or --select", async () => { + const plan = await buildSetupPlan({ + status: status({ gitleaks: "missing" }, "ready"), lockfile, preferencesPath, cosignAvailable: true, + }); + const output: string[] = []; + const install = vi.fn(); + const exit = await runSetupCli([], { + plan, + install, + io: { stdinIsTTY: false, stdout: (text) => output.push(text), stderr: (text) => output.push(text), question: vi.fn() }, + }); + + expect(exit).toBe(2); + expect(install).not.toHaveBeenCalled(); + expect(output.join("\n")).toContain("needs explicit approval"); + }); + + test("automatic setup reports a selected unsupported engine instead of claiming health", async () => { + const blocked = status({}, "ready"); + blocked.state = "unsupported_platform"; + blocked.engines[0] = { + engine: "opengrep", + version: "1.23.0", + state: "unsupported_platform", + detail: "This asset requires glibc.", + }; + const plan = await buildSetupPlan({ status: blocked, lockfile, preferencesPath, cosignAvailable: true }); + const output: string[] = []; + const install = vi.fn(); + const exit = await runSetupCli([], { + automatic: true, + plan, + install, + io: { stdinIsTTY: true, stdout: (text) => output.push(text), stderr: (text) => output.push(text), question: vi.fn() }, + }); + + expect(exit).toBe(2); + expect(install).not.toHaveBeenCalled(); + expect(output.join("\n")).toContain("requires glibc"); + expect(output.join("\n")).toContain("not usable on this runtime"); + expect(output.join("\n")).not.toContain("healthy"); + }); + + test("interactive component selection dispatches exactly the approved engines", async () => { + const plan = await buildSetupPlan({ + status: status({ opengrep: "missing", gitleaks: "missing", trivy: "missing" }), lockfile, preferencesPath, cosignAvailable: false, + }); + const answers = ["select", "opengrep,gitleaks"]; + const install = vi.fn(async () => ({ outcome: "installed" as const, message: "installed", plan })); + const exit = await runSetupCli([], { + plan, + install, + io: { + stdinIsTTY: true, + stdout: vi.fn(), + stderr: vi.fn(), + question: vi.fn(async () => answers.shift() ?? ""), + }, + }); + + expect(exit).toBe(0); + expect(install).toHaveBeenCalledWith(["opengrep", "gitleaks"], true, expect.any(Object)); + }); +}); diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 0000000..3520602 --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,517 @@ +/** + * Consent-driven external-engine setup. + * + * Inspection is always offline. Downloads happen only after an explicit CLI + * confirmation/flag or an MCP call with confirm_downloads=true. Preferences + * suppress repeated first-run prompts; they never change scan results. + */ + +import { randomUUID } from "node:crypto"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { stdin as processStdin, stdout as processStdout } from "node:process"; + +import { MANAGED_BIN, MANAGED_SETUP_PREFERENCES, type EngineName } from "./config.js"; +import { inspectEngineSetup } from "./engine-health.js"; +import { loadLockfile, platformKey, type Lockfile } from "./engines/lockfile.js"; +import { hasManagedCosign } from "./engines/signature.js"; +import { repairEngines, type InstallIo } from "./install.js"; +import { nativePackInventory } from "./packs/registry.js"; +import type { EngineSetupStatus } from "./types.js"; +import { sha256Hex } from "./util/hash.js"; + +export const SETUP_COMPONENTS = ["opengrep", "gitleaks", "trivy"] as const; +export type SetupComponentId = (typeof SETUP_COMPONENTS)[number]; +export type SetupAction = "none" | "download" | "redownload" | "refresh_database" | "blocked"; + +const TRIVY_DB_ESTIMATED_DISK_BYTES = 1_181_116_006; +const PREFERENCE_SCHEMA_VERSION = 1; + +interface SetupPreferences { + schema_version: 1; + updated_at: string; + choices: Record; +} + +export interface SetupComponentPlan { + id: SetupComponentId; + name: string; + version: string; + state: string; + action: SetupAction; + selected: boolean; + download_size_bytes?: number; + estimated_database_disk_bytes?: number; + coverage: string[]; + license: string; + note?: string; +} + +export interface SetupPlan { + schema_version: "1.0.0"; + platform: string; + native: { rule_count: number; download_required: false; coverage: string }; + components: SetupComponentPlan[]; + verifier: { + name: "Cosign"; + version: string; + required: boolean; + available: boolean; + download_size_bytes?: number; + license: "Apache-2.0"; + purpose: string; + }; + preference_state: "unconfigured" | "configured" | "invalid"; + network_required: boolean; + confirmation_required: boolean; + exact_download_bytes: number; + estimated_database_disk_bytes: number; + warnings: string[]; +} + +export interface SetupResult { + outcome: "planned" | "installed" | "declined"; + message: string; + plan: SetupPlan; +} + +interface PreferenceRead { + state: SetupPlan["preference_state"]; + value?: SetupPreferences; + warning?: string; +} + +function validPreferences(value: unknown): value is SetupPreferences { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return candidate.schema_version === PREFERENCE_SCHEMA_VERSION && + Boolean(candidate.choices) && + SETUP_COMPONENTS.every((id) => candidate.choices?.[id] === "enabled" || candidate.choices?.[id] === "declined"); +} + +async function readPreferences(path = MANAGED_SETUP_PREFERENCES): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(path, "utf8")); + if (!validPreferences(parsed)) { + return { state: "invalid", warning: `Ignored invalid setup preferences at ${path}.` }; + } + return { state: "configured", value: parsed }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { state: "unconfigured" }; + return { state: "invalid", warning: `Could not read setup preferences at ${path}.` }; + } +} + +async function writePreferences( + choices: Record, + path = MANAGED_SETUP_PREFERENCES, +): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + const value: SetupPreferences = { + schema_version: PREFERENCE_SCHEMA_VERSION, + updated_at: new Date().toISOString(), + choices, + }; + try { + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +function componentAction( + id: SetupComponentId, + status: EngineSetupStatus, +): { action: SetupAction; state: string; note?: string } { + const engine = status.engines.find((item) => item.engine === id); + if (!engine) return { action: "blocked", state: "unknown", note: "Engine health was not reported." }; + if (["unsupported_platform", "unpinned", "lockfile_error"].includes(engine.state)) { + return { action: "blocked", state: engine.state, note: engine.detail ?? "Packaged pin state cannot be repaired safely." }; + } + if (engine.state === "missing") return { action: "download", state: engine.state }; + if (engine.state === "hash_mismatch") return { action: "redownload", state: engine.state }; + if (id === "trivy" && status.trivy_db.state !== "ready") { + return { action: "refresh_database", state: `binary_ready;database_${status.trivy_db.state}` }; + } + return { action: "none", state: "ready" }; +} + +const COMPONENT_METADATA: Record> = { + opengrep: { + name: "Opengrep", + coverage: ["Curated SAST for SQL injection, XSS, SSRF, command injection, weak cryptography, and insecure deserialization."], + license: "GNU LGPL 2.1", + }, + gitleaks: { + name: "Gitleaks", + coverage: ["200+ upstream secret patterns plus 4 CodeInspectus rules for credentials, API keys, and private keys."], + license: "MIT", + }, + trivy: { + name: "Trivy", + coverage: ["Dependency CVEs, IaC misconfiguration, secrets, license inventory, and SBOM generation."], + license: "Apache-2.0", + }, +}; + +export async function buildSetupPlan(options: { + status?: EngineSetupStatus; + lockfile?: Lockfile; + preferencesPath?: string; + cosignAvailable?: boolean; + selection?: readonly SetupComponentId[]; +} = {}): Promise { + const [status, lockfile, preferences, cosignAvailable] = await Promise.all([ + options.status ?? inspectEngineSetup(), + options.lockfile ?? loadLockfile(), + readPreferences(options.preferencesPath), + options.cosignAvailable ?? hasManagedCosign(), + ]); + const key = status.platform || platformKey(); + const warnings = preferences.warning ? [preferences.warning] : []; + const components = SETUP_COMPONENTS.map((id): SetupComponentPlan => { + const state = componentAction(id, status); + const entry = lockfile.engines[id]?.platforms[key]; + const selected = options.selection + ? options.selection.includes(id) + : preferences.value + ? preferences.value.choices[id] === "enabled" + : true; + return { + id, + ...COMPONENT_METADATA[id], + version: lockfile.engines[id]?.version ?? "unknown", + state: state.state, + action: state.action, + selected, + ...(entry?.download_size_bytes !== undefined ? { download_size_bytes: entry.download_size_bytes } : {}), + ...(id === "trivy" ? { estimated_database_disk_bytes: TRIVY_DB_ESTIMATED_DISK_BYTES } : {}), + ...(state.note ? { note: state.note } : {}), + }; + }); + const selectedActionable = components.filter((item) => item.selected && item.action !== "none" && item.action !== "blocked"); + const requiresCosign = selectedActionable.some((item) => + (item.id === "opengrep" || item.id === "trivy") && (item.action === "download" || item.action === "redownload"), + ); + const verifierEntry = lockfile.verifiers?.cosign.platforms[key]; + const verifierDownload = requiresCosign && !cosignAvailable ? verifierEntry?.download_size_bytes ?? 0 : 0; + const exactDownloadBytes = selectedActionable.reduce((total, item) => + total + ((item.action === "download" || item.action === "redownload") ? item.download_size_bytes ?? 0 : 0), verifierDownload); + const estimatedDbBytes = selectedActionable.some((item) => item.id === "trivy" && item.action !== "none") + ? TRIVY_DB_ESTIMATED_DISK_BYTES + : 0; + if (requiresCosign && !cosignAvailable && !verifierEntry) { + warnings.push(`Cosign has no bootstrap pin for ${key}; Opengrep/Trivy installation is blocked.`); + } + return { + schema_version: "1.0.0", + platform: key, + native: { + rule_count: nativePackInventory().reduce((sum, pack) => sum + pack.rules.registered, 0), + download_required: false, + coverage: "First-party CodeInspectus rules are available immediately without external downloads.", + }, + components, + verifier: { + name: "Cosign", + version: lockfile.verifiers?.cosign.version ?? "unknown", + required: requiresCosign, + available: cosignAvailable, + ...(verifierEntry?.download_size_bytes !== undefined ? { download_size_bytes: verifierEntry.download_size_bytes } : {}), + license: "Apache-2.0", + purpose: "Verifies publisher signatures for Opengrep and Trivy before installation; a managed copy is bootstrapped from a shipped immutable SHA pin when absent.", + }, + preference_state: preferences.state, + network_required: selectedActionable.length > 0, + confirmation_required: selectedActionable.length > 0, + exact_download_bytes: exactDownloadBytes, + estimated_database_disk_bytes: estimatedDbBytes, + warnings, + }; +} + +function mib(bytes: number): string { + return `${(bytes / 1024 / 1024).toFixed(1)} MiB`; +} + +export function formatSetupPlan(plan: SetupPlan): string { + const lines = [ + `CodeInspectus engine setup (${plan.platform})`, + `Native CodeInspectus: ${plan.native.rule_count} rules, ready immediately, no download.`, + "", + ]; + for (const component of plan.components) { + const size = component.download_size_bytes === undefined + ? "size unavailable" + : component.action === "blocked" + ? `${mib(component.download_size_bytes)} upstream asset (not usable on this runtime)` + : component.action === "none" + ? `${mib(component.download_size_bytes)} upstream asset if a future repair is needed` + : `${mib(component.download_size_bytes)} engine download`; + const db = component.id === "trivy" ? ` + vulnerability DB (~${mib(component.estimated_database_disk_bytes ?? 0)} disk; network transfer varies)` : ""; + lines.push(`${component.selected ? "[selected]" : "[declined]"} ${component.name} ${component.version}: ${component.action}; ${size}${db}`); + lines.push(` Checks: ${component.coverage.join(" ")}`); + lines.push(` License: ${component.license}`); + if (component.note) lines.push(` Note: ${component.note}`); + } + if (plan.verifier.required && !plan.verifier.available) { + lines.push(`\nRequired verifier: Cosign ${plan.verifier.version}, ${plan.verifier.download_size_bytes ? mib(plan.verifier.download_size_bytes) : "size unavailable"}, Apache-2.0.`); + } + lines.push(`\nExact selected binary downloads: ${mib(plan.exact_download_bytes)}.`); + if (plan.estimated_database_disk_bytes) { + lines.push(`Additional Trivy DB disk estimate: ~${mib(plan.estimated_database_disk_bytes)} (database download size changes upstream).`); + } + lines.push("Downloads go only to ~/.codeinspectus. Scans remain offline and never modify the target repository."); + for (const warning of plan.warnings) lines.push(`Warning: ${warning}`); + return `${lines.join("\n")}\n`; +} + +export async function bootstrapCosign( + lockfile: Lockfile, + key: string, + io: InstallIo, + options: { managedBin?: string; managedReady?: () => Promise } = {}, +): Promise { + if (await (options.managedReady ?? hasManagedCosign)()) return; + const verifier = lockfile.verifiers?.cosign; + const entry = verifier?.platforms[key]; + if (!verifier || !entry?.sha256) throw new Error(`Cosign bootstrap is not pinned for ${key}.`); + io.stdout( + `• Cosign ${verifier.version} verifier — downloading${entry.download_size_bytes === undefined ? "" : ` ${mib(entry.download_size_bytes)}`} (this can take several minutes).`, + ); + const response = await fetch(`${verifier.release_base}/${entry.asset}`, { redirect: "follow" }); + if (!response.ok) throw new Error(`Cosign download failed (${response.status} ${response.statusText}).`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (entry.download_size_bytes !== undefined && bytes.byteLength !== entry.download_size_bytes) { + throw new Error(`Cosign download length mismatch (expected ${entry.download_size_bytes}, got ${bytes.byteLength}).`); + } + const digest = sha256Hex(bytes); + if (digest.toLowerCase() !== entry.sha256.toLowerCase()) { + throw new Error(`Cosign SHA256 mismatch (expected ${entry.sha256}, got ${digest}).`); + } + const managedBin = options.managedBin ?? MANAGED_BIN; + await mkdir(managedBin, { recursive: true }); + const destination = join(managedBin, process.platform === "win32" ? "cosign.exe" : "cosign"); + const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`; + const backup = `${destination}.${process.pid}.${randomUUID()}.backup`; + let preserveBackup = false; + try { + await writeFile(temporary, bytes, { flag: "wx", mode: 0o755 }); + await chmod(temporary, 0o755).catch(() => undefined); + try { + await rename(temporary, destination); + } catch (first) { + await rename(destination, backup); + try { + await rename(temporary, destination); + } catch (second) { + try { + await rename(backup, destination); + } catch (restore) { + preserveBackup = true; + throw new AggregateError( + [first, second, restore], + `Could not install Cosign or restore the prior managed binary. Backup preserved at ${backup}.`, + ); + } + throw new AggregateError([first, second], "Could not atomically install the verified Cosign binary."); + } + await rm(backup, { force: true }); + } + } finally { + await rm(temporary, { force: true }).catch(() => undefined); + if (!preserveBackup) await rm(backup, { force: true }).catch(() => undefined); + } + io.stdout(`✓ Installed SHA-pinned Cosign ${verifier.version} verifier in ${managedBin}.`); +} + +function choicesFor(selection: readonly SetupComponentId[], mode: "enabled" | "declined"): Record { + const selected = new Set(selection); + return Object.fromEntries(SETUP_COMPONENTS.map((id) => [id, selected.has(id) ? mode : mode === "enabled" ? "declined" : "enabled"])) as Record; +} + +export async function installSetupComponents( + selection: readonly SetupComponentId[], + confirmDownloads: boolean, + options: { + preferencesPath?: string; + io?: InstallIo; + repair?: typeof repairEngines; + plan?: SetupPlan; + bootstrap?: typeof bootstrapCosign; + loadLockfile?: typeof loadLockfile; + } = {}, +): Promise { + const unique = [...new Set(selection)]; + if (!unique.length) throw new Error("Select at least one component."); + const invalid = unique.filter((item) => !SETUP_COMPONENTS.includes(item)); + if (invalid.length) throw new Error(`Unknown setup component(s): ${invalid.join(", ")}.`); + const plan = options.plan ?? await buildSetupPlan({ preferencesPath: options.preferencesPath, selection: unique }); + const selectedPlan = plan.components.filter((item) => unique.includes(item.id)); + const networkRequired = selectedPlan.some((item) => item.action !== "none" && item.action !== "blocked"); + const blockers = selectedPlan.filter((item) => item.action === "blocked"); + if (blockers.length) throw new Error(blockers.map((item) => `${item.name}: ${item.note ?? item.state}`).join("; ")); + if (networkRequired && !confirmDownloads) { + throw new Error("Download confirmation is required. Review the plan, then retry with explicit approval."); + } + const io = options.io ?? { + stdout: (text: string) => process.stdout.write(`${text}\n`), + stderr: (text: string) => process.stderr.write(`${text}\n`), + }; + if (networkRequired) { + const needsVerifier = selectedPlan.some((item) => + (item.id === "opengrep" || item.id === "trivy") && (item.action === "download" || item.action === "redownload"), + ); + if (needsVerifier) await (options.bootstrap ?? bootstrapCosign)(await (options.loadLockfile ?? loadLockfile)(), plan.platform, io); + await (options.repair ?? repairEngines)(unique as EngineName[], io); + } + await writePreferences(choicesFor(unique, "enabled"), options.preferencesPath); + const finalPlan = await buildSetupPlan({ preferencesPath: options.preferencesPath }); + return { + outcome: "installed", + message: networkRequired ? "Selected components installed and verified." : "Selected components were already healthy; no download was needed.", + plan: finalPlan, + }; +} + +export async function declineSetupComponents( + selection: readonly SetupComponentId[] = SETUP_COMPONENTS, + preferencesPath?: string, +): Promise { + await writePreferences(choicesFor(selection, "declined"), preferencesPath); + return { + outcome: "declined", + message: "Saved your choice. Native CodeInspectus coverage remains available; declined external coverage will be reported as partial or unavailable.", + plan: await buildSetupPlan({ preferencesPath }), + }; +} + +export interface SetupCliIo { + stdinIsTTY: boolean; + stdout(text: string): void; + stderr(text: string): void; + question(prompt: string): Promise; +} + +export function setupHelp(): string { + return [ + "Usage: codeinspectus setup [option]", + "", + " (no option) Show the plan and ask before downloading.", + " --status Inspect setup without downloading or writing.", + " --all Approve all recommended external components.", + " --select opengrep,gitleaks,trivy Approve only named components.", + " --reset Forget saved component choices.", + "", + "No npm install hook or scan downloads engines. Setup writes only to ~/.codeinspectus.", + "", + ].join("\n"); +} + +function defaultSetupIo(): SetupCliIo { + return { + stdinIsTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY), + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + question: async (prompt) => { + const readline = createInterface({ input: processStdin, output: processStdout }); + try { + return await readline.question(prompt); + } finally { + readline.close(); + } + }, + }; +} + +function parseSelection(value: string): SetupComponentId[] { + const values = [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; + const invalid = values.filter((item) => !SETUP_COMPONENTS.includes(item as SetupComponentId)); + if (invalid.length || !values.length) throw new Error(`Expected a comma-separated selection from: ${SETUP_COMPONENTS.join(", ")}.`); + return values as SetupComponentId[]; +} + +export async function runSetupCli( + args: readonly string[], + options: { + automatic?: boolean; + io?: SetupCliIo; + preferencesPath?: string; + plan?: SetupPlan; + install?: typeof installSetupComponents; + decline?: typeof declineSetupComponents; + } = {}, +): Promise { + const io = options.io ?? defaultSetupIo(); + try { + if (args.includes("--help") || args.includes("-h")) { + io.stdout(setupHelp()); + return 0; + } + const status = args.includes("--status"); + const all = args.includes("--all"); + const reset = args.includes("--reset"); + const selectIndex = args.indexOf("--select"); + const known = new Set(["--status", "--all", "--reset", "--select"]); + const unknown = args.filter((arg, index) => !known.has(arg) && index !== selectIndex + 1); + if (unknown.length || (selectIndex >= 0 && !args[selectIndex + 1])) throw new Error(`Unknown or incomplete setup option: ${unknown[0] ?? "--select"}.`); + if ([status, all, reset, selectIndex >= 0].filter(Boolean).length > 1) throw new Error("Use only one of --status, --all, --select, or --reset."); + if (reset) { + await rm(options.preferencesPath ?? MANAGED_SETUP_PREFERENCES, { force: true }); + io.stdout("Setup choices reset. Run `codeinspectus setup` to choose again.\n"); + return 0; + } + let selection: SetupComponentId[] | undefined; + if (all) selection = [...SETUP_COMPONENTS]; + if (selectIndex >= 0) selection = parseSelection(args[selectIndex + 1]!); + const plan = options.plan ?? await buildSetupPlan({ preferencesPath: options.preferencesPath, ...(selection ? { selection } : {}) }); + io.stdout(formatSetupPlan(plan)); + if (status) return plan.components.some((item) => item.selected && item.action === "blocked") ? 2 : 0; + + const actionable = plan.components.filter((item) => item.action !== "none" && item.action !== "blocked"); + const selectedBlockers = plan.components.filter((item) => item.selected && item.action === "blocked"); + if (!selection && selectedBlockers.length) { + io.stderr(`${selectedBlockers.map((item) => `${item.name}: ${item.note ?? item.state}`).join("; ")}\n`); + return 2; + } + if (!selection && options.automatic && plan.preference_state === "configured" && !actionable.some((item) => item.selected)) { + io.stdout("Saved setup choices decline the currently missing components. Run `codeinspectus setup` to review or change them.\n"); + return 0; + } + if (!selection && !actionable.length) { + io.stdout("✓ Selected engine setup is healthy; no download needed.\n"); + return 0; + } + if (!selection && !io.stdinIsTTY) { + io.stderr("CodeInspectus setup needs explicit approval. Use `codeinspectus setup --all` or `--select opengrep,gitleaks,trivy`.\n"); + return 2; + } + if (!selection) { + const answer = (await io.question("Install all recommended external components? [Y]es / [n]o / [s]elect: ")).trim().toLowerCase(); + if (answer === "n" || answer === "no") { + const result = await (options.decline ?? declineSetupComponents)(actionable.map((item) => item.id), options.preferencesPath); + io.stdout(`${result.message}\n`); + return 0; + } + if (answer === "s" || answer === "select") { + selection = parseSelection(await io.question(`Choose components (${SETUP_COMPONENTS.join(",")}): `)); + } else if (!answer || answer === "y" || answer === "yes") { + selection = [...SETUP_COMPONENTS]; + } else { + throw new Error("Setup cancelled: expected yes, no, or select."); + } + } + const result = await (options.install ?? installSetupComponents)(selection, true, { + preferencesPath: options.preferencesPath, + io: { stdout: (text) => io.stdout(`${text}\n`), stderr: (text) => io.stderr(`${text}\n`) }, + }); + io.stdout(`${result.message}\n`); + return 0; + } catch (error) { + io.stderr(`CodeInspectus setup: ${error instanceof Error ? error.message : String(error)}\n`); + return 2; + } +}