Skip to main content

Supply Chain

Skylos checks the packages and tools that your project, your CI and your coding agents (Claude Code, Codex, Cursor) install and run, for npm and PyPI. It answers these questions:

  • Is a package version known malware? Hijacked releases such as chalk@5.6.1, debug@4.4.2, @ctrl/tinycolor@4.1.1 (the Shai-Hulud worm), @solana/web3.js@1.95.6 and xrpl@4.2.1 are reported as CRITICAL.
  • Is a version too new to trust yet? Most hijacked releases are found and pulled within hours. The agent hooks hold versions younger than 48 hours.
  • Can the install be redirected? Dependency confusion, Git and URL dependencies that can change under you, and lockfile entries without an integrity hash.
  • What runs on its own? Agent hooks, editor tasks that run when a folder opens, and MCP servers that fetch a new release every time they start.
  • What does a pull request change? New install-time or start-up code in an upgraded package, weaker install hardening, and changed publish jobs.

The checks run in the CLI, the agent hooks, the pull request gate and Skylos Cloud. The package-level logic comes from the open-source ca9 engine, which Skylos installs as a dependency (ca9>=0.7.0).

In A Scan​

skylos . --sca          # dependency checks only
skylos . -a # everything, including dependencies and agent configs
WhatRule IDSeverityWhen
Known malicious versionSKY-SCA-<advisory id> (for example SKY-SCA-MAL-2025-46969)CRITICALOSV lists a MAL-* record or alias, a record from the OpenSSF malicious-packages database, or CWE-506 (embedded malicious code) for the exact version
Dependency confusionSKY-SCA-SRC001CRITICAL direct, HIGH transitivea package matching your internal_packages can resolve from a public or undeclared index, including through --extra-index-url
Mutable sourceSKY-SCA-SRC003MEDIUMa Git dependency not pinned to a full commit, or a URL dependency without a hash. Reported, but not counted by the built-in --gate; a synced Cloud gate counts it
Lockfile entry without integritySKY-SCA-INT001MEDIUMa registry entry in a lockfile has no integrity hash
Version younger than your cooldownSKY-SCA-AGE001HIGHonly when you set min_release_age_hours above 0
Malware feed match without an advisory IDSKY-SCA-MAL000CRITICALOSV could not be reached and the local ca9 malware feed matched a package without naming an advisory

Malware findings say to remove the package, reinstall from a clean lockfile and rotate every credential an install could read (npm, GitHub and cloud tokens). They are never demoted as dev-only: a dev dependency still runs its install scripts on a developer machine. Ordinary CVEs keep their usual severity.

Every row above except SKY-SCA-SRC003 counts toward the built-in gate's max_dependency_vulnerabilities, which is 0 by default, so one finding fails --gate. If you set min_release_age_hours, a whole-repository --gate therefore fails while any locked version is younger than that window.

When OSV cannot be reached, the scan checks the local ca9 malware feed instead and says so in analysis_summary.sca_coverage.supply_chain. A feed match keeps its advisory rule ID (SKY-SCA-<advisory id>); a match without one is SKY-SCA-MAL000. Install or refresh the feed with:

ca9 feed update

Pull requests get three more checks (upgrade review, weakened hardening and changed publish jobs). See In Pull Requests.

Private And Git Packages (Unreleased)​

The next Skylos release stops failing a scan because a lockfile has a package from a private registry, Git or a URL. Advisory databases don't cover those packages, so they are listed as not checked and every public package is still checked. This covers package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock, bun.lock, uv.lock, poetry.lock, Pipfile.lock, Cargo.lock, composer.lock, Gemfile.lock and pubspec.lock.

The status is complete_with_non_registry_sources and complete is false, so the scan never claims those packages are clean. Known vulnerabilities in public packages still fail the gate. A pull request that introduces a new source is reported as SKY-SCA-SRC004. See New Package Sources.

These still stop the scan with exit code 2:

  • Most packages went unchecked. At least 5 packages are not checked and they outnumber the packages Skylos could check, with most_packages_not_checked: true in the receipt. A private mirror of public packages does this: a pass would say little about your dependencies. Findings for the packages it could check are still reported.
  • Contradictory records. A tarball on the public registry that doesn't match its package, a pnpm alternate id, an unrecorded Yarn virtual: peer, or a malformed source.

When OSV Cannot Be Reached (Unreleased)​

In the next Skylos release, a failed OSV request is retried twice, within a 20-second budget for the scan. If OSV still doesn't answer, Skylos reuses the last good answer for that exact package version, up to 7 days old, from osv-answers.json in the user cache ($XDG_CACHE_HOME/skylos, or ~/.cache/skylos). Every successful scan updates that file, and live answers always win.

A scan that used saved answers reports complete_with_saved_answers with complete: false, and prints a warning that newer advisories may be missing. Known vulnerabilities still fail the gate. A baseline refuses the scan, an upload can't use it to resolve earlier findings, and coverage.required = ["dependencies"] fails on it.

A package with no saved answer, or one older than 7 days, leaves the scan incomplete with exit code 2. A file inside the scanned repository is never read or written, so a repository can't supply its own answers. The GitHub workflow skylos cicd init writes keeps this file between runs with actions/cache. Other CI systems need their own cache of that path, otherwise a fresh runner has nothing saved.

How Each Package Is Used​

PyPI advisories carry metadata.reachability_tier, and terminal output shows it in a Use column:

TierShown asMeaning
usedcalled at app.py:4A listed vulnerable API is called, or coverage shows the code ran
importedimportedThe package is imported, directly or through a dependency you import, and no vulnerable call was found
unknownunknownNothing proves it either way: a transitive package, a command-line tool or plugin, a guessed import name, or an incomplete dependency graph
not_importedno import foundA declared direct dependency with a complete dependency graph, no import and no sign of indirect use
unreachablenot reachedThe version is outside the affected range, or the code was proved not to run

Packages sort by severity and, within a severity, what the code uses first. no import found and not reached sort last. Nothing is hidden and the gate counts every advisory: "no import found" isn't proof of safety, because a package can run without an import.

JS/TS Imports For npm Advisories​

From Skylos 5.6.0, --sca can show where selected JavaScript and TypeScript files import a directly declared npm dependency with an advisory. Skylos matches the import to the exact affected version in a v2 or v3 package-lock.json or npm-shrinkwrap.json. It recognizes static imports, re-exports, literal dynamic imports, and unshadowed bare require() calls.

The Use column shows JS/TS import at path:line. JSON, SARIF and Cloud upload payloads carry metadata.import_usage with up to five source locations. The source selection and scan limits appear in analysis_summary.npm_import_usage.

Type-only imports, missing evidence, unsupported lockfiles, and ambiguous resolution stay unknown. TypeScript baseUrl, inherited configurations, matching path aliases, and custom resolvers can leave a match unknown. File and changed-file scans collect evidence from their selected source files.

This evidence identifies a static source import and matching lock entry. Runtime execution and vulnerable API calls require separate analysis. The gate evaluates every advisory at its original severity. Ecosystems without usage analysis show not analyzed.

Package Inventory And Posture​

--format json adds these blocks to analysis_summary, and uploads carry them to Skylos Cloud:

  • dependency_inventory: every package found (ecosystem, name, version, direct or dev, manifest path), at most 10,000.
  • supply_chain_posture: whether the repository protects its installs.
  • ci_inventory: for each GitHub Actions and GitLab CI job, its triggers, runner, environment, write scopes and id-token, secret names, installs, actions, tools, download hosts and publish steps. Cloud uses it to tell which jobs could have installed a package and what they could reach. From ca9 releases that report it, each job also has indirect: commands such as make, just, tox, nox, docker build or docker compose, or a repository script, that may install packages where the job list cannot see them.
  • agent_hooks: committed agent and editor hooks, MCP servers and API base-URL overrides, with commands redacted. Hooks written by skylos agent install-hooks carry skylos_hook: true.

ci_inventory and agent_hooks come from ca9. Both are built from the repository top (the Git root), also when you scan a subfolder, so a monorepo project still uploads its CI jobs; file paths are relative to the repository. They keep names instead of values (secret names, scheme://host instead of URLs, token-like text replaced by [redacted]) and list only files Git tracks or does not ignore.

They are bounded: 200 workflows, 500 jobs, 100 items per CI list, 200 hooks and 200 MCP servers. A job whose own lists were cut carries truncated: true, and a cut workflow or job list sets the block's truncated, so a cut list is never read as complete.

For GitLab CI, ca9 follows local includes (exact paths, * and ** patterns, and includes inside included files, up to 150 files) and applies top-level before_script, after_script and default: to the jobs. An include it cannot resolve in the repository (remote, project, template or component includes, or a path built from variables) is listed in skipped and makes the block partial.

Agent configs reached through a symbolic link that stays inside the repository are listed under the path the agent opens. A link that leaves the repository or points to a missing file makes agent_hooks partial.

analysis_summary.supply_chain_checks says when ca9 could not build a block; it stays local and is not uploaded.

CheckPasses when
lockfile_committedevery npm and Python manifest has a committed lockfile (or pinned requirements)
frozen_install_ciCI installs from the lockfile (npm ci, --frozen-lockfile, --immutable, --require-hashes, uv sync --locked)
install_scripts_disabledignore-scripts=true in .npmrc, --ignore-scripts on every CI install, npm 12+ or pnpm 10+ (from packageManager), Yarn enableScripts: false, or Bun. pnpm dangerouslyAllowAllBuilds: true fails it
release_age_cooldowna minimum release age is set (.npmrc min-release-age, pnpm minimumReleaseAge or pnpm 11+ with its one-day default, Yarn npmMinimalAgeGate or Yarn 4.15+ with its one-day default, Bun minimumReleaseAge in seconds, uv exclude-newer, pip --uploaded-prior-to, or Skylos min_release_age_hours). An explicit 0 fails, and so does a Bun value under 3600 seconds. Dependabot's cooldown is named in the detail but does not pass it: it delays only Dependabot's own pull requests
actions_pinnedGitHub Actions are pinned to commit SHAs
no_pr_target_checkoutno pull_request_target workflow checks out pull request code

A check passes only with evidence in the repository; otherwise it is fail, unknown or not_applicable. Manifests under test, fixture, example and benchmark folders do not count. Settings ca9 reads from committed files are used first; a value from a file Git does not track is ignored.

Agent Configs And Publish Jobs​

skylos . --danger (and -a) reads the agent, editor and CI files committed to the repository. It reports what runs on its own when someone opens the repository with a coding agent or editor, and what can reach your package registry credentials. A SessionStart hook or a folderOpen task runs even with --ignore-scripts, because the agent or editor starts it, not the package manager.

WhatRule IDSeverity
Agent hook command committed in .claude/settings.json, .claude/settings.local.json, .gemini/settings.json, .cursor/hooks.json, .codex/hooks.json or .codex/config.tomlSKY-D365HIGH; CRITICAL when the command downloads remote code, decodes and runs a payload, or runs a script disguised as a font or image
Editor task that runs when the folder opens (runOptions.runOn: "folderOpen" in .vscode/tasks.json or *.code-workspace)SKY-D366as SKY-D365
MCP server without an exact version (npx -y pkg, pkg@latest, uvx pkg, an image without a digest)SKY-D367MEDIUM
MCP server launched from a hidden home-folder or temp path outside the repositorySKY-D368HIGH
MCP server package whose name imitates a vendor it is not published bySKY-D369MEDIUM
Agent API base URL in committed settings pointing somewhere other than the provider's own API (see below)SKY-D370HIGH
Agent or editor config with duplicated keys, so what you read is not what runsSKY-D377MEDIUM
Tool installed without a pinned version or hash in a job that can reach credentials: publish tokens, a publish step, id-token: write, or (GitHub Actions) every secret through secrets: inherit or toJSON(secrets)SKY-D371HIGH when the job holds publish credentials (a publish token, a publish step, or every secret in a repository whose workflows use a publish secret); otherwise MEDIUM
Download without a checksum or signature check that reaches the publish stepSKY-D372HIGH
OIDC publish job without a deployment environment:SKY-D373HIGH
Publishing workflow that an event can start without a condition that proves the release branch or excludes that event (see below)SKY-D374HIGH, MEDIUM or LOW (see below)
Secrets (secrets: inherit or named) passed to a reusable workflow from another repositorySKY-D376MEDIUM
A pull request adds or changes a job that publishes packages (diff scans only)SKY-D375MEDIUM

MCP servers are read from .mcp.json, .cursor/mcp.json, .vscode/mcp.json, .kiro/settings/mcp.json and other mcpServers blocks. The publish-job rules read GitHub Actions and GitLab CI (SKY-D374 and SKY-D376 are GitHub Actions only).

SKY-D370 reads the env block of .claude/settings.json and .claude/settings.local.json, and Codex model_providers.<name>.base_url in .codex/config.toml. It covers ANTHROPIC_BASE_URL, ANTHROPIC_API_URL, ANTHROPIC_BEDROCK_BASE_URL, ANTHROPIC_VERTEX_BASE_URL, ANTHROPIC_FOUNDRY_BASE_URL, OPENAI_BASE_URL, OPENAI_API_BASE, OPENAI_API_BASE_URL, GEMINI_BASE_URL and GOOGLE_GEMINI_BASE_URL. An https:// URL on port 443, without a user name or password, to the provider's own API host (for example api.anthropic.com, api.openai.com, generativelanguage.googleapis.com, or the provider's Bedrock, Vertex AI or Azure AI Foundry host) is not reported.

SKY-D374 looks at workflow_dispatch, a push to any branch, a push to a wildcard branch pattern, repository_dispatch, pull_request_target, issue_comment, issues, discussion and discussion_comment, including through a workflow that calls the publishing one. Its severity depends on what protects the publish job:

Publish jobSeverity
No fixed environment: (none, or one built from an expression), and any of these events except a wildcard branch pattern can start itHIGH
No fixed environment:, and only a wildcard branch pattern can start itMEDIUM
Every such job has a fixed environment:, which protects it only if the environment requires reviewers or limits deployment branchesLOW

Each finding names a file inside the scanned folder, or a repository-level config when you scan a subfolder (see below). A full scan reads files Git tracks or does not ignore; a diff scan reads only files Git tracks. Commands in messages, evidence and uploads are redacted: URLs keep only scheme://host, and tokens, passwords and NAME=value secrets become [redacted]. Turn one rule off for the project with ignore = ["SKY-D367"] under [tool.skylos].

Agents and editors follow symbolic links, and Git commits them as links, so Skylos follows a link to an agent folder or config (.claude pointing to tools/claude, or .claude/settings.json pointing to ../docs/claude.json) when it stays inside the repository. The target, and every link on the way, must be a file Git lists (in a diff scan: committed). The finding names the path the agent opens, and a file reached both directly and through a link is reported once. A link that leaves the repository or points to a missing file cannot be read: a diff that adds or changes one is an analysis error ("Agent config link … points outside the repository"), so a gated diff scan stops as incomplete (exit 2). An unchanged one does not.

Scanning a subfolder (skylos services/api --danger, or a monorepo project's upload) also reports the repository-level configs at the Git top: workflows under .github/workflows and .gitlab-ci.yml, and agent configs such as .claude/settings.json, .mcp.json or .vscode/tasks.json at the top. Configs in other subfolders are not reported.

How They Count In The Gate​

ScanBuilt-in --gate
Whole repositorySKY-D365 to SKY-D374, SKY-D376 and SKY-D377 are listed and not counted, so a repository that already commits agent hooks, unpinned MCP servers or a manually started release workflow keeps passing after an upgrade. A CRITICAL SKY-D365 or SKY-D366 still fails
Diff (--diff, --diff-base)A new or changed SKY-D365, SKY-D366, SKY-D368 or SKY-D370 fails the gate on its own ("supply-chain change to review"). SKY-D367, SKY-D369 and SKY-D371 to SKY-D377 count under the normal limits
Baseline (--baseline)As a diff, but only for the rules the baseline covered. See Baselines

A synced Skylos Cloud gate counts these findings the way Cloud does, and the diff rules above still apply.

What counts as "new" in a diff:

  • --diff: Skylos runs ca9 on the merge base's agent configs and workflows (copied into a temporary folder) and compares findings, not changed lines. A finding is new when its rule, file and identity (the event, task, server or job name plus the whole unredacted command) are not among the base's findings. Adding only "runOptions": {"runOn": "folderOpen"} to an existing task, or moving a hook from Stop to SessionStart, is new; reformatting the file is not. When the base cannot be compared (no usable ca9, too many files), every such finding in a changed file counts as new. analysis_summary.config_diff_basis says which was used (base_findings or changed_files).
  • --diff-base alone: file-level. Every agent-config and publish-job finding in a changed file counts as new, including hooks and servers that were already there.

Skylos's Own Hooks​

Hooks written by skylos agent install-hooks are not reported. A hook counts as Skylos's own only when:

  • its whole command is skylos hook <event> --client claude|codex|cursor, optionally followed by one of the fixed || fallbacks Skylos writes, and nothing else;
  • the program is skylos on your PATH, or an absolute path ending in /skylos outside the repository and outside temp folders;
  • the settings file's env does not set PATH or loader variables such as PYTHONPATH, NODE_OPTIONS, LD_* or DYLD_*.

python -m skylos.entry is reported, because -m imports from the working folder, which the repository controls. A hook named skylos that does anything else is reported like any other hook.

In Pull Requests​

With --diff BASE or --diff-base BASE, Skylos also compares the merge base of BASE and HEAD with the files Git tracks in your working tree. Untracked files never count. Both sides are copied into temporary folders for ca9; nothing in them is installed or run.

WhatRule IDSeverityNeedsBuilt-in gate
A dependency upgrade adds install-time or start-up codeSKY-SCA-UPG001CRITICAL--review-upgradesfails on its own
A dependency upgrade adds code worth reviewingSKY-SCA-UPG002MEDIUM--review-upgradeslisted, not counted
A dependency upgrade removes API your code uses (unreleased)SKY-SCA-API001MEDIUM--review-upgradeslisted, never counted
A package moves to a source this repository did not use (unreleased)SKY-SCA-SRC004HIGH--scacounts toward max_dependency_vulnerabilities
The change weakens install hardeningSKY-SCA-HRD001HIGH--scafails on its own
The change adds or modifies a job that publishes packagesSKY-D375MEDIUM--dangercounts under the normal limits

"Fails on its own" means one finding fails the gate whatever max_dependency_vulnerabilities allows. The verdict lists it as a supply-chain change to review.

Upgrade Review​

skylos . --sca --review-upgrades --gate --diff origin/main

--review-upgrades implies --sca and works with --diff or --diff-base. For each npm or PyPI version change in a lockfile (package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock, bun.lock, uv.lock, poetry.lock, Pipfile.lock), ca9 downloads the old and the new release from a trusted registry, checks their hashes and compares them as data. The packages are never installed, imported or run.

  • Blocks (SKY-SCA-UPG001): a new .pth file or sitecustomize with code, a new install or prepare hook, decode-and-exec (including code read from another file in the package and run when the package is imported, loaded or installed: exec, runpy or a module loader in Python; eval, Function, vm or Module._compile in JavaScript), a new Git or URL dependency, agent or editor hook files shipped inside the package, two archive entries that install to the same path or differ only by case, a new executable named like a system command (node, git, pip, sh, curl), bytecode without source that runs at import or start-up, or a lockfile hash that matches no file the registry serves for that version.
  • Listed for review (SKY-SCA-UPG002): new import-time network or exec calls, a much larger obfuscated file, a native binary, a new publisher, install-time code that changed beyond version strings and other literals. This is a review signal, not proof of a compromise.
  • Newly added packages (no version at the base) are reviewed on their own. Their install hooks are listed (SKY-SCA-UPG002); start-up .pth code, decode-and-exec, shipped agent hooks and Git or URL dependencies still block.

Findings follow ca9's block and warn lists, not the kind of change. Each finding sits on the lockfile line of the new version. metadata.block_deltas and metadata.warn_deltas name the file inside the package, the line and a redacted snippet; metadata.basis is upgrade or new_package.

Private packages are skipped, not looked up. Packages matching internal_packages, and entries the lockfile downloads from a host in private_registries, are never looked up on a public registry. The download URL comes from resolved in package-lock.json, npm-shrinkwrap.json and Yarn classic yarn.lock, and from resolution.tarball in pnpm-lock.yaml when pnpm recorded one. Yarn Berry and bun.lock record none, so list those packages in internal_packages. uv.lock, poetry.lock and Pipfile.lock entries from a non-public index are not reviewed at all. Skipped packages show in the verdict as Upgrade review limited: N of M dependency upgrades reviewed (internal_package: K), with private_registry: J for the registry case.

Incomplete reviews fail closed. Any other change that was not reviewed (a timeout, an unreachable registry, an artifact over the size cap, more than 100 changes, a missing or incomplete result) is an analysis error. A gated diff scan then stops as incomplete (exit 2) instead of passing those packages unread. What an incomplete review did find is still reported.

Lockfiles are read up to 64 MB per side. A changed lockfile Skylos cannot read (over 64 MB, not UTF-8, or a symbolic link) or that its parser rejects (invalid, or over 100,000 packages) is also an analysis error naming the file. A large lockfile that did not change is not.

Pins in requirements*.txt and requirements/*.txt (hashed or not), pdm.lock and pylock.toml are not reviewed yet. When a pull request changes one, the verdict says Upgrade review did not read changed dependency files: … and analysis_summary.supply_chain_diff.checks.upgrades.limitations lists them. They do not fail the gate on their own.

When the pull request gate exits 2. Because the review fails closed, the gate skylos cicd init writes (it passes --review-upgrades whenever --sca is selected) stops with exit 2 when:

  • a registry cannot be reached or does not answer in time;
  • more than 100 package versions change. Every entry of a new lockfile counts as a newly added package, so adding a lockfile or switching package manager usually crosses the cap;
  • a private package is neither in internal_packages nor downloaded from a host in private_registries. It is looked up on the public registry by name, so it cannot be reviewed, or, when a public package has the same name and version, it is reported as a lockfile hash mismatch (SKY-SCA-UPG001);
  • a changed lockfile is over 64 MB or cannot be read.

List your private packages and registries under [tool.skylos.supply_chain] on the default branch to avoid the third case.

skylos cicd init adds --review-upgrades to the generated pull request scan whenever dependency checks (--sca) are selected, on GitHub Actions and the other CI templates. Push and default-branch scans leave it out. Plain CLI runs need the flag.

Weakened Install Hardening​

With --sca in a diff scan, SKY-SCA-HRD001 reports a change that:

  • removes or lowers a cooldown: npm min-release-age, pnpm minimumReleaseAge, Yarn npmMinimalAgeGate, Bun minimumReleaseAge (seconds), uv exclude-newer, pip --uploaded-prior-to, Dependabot cooldown or Renovate minimumReleaseAge. Values are compared in minutes. Removing a setting whose default is as long or longer (pnpm 11's one day) is not reported;
  • lets a package skip the cooldown;
  • turns ignore-scripts off, adds packages to pnpm allowBuilds, npm allowScripts or Bun trustedDependencies, or turns pnpm strictDepBuilds off;
  • moves an action pinned to a commit SHA to a tag or branch.

Some packages need their build scripts. Record a reviewed exception on your default branch:

[tool.skylos.supply_chain.hardening_exceptions]
esbuild = "downloads its platform binary in postinstall"

Exceptions are read from the merge base only, so a pull request cannot add its own. Each one needs a non-empty reason and applies to regressions that name that package. analysis_summary.supply_chain_diff.checks.hardening.exceptions_applied lists the ones used.

Removed APIs (Unreleased)​

In the next Skylos release, --review-upgrades also names your lines that use something a Python upgrade removed. For each changed version in a lockfile or a requirements*.txt == pin, Skylos reads the old and the new release without installing, importing or running them, then checks your code:

Review these 2 call sites
pos_ciphers.py:1 · urllib3.util.ssl_.DEFAULT_CIPHERS removed in urllib3 2.0.0
pos_strict.py:3 · urllib3.HTTPConnectionPool(strict=) removed in urllib3 2.0.0

The pull request comment says either that, or No API-level impact found in N call sites (behaviour changes not checked). It never says an upgrade is safe.

  • Advice only. SKY-SCA-API001 never fails a gate and is not uploaded to Cloud. To turn it off, add it to ignore, or mark one line with # skylos: ignore[SKY-SCA-API001].
  • Could not check. Names behind a module __getattr__, a computed __all__, compiled modules with no .pyi, and failed downloads say so instead of reporting a clean result.
  • Not detected: behaviour changes, changed defaults or return types, positional argument shifts, newly required parameters, methods reached through self.x or a return value, and string references such as Django settings.
  • Python only for now. npm bumps say only Python package APIs are compared.
  • Bounds: 25 packages and 180 seconds per scan. The whole repository is searched, so a monorepo can name lines in another service.

New Package Sources (Unreleased)​

In the next Skylos release, a diff scan with --sca reports SKY-SCA-SRC004 when a changed lockfile points a package at a private, Git or URL source this repository did not use for it, or at a new host. A package that already came from that source stays quiet, and so does a lockfile moved to another folder.

If the package is yours, list it in internal_packages under [tool.skylos.supply_chain] on the target branch. A pull request can't add its own exemption, because the policy comes from the base commit.

A package moved between private indexes in uv.lock, poetry.lock or Pipfile.lock isn't caught, because those lockfiles don't give Skylos the host.

When ca9 Cannot Check​

Skylos depends on ca9>=0.7.0. A diff scan that changes agent or editor configs or workflows (with --danger), or lockfiles and requirements files (with --sca), while ca9 is missing, too old or lacks the function that checks them, records an analysis error that ends with Upgrade ca9 (pip install -U ca9) and rerun. A gated scan then stops as incomplete (exit 2); it never passes the change unchecked.

  • Changed hardening settings alone, with no ca9 installed at all, are a note in analysis_summary.supply_chain_diff. A ca9 that lacks or fails the hardening check is an error.
  • A whole-repository scan without ca9 reports nothing for these rules.
  • When a changed workflow calls a reusable workflow from another repository, or has a GitLab include ca9 cannot resolve in the repository (remote, project, template or component includes, a path built from variables, more than 150 included files, or includes nested too deep), ca9 cannot read all of it. The verdict shows Workflow audit incomplete for the changed workflows: … and the gate does not fail for that alone.
  • A diff that adds or changes a link to an agent folder or config that leaves the repository or points to a missing file is an analysis error (exit 2 in a gate): the agent can follow it, Skylos cannot check it.

Baselines​

skylos baseline records which agent-config and publish-job rules the installed ca9 can decide (rule_coverage in .skylos/baseline.json). A --baseline scan treats those findings as new only for the rules the baseline covered.

  • A baseline saved by an older Skylos, or without ca9, never saw these hooks and workflows. Their findings are listed, not failed as new. A CRITICAL SKY-D365 or SKY-D366 still fails. Saving the baseline again records the coverage.
  • These findings match the baseline by rule, file and a SHA-256 digest of the event, task, server or job name and the whole unredacted command. They do not match by line, or by the value shown in the report, which is redacted and cut at 200 characters (so a changed URL path or a change after 200 characters would look the same). Editing other settings does not make an old hook new; changing its command, URL or event does. The digest stays on your machine: uploads drop it.
  • If the baseline covers rules this run's ca9 cannot decide, the scan records an analysis error (exit 2 in a gate).

In pull requests the base branch's baseline decides. A gated diff scan (--gate with --diff or --diff-base) and the workflows from skylos cicd init set SKYLOS_POLICY_BASE. The .skylos/baseline.json in the working tree then comes from the pull request itself, so for these rules a baseline entry counts only when the base commit's .skylos/baseline.json has it too:

  • SKY-D365 to SKY-D377;
  • malware (SKY-SCA-MAL* and other advisories marked malicious);
  • SKY-SCA-UPG001, SKY-SCA-HRD001 and SKY-SCA-AGE001.

A pull request that adds a hook and its own baseline entry for it still fails. The terminal says how many such findings counted as new, and analysis_summary.baseline_policy_base gives that number (not_honoured) and whether the base baseline loaded (status). Other rules keep using the working-tree baseline.

Where Settings Come From​

Hardening exceptions always come from the merge base. ignore and the rest of [tool.skylos.supply_chain], including the internal_packages and registries the upgrade review uses, come from the base commit only under --gate with a diff base, or when SKYLOS_POLICY_BASE is set (as workflows from skylos cicd init do). A plain --diff run without --gate reads them from the working tree.

In The Agent Hooks​

skylos agent install-hooks            # Claude Code (.claude/settings.json)
skylos agent install-hooks --codex # Codex (.codex/hooks.json)
skylos agent install-hooks --cursor # Cursor (.cursor/hooks.json)

Type Checking At Agent Stop (Unreleased)​

The unreleased source implementation can include the repository's installed compiler in Done receipts used by agent stop hooks:

[tool.skylos.done]
type_checker = "mypy"
type_check_timeout_seconds = 120

[tool.skylos.done.checks]
type_check = "block"

This policy comes from the trusted baseline; the change under review cannot disable it or replace its checker. The default is "off"; "advise" reports without blocking, and skylos done --type-check explicitly strengthens it to blocking. --no-tests does not disable the compiler check. Run this on a trusted repository: installed checkers can execute their configured plugins.

The receipt includes diagnostic locations and introduced, preexisting or unclassified origins. Unsupported or incomplete baseline comparisons remain visible. See Type Checking for checker discovery and policy details. Edit hooks keep their existing static behavior; this policy applies when Done runs.

Package Installs​

Every install command an agent runs (npm install, npx, pip install, uvx, pnpm dlx, bunx, ...) is checked before it runs:

CaseExampleResult
Known malwarenpm install chalk@5.6.1, pip install aiocpa==0.1.13, npm i c@npm:chalk@5.6.1Blocked
Released less than 48 hours agonpm install typescript@next six hours after a nightlyBlocked, naming the newest older version to install instead
npm release that lost its provenancea version published from a stolen token instead of the project's CIBlocked
Lockfile installnpm ci, pnpm install, uv sync, pip install -r requirements.txt, and installs that add to a locked project (npm install left-pad, uv add, uv run)malware check over every locked package, wherever the lockfile says it resolves from
Download now, install laterpip download evil==1.0, npm pack chalk@5.6.1checked like an install
Cannot verifyOSV and the feed both unreachable; Git, URL or unknown-host entries; a lockfile or requirements file an earlier part of the same command may rewrite (curl -o, cp, git checkout --)asks you (Codex: denied)

Hallucinated and typosquatted package names are blocked as before. Packages from your own private or trusted registries are never sent to OSV or the public registries.

MCP Servers And Agent Configs​

An MCP server started with npx -y pkg or uvx pkg fetches the newest release every time it starts. A hook planted in .claude/settings.json or .vscode/tasks.json runs as soon as the agent or editor opens the project. The hooks check both when the agent changes them:

  • Commands (pre-bash): claude mcp add in all its forms (including add-json), codex mcp add and gemini mcp add, also inside bash -c or behind an environment prefix. claude mcp add-from-claude-desktop, claude plugin install, claude plugin marketplace add and gemini extensions install or link always ask, because Skylos cannot see what they load. A shell command that writes an agent config itself asks too: a redirect or program naming the file, cp or mv into a config folder, curl -o, sed -i, inline code naming the file, a git apply of a patch that touches one, or unpacking an archive Skylos cannot list. Read-only commands (cat, jq, git diff) pass.
  • Edits (pre-edit, Claude Code and Codex): .mcp.json, .cursor/mcp.json, .vscode/mcp.json, .vscode/tasks.json, .gemini/settings.json, .claude/settings.json, .claude/settings.local.json, .claude/agents/*.md, .cursor/hooks.json, .codex/hooks.json, .codex/config.toml, the same files in your home folder, and ~/.claude.json. Cursor has no pre-edit hook, so a Cursor edit to these files is reported at the next prompt.

Each MCP server the change adds gets one of these results:

ServerResult
Package at an exact version (pkg@1.4.2, pkg==2.1.0)Same checks as installing that version: it exists, it is not malware, it is older than the 48-hour hold, and npm provenance is kept. Clean passes; otherwise blocked
Package without an exact version (npx -y pkg, @latest, @^1, uvx --from pkg tool)Checked at the version the launcher would fetch today, then blocked with the pinned command to use, for example claude mcp add x -- npx -y some-mcp@1.4.2. When that version is too new, the pin is the newest older version without a malware report
ca9 flags it: a script in a hidden home folder, or a package name imitating a vendorAsks (known malware still blocks)
Git or URL source, untrusted registry, image without a digest, a launcher Skylos cannot readAsks
A wrapper that starts more than one program (sh -c "curl ... | sh"), inline code (node -e, python -c), a program outside the projectAsks
Script inside the project (node ./server.js), installed module (python -m pkg)Passes; it fetches nothing
Remote server (--transport http, "url")Passes with a note; plain http:// to a host that is not local says "could not verify"

Adding or changing anything that runs code also asks:

  • a hook command in an agent's settings, and Claude Code's statusLine, apiKeyHelper and similar helper commands;
  • hooks or MCP servers in .claude/agents/*.md, Codex notify, and a folderOpen task;
  • settings env that changes what runs or where traffic goes (PATH, NODE_OPTIONS, proxies, registry variables, ANTHROPIC_BASE_URL, OPENAI_BASE_URL);
  • plugins and marketplaces, and enableAllProjectMcpServers: true;
  • removing or weakening Skylos's own hooks, and disableAllHooks: true.

A config with duplicated keys asks, because agents keep the last value and Skylos cannot be sure what runs. Removing other entries is allowed.

Codex hooks cannot ask, so on Codex every "asks" above blocks instead. Without a ca9 that reads agent configs, every MCP addition and every edit to these files asks ("could not verify") until you run pip install -U ca9.

At Each Prompt​

The session-start hook keeps a snapshot of what these files run in .skylos/cache/agent-configs.json: names and hashes only, never commands, URLs or environment values. At each prompt it reports, one line each, what changed without a checked agent edit or command: MCP servers added, changed or removed, new hook commands and code-loading settings, removed or changed Skylos hooks, and duplicate keys. That covers another program writing a server into ~/.cursor/mcp.json, Cursor's edits, branch switches and edits the agent made through the shell. The first run only records the snapshot.

Once per session, every hour, or when a server changes, every configured MCP server is checked again at the version it would run now. Known malware, a release younger than the hold, or a package that no longer exists is reported. Servers that could not be checked within 8 seconds are counted as "could not be verified". The report never blocks the prompt. Claude Code shows it to you and the agent, Codex to you, and Cursor in its Hooks output channel.

Harden The Agents' Own Settings​

skylos agent harden            # print (default; writes nothing)
skylos agent harden --offline # no version lookups
skylos agent harden --write # merge the JSON settings after you confirm in a terminal
FlagWhat it does
--path DIRProject folder to read (default .)
--printPrint the settings and write nothing (the default)
--writeMerge the JSON settings into their files after you answer y in a terminal
--offlineDo not look up versions; unpinned MCP servers are left out
Exit codeWhen
0The settings were printed, or --write merged every file
1You did not answer y, or a file could not be written
2--path is not a folder, or --write ran without an interactive terminal (for example from an agent)

skylos agent harden prints each agent's own settings for this project:

  • Claude Code, ~/.claude/settings.json: permissions.deny Read rules for ~/.ssh, ~/.aws, ~/.config/gcloud, ~/.npmrc, ~/.pypirc and ~/.claude.json, and the sandbox with credentials.files denies for the same paths, failIfUnavailable: true and allowUnsandboxedCommands: false.
  • Claude Code, .claude/settings.json: Read(./.env) and Read(./.env.*) denies, and allowedMcpServers with each MCP server in this project's configs as an exact command or URL. Unpinned servers are pinned to the version that passes the malware and release-age checks; servers that fail are left out with the reason.
  • Cursor, .cursor/sandbox.json: "readBoundary": "workspace" (Cursor 3.23+), so the agent asks before reading outside the project.
  • Codex, ~/.codex/config.toml (printed only): sandbox_mode = "workspace-write", approval_policy = "on-request", no network.

This is policy and audit, not protection, and Skylos enforces none of it. A script the agent runs can still read files unless the agent's own sandbox blocks the path. allowedMcpServers binds only when an admin sets it in managed settings with allowManagedMcpServersOnly. --write runs only from an interactive terminal after you answer y, so an agent cannot run it.

MCP checks send the package name and version (or range) each server launches to OSV, npm and PyPI. SKYLOS_HOOKS_DISABLE=pre-bash,pre-edit,session-start turns those hooks off; skylos agent harden --offline skips the lookups.

Configuration​

[tool.skylos.supply_chain]
block_malware = true # false: known malware asks instead of blocking
min_release_age_hours = 48 # hook default 48; 0 turns the hook hold off;
# set it to also report SKY-SCA-AGE001 in scans,
# which counts toward --gate
check_provenance = true # npm only
internal_packages = ["acme-*", "@acme/*"]
private_registries = ["https://pypi.acme.dev/simple"]
trusted_registries = []

[tool.skylos.supply_chain.hardening_exceptions]
esbuild = "downloads its platform binary in postinstall"

Skylos reads this section from pyproject.toml in the project root.

A synced Skylos Cloud policy never loosens a value the repository sets. When both set a value, booleans combine with OR and the release age takes the larger value. A value only one of them sets is used as it is. Internal package patterns are combined, and registry lists set in Cloud replace the repository's lists (including [tool.skylos.hooks] trusted_registries). With a Cloud policy that blocks malware, hooks_allow_packages no longer lets a known malicious version through. In pull-request CI (SKYLOS_POLICY_BASE) the policy is read from the base commit, so a pull request cannot relax its own checks.

Cloud sends min_release_age_hours only when your workspace sets the waiting period in Policy › Supply chain. Leave it empty to keep the CLI default: a 48-hour hold for installs and no age check in scans. A value set in Cloud applies like one in pyproject.toml: below 48 it shortens the hook hold when the repository sets none, and above 0 it turns on SKY-SCA-AGE001 in scans.

internal_packages and private_registries also tell the upgrade review which packages never to look up on a public registry (see Upgrade Review).

In Skylos Cloud​

Uploads from skylos cloud or skylos . -a --upload give Cloud each branch's package list, posture, CI job list and agent hooks. Package lists need a Skylos CLI newer than 5.4.0 (the next Skylos release); 5.4.0 and older don't send them, and the Supply chain page says which version your latest upload came from. Cloud then:

  • Checks your npm and PyPI packages against OSV every hour. Each run reads the malicious records OSV published or changed since the last run and matches them against every stored package list. It also asks OSV about each package version a new upload added, so a version that was already known as malware alerts too, without waiting for a new record. If the hourly check could not run for more than 7 days, the next runs ask OSV again about every stored version, because records from the gap were never read. You do not need to rescan.
  • Raises one alert per package version on each branch, listing every advisory ID. Alerts show on the Supply chain page on every plan and go to the weekly digest. Webhooks (dependency.malicious_detected), Slack and Discord need the Workspace plan.
  • Closes and reopens alerts as described in Which Branches Are Watched.
  • Maps what a malicious version reached (Workspace plan and up): the branches whose lockfile had it, the CI jobs that could have installed it, the secrets those jobs could read, and an ordered checklist of what to isolate, revoke and rotate. You can also check a package or GitHub Action that is not in the feed yet. See Supply-Chain Exposure Map. The plan check is also enforced in the database, so the CI job lists and the history behind the map cannot be read through the API on Free.
  • Shows a Supply chain page with open alerts first, then each repository's posture.
  • Syncs a supply-chain policy (block malware, cooldown hours, internal packages, private and trusted registries, provenance) to the CLI and the agent hooks. Workspace plan and up.
  • Can ignore statically unreachable dependency CVEs in the gate (Workspace and up, off by default). Only plain CVE, GHSA and PYSEC advisories with a static unreachable_static verdict are skipped; malware and findings without a verdict always count. The CLI's --gate applies the same rule when the option is synced.

Compliance Reports And Private Packages​

A project with a few private or Git packages can get a compliance report. Each unchecked package is named as a disclosed limitation, and the report never says it was checked. This needs every package named, at most 25 of them, and under 25% of all packages. Past that the project shows as not ready, as it does after a failed query, saved answers or an unread lockfile. This limit is Skylos's own choice; no standard sets a number.

Which Branches Are Watched​

Cloud keeps up to two package lists per project and branch: the latest trusted one and, when it is newer, the latest unverified one. The hourly check matches both, so an unverified upload can add alerts.

  • Trusted uploads come from GitHub or GitLab OIDC in CI (such as the workflow from skylos cicd init), from scans Skylos Cloud runs itself, or from a project token an owner or admin marked with Mark for CI.
  • Unverified uploads include skylos . -a --upload or skylos cloud from a developer machine with the token skylos login saved. They never close an alert.

Lists are kept for the default branch and main, master, trunk and develop, and for the 25 most recently uploaded other branches of each project (counted separately for trusted and unverified lists). A list of another branch is also dropped after 90 days without an upload, unless that branch has an open alert.

An alert closes when:

  • a trusted upload of the branch sends a complete package list without the version ("removed");
  • a trusted upload finds that the branch no longer has any stored list, because the 25-branch limit or the 90-day rule dropped it ("branch no longer tracked");
  • OSV withdraws every advisory behind it ("advisory withdrawn").

A "removed" or "branch no longer tracked" alert reopens, and is sent again, when a later upload of that branch lists the version again. An "advisory withdrawn" alert reopens only when the hourly check confirms another malicious advisory for the version.

Limits​

  • The malware check knows what OSV and the OpenSSF malicious-packages database know. The release-age hold covers the hours before a report exists.
  • Release age and provenance need the registry. When the release date cannot be read, the hook asks ("could not verify"); when provenance cannot be read, it does not block.
  • Provenance is checked for npm only. Upgrade review covers the npm and PyPI lockfiles listed in Upgrade Review, up to 64 MB each; changed requirements pins, pdm.lock and pylock.toml are listed as not read.
  • Cloud's hourly check covers npm and PyPI packages only.
  • Repositories behind a private mirror of public packages have no way yet to declare that mirror as safe to check against OSV (unreleased behaviour: see Private And Git Packages).
  • The hooks check the packages inside lockfiles and requirements files (npm ci, uv sync, pip install -r) for malware only, not for existence or release age; skylos . -a checks the rest.
  • An exact MCP server pin freezes that package, not its dependencies. A config written by a program the hook does not see, or by a Cursor edit, is only reported at the next prompt.
  • bun.lockb (binary) is not read; use the text bun.lock.