Skip to content

Fail-closed security posture, hardware gates in code, auto-merge fixes, appliance preflight, reboot setting - #48

Merged
github-actions[bot] merged 47 commits into
masterfrom
integ/round4
Oct 11, 2026
Merged

github-actions[bot] merged 47 commits into
masterfrom
integ/round4

Conversation

@Amantux

@Amantux Amantux commented Oct 11, 2026

Copy link
Copy Markdown
Owner

Summary

Round 4 — five owner decisions implemented. Verified locally: sharded gate 3007 passed / 0 failed / 14 skipped, hardware (non-real) + i3 parity 123 passed, safety/contract 413 passed, ruff 0 / format clean / mypy strict 0 (180 files), actionlint + zizmor clean, operability gate scripts rc=0. Adversarial review ran twice; blocker and all should-fixes closed, every new guard mutation-checked.

1. Auto-merge (both gaps)

auto-merge-trusted.yml now triggers on CI and operability, verifies every required check on the head SHA with a pure decide(), merges with a sha pin, then dispatches CI + operability on master so full-suite runs for every merge (dispatch is the one event GITHUB_TOKEN can trigger — no new secret). Copilot-authored PRs additionally require an APPROVED review from the owner on the head SHA. cancel-in-progress only for PR runs. Proven with a Node harness (18 scenarios, mutation-checked).

2. Reboot persistence as a setting

tests/hardware/test_reboot_persistence.py + a destructive-lane step gated on repo var HARDWARE_REBOOT_TESTS=true (exports OPENBLADE_REBOOT_TESTS=enabled). Quiescence precondition, bounded waits, read-only guard bypassed for exactly POST /aml/system/reboot, runs after the recovery report and after-snapshot, TLS verification pinned on.

3. Appliance day-one

tools/hardware/preflight.py (read-only: env, tools, devices, sg_inq, mtx status, allowlist, appliance login/logout; fails if run armed), replaces the inline env checks in the three hardware lanes, make hw-preflight, runbook §0 checklist. Proven on the local mhvtl rig. Shared openblade/hardware/appliance_client.py.

4. Safest/most secure

  • Production is fail-closed: refuses to start without OPENBLADE_API_TOKEN, with default/short (<12) admin or service passwords, or * in CORS. Dev prints one loud banner. Secrets never echoed (URLs redacted).
  • POST /api/test-runner/run is dev-only + loopback-targeted. Compose binds 127.0.0.1 by default; LAN exposure only via deploy/lan/docker-compose.lan.yml (${VAR:?} secrets, production).
  • Hardware gates in code: format safety token consumed atomically before the job (single-use); scratch allowlist enforced by FormatService on a real backend (empty = refuse all); real backend requires an API token; ingest never auto-formats on a real backend; real lanes mint a masked per-run token.
  • Capacity: pool_id must match the plan (409), share-push books before registering, rollback on any start failure without masking the original error.

Not done / flags

  • H2 (removing the vacuous operator_acknowledgment constant) was blocked by the permission classifier as a security-test removal; it stays, documented as not a control.
  • Simulator ingest still self-mints its format token (real backend refuses); routing it through FormatService is a follow-up.
  • Cross-process single-use token test: SQLite's write lock already serialises the DELETE, so removing BEGIN IMMEDIATE does not break the test — recorded honestly.
  • 12-char password minimum is a new production deploy blocker.

🤖 Generated with Claude Code

https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD

Amantux and others added 30 commits October 11, 2026 06:09
…es after merge

Trigger on workflow_run of both CI and operability. Before merging, read the
required contexts from branch protection (fallback ["ci-gate",
"operability-gate"] on 403/404, which GITHUB_TOKEN normally gets) and merge
only when every one is "success" on the PR head SHA (check runs, newest per
name, plus combined commit statuses). Pending -> notice and exit; the other
workflow's completion is the retry (PR #47 merged-while-in-progress failure).
Failed -> notice and exit. The merge pins sha=head to avoid racing a push.

