> ## Documentation Index
> Fetch the complete documentation index at: https://docs.safedep.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Scanning from CI and AI Agents

> Use on-demand package scanning from scripts and AI agents: authentication model, JSON output, verdict gating, polling, and retries.

On-demand scanning is built to be driven by automation: platform teams wiring pre-adoption checks into internal tooling, and AI agents checking an external component before using it. This guide covers the contract that automation depends on.

## A signed-in session is required

`safedep package scan` calls a control-plane API that authenticates with your signed-in session, established by `safedep auth login`. **API keys do not submit scans.** This is a deliberate design decision, not a missing feature.

The reasoning: every scan draws down your plan's allowance, and with [on-demand billing](/governance/cloud/usage-billing) enabled it can create real charges. SafeDep treats operations that spend your money as control-plane operations, which require the stronger trust of a signed-in session. API keys are data-plane credentials: long-lived, distributed to tools and CI environments, and fine for reading curated threat intel. A leaked API key can read intel, but it cannot spend your scan budget.

What this means in practice:

* **Automation on a workstation works.** Scripts and AI agents running where a human has signed in (a developer machine, an agent sandbox with the CLI configured) submit scans normally. The CLI refreshes the session silently. Re-run `safedep auth login` when the refresh eventually expires.
* **Headless CI cannot submit scans today.** There is no non-interactive credential for scan submission. If your pipeline needs an inline malicious-package gate, use the free fast-path lookup with [Vet](/governance/cloud/malware-analysis), which works with an API key or no account at all.

## Machine-readable output

Every command takes `-o json` (also `-o plain` for tab-separated lines). A scan object looks like:

```json theme={null}
{
  "scan_id": "01J...",
  "target": {
    "ecosystem": "npm",
    "name": "express",
    "version": "4.18.2"
  },
  "status": "completed",
  "verdict": "benign",
  "confidence": 0.98,
  "created_at": "2026-08-07T10:00:00Z",
  "completed_at": "2026-08-07T10:03:12Z"
}
```

`status` ends at `completed` or `failed`. `verdict` is present only once the scan is `completed`, and is one of `malware`, `benign`, or `inconclusive`. On failure, `failure_reason` carries the human-readable cause and `failure_code` a stable classification when one exists (for example `package-not-found`).

## Gate on the verdict, not the exit code

`scan run` exits non-zero when the scan fails or the CLI hits an error (timeout, auth). A scan that completes exits zero **whatever the verdict**: completing the analysis is the command's job. A gate must check the verdict field:

```bash theme={null}
verdict=$(safedep package scan run "pkg:npm/express@4.18.2" -o json | jq -r .verdict)

case "$verdict" in
  benign) echo "ok" ;;
  malware) echo "blocked: malware verdict"; exit 1 ;;
  *) echo "needs review: verdict=$verdict"; exit 1 ;;
esac
```

Treat `inconclusive` as needs-review, not as a pass. The analysis completed but could not reach a confident verdict.

## Submit now, check later

For long-running pipelines or agents that should not block, submit without waiting and poll:

```bash theme={null}
scan_id=$(safedep package scan run "pkg:npm/express@4.18.2" --wait=false -o json | jq -r .scan_id)

safedep package scan get --scan-id "$scan_id" -o json
safedep package scan show --scan-id "$scan_id" -o json   # full report, once completed
```

`get` returns the current status and, once completed, the verdict. `show` returns the full report and errors while the scan is still running.

## Retries are free

Submitting the same ecosystem, name, and version again returns the existing scan instead of creating a new one. Retrying after a network failure or a CLI timeout is safe and does not draw down your allowance twice. `--rescan` opts out of this and forces a new analysis, which counts against the allowance.

A CLI timeout does not fail the scan. `scan run` waits up to 5 minutes by default (`--timeout` adjusts this). If it times out, the scan keeps running server-side, and the error message includes the `scan get` command to resume polling.

## Direct API access

The CLI is a thin client over `PackageScanService` (`SubmitScan`, `GetScan`, `ListScans`, `GetScanReport`) in `safedep.services.malysis.v1`, published at [buf.build/safedep/api](https://buf.build/safedep/api). The service is ConnectRPC, so it also speaks plain HTTP/JSON: POST to `https://cloud.safedep.io/safedep.services.malysis.v1.PackageScanService/<Method>`.

Direct API access uses the same authentication model as the CLI: an `authorization` header carrying the access token from your OAuth2 session (an API key does not work), and an `x-tenant-id` header carrying your tenant domain. The CLI does not print its session token today. For most integrations, drive the CLI or use a [generated SDK](https://buf.build/safedep/api/sdks) with your own OAuth2 session.

<Tabs>
  <Tab title="curl">
    ```bash theme={null}
    # Submit a scan
    curl https://cloud.safedep.io/safedep.services.malysis.v1.PackageScanService/SubmitScan \
      -H "authorization: $SAFEDEP_ACCESS_TOKEN" \
      -H "x-tenant-id: your-company.safedep.io" \
      --json '{"target":{"package_version":{"package":{"ecosystem":"ECOSYSTEM_NPM","name":"express"},"version":"4.18.2"}}}'

    # Poll the scan by id
    curl https://cloud.safedep.io/safedep.services.malysis.v1.PackageScanService/GetScan \
      -H "authorization: $SAFEDEP_ACCESS_TOKEN" \
      -H "x-tenant-id: your-company.safedep.io" \
      --json '{"scan_id":"<scan-id>"}'
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import requests

    BASE = "https://cloud.safedep.io/safedep.services.malysis.v1.PackageScanService"
    HEADERS = {
        "authorization": ACCESS_TOKEN,          # OAuth2 session access token
        "x-tenant-id": "your-company.safedep.io",
    }

    submit = requests.post(f"{BASE}/SubmitScan", headers=HEADERS, json={
        "target": {
            "package_version": {
                "package": {"ecosystem": "ECOSYSTEM_NPM", "name": "express"},
                "version": "4.18.2",
            }
        }
    })
    submit.raise_for_status()
    scan_id = submit.json()["scanId"]

    scan = requests.post(f"{BASE}/GetScan", headers=HEADERS, json={"scan_id": scan_id})
    scan.raise_for_status()
    print(scan.json())
    ```
  </Tab>
</Tabs>

See the [API introduction](/reference/api-introduction) for transport details and rate limits.

<CardGroup cols={2}>
  <Card title="Usage & On-Demand Billing" icon="coins" href="/governance/cloud/usage-billing">
    Allowances, spending caps, and the commands to inspect usage.
  </Card>

  <Card title="Package Scan Quickstart" icon="rocket" href="/package-security/scan/quickstart">
    First-time setup and your first scan.
  </Card>
</CardGroup>
