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.6andxrpl@4.2.1are reported asCRITICAL. - 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
| What | Rule ID | Severity | When |
|---|---|---|---|
| Known malicious version | SKY-SCA-<advisory id> (for example SKY-SCA-MAL-2025-46969) | CRITICAL | OSV 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 confusion | SKY-SCA-SRC001 | CRITICAL direct, HIGH transitive | a package matching your internal_packages can resolve from a public or undeclared index, including through --extra-index-url |
| Mutable source | SKY-SCA-SRC003 | MEDIUM | a 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 integrity | SKY-SCA-INT001 | MEDIUM | a registry entry in a lockfile has no integrity hash |
| Version younger than your cooldown | SKY-SCA-AGE001 | HIGH | only when you set min_release_age_hours above 0 |
| Malware feed match without an advisory ID | SKY-SCA-MAL000 | CRITICAL | OSV 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: truein 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 Yarnvirtual: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:
| Tier | Shown as | Meaning |
|---|---|---|
used | called at app.py:4 | A listed vulnerable API is called, or coverage shows the code ran |
imported | imported | The package is imported, directly or through a dependency you import, and no vulnerable call was found |
unknown | unknown | Nothing proves it either way: a transitive package, a command-line tool or plugin, a guessed import name, or an incomplete dependency graph |
not_imported | no import found | A declared direct dependency with a complete dependency graph, no import and no sign of indirect use |
unreachable | not reached | The 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 andid-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 hasindirect: commands such asmake,just,tox,nox,docker buildordocker 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 byskylos agent install-hookscarryskylos_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.
| Check | Passes when |
|---|---|
lockfile_committed | every npm and Python manifest has a committed lockfile (or pinned requirements) |
frozen_install_ci | CI installs from the lockfile (npm ci, --frozen-lockfile, --immutable, --require-hashes, uv sync --locked) |
install_scripts_disabled | ignore-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_cooldown | a 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_pinned | GitHub Actions are pinned to commit SHAs |
no_pr_target_checkout | no 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.
| What | Rule ID | Severity |
|---|---|---|
Agent hook command committed in .claude/settings.json, .claude/settings.local.json, .gemini/settings.json, .cursor/hooks.json, .codex/hooks.json or .codex/config.toml | SKY-D365 | HIGH; 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-D366 | as SKY-D365 |
MCP server without an exact version (npx -y pkg, pkg@latest, uvx pkg, an image without a digest) | SKY-D367 | MEDIUM |
| MCP server launched from a hidden home-folder or temp path outside the repository | SKY-D368 | HIGH |
| MCP server package whose name imitates a vendor it is not published by | SKY-D369 | MEDIUM |
| Agent API base URL in committed settings pointing somewhere other than the provider's own API (see below) | SKY-D370 | HIGH |
| Agent or editor config with duplicated keys, so what you read is not what runs | SKY-D377 | MEDIUM |
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-D371 | HIGH 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 step | SKY-D372 | HIGH |
OIDC publish job without a deployment environment: | SKY-D373 | HIGH |
| Publishing workflow that an event can start without a condition that proves the release branch or excludes that event (see below) | SKY-D374 | HIGH, MEDIUM or LOW (see below) |
Secrets (secrets: inherit or named) passed to a reusable workflow from another repository | SKY-D376 | MEDIUM |
| A pull request adds or changes a job that publishes packages (diff scans only) | SKY-D375 | MEDIUM |
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 job | Severity |
|---|---|
No fixed environment: (none, or one built from an expression), and any of these events except a wildcard branch pattern can start it | HIGH |
No fixed environment:, and only a wildcard branch pattern can start it | MEDIUM |
Every such job has a fixed environment:, which protects it only if the environment requires reviewers or limits deployment branches | LOW |
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
| Scan | Built-in --gate |
|---|---|
| Whole repository | SKY-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 fromStoptoSessionStart, 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_basissays which was used (base_findingsorchanged_files).--diff-basealone: 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
skyloson yourPATH, or an absolute path ending in/skylosoutside the repository and outside temp folders; - the settings file's
envdoes not setPATHor loader variables such asPYTHONPATH,NODE_OPTIONS,LD_*orDYLD_*.
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.
| What | Rule ID | Severity | Needs | Built-in gate |
|---|---|---|---|---|
| A dependency upgrade adds install-time or start-up code | SKY-SCA-UPG001 | CRITICAL | --review-upgrades | fails on its own |
| A dependency upgrade adds code worth reviewing | SKY-SCA-UPG002 | MEDIUM | --review-upgrades | listed, not counted |
| A dependency upgrade removes API your code uses (unreleased) | SKY-SCA-API001 | MEDIUM | --review-upgrades | listed, never counted |
| A package moves to a source this repository did not use (unreleased) | SKY-SCA-SRC004 | HIGH | --sca | counts toward max_dependency_vulnerabilities |
| The change weakens install hardening | SKY-SCA-HRD001 | HIGH | --sca | fails on its own |
| The change adds or modifies a job that publishes packages | SKY-D375 | MEDIUM | --danger | counts 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.pthfile orsitecustomizewith 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,runpyor a module loader in Python;eval,Function,vmorModule._compilein 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.pthcode, 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_packagesnor downloaded from a host inprivate_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, pnpmminimumReleaseAge, YarnnpmMinimalAgeGate, BunminimumReleaseAge(seconds), uvexclude-newer, pip--uploaded-prior-to, Dependabotcooldownor RenovateminimumReleaseAge. 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-scriptsoff, adds packages to pnpmallowBuilds, npmallowScriptsor BuntrustedDependencies, or turns pnpmstrictDepBuildsoff; - 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-API001never fails a gate and is not uploaded to Cloud. To turn it off, add it toignore, 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.xor 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
CRITICALSKY-D365orSKY-D366still 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-D365toSKY-D377;- malware (
SKY-SCA-MAL*and other advisories marked malicious); SKY-SCA-UPG001,SKY-SCA-HRD001andSKY-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:
| Case | Example | Result |
|---|---|---|
| Known malware | npm install chalk@5.6.1, pip install aiocpa==0.1.13, npm i c@npm:chalk@5.6.1 | Blocked |
| Released less than 48 hours ago | npm install typescript@next six hours after a nightly | Blocked, naming the newest older version to install instead |
| npm release that lost its provenance | a version published from a stolen token instead of the project's CI | Blocked |
| Lockfile install | npm 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 later | pip download evil==1.0, npm pack chalk@5.6.1 | checked like an install |
| Cannot verify | OSV 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 addin all its forms (includingadd-json),codex mcp addandgemini mcp add, also insidebash -cor behind an environment prefix.claude mcp add-from-claude-desktop,claude plugin install,claude plugin marketplace addandgemini extensions installorlinkalways 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,cpormvinto a config folder,curl -o,sed -i, inline code naming the file, agit applyof 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:
| Server | Result |
|---|---|
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 vendor | Asks (known malware still blocks) |
| Git or URL source, untrusted registry, image without a digest, a launcher Skylos cannot read | Asks |
A wrapper that starts more than one program (sh -c "curl ... | sh"), inline code (node -e, python -c), a program outside the project | Asks |
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,apiKeyHelperand similar helper commands; - hooks or MCP servers in
.claude/agents/*.md, Codexnotify, and afolderOpentask; - settings
envthat 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
| Flag | What it does |
|---|---|
--path DIR | Project folder to read (default .) |
--print | Print the settings and write nothing (the default) |
--write | Merge the JSON settings into their files after you answer y in a terminal |
--offline | Do not look up versions; unpinned MCP servers are left out |
| Exit code | When |
|---|---|
0 | The settings were printed, or --write merged every file |
1 | You 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.denyReadrules for~/.ssh,~/.aws,~/.config/gcloud,~/.npmrc,~/.pypircand~/.claude.json, and the sandbox withcredentials.filesdenies for the same paths,failIfUnavailable: trueandallowUnsandboxedCommands: false. - Claude Code,
.claude/settings.json:Read(./.env)andRead(./.env.*)denies, andallowedMcpServerswith 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_staticverdict are skipped; malware and findings without a verdict always count. The CLI's--gateapplies 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 --uploadorskylos cloudfrom a developer machine with the tokenskylos loginsaved. 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.lockandpylock.tomlare 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 . -achecks 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 textbun.lock.