After a successful merge, dispatch ci.yml and operability.yml on master: a
GITHUB_TOKEN merge emits no push run, so full-suite never ran on the merge
commit (PR #46). Job grants checks:read, statuses:read, actions:write
per-job; workflow-level stays contents:read.

decide() is a pure function with three worked examples in comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…merge dispatch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
delete_safety_token now runs DELETE under BEGIN IMMEDIATE and returns
whether this caller consumed the token; FormatService.confirm consumes
first and refuses on False, so concurrent confirms with one token run
the job once and a failed job does not re-arm the token.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Production refuses to start (typed ConfigurationError, names vars, never
values) when OPENBLADE_API_TOKEN/_FILE is unset, CORS origins contain '*',
or the AML admin/service password or service token is the default.
Elsewhere one multi-line banner lists every open gate. validate_config
reports the same set, so scripts/validate_config.py stays the gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Reboots via POST /aml/system/reboot only when OPENBLADE_REBOOT_TESTS=enabled
on a real-hardware run; otherwise skips. Refuses to reboot unless the library
is quiescent (no live leases, no drive loaded or mounted). A dropped
connection after the POST counts as accepted; login_ok treats
HTTPException as down; polls every 2s within bounded 300s/600s waits.
Does not call recover_after_restart (no report-only mode); the lane's
recovery-report step is the recovery assertion.

A no-hardware unit test pins that RebootClient.request keeps the read-only
guard (refuses POST /aml/media/move and POST /aml/system/reboot); mutation-
checked: widening request makes it fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Runs tests/hardware/test_reboot_persistence.py only when repo var
HARDWARE_REBOOT_TESTS == 'true' and every earlier step succeeded
(success(), never always()), exporting OPENBLADE_REBOOT_TESTS=enabled.
Placed after the recovery report and the lane's after-snapshot (so the lane
diff is not polluted by the reboot) and before the snapshot upload; same
environment approval. timeout-minutes: 20; TLS verification pinned on
(OPENBLADE_APPLIANCE_INSECURE=false); -p no:cacheprovider --tb=short.
An unset var yields a visible ::notice:: instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
python -m tools.hardware.preflight --lane readonly|destructive [--json]:
env, tools, device/inquiry/serial/by-id/serial-map, mtx status drive count,
SACRIFICIAL_BARCODES inventory + not-loaded, appliance login/logout,
armed-env warning. Route tools/hardware/* to the unit category.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Mutation-checked: disabling the duplicate-serial and allowlist-loaded
guards each fails its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
POST /api/test-runner/run returns 404 unless OPENBLADE_ENABLE_TEST_RUNNER=true
and OPENBLADE_ENV != production, and rejects any i3_aml_url whose host is not
localhost/127.0.0.1/::1/host.docker.internal (SSRF via spawned pytest env).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
§2 gains HARDWARE_REBOOT_TESTS; §5 "Reboot persistence" describes the
two-level opt-in (repo var + OPENBLADE_REBOOT_TESTS), the success()-only
ordering after the lane's after-snapshot, the quiescence precondition, the
bounded waits, the pinned TLS verification, and why the test does not call
recover_after_restart. The hard power-cycle stays a manual fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
All three rig lanes call python -m tools.hardware.preflight --json, print
the report on failure and upload preflight.json. The job-level
OPENBLADE_REAL_HARDWARE_ENABLED is blanked for that step so the preflight
never runs armed. HARDWARE_RIG_AVAILABLE gating is untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ail on dispatch error

- Drop the job-level conclusion=='success' filter: decide() already enforces
  success per required check, and skipping non-success completions could strand
  a PR that was waiting on that workflow.
- Count only check runs whose app is github-actions, so a look-alike gate
  name from another app cannot mask a real failure.
- core.setFailed when a post-merge dispatch fails, so a missing full-suite
  run on master is visible instead of a warning on a green job.
- Per-head-SHA concurrency group (no cancel) so near-simultaneous CI and
  operability completions do not race two merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…tch accurately

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…rride

docker-compose.yml publishes 8000/8010/5173/5175 on 127.0.0.1 only.
deploy/lan/docker-compose.lan.yml (make lan-up/lan-down) publishes api/web/
web-flask on ${OPENBLADE_BIND_IP:-0.0.0.0}:8090/8091/8092 via ports !override,
OPENBLADE_ENV=production, and requires every secret via ${VAR:?}.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…r cancel each other

Auto-merge now dispatches CI and operability on master after every merge. With
cancel-in-progress unconditional, two quick merges cancelled the first merge's
full-suite, leaving a commit on master with no post-merge result.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…N exposure

validate_config warns (insecure_session_cookies) when production sets
OPENBLADE_WEB_SECURE_COOKIES off. Tests pin the existing Flask refusal
without FLASK_SECRET_KEY and Secure-by-default cookies in production.
docs/deployment.md gains 'Exposing on a LAN'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…oked pool

start_ingest booked capacity against plan.pool but created the dataset in
request.pool_id, so a mismatch wrote to an unbudgeted pool and a pool-less
preview could start into any pool with nothing booked. Now 409 on mismatch
and the job is created in plan.pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…l backend

Barcode-list parsing moves to openblade/domain/media_allowlist.py (the CI
tool now imports it). On a REAL backend, dry_run and confirm refuse any
barcode not in OPENBLADE_SCRATCH_BARCODES; an empty allowlist refuses
everything. Simulator behaviour is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
A refused booking (409) used to leave the plan orphaned in the in-memory
registry. Book first, register after, and drop the plan via the new
unregister_archive_plan if job creation fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
load_config raises RealHardwareDisabledError when OPENBLADE_BACKEND=real
and OPENBLADE_REAL_HARDWARE_ENABLED=true but no OPENBLADE_API_TOKEN(_FILE)
is set, and validate_config reports it as BLOCKING. OPENBLADE_BACKEND=
simulator (the Dockerfile value) now maps to mock explicitly, and any
other unknown value logs a warning instead of silently mocking.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Both start paths (start_ingest and share-push) now release the plan's
booking and unregister the plan if anything after book_plan_capacity raises,
then re-raise the original error. release_plan_capacity logs and swallows a
failed release so it can never mask the start failure (booking expires by TTL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Amantux and others added 17 commits October 11, 2026 06:30
Real mode now refuses to start without OPENBLADE_API_TOKEN. Each rig lane
mints one per run (masked) before preflight/pytest; mhvtl threads it through
the explicit sudo env lists and env.sh emits it (keeping the caller's).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ane and preflight

Move ApplianceClient, ApplianceResponse, ReadOnlyViolation and the request
guard into openblade/hardware/appliance_client.py. The read-only conftest
re-exports them (fixtures and RebootClient unchanged); preflight's duplicate
_UrllibAppliance becomes a thin probe over the shared client, keeping its
scheme check and curated unreachable error. Every preflight request now also
passes the read-only guard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…indings

The startup banner logs WARNING findings verbatim; a postgres URL with a
password leaked into logs. Add redact_url() (scheme+host+path, creds ***).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
_ensure_formatted minted its own SafetyToken and called ltfs.format, so on
OPENBLADE_BACKEND=real ingest could mkltfs any unformatted tape, bypassing
the scratch allowlist and the persisted single-use token. On a real backend
(same detection FormatService uses, now real_backend_from_config) it now
raises FormatRequiresConfirmationError naming the barcode and the two-phase
/ltfs/format flow; run() marks the job FAILED and nothing is written.
Simulator keeps the direct auto-format.

Mutation-checked: dropping the real-backend check fails the refusal test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
is_scratch_barcode compared strip().upper() but dry_run/confirm used the raw
barcode, so " abc123" passed the allowlist and then failed as unknown.
dry_run and confirm now normalise (strip().upper()) before the allowlist,
inventory lookup, token and catalog. Mutation-checked: an identity
normalize_barcode fails the new test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
check_armed only WARNed when OPENBLADE_REAL_HARDWARE_ENABLED was set,
although preflight is read-only and must never run armed. It now FAILs,
so main() returns EXIT_FAIL. The three lane preflight steps already blank
the flag (hardware-nightly-readonly.yml:88, hardware-library-smoke.yml:89,
hardware-destructive.yml:102), so lanes are unaffected. Mutation-checked:
reverting to WARN fails both armed tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
password_problem() strips whitespace, enforces 12 chars and rejects known
defaults; shared by the production BLOCKING gate, the dev WARNING, and the
aml_state getters (production raise), so a ' ' value no longer passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…ted authors

Copilot PR content is steered by issue text; it now merges only when
Amantux's latest substantive review is APPROVED on pr.head.sha (pure
reviewGate(), listReviews). Amantux still merges directly. README updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
Real mode refuses to load without OPENBLADE_API_TOKEN(_FILE); the five tests
that arm real hardware through monkeypatched env now set a token, and the
clean-env fixture clears it so default assertions stay meaningful.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…propagates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
…approval for Copilot PRs, token test, rollback isolation
Production now refuses to promote with the native API open (native_api_open),
which is exactly what the gate is for; the positive control must therefore set
OPENBLADE_API_TOKEN like any real production deployment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uyKrkqBY5KxLuSjotaVGD
@github-actions
github-actions Bot merged commit 0dbc843 into master Oct 11, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant