Skip to content

Installing

Audience: whoever stands the system up — on a laptop for development, or on a host for a demonstration.

What works today. Everything below runs. The stack starts with one command and the ten-minute demonstration runs against it headlessly. The AWS deployment in §5 is written and validated but has not yet been applied against a live account — that line will go when it has.


1. Development environment

Requirements

Tool Version Why
uv ≥ 0.8 Manages Python and every dependency
Docker + Compose ≥ 29 / v5 Runs the stack
Git any recent —
Node ≥ 22 Only to develop the console; the image builds it

You do not need to install Python. uv fetches the pinned version (3.12, from .python-version). The system Python is usually too old — on current macOS it is 3.9 — and is used for nothing except the boundary check, deliberately.

Setup

git clone git@github.com:RA-Rob/RA-Cyber-Defense-Harness.git
cd RA-Cyber-Defense-Harness
uv sync

That is the whole installation. uv sync creates the virtual environment, installs the locked dependencies, and installs each service package in editable mode.

Verify

uv run pytest                  # unit, contract, integration, property
uv run ruff check .            # lint
uv run mypy                    # strict type checking
python3 tools/boundary-check/boundary_check.py

The fast tier runs with no Docker, no network and no model, and finishes in a few seconds. If it does not, something is wrong with the installation rather than with the tests.

The boundary check is run with the system Python on purpose: it must work before any toolchain exists, so it is dependency-free and stays compatible with old interpreters.

Test markers

uv run pytest                      # everything except acceptance
uv run pytest -m unit              # pure functions only
uv run pytest -m property          # Hypothesis properties
uv run pytest -m acceptance        # needs the stack running
uv run pytest -k a4                # "is acceptance criterion A4 passing?"

Acceptance tests skip cleanly when the stack is down, so the fast suite never depends on Docker.


2. Running the stack

./deploy/bin/harness up

Then:

Console http://localhost:8080
API http://localhost:8000

The console is a client of that same API — there is no endpoint reserved for it, and a contract test asserts so. Anything the console does, a script can do.

Every command

./deploy/bin/harness up            # start the stack
./deploy/bin/harness up-airgap     # start it with no route off the host at all
./deploy/bin/harness down          # stop it, keeping data
./deploy/bin/harness reset         # stop it, discard data, start clean
./deploy/bin/harness verify        # prove the network topology from inside it
./deploy/bin/harness verify-chain  # re-walk every audit chain
./deploy/bin/harness criteria      # which acceptance criteria are proven, from a real run
./deploy/bin/harness demo          # the ten-minute script, headless
./deploy/bin/harness dry-run       # three clean runs from reset
./deploy/bin/harness pull-model    # fetch local model weights
./deploy/bin/harness logs [svc]    # follow logs
./deploy/bin/harness ps            # what is running
./deploy/bin/harness shell <svc>   # a shell in a service

dry-run is the gate before showing anyone: it resets and runs the whole demonstration three times, and fails if any claim stops holding.

Signing in

Identity comes from Keycloak, running in the stack (ADR 0012). Open http://localhost:8080 and sign in; the console redirects to Keycloak and back.

Four lab accounts, defined in deploy/keycloak/realm-harness.json:

Username Password Role Organisation
nw.analyst analyst analyst first
nw.tradecraft tradecraft tradecraft owner first
nw.admin admin administrator first
co.analyst analyst analyst second

The passwords above are obvious because they protect nothing on a laptop. They are also only the starting state: that file seeds a new database and is not authoritative afterwards, so a password you change stays changed, a user you add stays added, and a session survives a restart. ./deploy/bin/harness reset keeps all of it; reset-id discards it and re-imports the realm from the file. §6a of the operating guide has the table.

Identity has a database of its own, on a network with Keycloak and nothing else — it is the only store here holding anything about people, and the services that handle incidents have no route to it.

Automation signs in the same way, with the direct grant rather than a redirect:

