Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ App 15368 is GitHub Actions, the app the current checks are pinned to.
| main.yml | `run-ci` | ubuntu-latest, Python 3.12 | ubuntu-latest, Python 3.12 | ubuntu-latest and macos-latest, Python 3.10 to 3.12 |
| main.yml | `unsupported-python-guard` | no | no | Python 3.13 and 3.14 |
| packages.yml | `types`, `agent` | Python 3.12 | Python 3.12 | Python 3.10 and 3.12 |
| packages.yml | `flow-lint`, `flow-critical` | yes | yes | yes |
| packages.yml | `flow-lint`, `flow-critical`, `flow-onprem-offline` | yes | yes | yes |
| ci.yml | `smoke`, `ci` | yes | yes | no |
| packages.yml | `flow-unit` | 3 shards packed by measured time, fast lane (without `nightly_only.txt`) | no | 2 shards |
| packages.yml | `flow-native-census` | no | no | Windows and macOS |
Expand Down
59 changes: 58 additions & 1 deletion .github/workflows/packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -604,13 +604,28 @@ jobs:
tests/test_admitted_composition_runtime.py \
tests/test_agent_guard.py \
--basetemp=runs/ci-critical --junitxml=runs/critical-junit.xml
- name: Golden free path with egress blocked (loopback-only network namespace)
# Direct IP sockets stay in a loopback-only namespace. The tutorial
# must still reach VERIFIED with no Python-hook refusal recorded.
# A native-child control measures the hook observation boundary.
run: |
sudo -n env "PATH=$PATH" "HOME=$HOME" \
deploy/on-prem/bin/run-egress-blocked.sh \
python -m pytest -v tests/e2e/test_egress_blocked_e2e.py \
--basetemp=runs/ci-onprem-egress --junitxml=runs/onprem-egress-junit.xml
- name: Network namespace setup and owned Unix broker boundary
env:
OPENADAPT_TEST_NETNS: "1"
run: |
python -m pytest -v tests/e2e/test_egress_namespace_e2e.py \
--basetemp=runs/ci-onprem-boundary --junitxml=runs/onprem-boundary-junit.xml
- name: Fail if any critical test was skipped or none ran
run: |
python - <<'PY'
import sys
import xml.etree.ElementTree as ET
bad = False
for name in ("critical-e2e", "critical"):
for name in ("critical-e2e", "critical", "onprem-egress", "onprem-boundary"):
root = ET.parse(f"runs/{name}-junit.xml").getroot()
suites = [root] if root.tag == "testsuite" else list(root.iter("testsuite"))
tests = sum(int(s.get("tests", 0)) for s in suites)
Expand All @@ -621,6 +636,46 @@ jobs:
sys.exit(1 if bad else 0)
PY

# The on-prem install path for real: build two signed offline releases of
# openadapt-flow[privacy] from uv.lock on this connected runner, then, as
# root in a network namespace with no route out, install the first from its
# wheelhouse, refuse a tampered second, update to the signed second, roll
# back, and run the air-gap attestation (egress probe, audit chain). pip has
# no index and the machine has no network, so a wheel missing from the
# archive fails the install. deploy/on-prem/bin/test-offline-install.sh.
# First, bin/test-update.sh: the update state machine's refusal, migration
# and recovery paths with a fake venv build, which nothing else runs.
flow-onprem-offline:
name: flow on-prem offline install, update and rollback (egress blocked)
needs: scope
if: github.event.pull_request.draft != true && (needs.scope.outputs.flow == 'true')
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: packages/flow
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
with:
enable-cache: false
- name: Signed update and rollback state machine (hermetic, fake build)
run: bash deploy/on-prem/bin/test-update.sh
- name: Build two signed offline releases from the lock (connected)
run: bash deploy/on-prem/bin/test-offline-install.sh stage "$RUNNER_TEMP/offline"
- name: Install, update and roll back with egress blocked
# env -u SUDO_UID keeps the command root inside the namespace:
# install.sh owns the release tree as root.
run: >-
sudo env -u SUDO_UID -u SUDO_GID "PATH=$PATH"
deploy/on-prem/bin/run-egress-blocked.sh
bash deploy/on-prem/bin/test-offline-install.sh verify "$RUNNER_TEMP/offline"

# The agent-framework adapters (openadapt_flow.integrations.agents): each
# framework's own agent loop with a scripted model, writing through a real
# HTTP app, from the lock with every framework extra (CrewAI pinned beside
Expand Down Expand Up @@ -1416,6 +1471,7 @@ jobs:
- flow-lint
- flow-unit
- flow-critical
- flow-onprem-offline
- flow-agent-frameworks
- flow-native-census
- flow-linux-atspi
Expand Down Expand Up @@ -1469,6 +1525,7 @@ jobs:
"flow-lint": "flow",
"flow-unit": "flow",
"flow-critical": "flow",
"flow-onprem-offline": "flow",
"flow-agent-frameworks": "flow",
"flow-native-census": "flow",
"flow-linux-atspi": "flow",
Expand Down
26 changes: 26 additions & 0 deletions packages/flow/deploy/on-prem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ install story) and **[COMPLIANCE.md](COMPLIANCE.md)** (posture + data-flow).
| `bin/run-queue.sh` | Local, directory-based run queue. Claims jobs, runs `openadapt-flow run`, records outcomes, files done/failed. Fail-closed on `SCRUB!=on`. | REAL (thin wrapper over the shipped CLI) |
| `bin/audit-log.sh` | Append-only, hash-chained, PHI-free audit writer (run + release-lifecycle events). | REAL |
| `bin/verify-airgap.sh` | Asserts no outbound path: config/env/deployment URL scan, optional active egress probe, optional audit-chain walk. | REAL (best-effort; firewall is primary) |
| `bin/run-egress-blocked.sh` | Runs a command in a loopback-only Linux network namespace, refusing to start unless no other interface or route exists. CI runs the golden free path through it (`tests/e2e/test_egress_blocked_e2e.py`): VERIFIED, zero model calls, and no refusal recorded by the strict Python audit hook. | REAL (Linux, root) |
| `bin/test-update.sh` | Hermetic offline proof of signed update, refusal paths, legacy migration, data preservation, rollback/recovery, locks, and audit-chain serialization. | REAL (43 baseline assertions; 45 when local GPG key generation is available) |
| `bin/build-release.sh` | Builds a release archive on a connected host from `uv.lock`: the exact pinned wheels, hash-checked, plus the `openadapt-flow` wheel, a `.sha256`, and an openssl `.sig` when given the key. | REAL |
| `bin/test-offline-install.sh` | Builds two signed releases, then as root with egress blocked installs the real engine from the first, refuses a tampered second, updates to the signed second, rolls back, and runs the air-gap attestation. CI runs it with `bin/test-update.sh` (job `flow-onprem-offline`). | REAL (Linux, root) |
| `UPDATE.md` | Offline update + rollback runbook: build a signed release, apply it atomically, revert instantly. | REAL (doc) |
| `systemd/openadapt-flow-runner.service` + `.path` | Event-driven runner unit with kernel-level egress denial (`IPAddressDeny=any`). | REAL (Linux/systemd) |
| `docker-compose.yml` + `Dockerfile` | Container alternative on an `internal:true` (egress-blocked) network; optional LAN-only VLM appliance profile. | REAL topology; STUB image build (needs offline `./wheels`) |
Expand Down Expand Up @@ -47,6 +50,29 @@ PHI-free record to `audit/audit.log`, and files the job under `jobs/done` (exit
OPENADAPT_FLOW_SCRUB=on ./bin/verify-airgap.sh --config onprem.yaml --probe --audit
```

## Golden run with direct IP networking restricted

On Linux with the engine installed, run the free path in a new network
namespace containing only loopback:

```bash
sudo -E env "PATH=$PATH" "HOME=$HOME" deploy/on-prem/bin/run-egress-blocked.sh \
python -m pytest -v tests/e2e/test_egress_blocked_e2e.py
```

The wrapper starts only after all interface and route inspections succeed
and confirm exactly loopback with no routes. The tests check
direct IP refusal independently, then require the tutorial to reach VERIFIED
with zero model calls and a receipt. CI runs this on every flow change.

The restriction covers direct IP sockets in the command and its children.
Filesystem access stays shared: pathname Unix sockets can reach a process
outside the namespace that forwards traffic. Isolate such brokers separately
when enforcing a deployment boundary. An empty strict-mode refusal log means
the Python hook recorded no refusals; it does not observe native sockets or
children that never install the hook. Native-child and owned Unix-relay
controls exercise these boundaries in CI.

## Update & rollback

Patch the box offline; revert instantly if a release misbehaves. Full runbook in
Expand Down
18 changes: 17 additions & 1 deletion packages/flow/deploy/on-prem/UPDATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,23 @@ all survive across any number of updates.
## Build a signed release bundle (off-site, on a connected host)

The bundle is a tarball containing a `VERSION` file and an offline pip
wheelhouse, plus a required detached signature and an optional checksum:
wheelhouse, plus a required detached signature and an optional checksum.

`bin/build-release.sh` builds it from a source checkout: it downloads exactly
the wheels `packages/flow/uv.lock` pins, checks each against the lock's hashes,
adds the `openadapt-flow` wheel, and writes the archive, its `.sha256`, and an
openssl `.sig` when given the signing key. Run it on a host with the same OS,
CPU architecture and Python minor version as the clinic box:

```bash
deploy/on-prem/bin/build-release.sh --out dist/ --sign-key vendor_priv.pem
```

CI builds two releases this way and installs, updates and rolls back the real
engine from them with the network cut off (`bin/test-offline-install.sh`).

To assemble one by hand instead (this resolves the newest matching
dependencies rather than the locked ones):

```bash
# 1. Assemble the payload
Expand Down
90 changes: 90 additions & 0 deletions packages/flow/deploy/on-prem/bin/build-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# build-release.sh — build an offline release archive from the lock file.
#
# Run on a CONNECTED build host with the same OS, CPU architecture and Python
# minor version as the air-gapped target. Produces, in --out:
#
# release-<version>.tar.gz VERSION, wheels/, requirements.lock.txt
# release-<version>.tar.gz.sha256 checksum (corruption check only)
# release-<version>.tar.gz.sig openssl signature, when --sign-key is given
#
# Every third-party wheel is the exact version pinned in packages/flow/uv.lock
# and is checked against the lock's hashes while downloading (pip
# --require-hashes), so the archive holds what CI tested and nothing newer.
# The openadapt-flow wheel is built from this checkout unless --flow-wheel
# names one. Stage the archive with `install.sh --update` (see UPDATE.md), or
# extract it and pass release/wheels to `install.sh --wheelhouse` for a first
# install.
#
# Usage:
# build-release.sh --out DIR [--flow-wheel FILE] [--sign-key PRIVATE.pem]
# [--python PYTHON]
#
# Needs uv and the target Python (default python3). Exit 0 on success.

set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FLOW_DIR="$(cd "$HERE/../../.." && pwd)"
OUT=""
FLOW_WHEEL=""
SIGN_KEY=""
PYTHON="python3"

while [[ $# -gt 0 ]]; do
case "$1" in
--out) OUT="${2:-}"; shift 2 ;;
--flow-wheel) FLOW_WHEEL="${2:-}"; shift 2 ;;
--sign-key) SIGN_KEY="${2:-}"; shift 2 ;;
--python) PYTHON="${2:-}"; shift 2 ;;
-h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "build-release.sh: unknown arg $1" >&2; exit 2 ;;
esac
done
[[ -n "$OUT" ]] || { echo "build-release.sh: --out DIR is required" >&2; exit 2; }
command -v uv >/dev/null || { echo "build-release.sh: uv is required" >&2; exit 2; }
[[ -f "$FLOW_DIR/uv.lock" ]] || { echo "build-release.sh: no uv.lock in $FLOW_DIR" >&2; exit 2; }
if [[ -n "$SIGN_KEY" && ! -f "$SIGN_KEY" ]]; then
echo "build-release.sh: signing key $SIGN_KEY not found" >&2; exit 2
fi

