A local tool for scanning open source repositories for security vulnerabilities and managing the disclosure process. You add a repo by URL, scrutineer runs a pipeline of agent skills against it inside a container, and presents the results in a web UI where you can triage findings, identify maintainers, and track disclosures. The agent CLI is pluggable: claude-code by default, or codex, opencode, or GitHub Copilot CLI with -backend.
You need one supported container runtime: Docker (the default), rootless Podman, or Apple's container CLI.
Install Scrutineer from Homebrew on macOS or Linux:
brew install scrutineer
Alternatively, download the Linux or macOS archive for your architecture from GitHub Releases, verify it against SHA256SUMS, and put scrutineer on your PATH. The macOS archives are currently unsigned and not notarized, so macOS may present a Gatekeeper warning even after you verify the checksum and GitHub build-provenance attestation.
To build or run from source instead, install Go 1.27+:
git clone https://github.com/alpha-omega-security/scrutineer
cd scrutineer
Authenticate the default claude-code backend with either a Claude Code subscription token (Max, Pro, Team, or Enterprise) generated by the Claude CLI:
claude setup-token
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
or an Anthropic API key from console.anthropic.com:
export ANTHROPIC_API_KEY=sk-ant-api03-...
Start a downloaded or installed binary with Docker:
scrutineer
Select another installed runtime explicitly:
scrutineer --runtime podman
scrutineer --runtime apple
The existing source-checkout command remains supported:
go run ./cmd/scrutineer -skills ./skills
Then open http://127.0.0.1:8080. The explicit -skills ./skills directory makes the checkout command useful while developing skills because it overrides the copies embedded in the binary. It is optional for ordinary use because Scrutineer ships its built-in skills and per-ecosystem runner profiles inside the executable.
You can also build a checkout-independent executable and run it from another directory:
go build -o scrutineer ./cmd/scrutineer
mkdir -p ~/.local/bin
install -m 0755 scrutineer ~/.local/bin/scrutineer
scrutineer
Precompiled binaries cover Linux and macOS on amd64 and arm64. Run scrutineer --version (or scrutineer version) to see the application version, source commit, build timestamp, and exact runner-image digest paired with that release.
The executable does not bundle Docker, Podman, or Apple's container CLI. Scrutineer remains a host application that asks the selected external runtime to launch an ephemeral runner container for each scan; it materialises the embedded profile Dockerfiles under the data directory when docker/profiles is not available from a source checkout. Content-addressed skill and profile bundles from older versions are retained because existing skill records may still reference their auxiliary files; incomplete extraction directories older than 24 hours are removed automatically. To run under codex, opencode, or copilot instead, see the Codex backend, Opencode backend, and Copilot backend sections; only the credential and -backend flag change.
Large batches pause automatically at an account-level rate limit or quota wall. When claude-code is running on a subscription token it emits a rate_limit_event carrying the reset time, and scrutineer re-queues the paused batch after that reset; API-key accounts and the codex/opencode/copilot backends report the error without a reset time, so those batches stay paused for manual resume from the /usage page, which also shows the most recent per-window status.
Scrutineer uses Docker by default: each scan runs in an ephemeral container with a read-only source mount and an egress allowlist proxy. The paired runner image (ghcr.io/alpha-omega-security/scrutineer-runner) is pulled on first use, so the first scan is slower while it downloads. A host without the selected runtime fails startup rather than silently weakening isolation; --no-container is the explicit, Claude-only escape hatch for running directly on a trusted host. To take only some skills out of the container (say verify for a project whose build toolchain only exists on the host, while triage and the deep-dive skills keep their profile containers), list them under host_skills in the config file: those run claude on the host with no egress proxy and reach the skill API on the loopback address, and the option is refused under --hardened and with non-claude backends, like --no-container.
Click Add repository in the sidebar, paste a git HTTPS URL, and scrutineer enqueues the triage skill. To scan a maintained branch instead of the default, fill the Branch field (it suggests the remote's branches as you type and also accepts a tag or commit), or append a /tree/<branch> suffix to the URL; the suffix also works one-per-line when bulk-importing. Triage then enqueues the rest of the pipeline in parallel. Metadata and package lookups finish in seconds; the security deep-dive takes a few minutes depending on repo size. Open the repo page and switch to the Scans tab to watch progress, or wait for the Findings tab to fill in.
To scan one package inside a monorepo, append #<sub/dir> to the URL -- e.g. https://github.com/rails/rails#activesupport (or paste a .../tree/<branch>/<sub/dir> URL). Scrutineer scopes the whole pipeline to that sub-folder and gives the sub-package its own page, findings, packages, advisories, disclosure channel, and export, instead of rolling every package up under the repository. By default a sub-package scan is confined to its own sub-folder (siblings pruned, .git kept), widening to the whole repository automatically if the sub-package cannot resolve its dependencies in isolation; set subproject_scope: soft to always scan against the whole tree, or monorepo_attribution: false to keep registry data repo-wide (see scrutineer.sample.yaml). The subprojects skill also auto-discovers a monorepo's sub-packages, so you can scan them from the repo page without composing URLs by hand. Findings, packages, advisories, the disclosure channel, and export are attributed per sub-package; dependents, maintainers, posture, and health stay repo-wide. There is no per-sub-package schedule -- to re-scan sub-packages on a cadence, drive the local API from cron or a systemd timer (see scripts/rescan-subprojects.sh).
To onboard a whole GitHub org at once, open Add multiple → Import a whole org and enter the org (or user) login. Scrutineer fetches every repository and queues each one with the default scan set, skipping forks, archived repos (unless you opt in), and any URL already in the database. Set GITHUB_TOKEN to raise GitHub's unauthenticated rate limit when importing large orgs.
You can also scan a directory on disk, useful before pushing, or for code not hosted on a git forge. Paste an absolute path (/path/to/project) in the same Add repository field. Scrutineer copies the directory into a per-scan workspace and runs the default skill set; skills that need a forge URL or ecosyste.ms enrichment (advisories, exposure, fork, maintainers, metadata, packages, public-issue, report-upstream) are skipped automatically. Symlinks are recreated as-is rather than dereferenced during the copy; in container mode their targets then resolve inside the container, so host files reached only through such a link are not visible to skills. Under --no-container, and for the skills listed in host_skills, the kernel dereferences them normally, so only point scrutineer at trees you trust.
The optional analysis tools (semgrep, bandit, zizmor, betterleaks, darnit, git-pkgs, brief) are bundled in the runner image, so you don't need them installed locally when the container runner is in use.
Scrutineer shells out to git clone with no explicit token passing, so it uses whatever credentials are already configured on the host: SSH keys, credential helpers, gh auth login, a .netrc file, or the macOS keychain.
To scan private repos, check that git clone https://github.com/org/repo works in the same environment you launch scrutineer from before adding the URL.
Common setups:
# GitHub CLI (easiest)
gh auth login
# Git credential helper
git config --global credential.helper store # or osxkeychain / manager-core
# SSH-based clone URLs are not supported -- scrutineer only accepts https:// URLs.
# Use a credential helper to authenticate HTTPS clones instead.
When running inside Docker (docker run ...), the container has no access to host credentials. Mount a credential store or set GIT_ASKPASS to provide access to private repos from inside the container.
When the containerised runner is active (the default when a container runtime is available), each scan runs in a separate container but the clone happens on the host before the source is mounted in. Host credentials are used for the clone and never enter the container.
triage) that enqueues the others; edit its SKILL.md to change what runsthreat-model scan, falling back to the deep-dive's boundaries and sink inventory on older repositories+N expandable location list, and findings that stop appearing are marked "not seen" with a miss countrevalidate skill auto-enqueues for High/Critical deep-dive findings and every imported finding, emitting true_positive / false_positive / already_fixed / uncertain plus an optional severity adjustment; every true positive chains into the critic release-build assessment, while High/Critical true positives also chain into verify/audit so the operator can spot-check the classifier; each review records an agreement-or-overturn verdict on the findingyes/no field on findings with free-text evidence, surfaced on the finding page, in the OSV database_specific block, in CSAF audit notes, and in markdown report exportsbreaking-change skill runs over a suggested-fix diff plus the top dependents, recording breaking / non_breaking / unknown with a rationale and the list of affected dependentsmitigate skill drafts short-term workarounds and an optional semgrep rule per finding, separate from the code fixdisclose skill all carry both forward, with the v4 metric set kept distinct from v3release-watch skill closes the gap between fix-landed and fix-shipped: once a finding reaches fixed, the skill polls upstream releases and records the release tag, URL, and timestamp when it appears/api/v1/import cannot place a payload, the ingest skill normalises it against the source checkout (resolving locations) before it enters the findings tablereport.md per repository or organisationbundle.tar.gz per finding: OSV, CSAF, markdown report, patch.diff, a runnable poc/ directory extracted from the finding's Validation step, and a manifest naming the contents; ready to hand to a coordinator or attach to a private email when filing outside GitHub PVR/api/v1/import, optionally age-encrypted to your team's keys. The default bundle is share-safe (finding substance only); include=all produces a lossless archival superset -- enrichment, disclosure fields, notes, communications, and references -- for backing up or moving a repo's findings between your own instances. See docs/encrypted-sharing.md/usage page totalling spend per skill, correlating cost with repository workload proxies and linking runs that cost at least ten times their skill median; see docs/usage.md. On a Claude subscription token, a rate-limit wall auto-pauses the batch and resumes it after the reported reset, with per-window status shown on /usage. Optionally (downgrade_on_overage), once the account crosses into overage the model tier falls back from max/high to the mid tier for new scans until overage clears (typically when the window resets) -- announced in the log, on the jobs page, and on /usage. Set pause_on_overage: true to pause model scans instead; this takes precedence over downgrading and preserves resumable work.Adding a repo enqueues the triage skill, whose SKILL.md lists the further skills to run from the bundled set in skills/:
| Skill | What it does |
|---|---|
triage | Orchestrates the default scan set via the scrutineer API |
metadata | Fetches repo metadata from repos.ecosyste.ms |
packages | Looks up published packages from packages.ecosyste.ms |
advisories | Fetches known security advisories |
dependencies | Runs git-pkgs list to index every manifest |
sbom | Runs git-pkgs sbom for a CycloneDX SBOM |
maintainers | Model-backed analysis identifying real maintainers and contact routes |
repo-overview | Runs brief --json for a structured project summary |
embedded-native | Runs brief --json at the repository root and each initialized shallow submodule to map native languages, extension bridges, build tools, manifests, and dependencies |
subprojects | Enumerates monorepo packages/workspaces so deep-dives can be scoped to a sub-path |
recon | Maps distinct externally reachable input-processing subsystems into focus areas; after threat-model completes, those areas fan out into parallel deep-dive audits |
history | Mines Git history for security fixes that never received an advisory, with ancestry-checked incremental caching and explicit partial-history reporting |
threat-model | Derives the project's security contract (components, entry-point trust table, claimed and disclaimed properties) for the deep-dive to load |
semgrep | Static analysis mapped into findings shape |
bandit | Python-only static analysis mapped into findings shape, carrying bandit's own confidence level; runs alongside semgrep on repositories with Python |
betterleaks | Secret scanning across the available Git history, with raw secret values removed before findings are written |
vuln-scan | High-recall model-backed static candidate scan adapted from Anthropic's defending-code reference harness |
zizmor | GitHub Actions workflow audit enriched with bundled trust-boundary, credential, and supply-chain guidance |
ingest | Normalizes external reports in arbitrary formats into findings when /v1/import cannot recognise the payload |
security-deep-dive | The model-backed audit producing structured findings |
advisory-deep-dive | Re-audits every past advisory against its fix commit for a fix bypass, an incomplete fix, or the same bug class in sibling code the patch never touched; the deep-dive scoped to the advisory space |
finding-dedup | Compares open findings and marks overlapping reports as duplicates |
verify | Re-checks one finding against current HEAD, records an evidenced attack tree and typed attacker prerequisites, runs three isolated attempts, scores five fixed evidence criteria, gates the verdict on host-matched design controls, and applies deterministic severity caps from reconciled controls and verified prerequisites |
critic | Assesses whether a true-positive finding can affect a real release build, stores an append-only attack-path record, and marks release paths as viable, non-viable, sample/test-only, or conditional |
revalidate | Cheap read-only classifier (prose + git log, no PoC execution) that emits true / false positive / already-fixed / uncertain; auto-enqueued for High/Critical from security-deep-dive and for every imported finding. Every true_positive chains to critic, and High/Critical true positives also chain to verify |
breaking-change | Static breaking-change check on the suggested-fix diff; records breaking/non_breaking/unknown with rationale and the affected dependents |
release-watch | After a finding reaches fixed, watches the upstream for a release containing the fix commit; records release tag, URL, and timestamp on the finding |
disclose | Drafts a GHSA-shaped advisory (title, description, CVSS, CWEs, references) for one finding |
patch | Proposes a unified diff fixing one finding; a diff that passes the applicability gate is stored on the finding as its suggested fix |
reattack | Applies one immutable gated patch to a fresh checkout and independently exercises three root-cause variants plus a benign control; records a bypass, verified resistance, or incomplete verification without overwriting prior attempts |
report-upstream | Files one finding on the upstream repository via GitHub PVR with the proposed patch attached; the action that moves a finding to reported |
public-issue | Files a low-severity finding as an ordinary public GitHub issue after analyst confirmation |
reachability | Traces dependency sinks through application code to determine which are reachable from trust boundaries |
cna-match | Matches a repository to its CVE Numbering Authority so disclosures route to the right contact |
posture | Records the repo's security posture (reporting policy, response history, hardening) on the Repository row |
compliance | Audits the repo against the 62 OpenSSF Baseline controls with darnit, resolves the controls darnit defers to LLM analysis or could not verify without a forge token, and records per-control verdicts and the attained Baseline level on the Compliance tab |
forensics | Read-only compromise timeline and evidence bundle from local Git history and public forge/archive records |
variants | Starting from one confirmed finding, searches the current repository for distinct, high-confidence sibling instances of the same root cause |
audit-injection | Opt-in static audit for command/code execution, unsafe deserialization, and server-side template injection with ecosystem-specific references |
audit-exfil | Opt-in static audit for SSRF, path traversal, XXE, and response or diagnostic leakage with ecosystem-specific references |
audit-authz | Opt-in static audit for IDOR, tenant isolation, fail-open guards, privilege escalation, and authorization decisions based on unverified claims |
audit-pii | Opt-in static audit for real personal or customer-identifying data committed to source or exposed through logs, URLs, telemetry, exports, and responses |
audit-memory | Opt-in static audit for reachable memory corruption in first-party C, C++, unsafe Rust, native extensions, and FFI boundaries |
Edit skills/triage/SKILL.md to change what gets run by default. Drop new skill directories in skills/ to add scan types; no code changes needed. See docs/skills.md for the frontmatter reference, the scrutineer.* metadata keys, the context.json shape, output kinds, schema validation, and the skill-facing HTTP API.
Before each scan, lockfiles, minified bundles, and generated trees are stripped from the workspace so the skill doesn't waste turns on them. The builtin skip list covers node_modules, dist, generated, __generated__, *.min.js/*.min.css, and the common lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, Cargo.lock, go.sum, Gemfile.lock, poetry.lock, composer.lock). Skills can override this with scrutineer.paths (allow-list) and layer scrutineer.ignore_paths on top; see docs/skills.md.
Scrutineer can ingest findings produced elsewhere so they enter the same triage and disclosure workflow:
curl --data-binary @report.sarif http://127.0.0.1:8080/api/v1/import
SARIF 2.1.0, CSV, markdown, and a minimal JSON shape are all accepted; the format is sniffed from the body. See docs/import.md for the full request and response shape, the per-format field mapping, and how to add support for a new format.
Every index page has a search box plus filter and sort dropdowns; the specifics vary by page. The sidebar sections:
Each finding from the security-deep-dive skill starts at new and moves through a guided workflow:
security-deep-dive and every imported finding auto-enqueue a revalidate pass first, which records true_positive / false_positive / already_fixed / uncertain on the finding. Every true positive chains into the release-build critic; High/Critical true positives also chain into verify. Outside that path: click "Run verification" to start independent confirmation using model tokens, "Mark triaged" if you have already verified it independently, or "Reject"NON_VIABLE assessment blocks private disclosure, public issues, and upstream reporting; VIABLE, SAMPLE_OR_TEST, CONDITIONAL_VIABLE, and unassessed findings remain analyst decisionsreport-upstream skill to file it via GitHub PVR (github.com only, requires gh auth), run public-issue for reviewed low-severity hardening findings that are safe to file publicly, or click "Mark as reported" after sending it yourself. When upstream has no PVR, follow the runbook in docs/disclosure-fallback.md: route to a CNA when cna-match names one, otherwise contact the channel maintainers returned. With federation_peers configured, every route out of this state (the button, the report-upstream and public-issue skills, and the VINCE submission) first asks each peer whether it already holds the same finding and, on a match, names their contact so you coordinate before proceeding (see docs/interchange.md)Each finding page has a notes section for recording triage reasoning and communication history.
The repository page carries a Federation opt-out control: recording one cancels the repository's queued, running and paused scans, then refuses every new scan on it, including scheduled runs and automatic follow-ups. It also withdraws the repository from the interchange feeds, and, when the public feed is configured, publishes the request so other federated instances honour it too; an opt-out arriving on a peer feed sets it here the same way. See docs/interchange.md.
The Chat tab on a repository or a finding opens a read-only conversation with the agent about that code: it works from a copy of the clone plus a snapshot of the findings, and is restricted to reading and searching, so it can explain and cross-check but never modify anything. Each conversation keeps its own working copy on disk, so delete the ones you are done with from the conversation page to reclaim the space.
A patch run whose diff survives the applicability gate (the diff parses, targets files that exist, touches the flagged file, and passes git apply --check) creates an immutable numbered remediation attempt and updates the finding's suggested_fix projection. The current diff is downloadable from the finding page as a .patch file and included in markdown report exports. To iterate on your own edits, push them to a branch, scan that branch, then run patch against the new scan. Re-attack patch checks out an attempt's exact base commit, applies its exact diff, and independently exercises at least three distinct root-cause variants plus a benign control. Only a complete failed_to_bypass result derives verified_secure; a same-class, same-sink bypass is retained as evidence and staged for later patch attempts, while missing or inconsistent evidence remains verification_incomplete. Earlier attempts and bypass evidence remain immutable.
The Dependencies tab on a repo groups packages by name and shows all manifest files where each appears. It shows runtime dependencies by default, with a toggle for test/build/dev rows. The import button (arrow icon) next to a dependency resolves it to a repository URL via packages.ecosyste.ms and queues the full pipeline for it. Dependencies you've already imported show a link icon instead.
The same applies to the Dependents tab -- you can import any dependent's repository with one click.
docker build -t scrutineer .
docker run -p 127.0.0.1:8080:8080 -v scrutineer-data:/data \
-e ANTHROPIC_API_KEY=sk-ant-api03-... \
-e ANTHROPIC_BASE_URL=https://... \
scrutineer
Or with a Claude Code OAuth token instead of an API key:
docker run -p 127.0.0.1:8080:8080 -v scrutineer-data:/data \
-e CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... \
scrutineer
For codex or opencode, pass -e CODEX_API_KEY=... / -e OPENAI_API_KEY=... (or ANTHROPIC_API_KEY for opencode with an Anthropic model) and add -backend codex / -backend opencode to the command. For copilot, pass -e GH_TOKEN=... (a fine-grained PAT or gh auth token value; classic ghp_ PATs are rejected by Copilot CLI) and add -backend copilot.
Always bind to 127.0.0.1: the UI has no authentication, so binding to 0.0.0.0 exposes your findings database to anyone on the network.
If a container runtime (docker, rootless podman, or Apple's container) is available on the host, scrutineer runs each scan in an ephemeral container for isolation. The runner image is published to GHCR as a multi-arch manifest (linux/amd64 and linux/arm64) and pulled automatically on first use:
go run ./cmd/scrutineer -skills ./skills
Use --runtime podman to run scans under podman instead of docker (see Podman (rootless) below), --runtime apple to run scans under Apple's container runtime on macOS (see Apple container (experimental) below), --no-container to disable containerised execution entirely, or --runner-image to specify a different image. To build the runner locally instead of pulling from GHCR (use podman build or container build instead if you run scans under those runtimes):
docker build -t scrutineer-runner -f Dockerfile.runner .
go run ./cmd/scrutineer -skills ./skills --runner-image scrutineer-runner
The runner image is not auto-updated, so the analysis toolchain stays on whatever digest you pulled until you pull again. To keep that drift visible, scrutineer checks the registry once at startup (in the background, and failing silently if the registry is unreachable) and flags the runner image when it is more than seven days behind the published :latest -- both in the boot log and as a banner on the Settings page. Update with:
docker pull ghcr.io/alpha-omega-security/scrutineer-runner:latest
On sidecar-backed hardened runtimes, scrutineer also refuses a runner image whose proxy binary does not support the current host-API tunnel policy. Pull or rebuild custom runner images when updating the host binary.
If you would rather update automatically, run watchtower against the runner image or pass --pull=always to the runtime; scrutineer deliberately does not pull on its own so a scan's toolchain only changes when you choose to update it.
When the container runner is active, scrutineer starts an authenticated egress proxy on the host and points HTTPS_PROXY/HTTP_PROXY inside the container at it. The proxy only permits an allowlist of hosts: the active model API, *.ecosyste.ms, the major forges (GitHub, GitLab, Codeberg, Bitbucket), common package registries (npm, PyPI, RubyGems, crates.io, Go module proxy, Packagist, Hex, NuGet), advisory sources (semgrep.dev, OSV, NVD, cwe.mitre.org), and the runtime's host endpoint (host.docker.internal for docker/podman, the default gateway IP for Apple's container) for the local skill API. That API port accepts inspected HTTP proxy requests but refuses raw CONNECT tunnels; separately configured host-local model ports remain tunnelable. Requests to anything else get a 403 and are logged. Extend the list with egress_allow in the config file. When -model-base-url is set (or, for Claude, falls back to the ANTHROPIC_BASE_URL env var), its hostname is automatically added to the allowlist. The proxy uses a per-process random token so it isn't an open relay; tools that ignore the proxy env are not blocked at the network layer (see threatmodel.md).
For deployments that treat skill prompts as untrusted, pass --hardened (or hardened: true in the config). The flag forces the container runner (--no-container is rejected), trims the egress allowlist to the active backend's model API hosts plus the host skill API (so egress_allow is ignored, drop the flag if you need to widen it), mounts the container rootfs read-only with no-new-privileges, attaches each scan to its own ephemeral network created with --internal (removed when the scan ends) so a process that ignores HTTPS_PROXY has no route out and concurrent scans cannot reach each other, and refuses scans whose workspace footprint exceeds 2 GiB once the clone completes. The 2 GiB check is post-clone: it bounds what hardened mode accepts, not what can land on disk during the clone itself; use OS-level disk quotas if you need a clone-time guarantee. Bundled skills that hit ecosyste.ms or a package registry directly will fail under hardened mode unless they route through the host skill API. Per-ecosystem runner profiles still apply, but profile images that need writable paths beyond /work and /tmp are incompatible. Under Docker Desktop and rootless podman the proxy runs as a per-scan sidecar container on the --internal network because the host proxy is unreachable there; see docs/podman.md for the rootless podman path. These runtimes verify that each hardened network blocks external egress while still reaching the sidecar, and refuse the scan if that cannot be confirmed, so the sandbox never silently weakens.
When the container runner is active, scrutineer auto-detects a per-ecosystem profile for each scan: it runs brief against the clone and matches its structured package-manager, language, and tool detections. If brief fails or no selector matches, the scan uses the default runner image. The matched profile selects a runner image (built on demand from docker/profiles/<name>/Dockerfile, cached content-addressed by the Dockerfile's hash) and injects that profile's PROFILE.md as the agent's orientation. The most specific profile wins, so a native-extension repo resolves to its *-ext profile ahead of the plain language profile. Force one with ?profile=<name> on the scan API (validated against the skill's requires_profile); default forces the base runner image.
| Profile | Selected when | Adds |
|---|---|---|
php / php-ext | package_manager:Composer / tools.native_extension:phpize | PHP; php-ext builds PHP debug + ASan/UBSan for C extensions |
python / python-ext | package_manager:pip / Pipenv / Poetry / uv / PDM / setuptools; python-ext when tools.native_extension:setuptools Extension is present | CPython; python-ext builds CPython debug + ASan/UBSan |
ruby | Bundler | Ruby 3.4 + Bundler; metaprogramming / dynamic-dispatch guidance, plus a tripwire that flags an un-instrumented native extension |
ruby-ext | tools.native_extension:mkmf | A superset of ruby: adds an ASan/UBSan Ruby (the default interpreter), valgrind on the stock interpreter, Rust nightly for rb-sys gems, and Brakeman |
ruby-rails | tools.build:Rails | A superset of ruby plus Brakeman, Rails-specific SAST |
node | npm/pnpm/Yarn/Bun | Node.js |
go | Go Modules | Go toolchain |
scala | sbt or Scala language detection | A superset of java plus sbt and Scala-specific reproducer guidance |
java | Maven/Gradle | JDK |
dotnet | NuGet/dotnet CLI | .NET SDK |
beam | Mix/rebar3 | Erlang/Elixir |
rust | Cargo | Rust stable + nightly, Miri, sanitizers |
ocaml | opam, tools.build:Dune, or OCaml language detection | opam + a compiled OCaml 5.x switch + dune |
swift | Swift Package Manager or Swift language detection | Swift toolchain + swiftly |
perl | cpanm or Perl language detection | Perl toolchain |
c-cpp | tools.build:CMake / Make / Autotools / Meson, or C/C++ language detection, after language ecosystems have had a chance to match | C/C++ build toolchain |
The three Ruby profiles are mutually exclusive at selection time, but ruby-ext and ruby-rails are deliberate supersets of ruby (the same Ruby-level audit, plus their extra coverage), so a detection that errs toward ruby-ext costs only build time, never coverage. A native gem that slips through to the plain ruby profile is caught by that profile's tripwire, which records that memory-safety scanning needs ruby-ext. Add a profile by registering it in internal/worker/profile.go and adding docker/profiles/<name>/{Dockerfile,PROFILE.md}.
Refreshing profile images. Because profile images cache on the Dockerfile's hash, a moved :latest base runner -- or an edited PROFILE.md -- won't always force a rebuild (without skopeo the cache keys on the image ref alone; see Podman). Two helpers give you a clean slate: scripts/ruby-image-reset.sh removes the Ruby profile images (ruby, ruby-ext, ruby-rails), scripts/all-images-reset.sh removes every ecosystem's, and both then pull the latest scrutineer-runner so the next scan rebuilds from a fresh base. Each prompts before removing anything (--force skips it) and uses docker if present, otherwise podman; both run under bash 3.2+, so macOS's stock /bin/bash works.
Pass --runtime podman (or runtime: podman in the config) to run scans under podman instead of docker. Rootless podman is the recommended posture: because the runtime is not root-equivalent, a hostile repository that escapes the scan container lands as an unprivileged subordinate user rather than near-root on the host (see threatmodel.md, T12). There is no auto-detection -- a podman-only host must set --runtime podman explicitly; the default stays docker.
Requirements:
--add-host host.docker.internal:host-gateway, which older podman does not support; without it, scans fail with network errors. Startup logs a warning if the detected version looks too old or if the host-gateway address can't be resolved.--hardened) -- the rootless egress proxy sidecar must reach the loopback-bound host skill API, which needs the network backend to forward host-gateway to the host loopback. podman ≥ 5.0 defaults to pasta (--map-host-loopback); older podman can work with a slirp4netns backend that has host-loopback enabled. Where unavailable, hardened scans are refused (fail closed). Startup warns on podman < 5.0 under --hardened. See docs/egress-sidecar.md./etc/subuid + /etc/subgid -- rootless podman maps the container user back to your host user with --userns=keep-id so scan output and the resumable session store stay host-owned. Your user needs a sub-id range (the usual useradd default provides one; run podman system migrate after changing it). Scrutineer runs a one-off keep-id smoke test at startup and fails fast with a hint if this is misconfigured.skopeo (optional) -- used in place of docker buildx to detect when a moved :latest runner tag should rebuild per-ecosystem profile images. Without it, profiles still build but key their cache on the image ref alone.:z so the container can read the clone and write its output; without it every scan fails with permission errors. This is handled automatically: --selinux auto (the default) detects the host, and a startup smoke test verifies a real relabeled mount works. Use --selinux off if you pre-label paths yourself, or --selinux on to force it. See docs/podman.md for the :z-vs-:Z rationale.Under rootless podman with --hardened (verified fail-closed per scan), the scan can't route to a host proxy across the network-namespace boundary, so the egress proxy runs as a per-scan sidecar container on the --internal network to keep enforced egress working. It requires a network backend that forwards host-gateway to the host loopback (modern pasta, default in podman ≥ 5.0, or slirp4netns with host-loopback); where that isn't available the sidecar can't reach the host skill API and the scan is refused (fail closed). Fall back to --hardened-runtime-only for the non-network half (read-only rootfs + no-new-privileges + the 2 GiB post-clone workspace cap), or use rootful podman or Docker Engine for --hardened without the host-loopback-forwarding requirement -- the always-on --cap-drop ALL / non-root user / /tmp tmpfs / SELinux :z baseline applies in every mode regardless. See docs/podman.md for the full security model and docs/egress-sidecar.md for the operator validation checklist.
The docker build / docker run commands shown in this repo -- for the runner image and the per-ecosystem profile images under docker/profiles/ -- are CLI-compatible with podman; substitute podman for docker.
Pass --runtime apple (or runtime: apple in the config) to run scans under Apple's container runtime instead of docker. This path is explicit opt-in; the default stays docker and scrutineer does not auto-detect a docker-less Mac. It is labelled experimental because it is new and Apple's networking has known rough edges, not because of a capability gap: both ordinary and --hardened scans are supported.
Requirements and notes:
container only on macOS 26 and will not address issues that cannot be reproduced there, so older macOS is out of scope.container with container system start running: startup checks container system status and refuses the runtime if the service is unavailable./proc/net/route inside the runner image. The proxy rewrites local skill-API requests back to 127.0.0.1.--hardened is supported. Each container runs in its own lightweight VM, so the VM boundary is the isolation; container network create --internal is a vmnet host-only network (egress blocked, host proxy reachable) and each hardened scan proves that fail-closed before running. The one --hardened flag Apple's CLI cannot set is --security-opt no-new-privileges, which the per-container VM boundary substitutes for (Apple's own untrusted-code sandbox hardens the same way). --hardened-runtime-only is a rootless-podman concept and is refused; use --hardened.The docker build commands shown for the runner image and profiles can be run as container build when you use this runtime. See docs/apple.md for the full parity matrix, the VM-isolation security model, and how hardened mode works.
| Flag | Default | Description |
|---|---|---|
-config | ./scrutineer.yaml if present | Path to YAML config file |
-addr | 127.0.0.1:8080 | Listen address |
-data | ./data | Data directory for the database and workspaces |
-effort | high | Reasoning effort level, applied by the claude and copilot backends and ignored by codex and opencode. Copilot CLI stops at xhigh, so max is capped to it there |
-skills | - | Additional local directory to load SKILL.md files from; same-named skills override the bundled copies (repeatable) |
-skills-repo | - | owner/repo[@ref] or credential-free git HTTPS URL https://host/path[@ref] to clone skills from on startup; @ref pins a branch, tag or commit and the resolved SHA is recorded on every scan; private repositories use config-file-only skills_repo_token |
-backend | claude | Agent CLI the container runner execs: claude, codex, opencode, or copilot. Non-claude backends require the containerised runner |
--runtime | docker | Container runtime: docker, podman (rootless podman supported), or apple (Apple, experimental) |
--selinux | auto | Bind-mount SELinux relabeling: auto (relabel when SELinux is detected), on, or off |
--no-container | false | Disable the containerised runner; run claude directly on the host (no isolation). Deprecated alias: --no-docker |
--hardened | false | Strict sandbox: container runtime required, egress restricted to the backend's model API hosts + host skill API, read-only rootfs, internal network |
--hardened-runtime-only | false | The non-network half of --hardened (read-only rootfs + no-new-privileges + 2 GiB workspace cap) without the per-scan --internal network; the rootless fallback for hosts where the --hardened egress sidecar can't run (implied by --hardened). Deprecated alias: --hardened-rootless-runtime |
--runner-image | release-matched digest (ghcr.io/alpha-omega-security/scrutineer-runner:latest in development builds) | Container image for per-scan containers |
-concurrency | 4 | Number of scans to run in parallel. Chat turns run from a separate pool sized at half this value, so a busy host can reach 1.5x this many agent containers. With codex.auth_file, scans and chat turns share one execution slot |
-clone | shallow | Clone depth: shallow (--depth 1) or full |
-scan-timeout | 1h | Wall-clock limit per scan; exceeded scans fail |
-max-turns | 0 | Per-scan turn cap (0 = unlimited); claude and copilot backends only, codex and opencode have no turn cap |
-schema-strict | false | Fail a scan when its report.json does not validate against the skill's schema.json (default: warn in the scan log and parse anyway) |
-model-base-url | - | Custom model API base URL for the active backend (env fallback: ANTHROPIC_BASE_URL for claude). -anthropic-base-url is a deprecated alias. |
-ecosystems-enrichment | true | Enrich repositories from ecosyste.ms: the per-repository cache, the warm on repo add, and the PURL-to-repository resolution behind SBOM and dependency import. =false stops every lookup scrutineer's own process makes, leaves egress_allow untouched (the bundled skills still fetch ecosyste.ms themselves), and leaves the Dependents tab and dependent-exposure analysis with no data |
-recipients-file | - | Age recipients file (public keys) for encrypted export |
-identity-file | - | Age identity file or SSH private key for decrypting imports and encrypted federation feeds |
-identity-plugin | - | Data-less age identity plugin name (age -j) for decrypting imports and encrypted federation feeds (repeatable; replaces identity_plugins from config when set) |
Every flag above can be set in a YAML config file instead, loaded from ./scrutineer.yaml by default (override with -config path/to/file; command-line flags always take precedence). See scrutineer.sample.yaml for the full shape.
scrutineer.yaml may contain long-lived credentials such as skills_repo_token, the VINCE API key and federation salt. Keep it out of source control and backups, and set owner-only permissions with chmod 600 scrutineer.yaml. A private skills_repo must use a credential-free HTTPS URL; Scrutineer passes skills_repo_token to Git through GIT_ASKPASS, and intentionally provides no token command-line flag.
Identity files and age identity plugins can be used together. The interface is
provider-neutral: any plugin implementing age's data-less identity (age -j)
contract can be named. Scrutineer delegates only through age's official plugin
protocol, with no provider-specific SDK or command parsing. For example,
age-plugin-1p can find an SSH key in 1Password while the existing recipients
file continues to contain ordinary SSH public keys:
recipients_file: /path/to/recipients.txt
identity_plugins:
- 1p
age-plugin-1p and the 1Password op CLI must be installed separately and
available through PATH. Scrutineer uses age's plugin protocol and never
receives or writes the SSH private key; authentication and Touch ID remain the
plugin's and 1Password's responsibility. See
docs/encrypted-sharing.md for
interactive and headless-use details.
The config file can also replace the model pick list and pin the fallback default model used by the high tier:
default_model: claude-sonnet-5
models:
- name: Sonnet 5.0
id: claude-sonnet-5
- name: Opus
id: claude-opus-5
Skills resolve to a model through a tier: high by default, unless the skill's SKILL.md metadata pins scrutineer.model to another tier or an exact model id (bundled lightweight skills such as metadata use mid, security-deep-dive uses max). The Settings page maps each tier to any configured model.
Scrutineer can drive OpenAI's codex CLI instead of claude-code. The runner image bundles the codex binary, so switching is the flag (or backend: codex in scrutineer.yaml) plus a credential:
export CODEX_API_KEY=sk-...
go run ./cmd/scrutineer -skills ./skills -backend codex
It can also use a ChatGPT subscription login without consuming Platform API credits. Create an isolated file-backed login:
mkdir -p ~/.config/scrutineer/codex-rubygems
chmod 700 ~/.config/scrutineer/codex-rubygems
CODEX_HOME=~/.config/scrutineer/codex-rubygems \
codex -c cli_auth_credentials_store=file login --device-auth
chmod 600 ~/.config/scrutineer/codex-rubygems/auth.json
Then configure its credential file:
backend: codex
codex:
auth_file: ~/.config/scrutineer/codex-rubygems/auth.json
The credential must be mode 0600. Scrutineer refuses this configuration while
CODEX_API_KEY or OPENAI_API_KEY is set, mounts only auth.json into each
scan's otherwise private Codex home, and serializes account-authenticated scans
so token refreshes cannot race.
The container, egress proxy, language profiles and skill staging stay the same; only the agent CLI inside the container changes. The egress allowlist picks up the required OpenAI hosts automatically, and the model pick list defaults to codex's own catalog with tier tags already set -- override with models: in the config if you want a different set. Use -model-base-url or model_base_url: for a custom OpenAI-compatible endpoint; under codex it is passed as openai_base_url to codex exec. The codex backend requires the containerised runner; --no-container with -backend codex is rejected at startup.
See docs/codex.md for what differs from claude (argv, skill staging, credentials, egress), which model ids the pinned codex version accepts, and why codex's own sandbox is disabled inside scrutineer's container.
OpenCode can run models from several providers. The stock runner includes OpenCode and its common built-in adapters; select it with -backend opencode and use a model id in provider/model form:
backend: opencode
default_model: anthropic/claude-sonnet-5
Anthropic and OpenAI keep their existing setup. Other providers can use opencode.providers to select provider-only credentials, hardened-mode egress hosts, a host-local model server port, stored OAuth state, OpenCode config, and a derived runner image. Scrutineer checks that the selected model exists in that image before starting the skill, then records the provider image and digest on the scan. Like codex, the OpenCode backend requires the container runner. See docs/opencode.md for configuration and image requirements.
Scrutineer can drive GitHub Copilot CLI instead of claude-code. The runner image bundles the copilot binary, so switching is the flag (or backend: copilot in scrutineer.yaml) plus a credential -- a GitHub token with Copilot access. Copilot CLI rejects classic (ghp_) PATs, so use a fine-grained PAT or the OAuth token gh auth login already stored:
export GH_TOKEN=$(gh auth token)
go run ./cmd/scrutineer -skills ./skills -backend copilot
The container, egress proxy, language profiles and skill staging stay the same; only the agent CLI inside the container changes. The default egress allowlist is entirely GitHub infrastructure (github.com, api.github.com, api.mcp.github.com, *.githubcopilot.com), since Copilot CLI proxies every model through GitHub's own API; setting -model-base-url overrides that endpoint and adds the override host to the allowlist. The model pick list defaults to Copilot's own catalog (Claude and GPT models) with tier tags already set; override with models: in the config for a different set. Unlike codex and opencode, -max-turns is honoured by this backend. The copilot backend requires the containerised runner; --no-container with -backend copilot is rejected at startup. See docs/copilot.md for the full credential and event-mapping details.
In --no-container mode, and for the skills listed in host_skills, the claude subprocess inherits your ~/.claude/settings.json, so sandbox settings that restrict network or filesystem access there will fail skills that need them. Point claude at a separate config directory just for scrutineer runs:
CLAUDE_CONFIG_DIR=~/.claude-scrutineer go run ./cmd/scrutineer -skills ./skills
Copy your settings.json into that directory and drop the sandbox keys; your normal Claude Code config is untouched. Container mode is not affected for the skills that stay in the container: there claude runs inside the container with its own environment regardless of the host config.
See SECURITY.md for the reporting policy and threatmodel.md for the full threat model. The short version: scanning a repository is equivalent to running code from it. The containerised runner (when available) isolates each scan, but the default bare-metal mode runs everything as your user. Only scan repositories you'd be willing to clone and build locally.
scrutineer backup/restore, sqlite3, Litestream)--hardened egress proxy sidecarMIT. See LICENSE. Copyright (c) 2026 Alpha-Omega.
Go
89.3%
HTML
6.5%
Dockerfile
1.8%
A local tool for scanning open source repositories for security vulnerabilities and managing the disclosure process. You add a repo by URL, scrutineer runs a pipeline of agent skills against it inside a container, and presents the results in a web UI where you can triage findings, identify maintainers, and track disclosures. The agent CLI is pluggable: claude-code by default, or codex, opencode, or GitHub Copilot CLI with -backend.
You need one supported container runtime: Docker (the default), rootless Podman, or Apple's container CLI.
Install Scrutineer from Homebrew on macOS or Linux:
brew install scrutineer
Alternatively, download the Linux or macOS archive for your architecture from GitHub Releases, verify it against SHA256SUMS, and put scrutineer on your PATH. The macOS archives are currently unsigned and not notarized, so macOS may present a Gatekeeper warning even after you verify the checksum and GitHub build-provenance attestation.
To build or run from source instead, install Go 1.27+:
git clone https://github.com/alpha-omega-security/scrutineer
cd scrutineer
Authenticate the default claude-code backend with either a Claude Code subscription token (Max, Pro, Team, or Enterprise) generated by the Claude CLI:
claude setup-token
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
or an Anthropic API key from console.anthropic.com:
export ANTHROPIC_API_KEY=sk-ant-api03-...
Start a downloaded or installed binary with Docker:
scrutineer
Select another installed runtime explicitly:
scrutineer --runtime podman
scrutineer --runtime apple
The existing source-checkout command remains supported:
go run ./cmd/scrutineer -skills ./skills
Then open http://127.0.0.1:8080. The explicit -skills ./skills directory makes the checkout command useful while developing skills because it overrides the copies embedded in the binary. It is optional for ordinary use because Scrutineer ships its built-in skills and per-ecosystem runner profiles inside the executable.
You can also build a checkout-independent executable and run it from another directory:
go build -o scrutineer ./cmd/scrutineer
mkdir -p ~/.local/bin
install -m 0755 scrutineer ~/.local/bin/scrutineer
scrutineer
Precompiled binaries cover Linux and macOS on amd64 and arm64. Run scrutineer --version (or scrutineer version) to see the application version, source commit, build timestamp, and exact runner-image digest paired with that release.
The executable does not bundle Docker, Podman, or Apple's container CLI. Scrutineer remains a host application that asks the selected external runtime to launch an ephemeral runner container for each scan; it materialises the embedded profile Dockerfiles under the data directory when docker/profiles is not available from a source checkout. Content-addressed skill and profile bundles from older versions are retained because existing skill records may still reference their auxiliary files; incomplete extraction directories older than 24 hours are removed automatically. To run under codex, opencode, or copilot instead, see the Codex backend, Opencode backend, and Copilot backend sections; only the credential and -backend flag change.
Large batches pause automatically at an account-level rate limit or quota wall. When claude-code is running on a subscription token it emits a rate_limit_event carrying the reset time, and scrutineer re-queues the paused batch after that reset; API-key accounts and the codex/opencode/copilot backends report the error without a reset time, so those batches stay paused for manual resume from the /usage page, which also shows the most recent per-window status.
Scrutineer uses Docker by default: each scan runs in an ephemeral container with a read-only source mount and an egress allowlist proxy. The paired runner image (ghcr.io/alpha-omega-security/scrutineer-runner) is pulled on first use, so the first scan is slower while it downloads. A host without the selected runtime fails startup rather than silently weakening isolation; --no-container is the explicit, Claude-only escape hatch for running directly on a trusted host. To take only some skills out of the container (say verify for a project whose build toolchain only exists on the host, while triage and the deep-dive skills keep their profile containers), list them under host_skills in the config file: those run claude on the host with no egress proxy and reach the skill API on the loopback address, and the option is refused under --hardened and with non-claude backends, like --no-container.
Click Add repository in the sidebar, paste a git HTTPS URL, and scrutineer enqueues the triage skill. To scan a maintained branch instead of the default, fill the Branch field (it suggests the remote's branches as you type and also accepts a tag or commit), or append a /tree/<branch> suffix to the URL; the suffix also works one-per-line when bulk-importing. Triage then enqueues the rest of the pipeline in parallel. Metadata and package lookups finish in seconds; the security deep-dive takes a few minutes depending on repo size. Open the repo page and switch to the Scans tab to watch progress, or wait for the Findings tab to fill in.
To scan one package inside a monorepo, append #<sub/dir> to the URL -- e.g. https://github.com/rails/rails#activesupport (or paste a .../tree/<branch>/<sub/dir> URL). Scrutineer scopes the whole pipeline to that sub-folder and gives the sub-package its own page, findings, packages, advisories, disclosure channel, and export, instead of rolling every package up under the repository. By default a sub-package scan is confined to its own sub-folder (siblings pruned, .git kept), widening to the whole repository automatically if the sub-package cannot resolve its dependencies in isolation; set subproject_scope: soft to always scan against the whole tree, or monorepo_attribution: false to keep registry data repo-wide (see scrutineer.sample.yaml). The subprojects skill also auto-discovers a monorepo's sub-packages, so you can scan them from the repo page without composing URLs by hand. Findings, packages, advisories, the disclosure channel, and export are attributed per sub-package; dependents, maintainers, posture, and health stay repo-wide. There is no per-sub-package schedule -- to re-scan sub-packages on a cadence, drive the local API from cron or a systemd timer (see scripts/rescan-subprojects.sh).
To onboard a whole GitHub org at once, open Add multiple → Import a whole org and enter the org (or user) login. Scrutineer fetches every repository and queues each one with the default scan set, skipping forks, archived repos (unless you opt in), and any URL already in the database. Set GITHUB_TOKEN to raise GitHub's unauthenticated rate limit when importing large orgs.
You can also scan a directory on disk, useful before pushing, or for code not hosted on a git forge. Paste an absolute path (/path/to/project) in the same Add repository field. Scrutineer copies the directory into a per-scan workspace and runs the default skill set; skills that need a forge URL or ecosyste.ms enrichment (advisories, exposure, fork, maintainers, metadata, packages, public-issue, report-upstream) are skipped automatically. Symlinks are recreated as-is rather than dereferenced during the copy; in container mode their targets then resolve inside the container, so host files reached only through such a link are not visible to skills. Under --no-container, and for the skills listed in host_skills, the kernel dereferences them normally, so only point scrutineer at trees you trust.
The optional analysis tools (semgrep, bandit, zizmor, betterleaks, darnit, git-pkgs, brief) are bundled in the runner image, so you don't need them installed locally when the container runner is in use.
Scrutineer shells out to git clone with no explicit token passing, so it uses whatever credentials are already configured on the host: SSH keys, credential helpers, gh auth login, a .netrc file, or the macOS keychain.
To scan private repos, check that git clone https://github.com/org/repo works in the same environment you launch scrutineer from before adding the URL.
Common setups:
# GitHub CLI (easiest)
gh auth login
# Git credential helper
git config --global credential.helper store # or osxkeychain / manager-core
# SSH-based clone URLs are not supported -- scrutineer only accepts https:// URLs.
# Use a credential helper to authenticate HTTPS clones instead.
When running inside Docker (docker run ...), the container has no access to host credentials. Mount a credential store or set GIT_ASKPASS to provide access to private repos from inside the container.
When the containerised runner is active (the default when a container runtime is available), each scan runs in a separate container but the clone happens on the host before the source is mounted in. Host credentials are used for the clone and never enter the container.
triage) that enqueues the others; edit its SKILL.md to change what runsthreat-model scan, falling back to the deep-dive's boundaries and sink inventory on older repositories+N expandable location list, and findings that stop appearing are marked "not seen" with a miss countrevalidate skill auto-enqueues for High/Critical deep-dive findings and every imported finding, emitting true_positive / false_positive / already_fixed / uncertain plus an optional severity adjustment; every true positive chains into the critic release-build assessment, while High/Critical true positives also chain into verify/audit so the operator can spot-check the classifier; each review records an agreement-or-overturn verdict on the findingyes/no field on findings with free-text evidence, surfaced on the finding page, in the OSV database_specific block, in CSAF audit notes, and in markdown report exportsbreaking-change skill runs over a suggested-fix diff plus the top dependents, recording breaking / non_breaking / unknown with a rationale and the list of affected dependentsmitigate skill drafts short-term workarounds and an optional semgrep rule per finding, separate from the code fixdisclose skill all carry both forward, with the v4 metric set kept distinct from v3release-watch skill closes the gap between fix-landed and fix-shipped: once a finding reaches fixed, the skill polls upstream releases and records the release tag, URL, and timestamp when it appears/api/v1/import cannot place a payload, the ingest skill normalises it against the source checkout (resolving locations) before it enters the findings tablereport.md per repository or organisationbundle.tar.gz per finding: OSV, CSAF, markdown report, patch.diff, a runnable poc/ directory extracted from the finding's Validation step, and a manifest naming the contents; ready to hand to a coordinator or attach to a private email when filing outside GitHub PVR/api/v1/import, optionally age-encrypted to your team's keys. The default bundle is share-safe (finding substance only); include=all produces a lossless archival superset -- enrichment, disclosure fields, notes, communications, and references -- for backing up or moving a repo's findings between your own instances. See docs/encrypted-sharing.md/usage page totalling spend per skill, correlating cost with repository workload proxies and linking runs that cost at least ten times their skill median; see docs/usage.md. On a Claude subscription token, a rate-limit wall auto-pauses the batch and resumes it after the reported reset, with per-window status shown on /usage. Optionally (downgrade_on_overage), once the account crosses into overage the model tier falls back from max/high to the mid tier for new scans until overage clears (typically when the window resets) -- announced in the log, on the jobs page, and on /usage. Set pause_on_overage: true to pause model scans instead; this takes precedence over downgrading and preserves resumable work.Adding a repo enqueues the triage skill, whose SKILL.md lists the further skills to run from the bundled set in skills/:
| Skill | What it does |
|---|---|
triage | Orchestrates the default scan set via the scrutineer API |
metadata | Fetches repo metadata from repos.ecosyste.ms |
packages | Looks up published packages from packages.ecosyste.ms |
advisories | Fetches known security advisories |
dependencies | Runs git-pkgs list to index every manifest |
sbom | Runs git-pkgs sbom for a CycloneDX SBOM |
maintainers | Model-backed analysis identifying real maintainers and contact routes |
repo-overview | Runs brief --json for a structured project summary |
embedded-native | Runs brief --json at the repository root and each initialized shallow submodule to map native languages, extension bridges, build tools, manifests, and dependencies |
subprojects | Enumerates monorepo packages/workspaces so deep-dives can be scoped to a sub-path |
recon | Maps distinct externally reachable input-processing subsystems into focus areas; after threat-model completes, those areas fan out into parallel deep-dive audits |
history | Mines Git history for security fixes that never received an advisory, with ancestry-checked incremental caching and explicit partial-history reporting |
threat-model | Derives the project's security contract (components, entry-point trust table, claimed and disclaimed properties) for the deep-dive to load |
semgrep | Static analysis mapped into findings shape |
bandit | Python-only static analysis mapped into findings shape, carrying bandit's own confidence level; runs alongside semgrep on repositories with Python |
betterleaks | Secret scanning across the available Git history, with raw secret values removed before findings are written |
vuln-scan | High-recall model-backed static candidate scan adapted from Anthropic's defending-code reference harness |
zizmor | GitHub Actions workflow audit enriched with bundled trust-boundary, credential, and supply-chain guidance |
ingest | Normalizes external reports in arbitrary formats into findings when /v1/import cannot recognise the payload |
security-deep-dive | The model-backed audit producing structured findings |
advisory-deep-dive | Re-audits every past advisory against its fix commit for a fix bypass, an incomplete fix, or the same bug class in sibling code the patch never touched; the deep-dive scoped to the advisory space |
finding-dedup | Compares open findings and marks overlapping reports as duplicates |
verify | Re-checks one finding against current HEAD, records an evidenced attack tree and typed attacker prerequisites, runs three isolated attempts, scores five fixed evidence criteria, gates the verdict on host-matched design controls, and applies deterministic severity caps from reconciled controls and verified prerequisites |
critic | Assesses whether a true-positive finding can affect a real release build, stores an append-only attack-path record, and marks release paths as viable, non-viable, sample/test-only, or conditional |
revalidate | Cheap read-only classifier (prose + git log, no PoC execution) that emits true / false positive / already-fixed / uncertain; auto-enqueued for High/Critical from security-deep-dive and for every imported finding. Every true_positive chains to critic, and High/Critical true positives also chain to verify |
breaking-change | Static breaking-change check on the suggested-fix diff; records breaking/non_breaking/unknown with rationale and the affected dependents |
release-watch | After a finding reaches fixed, watches the upstream for a release containing the fix commit; records release tag, URL, and timestamp on the finding |
disclose | Drafts a GHSA-shaped advisory (title, description, CVSS, CWEs, references) for one finding |
patch | Proposes a unified diff fixing one finding; a diff that passes the applicability gate is stored on the finding as its suggested fix |
reattack | Applies one immutable gated patch to a fresh checkout and independently exercises three root-cause variants plus a benign control; records a bypass, verified resistance, or incomplete verification without overwriting prior attempts |
report-upstream | Files one finding on the upstream repository via GitHub PVR with the proposed patch attached; the action that moves a finding to reported |
public-issue | Files a low-severity finding as an ordinary public GitHub issue after analyst confirmation |
reachability | Traces dependency sinks through application code to determine which are reachable from trust boundaries |
cna-match | Matches a repository to its CVE Numbering Authority so disclosures route to the right contact |
posture | Records the repo's security posture (reporting policy, response history, hardening) on the Repository row |
compliance | Audits the repo against the 62 OpenSSF Baseline controls with darnit, resolves the controls darnit defers to LLM analysis or could not verify without a forge token, and records per-control verdicts and the attained Baseline level on the Compliance tab |
forensics | Read-only compromise timeline and evidence bundle from local Git history and public forge/archive records |
variants | Starting from one confirmed finding, searches the current repository for distinct, high-confidence sibling instances of the same root cause |
audit-injection | Opt-in static audit for command/code execution, unsafe deserialization, and server-side template injection with ecosystem-specific references |
audit-exfil | Opt-in static audit for SSRF, path traversal, XXE, and response or diagnostic leakage with ecosystem-specific references |
audit-authz | Opt-in static audit for IDOR, tenant isolation, fail-open guards, privilege escalation, and authorization decisions based on unverified claims |
audit-pii | Opt-in static audit for real personal or customer-identifying data committed to source or exposed through logs, URLs, telemetry, exports, and responses |
audit-memory | Opt-in static audit for reachable memory corruption in first-party C, C++, unsafe Rust, native extensions, and FFI boundaries |
Edit skills/triage/SKILL.md to change what gets run by default. Drop new skill directories in skills/ to add scan types; no code changes needed. See docs/skills.md for the frontmatter reference, the scrutineer.* metadata keys, the context.json shape, output kinds, schema validation, and the skill-facing HTTP API.
Before each scan, lockfiles, minified bundles, and generated trees are stripped from the workspace so the skill doesn't waste turns on them. The builtin skip list covers node_modules, dist, generated, __generated__, *.min.js/*.min.css, and the common lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, Cargo.lock, go.sum, Gemfile.lock, poetry.lock, composer.lock). Skills can override this with scrutineer.paths (allow-list) and layer scrutineer.ignore_paths on top; see docs/skills.md.
Scrutineer can ingest findings produced elsewhere so they enter the same triage and disclosure workflow:
curl --data-binary @report.sarif http://127.0.0.1:8080/api/v1/import
SARIF 2.1.0, CSV, markdown, and a minimal JSON shape are all accepted; the format is sniffed from the body. See docs/import.md for the full request and response shape, the per-format field mapping, and how to add support for a new format.
Every index page has a search box plus filter and sort dropdowns; the specifics vary by page. The sidebar sections:
Each finding from the security-deep-dive skill starts at new and moves through a guided workflow:
security-deep-dive and every imported finding auto-enqueue a revalidate pass first, which records true_positive / false_positive / already_fixed / uncertain on the finding. Every true positive chains into the release-build critic; High/Critical true positives also chain into verify. Outside that path: click "Run verification" to start independent confirmation using model tokens, "Mark triaged" if you have already verified it independently, or "Reject"NON_VIABLE assessment blocks private disclosure, public issues, and upstream reporting; VIABLE, SAMPLE_OR_TEST, CONDITIONAL_VIABLE, and unassessed findings remain analyst decisionsreport-upstream skill to file it via GitHub PVR (github.com only, requires gh auth), run public-issue for reviewed low-severity hardening findings that are safe to file publicly, or click "Mark as reported" after sending it yourself. When upstream has no PVR, follow the runbook in docs/disclosure-fallback.md: route to a CNA when cna-match names one, otherwise contact the channel maintainers returned. With federation_peers configured, every route out of this state (the button, the report-upstream and public-issue skills, and the VINCE submission) first asks each peer whether it already holds the same finding and, on a match, names their contact so you coordinate before proceeding (see docs/interchange.md)Each finding page has a notes section for recording triage reasoning and communication history.
The repository page carries a Federation opt-out control: recording one cancels the repository's queued, running and paused scans, then refuses every new scan on it, including scheduled runs and automatic follow-ups. It also withdraws the repository from the interchange feeds, and, when the public feed is configured, publishes the request so other federated instances honour it too; an opt-out arriving on a peer feed sets it here the same way. See docs/interchange.md.
The Chat tab on a repository or a finding opens a read-only conversation with the agent about that code: it works from a copy of the clone plus a snapshot of the findings, and is restricted to reading and searching, so it can explain and cross-check but never modify anything. Each conversation keeps its own working copy on disk, so delete the ones you are done with from the conversation page to reclaim the space.
A patch run whose diff survives the applicability gate (the diff parses, targets files that exist, touches the flagged file, and passes git apply --check) creates an immutable numbered remediation attempt and updates the finding's suggested_fix projection. The current diff is downloadable from the finding page as a .patch file and included in markdown report exports. To iterate on your own edits, push them to a branch, scan that branch, then run patch against the new scan. Re-attack patch checks out an attempt's exact base commit, applies its exact diff, and independently exercises at least three distinct root-cause variants plus a benign control. Only a complete failed_to_bypass result derives verified_secure; a same-class, same-sink bypass is retained as evidence and staged for later patch attempts, while missing or inconsistent evidence remains verification_incomplete. Earlier attempts and bypass evidence remain immutable.
The Dependencies tab on a repo groups packages by name and shows all manifest files where each appears. It shows runtime dependencies by default, with a toggle for test/build/dev rows. The import button (arrow icon) next to a dependency resolves it to a repository URL via packages.ecosyste.ms and queues the full pipeline for it. Dependencies you've already imported show a link icon instead.
The same applies to the Dependents tab -- you can import any dependent's repository with one click.
docker build -t scrutineer .
docker run -p 127.0.0.1:8080:8080 -v scrutineer-data:/data \
-e ANTHROPIC_API_KEY=sk-ant-api03-... \
-e ANTHROPIC_BASE_URL=https://... \
scrutineer
Or with a Claude Code OAuth token instead of an API key:
docker run -p 127.0.0.1:8080:8080 -v scrutineer-data:/data \
-e CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... \
scrutineer
For codex or opencode, pass -e CODEX_API_KEY=... / -e OPENAI_API_KEY=... (or ANTHROPIC_API_KEY for opencode with an Anthropic model) and add -backend codex / -backend opencode to the command. For copilot, pass -e GH_TOKEN=... (a fine-grained PAT or gh auth token value; classic ghp_ PATs are rejected by Copilot CLI) and add -backend copilot.
Always bind to 127.0.0.1: the UI has no authentication, so binding to 0.0.0.0 exposes your findings database to anyone on the network.
If a container runtime (docker, rootless podman, or Apple's container) is available on the host, scrutineer runs each scan in an ephemeral container for isolation. The runner image is published to GHCR as a multi-arch manifest (linux/amd64 and linux/arm64) and pulled automatically on first use:
go run ./cmd/scrutineer -skills ./skills
Use --runtime podman to run scans under podman instead of docker (see Podman (rootless) below), --runtime apple to run scans under Apple's container runtime on macOS (see Apple container (experimental) below), --no-container to disable containerised execution entirely, or --runner-image to specify a different image. To build the runner locally instead of pulling from GHCR (use podman build or container build instead if you run scans under those runtimes):
docker build -t scrutineer-runner -f Dockerfile.runner .
go run ./cmd/scrutineer -skills ./skills --runner-image scrutineer-runner
The runner image is not auto-updated, so the analysis toolchain stays on whatever digest you pulled until you pull again. To keep that drift visible, scrutineer checks the registry once at startup (in the background, and failing silently if the registry is unreachable) and flags the runner image when it is more than seven days behind the published :latest -- both in the boot log and as a banner on the Settings page. Update with:
docker pull ghcr.io/alpha-omega-security/scrutineer-runner:latest
On sidecar-backed hardened runtimes, scrutineer also refuses a runner image whose proxy binary does not support the current host-API tunnel policy. Pull or rebuild custom runner images when updating the host binary.
If you would rather update automatically, run watchtower against the runner image or pass --pull=always to the runtime; scrutineer deliberately does not pull on its own so a scan's toolchain only changes when you choose to update it.
When the container runner is active, scrutineer starts an authenticated egress proxy on the host and points HTTPS_PROXY/HTTP_PROXY inside the container at it. The proxy only permits an allowlist of hosts: the active model API, *.ecosyste.ms, the major forges (GitHub, GitLab, Codeberg, Bitbucket), common package registries (npm, PyPI, RubyGems, crates.io, Go module proxy, Packagist, Hex, NuGet), advisory sources (semgrep.dev, OSV, NVD, cwe.mitre.org), and the runtime's host endpoint (host.docker.internal for docker/podman, the default gateway IP for Apple's container) for the local skill API. That API port accepts inspected HTTP proxy requests but refuses raw CONNECT tunnels; separately configured host-local model ports remain tunnelable. Requests to anything else get a 403 and are logged. Extend the list with egress_allow in the config file. When -model-base-url is set (or, for Claude, falls back to the ANTHROPIC_BASE_URL env var), its hostname is automatically added to the allowlist. The proxy uses a per-process random token so it isn't an open relay; tools that ignore the proxy env are not blocked at the network layer (see threatmodel.md).
For deployments that treat skill prompts as untrusted, pass --hardened (or hardened: true in the config). The flag forces the container runner (--no-container is rejected), trims the egress allowlist to the active backend's model API hosts plus the host skill API (so egress_allow is ignored, drop the flag if you need to widen it), mounts the container rootfs read-only with no-new-privileges, attaches each scan to its own ephemeral network created with --internal (removed when the scan ends) so a process that ignores HTTPS_PROXY has no route out and concurrent scans cannot reach each other, and refuses scans whose workspace footprint exceeds 2 GiB once the clone completes. The 2 GiB check is post-clone: it bounds what hardened mode accepts, not what can land on disk during the clone itself; use OS-level disk quotas if you need a clone-time guarantee. Bundled skills that hit ecosyste.ms or a package registry directly will fail under hardened mode unless they route through the host skill API. Per-ecosystem runner profiles still apply, but profile images that need writable paths beyond /work and /tmp are incompatible. Under Docker Desktop and rootless podman the proxy runs as a per-scan sidecar container on the --internal network because the host proxy is unreachable there; see docs/podman.md for the rootless podman path. These runtimes verify that each hardened network blocks external egress while still reaching the sidecar, and refuse the scan if that cannot be confirmed, so the sandbox never silently weakens.
When the container runner is active, scrutineer auto-detects a per-ecosystem profile for each scan: it runs brief against the clone and matches its structured package-manager, language, and tool detections. If brief fails or no selector matches, the scan uses the default runner image. The matched profile selects a runner image (built on demand from docker/profiles/<name>/Dockerfile, cached content-addressed by the Dockerfile's hash) and injects that profile's PROFILE.md as the agent's orientation. The most specific profile wins, so a native-extension repo resolves to its *-ext profile ahead of the plain language profile. Force one with ?profile=<name> on the scan API (validated against the skill's requires_profile); default forces the base runner image.
| Profile | Selected when | Adds |
|---|---|---|
php / php-ext | package_manager:Composer / tools.native_extension:phpize | PHP; php-ext builds PHP debug + ASan/UBSan for C extensions |
python / python-ext | package_manager:pip / Pipenv / Poetry / uv / PDM / setuptools; python-ext when tools.native_extension:setuptools Extension is present | CPython; python-ext builds CPython debug + ASan/UBSan |
ruby | Bundler | Ruby 3.4 + Bundler; metaprogramming / dynamic-dispatch guidance, plus a tripwire that flags an un-instrumented native extension |
ruby-ext | tools.native_extension:mkmf | A superset of ruby: adds an ASan/UBSan Ruby (the default interpreter), valgrind on the stock interpreter, Rust nightly for rb-sys gems, and Brakeman |
ruby-rails | tools.build:Rails | A superset of ruby plus Brakeman, Rails-specific SAST |
node | npm/pnpm/Yarn/Bun | Node.js |
go | Go Modules | Go toolchain |
scala | sbt or Scala language detection | A superset of java plus sbt and Scala-specific reproducer guidance |
java | Maven/Gradle | JDK |
dotnet | NuGet/dotnet CLI | .NET SDK |
beam | Mix/rebar3 | Erlang/Elixir |
rust | Cargo | Rust stable + nightly, Miri, sanitizers |
ocaml | opam, tools.build:Dune, or OCaml language detection | opam + a compiled OCaml 5.x switch + dune |
swift | Swift Package Manager or Swift language detection | Swift toolchain + swiftly |
perl | cpanm or Perl language detection | Perl toolchain |
c-cpp | tools.build:CMake / Make / Autotools / Meson, or C/C++ language detection, after language ecosystems have had a chance to match | C/C++ build toolchain |
The three Ruby profiles are mutually exclusive at selection time, but ruby-ext and ruby-rails are deliberate supersets of ruby (the same Ruby-level audit, plus their extra coverage), so a detection that errs toward ruby-ext costs only build time, never coverage. A native gem that slips through to the plain ruby profile is caught by that profile's tripwire, which records that memory-safety scanning needs ruby-ext. Add a profile by registering it in internal/worker/profile.go and adding docker/profiles/<name>/{Dockerfile,PROFILE.md}.
Refreshing profile images. Because profile images cache on the Dockerfile's hash, a moved :latest base runner -- or an edited PROFILE.md -- won't always force a rebuild (without skopeo the cache keys on the image ref alone; see Podman). Two helpers give you a clean slate: scripts/ruby-image-reset.sh removes the Ruby profile images (ruby, ruby-ext, ruby-rails), scripts/all-images-reset.sh removes every ecosystem's, and both then pull the latest scrutineer-runner so the next scan rebuilds from a fresh base. Each prompts before removing anything (--force skips it) and uses docker if present, otherwise podman; both run under bash 3.2+, so macOS's stock /bin/bash works.
Pass --runtime podman (or runtime: podman in the config) to run scans under podman instead of docker. Rootless podman is the recommended posture: because the runtime is not root-equivalent, a hostile repository that escapes the scan container lands as an unprivileged subordinate user rather than near-root on the host (see threatmodel.md, T12). There is no auto-detection -- a podman-only host must set --runtime podman explicitly; the default stays docker.
Requirements:
--add-host host.docker.internal:host-gateway, which older podman does not support; without it, scans fail with network errors. Startup logs a warning if the detected version looks too old or if the host-gateway address can't be resolved.--hardened) -- the rootless egress proxy sidecar must reach the loopback-bound host skill API, which needs the network backend to forward host-gateway to the host loopback. podman ≥ 5.0 defaults to pasta (--map-host-loopback); older podman can work with a slirp4netns backend that has host-loopback enabled. Where unavailable, hardened scans are refused (fail closed). Startup warns on podman < 5.0 under --hardened. See docs/egress-sidecar.md./etc/subuid + /etc/subgid -- rootless podman maps the container user back to your host user with --userns=keep-id so scan output and the resumable session store stay host-owned. Your user needs a sub-id range (the usual useradd default provides one; run podman system migrate after changing it). Scrutineer runs a one-off keep-id smoke test at startup and fails fast with a hint if this is misconfigured.skopeo (optional) -- used in place of docker buildx to detect when a moved :latest runner tag should rebuild per-ecosystem profile images. Without it, profiles still build but key their cache on the image ref alone.:z so the container can read the clone and write its output; without it every scan fails with permission errors. This is handled automatically: --selinux auto (the default) detects the host, and a startup smoke test verifies a real relabeled mount works. Use --selinux off if you pre-label paths yourself, or --selinux on to force it. See docs/podman.md for the :z-vs-:Z rationale.Under rootless podman with --hardened (verified fail-closed per scan), the scan can't route to a host proxy across the network-namespace boundary, so the egress proxy runs as a per-scan sidecar container on the --internal network to keep enforced egress working. It requires a network backend that forwards host-gateway to the host loopback (modern pasta, default in podman ≥ 5.0, or slirp4netns with host-loopback); where that isn't available the sidecar can't reach the host skill API and the scan is refused (fail closed). Fall back to --hardened-runtime-only for the non-network half (read-only rootfs + no-new-privileges + the 2 GiB post-clone workspace cap), or use rootful podman or Docker Engine for --hardened without the host-loopback-forwarding requirement -- the always-on --cap-drop ALL / non-root user / /tmp tmpfs / SELinux :z baseline applies in every mode regardless. See docs/podman.md for the full security model and docs/egress-sidecar.md for the operator validation checklist.
The docker build / docker run commands shown in this repo -- for the runner image and the per-ecosystem profile images under docker/profiles/ -- are CLI-compatible with podman; substitute podman for docker.
Pass --runtime apple (or runtime: apple in the config) to run scans under Apple's container runtime instead of docker. This path is explicit opt-in; the default stays docker and scrutineer does not auto-detect a docker-less Mac. It is labelled experimental because it is new and Apple's networking has known rough edges, not because of a capability gap: both ordinary and --hardened scans are supported.
Requirements and notes:
container only on macOS 26 and will not address issues that cannot be reproduced there, so older macOS is out of scope.container with container system start running: startup checks container system status and refuses the runtime if the service is unavailable./proc/net/route inside the runner image. The proxy rewrites local skill-API requests back to 127.0.0.1.--hardened is supported. Each container runs in its own lightweight VM, so the VM boundary is the isolation; container network create --internal is a vmnet host-only network (egress blocked, host proxy reachable) and each hardened scan proves that fail-closed before running. The one --hardened flag Apple's CLI cannot set is --security-opt no-new-privileges, which the per-container VM boundary substitutes for (Apple's own untrusted-code sandbox hardens the same way). --hardened-runtime-only is a rootless-podman concept and is refused; use --hardened.The docker build commands shown for the runner image and profiles can be run as container build when you use this runtime. See docs/apple.md for the full parity matrix, the VM-isolation security model, and how hardened mode works.
| Flag | Default | Description |
|---|---|---|
-config | ./scrutineer.yaml if present | Path to YAML config file |
-addr | 127.0.0.1:8080 | Listen address |
-data | ./data | Data directory for the database and workspaces |
-effort | high | Reasoning effort level, applied by the claude and copilot backends and ignored by codex and opencode. Copilot CLI stops at xhigh, so max is capped to it there |
-skills | - | Additional local directory to load SKILL.md files from; same-named skills override the bundled copies (repeatable) |
-skills-repo | - | owner/repo[@ref] or credential-free git HTTPS URL https://host/path[@ref] to clone skills from on startup; @ref pins a branch, tag or commit and the resolved SHA is recorded on every scan; private repositories use config-file-only skills_repo_token |
-backend | claude | Agent CLI the container runner execs: claude, codex, opencode, or copilot. Non-claude backends require the containerised runner |
--runtime | docker | Container runtime: docker, podman (rootless podman supported), or apple (Apple, experimental) |
--selinux | auto | Bind-mount SELinux relabeling: auto (relabel when SELinux is detected), on, or off |
--no-container | false | Disable the containerised runner; run claude directly on the host (no isolation). Deprecated alias: --no-docker |
--hardened | false | Strict sandbox: container runtime required, egress restricted to the backend's model API hosts + host skill API, read-only rootfs, internal network |
--hardened-runtime-only | false | The non-network half of --hardened (read-only rootfs + no-new-privileges + 2 GiB workspace cap) without the per-scan --internal network; the rootless fallback for hosts where the --hardened egress sidecar can't run (implied by --hardened). Deprecated alias: --hardened-rootless-runtime |
--runner-image | release-matched digest (ghcr.io/alpha-omega-security/scrutineer-runner:latest in development builds) | Container image for per-scan containers |
-concurrency | 4 | Number of scans to run in parallel. Chat turns run from a separate pool sized at half this value, so a busy host can reach 1.5x this many agent containers. With codex.auth_file, scans and chat turns share one execution slot |
-clone | shallow | Clone depth: shallow (--depth 1) or full |
-scan-timeout | 1h | Wall-clock limit per scan; exceeded scans fail |
-max-turns | 0 | Per-scan turn cap (0 = unlimited); claude and copilot backends only, codex and opencode have no turn cap |
-schema-strict | false | Fail a scan when its report.json does not validate against the skill's schema.json (default: warn in the scan log and parse anyway) |
-model-base-url | - | Custom model API base URL for the active backend (env fallback: ANTHROPIC_BASE_URL for claude). -anthropic-base-url is a deprecated alias. |
-ecosystems-enrichment | true | Enrich repositories from ecosyste.ms: the per-repository cache, the warm on repo add, and the PURL-to-repository resolution behind SBOM and dependency import. =false stops every lookup scrutineer's own process makes, leaves egress_allow untouched (the bundled skills still fetch ecosyste.ms themselves), and leaves the Dependents tab and dependent-exposure analysis with no data |
-recipients-file | - | Age recipients file (public keys) for encrypted export |
-identity-file | - | Age identity file or SSH private key for decrypting imports and encrypted federation feeds |
-identity-plugin | - | Data-less age identity plugin name (age -j) for decrypting imports and encrypted federation feeds (repeatable; replaces identity_plugins from config when set) |
Every flag above can be set in a YAML config file instead, loaded from ./scrutineer.yaml by default (override with -config path/to/file; command-line flags always take precedence). See scrutineer.sample.yaml for the full shape.
scrutineer.yaml may contain long-lived credentials such as skills_repo_token, the VINCE API key and federation salt. Keep it out of source control and backups, and set owner-only permissions with chmod 600 scrutineer.yaml. A private skills_repo must use a credential-free HTTPS URL; Scrutineer passes skills_repo_token to Git through GIT_ASKPASS, and intentionally provides no token command-line flag.
Identity files and age identity plugins can be used together. The interface is
provider-neutral: any plugin implementing age's data-less identity (age -j)
contract can be named. Scrutineer delegates only through age's official plugin
protocol, with no provider-specific SDK or command parsing. For example,
age-plugin-1p can find an SSH key in 1Password while the existing recipients
file continues to contain ordinary SSH public keys:
recipients_file: /path/to/recipients.txt
identity_plugins:
- 1p
age-plugin-1p and the 1Password op CLI must be installed separately and
available through PATH. Scrutineer uses age's plugin protocol and never
receives or writes the SSH private key; authentication and Touch ID remain the
plugin's and 1Password's responsibility. See
docs/encrypted-sharing.md for
interactive and headless-use details.
The config file can also replace the model pick list and pin the fallback default model used by the high tier:
default_model: claude-sonnet-5
models:
- name: Sonnet 5.0
id: claude-sonnet-5
- name: Opus
id: claude-opus-5
Skills resolve to a model through a tier: high by default, unless the skill's SKILL.md metadata pins scrutineer.model to another tier or an exact model id (bundled lightweight skills such as metadata use mid, security-deep-dive uses max). The Settings page maps each tier to any configured model.
Scrutineer can drive OpenAI's codex CLI instead of claude-code. The runner image bundles the codex binary, so switching is the flag (or backend: codex in scrutineer.yaml) plus a credential:
export CODEX_API_KEY=sk-...
go run ./cmd/scrutineer -skills ./skills -backend codex
It can also use a ChatGPT subscription login without consuming Platform API credits. Create an isolated file-backed login:
mkdir -p ~/.config/scrutineer/codex-rubygems
chmod 700 ~/.config/scrutineer/codex-rubygems
CODEX_HOME=~/.config/scrutineer/codex-rubygems \
codex -c cli_auth_credentials_store=file login --device-auth
chmod 600 ~/.config/scrutineer/codex-rubygems/auth.json
Then configure its credential file:
backend: codex
codex:
auth_file: ~/.config/scrutineer/codex-rubygems/auth.json
The credential must be mode 0600. Scrutineer refuses this configuration while
CODEX_API_KEY or OPENAI_API_KEY is set, mounts only auth.json into each
scan's otherwise private Codex home, and serializes account-authenticated scans
so token refreshes cannot race.
The container, egress proxy, language profiles and skill staging stay the same; only the agent CLI inside the container changes. The egress allowlist picks up the required OpenAI hosts automatically, and the model pick list defaults to codex's own catalog with tier tags already set -- override with models: in the config if you want a different set. Use -model-base-url or model_base_url: for a custom OpenAI-compatible endpoint; under codex it is passed as openai_base_url to codex exec. The codex backend requires the containerised runner; --no-container with -backend codex is rejected at startup.
See docs/codex.md for what differs from claude (argv, skill staging, credentials, egress), which model ids the pinned codex version accepts, and why codex's own sandbox is disabled inside scrutineer's container.
OpenCode can run models from several providers. The stock runner includes OpenCode and its common built-in adapters; select it with -backend opencode and use a model id in provider/model form:
backend: opencode
default_model: anthropic/claude-sonnet-5
Anthropic and OpenAI keep their existing setup. Other providers can use opencode.providers to select provider-only credentials, hardened-mode egress hosts, a host-local model server port, stored OAuth state, OpenCode config, and a derived runner image. Scrutineer checks that the selected model exists in that image before starting the skill, then records the provider image and digest on the scan. Like codex, the OpenCode backend requires the container runner. See docs/opencode.md for configuration and image requirements.
Scrutineer can drive GitHub Copilot CLI instead of claude-code. The runner image bundles the copilot binary, so switching is the flag (or backend: copilot in scrutineer.yaml) plus a credential -- a GitHub token with Copilot access. Copilot CLI rejects classic (ghp_) PATs, so use a fine-grained PAT or the OAuth token gh auth login already stored:
export GH_TOKEN=$(gh auth token)
go run ./cmd/scrutineer -skills ./skills -backend copilot
The container, egress proxy, language profiles and skill staging stay the same; only the agent CLI inside the container changes. The default egress allowlist is entirely GitHub infrastructure (github.com, api.github.com, api.mcp.github.com, *.githubcopilot.com), since Copilot CLI proxies every model through GitHub's own API; setting -model-base-url overrides that endpoint and adds the override host to the allowlist. The model pick list defaults to Copilot's own catalog (Claude and GPT models) with tier tags already set; override with models: in the config for a different set. Unlike codex and opencode, -max-turns is honoured by this backend. The copilot backend requires the containerised runner; --no-container with -backend copilot is rejected at startup. See docs/copilot.md for the full credential and event-mapping details.
In --no-container mode, and for the skills listed in host_skills, the claude subprocess inherits your ~/.claude/settings.json, so sandbox settings that restrict network or filesystem access there will fail skills that need them. Point claude at a separate config directory just for scrutineer runs:
CLAUDE_CONFIG_DIR=~/.claude-scrutineer go run ./cmd/scrutineer -skills ./skills
Copy your settings.json into that directory and drop the sandbox keys; your normal Claude Code config is untouched. Container mode is not affected for the skills that stay in the container: there claude runs inside the container with its own environment regardless of the host config.
See SECURITY.md for the reporting policy and threatmodel.md for the full threat model. The short version: scanning a repository is equivalent to running code from it. The containerised runner (when available) isolates each scan, but the default bare-metal mode runs everything as your user. Only scan repositories you'd be willing to clone and build locally.
scrutineer backup/restore, sqlite3, Litestream)--hardened egress proxy sidecarMIT. See LICENSE. Copyright (c) 2026 Alpha-Omega.
Go
89.3%
HTML
6.5%
Dockerfile
1.8%