TOKEN=$(curl -s -X POST \
  http://localhost:8080/auth/realms/harness/protocol/openid-connect/token \
  -d client_id=harness-console -d username=nw.analyst \
  -d password=analyst -d grant_type=password \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')

curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8000/v1/status

The Keycloak admin console is at http://localhost:8080/auth/admin (admin / admin by default; override with KEYCLOAK_ADMIN_PASSWORD).

Statically issued tokens still exist for the in-process test suite, which runs with no provider at all. A deployed stack sets HARNESS_AUTH_MODE=oidc and then refuses them on every route — there is deliberately no mode that accepts both, since that would keep standing credentials behind a login page implying they are gone.

Containers

Container Role
ingress The only inbound route. Serves the console and proxies the API.
console The analyst UI — static files, no credential of its own
api Public API, orchestration, agents, policy
audit-store Sole writer of every hash chain
tool-gateway The only path to the estate and the only path outward
model-router Chooses a backend; enforces air-gap mode
local-model Locally hosted open-weight model
egress-proxy The only container with a route to the internet
sim-it, sim-jumphost, sim-ot The synthetic estate

Two more run only on demand: netprobe (profile tools) for the topology proofs, and model-puller (profile provision) for fetching weights.

Networks

Most networks are internal, meaning containers on them have no route off the host at all. That is what makes the air-gap claim checkable rather than asserted: there is no egress to block. deploy/topology.expected.yaml writes the whole layout down and a test asserts the compose file matches it.


3. The local model

The local model sits on an internal network and cannot fetch its own weights — that is the property air-gap mode depends on, so it is not worked around. Pulling them is a separate step:

./deploy/bin/harness pull-model               # qwen2.5:3b, about 2 GB
./deploy/bin/harness pull-model llama3.2:1b   # or another

Weights live in their own volume, so reset does not cost a re-download. Measured on a development machine: ~400 MB on disk, ~676 MB resident, 1–2 s per call.


4. Configuration

Environment variables, read by the services. No secret is baked into an image.

Variable Purpose Default
HARNESS_MODEL_MODE stub, replay, hosted or local stub
HARNESS_AIRGAP Blocks all egress, forces the local model false
ANTHROPIC_API_KEY Only needed for hosted unset
HARNESS_LOCAL_MODEL Which local model to serve qwen2.5:3b
HARNESS_TRADECRAFT_ROOT Where tenant playbooks are mounted /tradecraft
HARNESS_PRINCIPALS The identity roster /etc/harness/principals.yaml
HARNESS_AUDIT_ENDPOINT The audit store; unset keeps chains in process set in compose
HARNESS_IT_TARGETS Targets the inventory classifies as IT set in compose
HARNESS_OT_TARGETS Targets it classifies as OT set in compose

The system runs with no credentials at all. The default stub backend is deterministic, which is what lets the whole test suite and the demonstration run offline and for free. A model key is only needed to show a hosted model being swapped for a local one.

A target in neither HARNESS_IT_TARGETS nor HARNESS_OT_TARGETS is unknown, and unknown is treated as OT. Not knowing what something is must not be safer than knowing.


5. Deploying to AWS

One instance, one region, created and destroyed with one command. The reasoning is in ADR 0013; this is the procedure.

Before the first apply

Two things that are easier to do now than to regret later.

  1. A budget. AWS → Billing → Budgets → a cost budget with an email alert. A stopped instance still bills for its disk, and an instance nobody remembers is the most common way a proof of concept becomes expensive.
  2. Bedrock model access. AWS → Bedrock → Model access, in the region you are deploying to, and request the Claude models. It is usually granted in minutes, but it is a human step and nothing here can do it for you. Without it the stack comes up and model calls fail with an access error that reads like a bug.

Credentials

With IAM Identity Center, which is the better option because nothing long-lived ends up on disk:

aws configure sso          # paste your start URL, approve in the browser
aws sts get-caller-identity

Create the instance

cd infra
tofu init
tofu plan  -var="allowed_cidr=$(curl -s ifconfig.me)/32" \
           -var="hostname=harness.example.com"
tofu apply -var="allowed_cidr=$(curl -s ifconfig.me)/32" \
           -var="hostname=harness.example.com"

Read the plan before the first apply. allowed_cidr is the only address allowed to reach SSH and HTTPS, and it refuses 0.0.0.0/0 outright.

Then point DNS at the address it prints:

harness.example.com.  A  <the public_ip output>

Configure and start

tofu -chdir=infra output -raw inventory > ansible/inventory.ini
ansible-galaxy collection install community.docker
ansible-playbook -i ansible/inventory.ini ansible/site.yml

It waits for Docker, clones the repository, builds the images on the host, fetches the local model weights, starts the stack behind a Let's Encrypt certificate, and then checks that the token issuer matches the address people will use — a mismatch there means login succeeds and every call afterwards is refused, which looks like a broken console rather than a configuration error.

The console is then at https://harness.example.com, signed into exactly as locally.

Afterwards

ssh ubuntu@<ip> 'cat /opt/harness/DEPLOYED'     # what is actually running
aws ec2 stop-instances --instance-ids <id>      # stop it between sessions
tofu -chdir=infra destroy                       # remove everything

Stop it when you are not using it. Roughly $48/month running, $3/month stopped (the disk), nothing destroyed. destroy is the real protection against a forgotten instance.

What runs differently there

Locally On AWS
Model qwen2.5:3b in a container Claude on Bedrock, in your own account, via an instance role — no API key anywhere
Front door :8080 plain HTTP :443 with a real certificate; :80 open only for ACME
Identity Keycloak on localhost Keycloak on the hostname, with the issuer following it
Air-gap Still works Still works — Bedrock is off-host, so air-gap forces the local model

6. Versioning

Calendar versioning: YYYY.M.D.N — year, month, day, and a counter that increments once per push. 2026.10.15.3 is the third thing pushed on 15 October 2026.

Unpadded on purpose: PEP 440 strips leading zeros, so a padded 2026.01.05.1 would be reported by pip and uv as 2026.1.5.1, and the file would disagree with the tooling.

./deploy/bin/version show     # what the repository says
./deploy/bin/version next     # what a bump would produce
./deploy/bin/version bump     # write it everywhere and stage the changes
./deploy/bin/version check    # fail if any manifest disagrees

Run bump before committing. It updates VERSION, every service manifest and the console's package.json together, and stages them. CI then tags v<version> on main, so a tag always records something that actually reached the remote.

CI fails the push if any manifest disagrees with VERSION, so a forgotten bump is caught rather than silently shipping a stale number. The running version is on the console's status bar, in GET /health, and in GET /v1/status.

This is not a compatibility signal

A calendar version says when a build was made, not what changed. The playbook schema keeps its own semantic schema_version precisely because customers write files against it and need to know whether theirs will still load. The two are different things and must not be conflated.


7. Troubleshooting

Symptom Cause
uv sync picks the wrong Python .python-version pins 3.12. Run uv python install 3.12.
ModuleNotFoundError for a service package Run uv sync again — packages install in editable mode.
Acceptance tests all skip The stack is not running. ./deploy/bin/harness up.
The local model answers unreachable Weights were never pulled, or reset -v removed them. ./deploy/bin/harness pull-model.
Boundary check fails after editing a playbook Expected. See below.
CI fails on formatting but local passes uv run ruff format .

"The boundary check failed and I only edited a playbook"

The check derives its forbidden words from the committed playbooks — organisation ids, playbook ids, rule ids, context set names. Name a rule something the platform's own source also uses and that word becomes forbidden everywhere, so an unrelated file starts failing.

A dedicated test catches this in the playbook change itself and names the collision. The fix is to rename the rule, not to add an exception.