# shellcheck source=bin/lib-release.sh
source "$HERE/lib-release.sh"

mkdir -p "$OUT"
OUT="$(cd "$OUT" && pwd)"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/oa-build-release.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/release/wheels"

if [[ -z "$FLOW_WHEEL" ]]; then
echo "== building the openadapt-flow wheel from $FLOW_DIR =="
(cd "$FLOW_DIR" && uv build --wheel --out-dir "$WORK/flow" >/dev/null)
FLOW_WHEEL="$(find "$WORK/flow" -maxdepth 1 -name 'openadapt_flow-*.whl' -print)"
fi
[[ -f "$FLOW_WHEEL" ]] || { echo "build-release.sh: flow wheel not found" >&2; exit 2; }
cp "$FLOW_WHEEL" "$WORK/release/wheels/"
VERSION="$(rel_wheelhouse_version "$WORK/release/wheels")"
rel_validate_version "$VERSION"

echo "== exporting the locked dependencies of openadapt-flow[privacy] $VERSION =="
(cd "$FLOW_DIR" && uv export --frozen --no-dev --extra privacy --no-emit-project \
--format requirements-txt --no-header -o "$WORK/release/requirements.lock.txt" >/dev/null)

echo "== downloading the locked wheels for $("$PYTHON" -c 'import platform, sys; print(sys.platform, platform.machine(), "py%d.%d" % sys.version_info[:2])') =="
"$PYTHON" -m pip download --quiet --disable-pip-version-check \
--only-binary=:all: --no-deps --require-hashes \
-r "$WORK/release/requirements.lock.txt" -d "$WORK/release/wheels"

