Use scripts/run-headless.sh to run a full security assessment from a
shell, CI job, or scheduled task. The wrapper checks authentication,
provisions Git URLs, and hands every assessment to the deterministic
controller. No Claude Code session orchestrates the run: the controller calls
each role's model itself and owns the trust preflight, cost and duration
limits, publication, and exit codes.
Every run is a full assessment.
| Mode | Command | Behavior |
|---|---|---|
| New run | run-headless.sh |
Assesses the repository. A completed assessment in the same directory is replaced and its history continues; --full is accepted and changes nothing. |
| Resume | --resume |
Continues an interrupted run without repeating its accepted steps, as long as sources and settings are unchanged. |
| Rebuild | --rebuild |
Redoes every model step and resets the history; IDs may change. It cannot be combined with --resume. |
--incremental, --rerender, --dry-run, --base, --pr-mode,
--restore-from, --clean-cache, --clean-all, --max-wall-time and a
free-text focus are refused before any model call.
- Install the Claude Code CLI and place
claudeonPATH. - Authenticate with an API key or a stored Claude subscription login.
- Clone or install the plugin.
With API billing, supply the key through the environment and keep it in the CI secret store. The wrapper never needs the key as a command-line argument.
export ANTHROPIC_API_KEY="<from-secret-store>"
./scripts/run-headless.sh --repo /path/to/repository --hard-budget 30A headless run requires --hard-budget; see Cost and duration limits.
An installed wrapper can be invoked from its plugin directory. Set
CLAUDE_PLUGIN_DIR when several installed copies exist and the desired one is
otherwise ambiguous.
Set APPSEC_CLAUDE_EXECUTABLE when the environment must start Claude Code
through a gateway, authentication, or observability wrapper. The value names
one executable; command prefixes and shell fragments are not accepted. The
headless runner uses it for both the authentication preflight and the assessment
process. Without the variable, it continues to invoke claude from PATH.
For example, keep a LiteLLM-specific launcher outside the plugin:
#!/bin/sh
exec lite --base-url "${LITELLM_PROXY_URL:?}" claude "$@"Then select it for one run:
APPSEC_CLAUDE_EXECUTABLE=/opt/company/bin/claude-via-lite \
LITELLM_PROXY_URL=https://litellm.example.com \
./scripts/run-headless.sh --repo /repos/team-api --hard-budget 30Use an absolute path in unattended environments. Store proxy credentials in the environment's secret store rather than in the launcher or plugin configuration.
Without --output, a run writes to the application's private directory under the user data directory, <base>/<repo-name>-<hash8>, and several --repo selections share one combined-<hash8> directory. Where results are stored lists the base directory per platform and the limits of this protection. Print the directory of a checkout with:
python3 scripts/shared/assessment_location.py destination --repo /path/to/repository--output <path> writes elsewhere. It must lie outside every selected checkout; the only directory inside a checkout that a single-repository run accepts is its docs/security.
If <repo>/docs/security holds a finished assessment from an earlier release, a headless run stops until you choose: --migrate-legacy imports it into the destination and continues its history, and --ignore-legacy starts without it. Either way the files in the repository stay. An interrupted earlier run is reported as incomplete and not imported.
Pass an HTTPS URL, an SSH URL (ssh://git@host/team/repo.git), or an SSH selection in git@host:team/repo.git form to --repo. Repeat --repo to combine remote repositories or mix URLs with local checkouts. Git URLs require Git 2.34 or newer and an explicit --output outside the temporary checkout area.
./scripts/run-headless.sh \
--repo https://github.com/org/frontend.git \
--repo git@github.com:org/backend.git \
--output ./reports/system \
--hard-budget 50HTTPS authentication uses existing Git credential helpers or APPSEC_GIT_TOKEN, with APPSEC_GIT_USERNAME when the provider requires a particular username (default: git). Supply the token through your environment or CI secret store, never inside a URL. An environment token takes precedence over credential helpers and reaches Git through a private helper pipe; the runner does not write it to disk or pass it to the analysis process. Credential configuration comes from the operator's global/system Git configuration, including GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM, not from a selected repository.
SSH uses the SSH agent (SSH_AUTH_SOCK), existing SSH identity configuration, or the existing private key file named by APPSEC_GIT_SSH_KEY_FILE. Unlock encrypted keys in the agent before starting. Host keys must already be trusted in known_hosts; missing authentication or an unknown host key fails without prompting. Use a resolvable hostname, rather than an SSH alias. The runner pins the destination address and does not use HTTP proxies, SSH jump hosts, connection multiplexing or custom GIT_SSH_COMMAND commands. TLS certificate verification and SSH host verification remain enabled. If APPSEC_URL_ALLOWLIST is set, each selected remote hostname must match an entry exactly. Explicitly selected private-network Git hosts are allowed; loopback, link-local and metadata destinations are refused.
The runner shallow-clones each remote's default branch into a private area under the system temporary directory. It neither executes checkout hooks or configured content filters nor fetches submodules or Git LFS objects. Each clone and checkout is bounded to 120 seconds, 512 MiB and 100,000 filesystem entries; exceeding a limit stops provisioning before assessment. Clone diagnostics omit raw Git output to keep credentials out of logs.
Checkout paths stay stable for the same output location, URL and temporary directory, so an unchanged source revision retains its repository identity. A concurrent remote run with the same output location is refused. Checkouts are removed on success, failure and handled interruption; reports and recovery state remain in --output. The source-revision checks of --resume still apply on retry. A forced kill or machine shutdown can leave a checkout behind; the next invocation refuses to reuse it and requires operator cleanup of the stale checkout beneath appsec-remotes-<uid> in the temporary directory. Small private directories and lock files remain to preserve safe locking and path identity.
Assess the current repository; the results go to its application directory:
/path/to/appsec-advisor/scripts/run-headless.sh --hard-budget 30Assess another team's repository into a directory you choose:
./scripts/run-headless.sh \
--repo /repos/team-api \
--output /appsec-reports/team-api \
--hard-budget 30 --sarifStart over and reset the history:
./scripts/run-headless.sh \
--repo /repos/team-api \
--output /appsec-reports/team-api \
--hard-budget 30 --rebuildThis mode is experimental. The report says so in its Management Summary, and the run says so when it starts.
Repeat --repo to assess several repositories as one system, using local Git checkouts, Git URLs, or both. Every selected repository is scanned freshly, and the run publishes one security-assessment.yaml, one security-assessment.md and one Figure 1. --project sets the system name in the report, and --slug adds a stamped copy of the deliverables.
./scripts/run-headless.sh \
--repo /repos/frontend \
--repo /repos/backend \
--output /appsec-reports/order-system \
--hard-budget 50 \
--sarifA combined run has these requirements:
- Select 2–16 repositories. Local selections must name top-level Git checkouts. A checkout inside another selected checkout, or two paths that name the same directory, stop the run.
- Without
--output, the run writes to the sharedcombined-<hash8>directory of the selection. An explicit--outputmust lie outside every selected checkout. The run writes nothing into a selected checkout. - Set
--hard-budgetto the total model-cost limit; see Cost and duration limits. - The untrusted-repository preflight checks every selected checkout.
--trust-mode trustedskips it for all of them.
What the result shows:
- Each component card and source location names its repository, for example
Order API [backend]andbackend/src/orders.ts:42. The YAML records the repository separately in asource_inventoryand in every source reference, and SARIF uses one location base per repository. - The order of the
--repoflags does not change the result. No repository takes precedence. - A connection between repositories appears only when source evidence on both sides supports it. Matching names alone never connect two repositories, and repository membership never creates a trust boundary.
- Symbolic links, submodules and nested repositories inside a checkout are not read. The model lists each of them as an omitted path.
Business context, known threats and a run-wide --requirements catalog are supported; each repository keeps its own business context and known threats. A requirements catalog inside one of the selected repositories stops the run unless you pass --requirements <source>. Repository-local abuse-case files stop the run; use --no-abuse-cases or move the cases to the organization profile. The follow-on skills that only support single-repository models, such as fixing findings in place, say so when they open a combined model.
A single --repo and a combined selection run through the same procedure.
Every role has a fixed model version, independent of the session that started the run, so cost, budget and later replays stay predictable. At quick and standard depth STRIDE runs on Sonnet 5.5: each STRIDE result must come back from one model call, and Sonnet 4.6 exhausts its thinking there without returning one. STRIDE calls therefore cost more: Sonnet 5.5 counts the same text in about 30% more tokens at the same price per token. The run prints each role's model at the start and warns about this change, even with --quiet. An explicit STRIDE model through --stride-model, APPSEC_STRIDE_MODEL or --model is kept; if it is Sonnet 4.6, the run warns that STRIDE is expected to return no result.
To choose other models, pass --model <id> for every role you do not set separately, --stride-model, --triage-model or --merger-model for one stage, or --reasoning-model for a tier; set APPSEC_<ROLE>_MODEL, such as APPSEC_EVIDENCE_VERIFIER_MODEL, for any other role. --no-opus turns every Opus selection into Sonnet. /appsec-advisor:appsec-analyzer passes these flags to the run. A stronger model can reach the budget sooner; choose an appropriate --hard-budget.
The models have no file, shell or network tools. The controller serves every source excerpt a model reads, only from the selected checkouts, with secrets masked. Each analysis step is recorded, so --resume continues an interrupted run without repeating accepted steps, as long as the sources and settings are unchanged. After a plugin update, --resume reuses only the steps whose input is unchanged and redoes the rest. A run whose model cost became unknown cannot resume; start a new run.
The controller supplies component-specific analysis guides and requires a source-backed decision for each investigation target: detected handlers, outbound calls, capability and flow evidence, and component files without a detected surface. Large components use several bounded jobs within the existing run budget. An unread target prevents that component's analysis from completing; a target that was read but left unresolved is reported as a warning. This checks the supplied targets; it does not prove that every vulnerability or framework-specific mechanism was found.
--component-workers N sets how many components the controller analyzes at once (1–4, default 3). A lower value sends fewer simultaneous model calls and can reduce rate-limit errors, but takes longer. It changes neither the selected components nor accepted job results; you can change it when resuming an interrupted run.
A new run in the same output directory reuses every model step of an unfinished earlier run whose input is unchanged. Over a completed assessment, it replaces the report and continues its history. --rebuild redoes every model step and resets the history.
The controller enforces the limits itself:
./scripts/run-headless.sh \
--repo /repos/team-api \
--max-duration 3600 \
--hard-budget 30--hard-budget is the total model-cost limit, including running reservations and reused jobs. --soft-budget is a deprecated compatibility spelling that derives a limit of 1.25 times its value unless an explicit hard budget is supplied. A headless run without either stops before any model call.
--max-duration sets the controller's cumulative deadline, 14,400 seconds by default, and is retained on --resume. The controller protects time for required report work before dispatching optional calls.
The completion reserve is an estimate, updated from accepted jobs under the run's fixed model selection. Running calls count against the limit. Required report authoring and local finalization also retain a time allowance. Optional work uses its existing deterministic replacement when money, time or calls are reserved for required work; the console names every affected job, and unverified findings remain unproven. A reserve improves the chance of completion but cannot guarantee that mandatory analysis fits every budget.
If the budget cannot cover required work, the run exits 3 and writes .assessment-work/budget-report.md. This separate, explicitly incomplete snapshot lists accepted steps, preliminary STRIDE candidates and outstanding work; it contains no final ratings, consolidated scanner results or security verdict. It does not replace security-assessment.md or security-assessment.yaml and produces no exports. Its bounded structured projection is recorded in .assessment-state.json. Increase --hard-budget or --max-duration and rerun without --resume or --rebuild to reuse compatible accepted steps; reused job costs remain part of the accounting. --resume retains the original limits. Invalid source state, unsettled accounting and failed security checks do not produce this snapshot.
Run full assessments on a schedule or by explicit manual trigger. Pull-request delta mode is not supported.
A runner's user data directory does not outlive the job, so pass an explicit
--output into the workspace, outside the checkout, and archive that directory
in a later step if you need the results.
name: Security assessment
on:
schedule:
- cron: "0 3 * * 1"
workflow_dispatch:
jobs:
security-assessment:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
path: repo
fetch-depth: 0
- name: Run full assessment
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
/path/to/appsec-advisor/scripts/run-headless.sh \
--repo "$GITHUB_WORKSPACE/repo" \
--output "$GITHUB_WORKSPACE/assessment" \
--sarif --max-duration 7200 --hard-budget 25Do not execute repository-owned hooks from an untrusted checkout. The default
--trust-mode untrusted preflight rejects escaping symbolic links, IDE-loaded
files and unlisted related-repository URLs; the model sessions never load
Claude Code configuration from the repository. Use --trust-mode trusted only
for a repository you control.
The standalone requirements audit runs its own skill, not an assessment:
./scripts/run-headless.sh --audit-requirements
./scripts/run-headless.sh --audit-requirements --category SEC-AUTH --save-report
./scripts/run-headless.sh --audit-requirements --repo /repos/team-apiThe run prints each phase, the component or report section in work, and a warning when a model call stalls; --verbose adds every step and model call with the source ranges it reads, and --quiet suppresses progress. In a terminal, one status line with a spinner and a progress bar is redrawn in place with each running model call's duration and whether it is thinking, writing or waiting for the API; output that is not a terminal, such as a CI log, gets no status line. --width <columns> (at least 60) wraps every console line at that width, on a terminal at most at the terminal's width and also when the output is not a terminal, so the output fits a window of fixed width. A warning never ends the run.
--progress-file <path> opens with a run_start record whose lines state the repository, output, depth, budget, time limit and models, and also writes each progress event as one JSON line (phase_start, phase_done, component_done, progress, correction, warning) with the fields phase, number, total, progress, elapsed, left and detail, and ends every run with an end record carrying status, exit_code and resumable. The file is replaced at start, so a program can follow it without parsing the console.
In a terminal the output is coloured: phases, status marks, warnings and finding severities each have their own colour. Output that is not a terminal, such as a CI log or a file, carries no colour codes. NO_COLOR turns colour off, FORCE_COLOR=1 turns it on for output that is not a terminal, and TERM=dumb turns it off; NO_COLOR wins over FORCE_COLOR. Terminals that set COLORTERM=truecolor get the full palette, others the basic terminal colours.
A successful run writes security-assessment.md, security-assessment.yaml
and the report's security-assessment.figure*.svg figures. Optional flags add SARIF, Threat Dragon, HTML, PDF, or pentest task
exports. The Markdown report is the review surface and YAML is the structured
source for deterministic exports.
| Flag | Meaning |
|---|---|
--repo <path|url> |
Local repository or HTTPS/SSH Git URL; defaults to the current directory. URLs require --output. Repeat it to assess several repositories as one system. |
--output <path> |
Output directory; defaults to the application's private directory under the user data directory. |
--migrate-legacy |
Import a finished assessment from <repo>/docs/security before the run; its files there stay. |
--ignore-legacy |
Start without that assessment. |
--resume |
Continue an interrupted run. |
--rebuild |
Redo every model step and reset the history. |
--assessment-depth quick|standard|thorough |
Select analysis depth. |
--reasoning-model <tier> |
Select the reasoning tier. |
--model <id> |
Model for every role not set separately. |
--sarif |
Write SARIF in addition to Markdown and YAML. |
--threatdragon |
Write the alpha Threat Dragon export. |
--slug [value] |
Also write a copy of every deliverable named security-assessment-<value>.* and pentest-tasks-<value>.yaml, so several models can share one directory. A bare --slug picks a random value. |
--requirements [source] |
Include the requirements catalog. |
--context <source> |
Supply business context as untrusted data for this run. |
--max-duration <seconds> |
Cumulative deadline of the run. |
--hard-budget <usd> |
Total model-cost limit with an automatic completion reserve. |
--soft-budget <usd> |
Deprecated: derives a limit of 1.25 × this value. |
--pause-after <phase> |
Stop before the phase after <phase> (1-based); --resume continues the run. |
--trust-mode trusted|untrusted |
Select repository trust preflight; default is untrusted. |
--fail-on critical|high|medium |
Exit non-zero when the latest changelog entry adds a finding at or above this level. |
--width <columns> |
Wrap console lines at most at this width. |
--progress-file <path> |
Also write progress events as JSON lines. |
--verbose |
Add every step and model call to the progress output. |
--quiet |
Suppress live progress. |
APPSEC_CLAUDE_EXECUTABLE selects one Claude-compatible executable for the
authentication check and assessment launch. It defaults to claude on PATH.
An exit code of 0 means the assessment completed and its report exists. A run that stopped incomplete but can continue, at its --pause-after boundary or on a stop request, exits 3; continue it with --resume. A run whose budget could not cover required work also exits 3. A refused invocation, such as an unsupported flag, a missing budget, trust-preflight findings or an earlier docs/security assessment without --migrate-legacy or --ignore-legacy, exits 2 and changes no assessment. A completed run whose new findings reach the --fail-on level exits 20, an interrupt 130; validation and other failures exit 1. A refused run analyzes nothing, so it never reports success even when an earlier report is still on disk.
Check whether a run still holds an output directory:
python3 scripts/runtime/live_run_holder.py --output /path/to/outputIt prints free, or live with the run ID, start time and heartbeat age. After an interrupted run, inspect the reported issue, then continue with --resume or start a new run. Do not copy state files into a new run; that is not an admitted runtime path.