printf '%s\n' "$VERSION" > "$WORK/release/VERSION"
ARCHIVE="$OUT/release-$VERSION.tar.gz"
COPYFILE_DISABLE=1 tar czf "$ARCHIVE" -C "$WORK" release
sha256_of "$ARCHIVE" > "$ARCHIVE.sha256"
if [[ -n "$SIGN_KEY" ]]; then
openssl dgst -sha256 -sign "$SIGN_KEY" -out "$ARCHIVE.sig" "$ARCHIVE"
fi

count="$(find "$WORK/release/wheels" -name '*.whl' | wc -l | tr -d ' ')"
echo "built $ARCHIVE ($count wheels)"
[[ -n "$SIGN_KEY" ]] && echo "signed $ARCHIVE.sig" || echo "unsigned: sign it before staging (UPDATE.md)"
78 changes: 78 additions & 0 deletions packages/flow/deploy/on-prem/bin/run-egress-blocked.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# run-egress-blocked.sh — run a command with no route off this machine.
#
# Linux only. Starts the command in a new network namespace containing only
# loopback. Direct IP connections from the command and its children cannot
# reach networks outside that namespace. Services started inside it can use
# loopback to communicate with each other.
#
# Filesystem access is unchanged: pathname Unix sockets can reach processes
# outside the namespace, including brokers that forward network traffic. This
# is a direct-IP restriction, not a filesystem or untrusted-code sandbox.
#
# Before running the command, all interface and route inspections must succeed
# and confirm only loopback and no routes. The command runs as the invoking
# sudo user, with OPENADAPT_EGRESS_BLOCKED=netns as a test-location hint. Tests
# independently check the restriction rather than trusting that variable.
#
# Usage (as root). Pass every needed variable on the env command line; do not
# rely on sudo -E (GitHub-hosted runners ignore it):
# sudo -n env "PATH=$PATH" "HOME=$HOME" KEY=VAL \
# deploy/on-prem/bin/run-egress-blocked.sh CMD [ARGS...]
#
# Exit code: the command's, or 2 when the namespace cannot be set up.

set -euo pipefail

if [[ $# -eq 0 || "$1" == "-h" || "$1" == "--help" ]]; then
grep '^#' "$0" | sed 's/^# \{0,1\}//'
[[ $# -eq 0 ]] && exit 2 || exit 0
fi
if [[ "$(uname -s)" != "Linux" ]]; then
echo "run-egress-blocked.sh: needs Linux network namespaces" >&2
exit 2
fi
if [[ "$(id -u)" -ne 0 ]]; then
echo "run-egress-blocked.sh: run as root (sudo); the command runs as the invoking user" >&2
exit 2
fi
for tool in unshare ip setpriv; do
command -v "$tool" >/dev/null || { echo "run-egress-blocked.sh: missing $tool" >&2; exit 2; }
done

RUN_UID="${SUDO_UID:-0}"
RUN_GID="${SUDO_GID:-0}"
export RUN_UID RUN_GID

# The inner script expands its own variables, inside the namespace.
# shellcheck disable=SC2016
exec unshare --net -- bash -euo pipefail -c '
ip link set lo up
if ! links="$(ip -o link show)"; then
echo "run-egress-blocked.sh: cannot inspect interfaces" >&2
exit 2
fi
interfaces="$(awk -F": " "{print \$2}" <<< "$links")"
if [[ "$interfaces" != lo ]]; then
echo "run-egress-blocked.sh: expected only loopback, found: $interfaces" >&2
exit 2
fi
if ! routes4="$(ip route show)"; then
echo "run-egress-blocked.sh: cannot inspect IPv4 routes" >&2
exit 2
fi
if ! routes6="$(ip -6 route show)"; then
echo "run-egress-blocked.sh: cannot inspect IPv6 routes" >&2
exit 2
fi
if [[ -n "$routes4" || -n "$routes6" ]]; then
echo "run-egress-blocked.sh: unexpected routes: $routes4 $routes6" >&2
exit 2
fi
export OPENADAPT_EGRESS_BLOCKED=netns
if [[ "$RUN_UID" == 0 ]]; then
exec "$@"
fi
exec setpriv --reuid="$RUN_UID" --regid="$RUN_GID" --init-groups -- "$@"
' run-egress-blocked "$@"
Loading
Loading