From a95cb94896b4ffa0edf2a0691555b2b7e0b9169d Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 2 Apr 2026 09:06:50 -0300 Subject: [PATCH 1/8] Add executable channels and recovery checks to the security gate This hardens launch-readiness evidence by running two semantic checks in the existing security workflow: channels contract parity and a disposable backup/verify/restore smoke cycle. The helper CLI and tests were extended to keep the workflow thin and to lock behavior with unit+contract coverage. Constraint: Workflow contract forbids shell blobs and heredoc Python in YAML Rejected: Add checks as inline multi-command shell blocks | violates workflow-thin contract and is harder to test Rejected: Keep checks as documentation-only evidence | does not produce executable CI proof Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep new security workflow checks routed through tests/scripts/security_workflow.py rather than embedding ad-hoc shell logic Tested: ruff targeted checks; pyright; mypy; pytest tests/suites/unit/ci/test_security_workflow.py tests/suites/contracts/repo/test_ci_workflow_surfaces.py Not-tested: Full GitHub Actions matrix execution (deferred to PR checks) --- .github/workflows/security.yml | 4 + platform/docs/CI_AND_SECURITY.md | 5 + tests/scripts/security_workflow.py | 32 +++++ .../repo/test_ci_workflow_surfaces.py | 2 + .../suites/unit/ci/test_security_workflow.py | 116 ++++++++++++++++++ tests/utils/helpers/_ci_workflows/security.py | 45 +++++++ tests/utils/helpers/ci_workflows.py | 4 + 7 files changed, 208 insertions(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 200608d5..4de3e043 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -45,6 +45,10 @@ jobs: - name: Install shellcheck run: sudo apt-get update && sudo apt-get install --yes shellcheck - run: uv sync --locked + - name: Verify channels rollout contract + run: python3 ./tests/scripts/security_workflow.py verify-channels-contract --repo-root . + - name: Run recovery backup/restore smoke + run: python3 ./tests/scripts/security_workflow.py run-recovery-smoke --tmp-root "${RUNNER_TEMP}" - name: Run repository quality gate run: uv run python -m clawops supply-chain --repo-root . quality-gate - name: Coverage summary diff --git a/platform/docs/CI_AND_SECURITY.md b/platform/docs/CI_AND_SECURITY.md index 61fc0039..0256d121 100644 --- a/platform/docs/CI_AND_SECURITY.md +++ b/platform/docs/CI_AND_SECURITY.md @@ -112,6 +112,11 @@ raw tar extraction, traversal-prone archive-member joins, `subprocess` `shell=True`, and unsafe deserialization helpers. - `.github/workflows/security.yml` verifies the pinned `gitleaks` and `syft` tarball SHA-256 digests before extracting the binaries through the dedicated helper script. +- That same security lane now executes two operational smoke checks through +`tests/scripts/security_workflow.py`: channel rollout contract parity +(`verify-channels-contract`) plus a disposable backup/verify/restore cycle +(`run-recovery-smoke`) so launch-critical channel and recovery paths produce +executable CI evidence. - `.github/workflows/release.yml` now blocks publication on three repo-controlled prerequisites: the centralized release quality gate, the reusable fresh-host acceptance workflow, and the reusable memory-plugin verification workflow. It diff --git a/tests/scripts/security_workflow.py b/tests/scripts/security_workflow.py index 4296c01e..98952337 100644 --- a/tests/scripts/security_workflow.py +++ b/tests/scripts/security_workflow.py @@ -19,6 +19,8 @@ enforce_coverage_thresholds, install_gitleaks, install_syft, + run_recovery_smoke, + verify_channels_contract, write_empty_sarif, ) @@ -63,6 +65,26 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: default="https://github.com/jsugg/strongclaw", ) + channels_parser = subparsers.add_parser( + "verify-channels-contract", + help="Validate shipped channels/docs/allowlist parity in one semantic command.", + ) + channels_parser.add_argument( + "--repo-root", + type=Path, + default=REPO_ROOT, + ) + + recovery_parser = subparsers.add_parser( + "run-recovery-smoke", + help="Exercise backup-create, backup-verify, and restore against a disposable home.", + ) + recovery_parser.add_argument( + "--tmp-root", + type=Path, + required=True, + ) + return parser.parse_args(argv) @@ -109,6 +131,16 @@ def main(argv: list[str] | None = None) -> int: information_uri=str(args.information_uri), ) return 0 + if args.command == "verify-channels-contract": + verify_channels_contract( + repo_root=Path(args.repo_root).expanduser().resolve(), + ) + return 0 + if args.command == "run-recovery-smoke": + run_recovery_smoke( + tmp_root=Path(args.tmp_root).expanduser().resolve(), + ) + return 0 except CiWorkflowError as exc: print(f"security-workflow error: {exc}", file=sys.stderr) return 1 diff --git a/tests/suites/contracts/repo/test_ci_workflow_surfaces.py b/tests/suites/contracts/repo/test_ci_workflow_surfaces.py index 66e099c1..23738e91 100644 --- a/tests/suites/contracts/repo/test_ci_workflow_surfaces.py +++ b/tests/suites/contracts/repo/test_ci_workflow_surfaces.py @@ -337,6 +337,8 @@ def test_remaining_workflow_logic_routes_through_semantic_scripts() -> None: assert "./tests/scripts/memory_plugin_verification.py run-vendored-host-checks" in memory_plugin assert "./tests/scripts/memory_plugin_verification.py wait-for-qdrant" in memory_plugin assert "./tests/scripts/security_workflow.py write-coverage-summary" in security + assert "./tests/scripts/security_workflow.py verify-channels-contract --repo-root ." in security + assert "./tests/scripts/security_workflow.py run-recovery-smoke --tmp-root" in security assert "./tests/scripts/security_workflow.py install-gitleaks" in security assert "./tests/scripts/security_workflow.py install-syft" in security assert "./tests/scripts/security_workflow.py write-empty-sarif" in security diff --git a/tests/suites/unit/ci/test_security_workflow.py b/tests/suites/unit/ci/test_security_workflow.py index f3390737..d0daf6d2 100644 --- a/tests/suites/unit/ci/test_security_workflow.py +++ b/tests/suites/unit/ci/test_security_workflow.py @@ -4,6 +4,7 @@ import json from pathlib import Path +from types import SimpleNamespace import pytest @@ -143,6 +144,65 @@ def test_write_empty_sarif_writes_expected_payload(tmp_path: Path) -> None: assert payload["runs"][0]["tool"]["driver"]["informationUri"] == "https://example.test/repo" +def test_verify_channels_contract_raises_ci_error_when_report_fails( + test_context: TestContext, + tmp_path: Path, +) -> None: + """Channel contract verification should surface failed checks as CI errors.""" + + failed_report = SimpleNamespace( + ok=False, + checks=[ + SimpleNamespace(ok=True, name="ok-check", message="ok"), + SimpleNamespace(ok=False, name="channel-docs-pairing", message="drift"), + ], + ) + + test_context.patch.patch_object( + security_helpers, + "verify_channels", + return_value=failed_report, + ) + + with pytest.raises(CiWorkflowError, match="channel-docs-pairing"): + ci_workflows.verify_channels_contract(repo_root=tmp_path) + + +def test_run_recovery_smoke_executes_backup_verify_restore_flow( + test_context: TestContext, + tmp_path: Path, +) -> None: + """Recovery smoke should execute backup, verify, and restore in sequence.""" + seen_calls: list[tuple[str, Path, Path | None]] = [] + archive_path = tmp_path / "archive.tar.gz" + + def fake_create_backup(*, home_dir: Path) -> Path: + seen_calls.append(("create", home_dir, None)) + archive_path.write_text("archive", encoding="utf-8") + return archive_path + + def fake_verify_backup(target: Path, *, home_dir: Path) -> Path: + seen_calls.append(("verify", home_dir, target)) + return target + + def fake_restore_backup(target: Path, *, destination: Path, home_dir: Path) -> Path: + seen_calls.append(("restore", home_dir, target)) + marker = destination / ".openclaw" / "logs" / "smoke.log" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("restored\n", encoding="utf-8") + return destination + + test_context.patch.patch_object(security_helpers, "create_backup", new=fake_create_backup) + test_context.patch.patch_object(security_helpers, "verify_backup", new=fake_verify_backup) + test_context.patch.patch_object(security_helpers, "restore_backup", new=fake_restore_backup) + + ci_workflows.run_recovery_smoke(tmp_root=tmp_path) + + assert seen_calls[0][0] == "create" + assert seen_calls[1][0] == "verify" + assert seen_calls[2][0] == "restore" + + def test_security_workflow_main_dispatches_write_summary( test_context: TestContext, tmp_path: Path, @@ -201,3 +261,59 @@ def fake_enforce_coverage_thresholds(coverage_file: Path) -> None: assert exit_code == 0 assert seen_calls == [(tmp_path / "coverage.xml").resolve()] + + +def test_security_workflow_main_dispatches_verify_channels_contract( + test_context: TestContext, + tmp_path: Path, +) -> None: + """The CLI should dispatch channel contract verification.""" + seen_calls: list[Path] = [] + + def fake_verify_channels_contract(*, repo_root: Path) -> None: + seen_calls.append(repo_root) + + test_context.patch.patch_object( + security_workflow_script, + "verify_channels_contract", + new=fake_verify_channels_contract, + ) + + exit_code = security_workflow_script.main( + [ + "verify-channels-contract", + "--repo-root", + str(tmp_path), + ] + ) + + assert exit_code == 0 + assert seen_calls == [tmp_path.resolve()] + + +def test_security_workflow_main_dispatches_run_recovery_smoke( + test_context: TestContext, + tmp_path: Path, +) -> None: + """The CLI should dispatch recovery smoke execution.""" + seen_calls: list[Path] = [] + + def fake_run_recovery_smoke(*, tmp_root: Path) -> None: + seen_calls.append(tmp_root) + + test_context.patch.patch_object( + security_workflow_script, + "run_recovery_smoke", + new=fake_run_recovery_smoke, + ) + + exit_code = security_workflow_script.main( + [ + "run-recovery-smoke", + "--tmp-root", + str(tmp_path), + ] + ) + + assert exit_code == 0 + assert seen_calls == [tmp_path.resolve()] diff --git a/tests/utils/helpers/_ci_workflows/security.py b/tests/utils/helpers/_ci_workflows/security.py index e21ff89a..37ec8543 100644 --- a/tests/utils/helpers/_ci_workflows/security.py +++ b/tests/utils/helpers/_ci_workflows/security.py @@ -7,6 +7,8 @@ from collections.abc import Mapping from pathlib import Path +from clawops.platform_verify import verify_channels +from clawops.strongclaw_recovery import create_backup, restore_backup, verify_backup from tests.utils.helpers._ci_workflows.common import ( CiWorkflowError, append_github_path, @@ -135,3 +137,46 @@ def write_empty_sarif(output_path: Path, *, information_uri: str) -> None: } output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def verify_channels_contract(*, repo_root: Path) -> None: + """Fail when the shipped channels/doc/allowlist contract drifts.""" + resolved_root = repo_root.expanduser().resolve() + report = verify_channels( + overlay_path=resolved_root / "platform/configs/openclaw/30-channels.json5", + channels_doc_path=resolved_root / "platform/docs/CHANNELS.md", + telegram_guidance_path=resolved_root / "platform/docs/channels/telegram.md", + whatsapp_guidance_path=resolved_root / "platform/docs/channels/whatsapp.md", + allowlist_source_path=resolved_root / "platform/configs/source-allowlists.example.yaml", + ) + if report.ok: + return + + failed_checks = [check for check in report.checks if not check.ok] + if not failed_checks: + raise CiWorkflowError("channel contract verification failed without explicit checks") + detail = "; ".join(f"{check.name}: {check.message}" for check in failed_checks) + raise CiWorkflowError(f"channel contract drift detected: {detail}") + + +def run_recovery_smoke(*, tmp_root: Path) -> None: + """Exercise backup/verify/restore against a disposable OpenClaw home.""" + resolved_tmp_root = tmp_root.expanduser().resolve() + home_dir = resolved_tmp_root / "recovery-home" + state_dir = home_dir / ".openclaw" + marker_path = state_dir / "logs" / "smoke.log" + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.write_text("recovery smoke marker\n", encoding="utf-8") + (state_dir / "settings.json").write_text('{"ok":true}\n', encoding="utf-8") + + archive_path = create_backup(home_dir=home_dir) + verified_archive = verify_backup(archive_path, home_dir=home_dir) + + restore_destination = resolved_tmp_root / "recovery-restore" + restore_backup(verified_archive, destination=restore_destination, home_dir=home_dir) + + restored_marker = restore_destination / ".openclaw" / "logs" / "smoke.log" + if not restored_marker.exists(): + raise CiWorkflowError( + "recovery smoke failed: restored marker missing after backup/verify/restore cycle" + ) diff --git a/tests/utils/helpers/ci_workflows.py b/tests/utils/helpers/ci_workflows.py index 6ecfc7b9..9dc1d35b 100644 --- a/tests/utils/helpers/ci_workflows.py +++ b/tests/utils/helpers/ci_workflows.py @@ -26,6 +26,8 @@ enforce_coverage_thresholds, install_gitleaks, install_syft, + run_recovery_smoke, + verify_channels_contract, write_empty_sarif, ) @@ -45,8 +47,10 @@ "prepare_setup_smoke", "publish_github_release", "resolve_setup_smoke_paths", + "run_recovery_smoke", "run_clawops_memory_migration", "run_vendored_host_checks", + "verify_channels_contract", "verify_release_artifacts", "wait_for_qdrant", "write_empty_sarif", From 50385408119f290c7c8dded4340aa392974aab2e Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 2 Apr 2026 09:17:35 -0300 Subject: [PATCH 2/8] Clarify launch checklist with command-backed recovery and channel gates This converts the production readiness checklist into an explicit launch gate with concrete verification commands and an out-of-scope note for host-only doctor shortcuts. A docs parity contract test now locks those required commands so checklist drift is caught in CI. Constraint: Checklist guidance must match actual CLI command surfaces in source code Rejected: Keep checklist as high-level bullets without executable commands | too ambiguous for launch evidence collection Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep checklist command examples aligned with real CLI argument contracts (especially recovery restore positional destination) Tested: ruff check tests/suites/contracts/repo/test_docs_parity.py; pytest tests/suites/contracts/repo/test_docs_parity.py tests/suites/contracts/repo/test_ci_workflow_surfaces.py tests/suites/unit/ci/test_security_workflow.py Not-tested: Full CI matrix rerun (deferred to PR checks) --- .../docs/PRODUCTION_READINESS_CHECKLIST.md | 62 ++++++++++++++----- .../suites/contracts/repo/test_docs_parity.py | 18 ++++++ 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/platform/docs/PRODUCTION_READINESS_CHECKLIST.md b/platform/docs/PRODUCTION_READINESS_CHECKLIST.md index b0d00da2..1d8b5886 100644 --- a/platform/docs/PRODUCTION_READINESS_CHECKLIST.md +++ b/platform/docs/PRODUCTION_READINESS_CHECKLIST.md @@ -1,23 +1,51 @@ # Production Readiness Checklist -- [ ] dedicated OS user -- [ ] loopback-bound gateway -- [ ] token auth enabled +Use this checklist as the operator-facing go/no-go gate after setup succeeds and +before you treat a host as launch-ready. + +## 1. Host and gateway boundary + +- [ ] dedicated OS user owns the StrongClaw/OpenClaw runtime +- [ ] gateway stays loopback-bound and is only reached through an SSH tunnel +- [ ] token auth is enabled - [ ] `session.dmScope = per-channel-peer` -- [ ] sandbox mode `all` -- [ ] elevated exec disabled -- [ ] plugins/skills default-deny -- [ ] sidecars healthy -- [ ] `openclaw doctor` clean -- [ ] `openclaw security audit --deep` clean -- [ ] `openclaw secrets audit --check` clean -- [ ] operation journal initialized -- [ ] policy regression suite green -- [ ] backup and restore tested -- [ ] channel allowlists durable -- [ ] browser lab isolated or disabled -- [ ] if browser-lab is enabled, run `clawops verify-platform browser-lab` and confirm loopback-only bindings (`clawops baseline verify` includes browser-lab by default; use `--exclude-browser-lab` only when browser-lab is intentionally out of scope) -- [ ] remote operator access uses SSH tunnel to gateway only +- [ ] sandbox mode is `all` +- [ ] elevated exec is disabled +- [ ] plugins and skills stay default-deny until reviewed + +## 2. Release-ready verification commands + +Run these command-backed checks and keep the resulting evidence with the host +handoff or launch packet: + +- [ ] `clawops doctor` is clean +- [ ] `clawops baseline verify` is clean +- [ ] `clawops verify-platform sidecars` is clean +- [ ] `clawops verify-platform observability` is clean +- [ ] `clawops verify-platform channels` is clean when rollout includes operator channels +- [ ] `openclaw doctor` is clean +- [ ] `openclaw security audit --deep` is clean +- [ ] `openclaw secrets audit --check` is clean + +`clawops doctor-host` and `clawops doctor --skip-runtime --no-model-probe` are +useful host-only or degraded diagnostics, but they are **not** launch-ready or +release-ready substitutes for `clawops doctor`. + +## 3. Recovery and durability + +- [ ] operation journal is initialized and writable +- [ ] `clawops recovery backup-create` succeeds against the production home +- [ ] `clawops recovery backup-verify ` succeeds for a freshly created archive +- [ ] `clawops recovery restore ` has been tested on a clean destination +- [ ] backup retention is configured and reviewed +- [ ] channel allowlists are durable and versioned + +## 4. Optional-but-exposed launch surfaces + +- [ ] browser-lab is either disabled or isolated to a separate host or hardened OS user +- [ ] if browser-lab is enabled, `clawops verify-platform browser-lab` is clean and browser-lab ports remain loopback-only +- [ ] if browser-lab is intentionally out of scope for this rollout, `clawops baseline verify --exclude-browser-lab` is the documented gate result for the launch packet +- [ ] remote operator access uses an SSH tunnel to the gateway only; never tunnel browser-lab ports such as `9222` or `3128` `clawops repo doctor` remains an operator/development contract for `repo/upstream` and managed worktree flows. It is not part of the production baseline or launch diff --git a/tests/suites/contracts/repo/test_docs_parity.py b/tests/suites/contracts/repo/test_docs_parity.py index 6e92ff06..30a033c4 100644 --- a/tests/suites/contracts/repo/test_docs_parity.py +++ b/tests/suites/contracts/repo/test_docs_parity.py @@ -230,6 +230,24 @@ def test_operator_docs_surface_plugin_inventory_and_degradation_contract() -> No assert "[Plugin Inventory](./PLUGIN_INVENTORY.md)" in ci_doc +def test_production_readiness_checklist_surfaces_launch_verification_commands() -> None: + checklist = (REPO_ROOT / "platform/docs/PRODUCTION_READINESS_CHECKLIST.md").read_text( + encoding="utf-8" + ) + + assert "clawops doctor" in checklist + assert "clawops baseline verify" in checklist + assert "clawops verify-platform sidecars" in checklist + assert "clawops verify-platform observability" in checklist + assert "clawops verify-platform channels" in checklist + assert "clawops recovery backup-create" in checklist + assert "clawops recovery backup-verify " in checklist + assert "clawops recovery restore " in checklist + assert "clawops doctor-host" in checklist + assert "release-ready substitutes for `clawops doctor`" in checklist + assert "clawops repo doctor" in checklist + + def test_browser_lab_docs_surface_first_class_verification_commands() -> None: browser_lab = (REPO_ROOT / "platform/docs/BROWSER_LAB.md").read_text(encoding="utf-8") setup = (REPO_ROOT / "SETUP_GUIDE.md").read_text(encoding="utf-8") From 1d54993433acce01f08e1dcfacdd05bb4ffa81fc Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 2 Apr 2026 09:22:24 -0300 Subject: [PATCH 3/8] Keep packaged docs in sync with launch-readiness checklist updates The source docs were expanded to add executable launch checks, so the packaged runtime doc copies are updated to preserve source-vs-packaged parity contracts used in CI and release verification. Constraint: Packaged runtime assets must byte-match source docs for parity contracts Rejected: Leave packaged docs stale and rely on source docs only | breaks packaged-runtime contract tests Confidence: high Scope-risk: narrow Reversibility: clean Directive: Whenever platform/docs docs change, mirror updates under src/clawops/assets/platform/docs in the same change Tested: pytest tests/suites/contracts/repo/test_packaged_runtime_assets.py tests/suites/contracts/repo/test_docs_parity.py Not-tested: Full compatibility matrix rerun (deferred to PR checks) --- .../assets/platform/docs/CI_AND_SECURITY.md | 5 ++ .../docs/PRODUCTION_READINESS_CHECKLIST.md | 62 ++++++++++++++----- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/clawops/assets/platform/docs/CI_AND_SECURITY.md b/src/clawops/assets/platform/docs/CI_AND_SECURITY.md index 61fc0039..0256d121 100644 --- a/src/clawops/assets/platform/docs/CI_AND_SECURITY.md +++ b/src/clawops/assets/platform/docs/CI_AND_SECURITY.md @@ -112,6 +112,11 @@ raw tar extraction, traversal-prone archive-member joins, `subprocess` `shell=True`, and unsafe deserialization helpers. - `.github/workflows/security.yml` verifies the pinned `gitleaks` and `syft` tarball SHA-256 digests before extracting the binaries through the dedicated helper script. +- That same security lane now executes two operational smoke checks through +`tests/scripts/security_workflow.py`: channel rollout contract parity +(`verify-channels-contract`) plus a disposable backup/verify/restore cycle +(`run-recovery-smoke`) so launch-critical channel and recovery paths produce +executable CI evidence. - `.github/workflows/release.yml` now blocks publication on three repo-controlled prerequisites: the centralized release quality gate, the reusable fresh-host acceptance workflow, and the reusable memory-plugin verification workflow. It diff --git a/src/clawops/assets/platform/docs/PRODUCTION_READINESS_CHECKLIST.md b/src/clawops/assets/platform/docs/PRODUCTION_READINESS_CHECKLIST.md index b0d00da2..1d8b5886 100644 --- a/src/clawops/assets/platform/docs/PRODUCTION_READINESS_CHECKLIST.md +++ b/src/clawops/assets/platform/docs/PRODUCTION_READINESS_CHECKLIST.md @@ -1,23 +1,51 @@ # Production Readiness Checklist -- [ ] dedicated OS user -- [ ] loopback-bound gateway -- [ ] token auth enabled +Use this checklist as the operator-facing go/no-go gate after setup succeeds and +before you treat a host as launch-ready. + +## 1. Host and gateway boundary + +- [ ] dedicated OS user owns the StrongClaw/OpenClaw runtime +- [ ] gateway stays loopback-bound and is only reached through an SSH tunnel +- [ ] token auth is enabled - [ ] `session.dmScope = per-channel-peer` -- [ ] sandbox mode `all` -- [ ] elevated exec disabled -- [ ] plugins/skills default-deny -- [ ] sidecars healthy -- [ ] `openclaw doctor` clean -- [ ] `openclaw security audit --deep` clean -- [ ] `openclaw secrets audit --check` clean -- [ ] operation journal initialized -- [ ] policy regression suite green -- [ ] backup and restore tested -- [ ] channel allowlists durable -- [ ] browser lab isolated or disabled -- [ ] if browser-lab is enabled, run `clawops verify-platform browser-lab` and confirm loopback-only bindings (`clawops baseline verify` includes browser-lab by default; use `--exclude-browser-lab` only when browser-lab is intentionally out of scope) -- [ ] remote operator access uses SSH tunnel to gateway only +- [ ] sandbox mode is `all` +- [ ] elevated exec is disabled +- [ ] plugins and skills stay default-deny until reviewed + +## 2. Release-ready verification commands + +Run these command-backed checks and keep the resulting evidence with the host +handoff or launch packet: + +- [ ] `clawops doctor` is clean +- [ ] `clawops baseline verify` is clean +- [ ] `clawops verify-platform sidecars` is clean +- [ ] `clawops verify-platform observability` is clean +- [ ] `clawops verify-platform channels` is clean when rollout includes operator channels +- [ ] `openclaw doctor` is clean +- [ ] `openclaw security audit --deep` is clean +- [ ] `openclaw secrets audit --check` is clean + +`clawops doctor-host` and `clawops doctor --skip-runtime --no-model-probe` are +useful host-only or degraded diagnostics, but they are **not** launch-ready or +release-ready substitutes for `clawops doctor`. + +## 3. Recovery and durability + +- [ ] operation journal is initialized and writable +- [ ] `clawops recovery backup-create` succeeds against the production home +- [ ] `clawops recovery backup-verify ` succeeds for a freshly created archive +- [ ] `clawops recovery restore ` has been tested on a clean destination +- [ ] backup retention is configured and reviewed +- [ ] channel allowlists are durable and versioned + +## 4. Optional-but-exposed launch surfaces + +- [ ] browser-lab is either disabled or isolated to a separate host or hardened OS user +- [ ] if browser-lab is enabled, `clawops verify-platform browser-lab` is clean and browser-lab ports remain loopback-only +- [ ] if browser-lab is intentionally out of scope for this rollout, `clawops baseline verify --exclude-browser-lab` is the documented gate result for the launch packet +- [ ] remote operator access uses an SSH tunnel to the gateway only; never tunnel browser-lab ports such as `9222` or `3128` `clawops repo doctor` remains an operator/development contract for `repo/upstream` and managed worktree flows. It is not part of the production baseline or launch From feec9fe1273cffe75c45efdcbf164a7dd18e7e18 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 2 Apr 2026 09:46:46 -0300 Subject: [PATCH 4/8] Protect policy review state with owner-only journal permissions Normalize op-journal and review packet artifacts to owner-only modes on supported POSIX hosts, and surface the security-policy docs in the operator entrypoint list so the launch-readiness lane is easier to verify. Constraint: RC8 task 2 is limited to security/policy/host/degradation hardening with no .omx artifact changes Rejected: Leave filesystem hardening as documentation only | approval packets and journal state should default closed on disk Directive: Keep platform/docs and src/clawops/assets/platform/docs mirrored whenever operator-facing policy docs change Confidence: high Scope-risk: narrow Tested: uv run --project . pytest -q tests/suites/unit/clawops/test_op_journal.py tests/suites/unit/clawops/test_approval_dispatch.py tests/suites/contracts/repo/test_docs_parity.py Tested: uv run --project . ruff check src/clawops/op_journal.py src/clawops/approval_dispatch.py tests/suites/unit/clawops/test_op_journal.py tests/suites/unit/clawops/test_approval_dispatch.py tests/suites/contracts/repo/test_docs_parity.py Tested: uv run --project . pyright src/clawops/op_journal.py src/clawops/approval_dispatch.py tests/suites/unit/clawops/test_op_journal.py tests/suites/unit/clawops/test_approval_dispatch.py tests/suites/contracts/repo/test_docs_parity.py Tested: uv run --project . python -m compileall -q src tests Not-tested: make typecheck / full-project mypy did not complete within this worker session --- README.md | 9 ++++++--- platform/docs/POLICY_ENGINE_AND_WRAPPERS.md | 2 ++ src/clawops/approval_dispatch.py | 17 +++++++++++++++++ .../platform/docs/POLICY_ENGINE_AND_WRAPPERS.md | 2 ++ src/clawops/op_journal.py | 17 +++++++++++++++++ tests/suites/contracts/repo/test_docs_parity.py | 3 +++ .../unit/clawops/test_approval_dispatch.py | 4 ++++ tests/suites/unit/clawops/test_op_journal.py | 14 ++++++++++++++ 8 files changed, 65 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ae5611aa..00c7c45e 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,12 @@ Read these in order: 4. [`USAGE_GUIDE.md`](USAGE_GUIDE.md) 5. [`platform/docs/ARCHITECTURE.md`](platform/docs/ARCHITECTURE.md) 6. [`platform/docs/PRODUCTION_READINESS_CHECKLIST.md`](platform/docs/PRODUCTION_READINESS_CHECKLIST.md) -7. [`platform/docs/DEVFLOW.md`](platform/docs/DEVFLOW.md) -8. [`platform/docs/PLUGIN_INVENTORY.md`](platform/docs/PLUGIN_INVENTORY.md) -9. [`platform/docs/DEGRADATION.md`](platform/docs/DEGRADATION.md) +7. [`platform/docs/POLICY_ENGINE_AND_WRAPPERS.md`](platform/docs/POLICY_ENGINE_AND_WRAPPERS.md) +8. [`platform/docs/SECRETS_AND_ENV.md`](platform/docs/SECRETS_AND_ENV.md) +9. [`platform/docs/SECURITY_MODEL.md`](platform/docs/SECURITY_MODEL.md) +10. [`platform/docs/DEVFLOW.md`](platform/docs/DEVFLOW.md) +11. [`platform/docs/PLUGIN_INVENTORY.md`](platform/docs/PLUGIN_INVENTORY.md) +12. [`platform/docs/DEGRADATION.md`](platform/docs/DEGRADATION.md) ## Repository map diff --git a/platform/docs/POLICY_ENGINE_AND_WRAPPERS.md b/platform/docs/POLICY_ENGINE_AND_WRAPPERS.md index 6abb85eb..0801b1a1 100644 --- a/platform/docs/POLICY_ENGINE_AND_WRAPPERS.md +++ b/platform/docs/POLICY_ENGINE_AND_WRAPPERS.md @@ -86,6 +86,8 @@ Treat `~/.openclaw/clawops` as service-owned state. - keep directory mode `0700` on `~/.openclaw/clawops` - keep file mode `0600` on `~/.openclaw/clawops/op_journal.sqlite` +- keep reviewer packet directories mode `0700` and packet files mode `0600` +- `clawops` normalizes those journal and reviewer-artifact permissions on supported POSIX hosts when it creates or updates them - do not grant write access to lower-trust workers or shared workspaces ## CLI flow diff --git a/src/clawops/approval_dispatch.py b/src/clawops/approval_dispatch.py index 493f03dc..7c6a3385 100644 --- a/src/clawops/approval_dispatch.py +++ b/src/clawops/approval_dispatch.py @@ -4,6 +4,7 @@ import dataclasses import json +import os import pathlib from clawops.common import write_json @@ -11,6 +12,8 @@ REVIEW_PACKET_VERSION = 1 LOCAL_DISPATCH_CHANNEL = "local_file" +_OWNER_ONLY_DIR_MODE = 0o700 +_OWNER_ONLY_FILE_MODE = 0o600 @dataclasses.dataclass(frozen=True, slots=True) @@ -50,6 +53,18 @@ def _default_artifact_path(journal: OperationJournal, *, op_id: str) -> pathlib. return journal.db_path.parent / "reviews" / f"{op_id}.json" +def _normalize_permissions(path: pathlib.Path, mode: int) -> None: + """Apply owner-only permissions on supported hosts.""" + if os.name == "nt": + return + try: + current_mode = path.stat().st_mode & 0o777 + except FileNotFoundError: + return + if current_mode != mode: + path.chmod(mode) + + def build_review_packet(operation: Operation) -> dict[str, object]: """Build a stable reviewer packet for one pending operation.""" return { @@ -101,6 +116,8 @@ def dispatch_pending_approval( payload = build_review_packet(operation) try: write_json(artifact_path, payload) + _normalize_permissions(artifact_path.parent, _OWNER_ONLY_DIR_MODE) + _normalize_permissions(artifact_path, _OWNER_ONLY_FILE_MODE) updated = journal.transition( operation.op_id, "pending_approval", diff --git a/src/clawops/assets/platform/docs/POLICY_ENGINE_AND_WRAPPERS.md b/src/clawops/assets/platform/docs/POLICY_ENGINE_AND_WRAPPERS.md index 6abb85eb..0801b1a1 100644 --- a/src/clawops/assets/platform/docs/POLICY_ENGINE_AND_WRAPPERS.md +++ b/src/clawops/assets/platform/docs/POLICY_ENGINE_AND_WRAPPERS.md @@ -86,6 +86,8 @@ Treat `~/.openclaw/clawops` as service-owned state. - keep directory mode `0700` on `~/.openclaw/clawops` - keep file mode `0600` on `~/.openclaw/clawops/op_journal.sqlite` +- keep reviewer packet directories mode `0700` and packet files mode `0600` +- `clawops` normalizes those journal and reviewer-artifact permissions on supported POSIX hosts when it creates or updates them - do not grant write access to lower-trust workers or shared workspaces ## CLI flow diff --git a/src/clawops/op_journal.py b/src/clawops/op_journal.py index 09481969..3c072876 100644 --- a/src/clawops/op_journal.py +++ b/src/clawops/op_journal.py @@ -5,6 +5,7 @@ import argparse import dataclasses import json +import os import pathlib import sqlite3 import time @@ -196,6 +197,8 @@ _RETRYABLE_OPEN_ERROR = "unable to open database file" _CONNECT_ATTEMPTS = 3 _CONNECT_RETRY_DELAY_SECONDS = 0.01 +_OWNER_ONLY_DIR_MODE = 0o700 +_OWNER_ONLY_FILE_MODE = 0o600 def _is_retryable_open_error(error: sqlite3.OperationalError) -> bool: @@ -224,6 +227,18 @@ def _merge_review_payload( return canonical_json(payload) +def _normalize_permissions(path: pathlib.Path, mode: int) -> None: + """Apply owner-only permissions on supported hosts.""" + if os.name == "nt": + return + try: + current_mode = path.stat().st_mode & 0o777 + except FileNotFoundError: + return + if current_mode != mode: + path.chmod(mode) + + @dataclasses.dataclass(slots=True) class Operation: """Operation journal row.""" @@ -316,6 +331,7 @@ def __init__(self, db_path: pathlib.Path) -> None: def connect(self) -> sqlite3.Connection: """Open a SQLite connection with row access by name.""" self.db_path.parent.mkdir(parents=True, exist_ok=True) + _normalize_permissions(self.db_path.parent, _OWNER_ONLY_DIR_MODE) for attempt in range(1, _CONNECT_ATTEMPTS + 1): conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row @@ -327,6 +343,7 @@ def connect(self) -> sqlite3.Connection: raise time.sleep(_CONNECT_RETRY_DELAY_SECONDS * attempt) continue + _normalize_permissions(self.db_path, _OWNER_ONLY_FILE_MODE) return conn raise AssertionError("unreachable: connect loop returned no SQLite connection") diff --git a/tests/suites/contracts/repo/test_docs_parity.py b/tests/suites/contracts/repo/test_docs_parity.py index 30a033c4..649fe6c2 100644 --- a/tests/suites/contracts/repo/test_docs_parity.py +++ b/tests/suites/contracts/repo/test_docs_parity.py @@ -112,6 +112,9 @@ def test_operator_docs_surface_platform_verification_commands() -> None: assert "Continuously validated on Linux `x86_64`" in host_platforms assert "platform-native user-management tooling" in macos_runbook assert "platform-native user-management tooling" in linux_runbook + assert "platform/docs/POLICY_ENGINE_AND_WRAPPERS.md" in readme + assert "platform/docs/SECRETS_AND_ENV.md" in readme + assert "platform/docs/SECURITY_MODEL.md" in readme def test_operator_docs_surface_repo_local_dev_sidecar_state_commands() -> None: diff --git a/tests/suites/unit/clawops/test_approval_dispatch.py b/tests/suites/unit/clawops/test_approval_dispatch.py index e38a6a1b..b101a1a5 100644 --- a/tests/suites/unit/clawops/test_approval_dispatch.py +++ b/tests/suites/unit/clawops/test_approval_dispatch.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import pathlib from clawops.approval_dispatch import dispatch_pending_approval @@ -50,6 +51,9 @@ def test_dispatch_pending_approval_writes_packet_and_updates_journal( assert packet["opId"] == pending.op_id assert packet["review"]["status"] == "pending" assert packet["policy"]["decision"] == "require_approval" + if os.name != "nt": + assert (outcome.artifact_path.parent.stat().st_mode & 0o777) == 0o700 + assert (outcome.artifact_path.stat().st_mode & 0o777) == 0o600 persisted = journal.get(pending.op_id) assert persisted.review_artifact_path == outcome.artifact_path.as_posix() assert persisted.status == "pending_approval" diff --git a/tests/suites/unit/clawops/test_op_journal.py b/tests/suites/unit/clawops/test_op_journal.py index 4cbfe3b0..88bdce1f 100644 --- a/tests/suites/unit/clawops/test_op_journal.py +++ b/tests/suites/unit/clawops/test_op_journal.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import pathlib import sqlite3 from typing import Any, Callable, cast @@ -280,6 +281,19 @@ def flaky_ensure_schema(conn: sqlite3.Connection) -> None: assert attempts["count"] == 2 +def test_init_normalizes_owner_only_permissions(tmp_path: pathlib.Path) -> None: + db_dir = tmp_path / "journal-state" + db_dir.mkdir(mode=0o755) + db = db_dir / "journal.sqlite" + + journal = OperationJournal(db) + journal.init() + + if os.name != "nt": + assert (db_dir.stat().st_mode & 0o777) == 0o700 + assert (db.stat().st_mode & 0o777) == 0o600 + + def test_session_leases_are_visible_and_releasable(tmp_path: pathlib.Path) -> None: db = tmp_path / "journal.sqlite" journal = OperationJournal(db) From 0a074fc982639e155112ad07f934a5b3412772de Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 2 Apr 2026 09:44:09 -0300 Subject: [PATCH 5/8] Surface launch-readiness docs in operator entrypoints and contracts Constraint: RC8 task 1 is limited to docs and contract coverage; no .omx artifacts Directive: Keep README launch entrypoints and CI/security workflow docs aligned with the repo contract tests Confidence: high Scope-risk: narrow Tested: uv run --locked pytest -q tests/suites/contracts/repo/test_docs_parity.py Tested: uv run ruff check tests/suites/contracts/repo/test_docs_parity.py Tested: uv run pyright Tested: uv run python -m compileall -q src tests Not-tested: uv run mypy remained slow/non-terminating in this worker session --- README.md | 11 +++++---- platform/docs/CI_AND_SECURITY.md | 3 ++- .../suites/contracts/repo/test_docs_parity.py | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 00c7c45e..ccefa1bb 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,13 @@ Read these in order: 4. [`USAGE_GUIDE.md`](USAGE_GUIDE.md) 5. [`platform/docs/ARCHITECTURE.md`](platform/docs/ARCHITECTURE.md) 6. [`platform/docs/PRODUCTION_READINESS_CHECKLIST.md`](platform/docs/PRODUCTION_READINESS_CHECKLIST.md) -7. [`platform/docs/POLICY_ENGINE_AND_WRAPPERS.md`](platform/docs/POLICY_ENGINE_AND_WRAPPERS.md) +7. [`platform/docs/SECURITY_MODEL.md`](platform/docs/SECURITY_MODEL.md) 8. [`platform/docs/SECRETS_AND_ENV.md`](platform/docs/SECRETS_AND_ENV.md) -9. [`platform/docs/SECURITY_MODEL.md`](platform/docs/SECURITY_MODEL.md) -10. [`platform/docs/DEVFLOW.md`](platform/docs/DEVFLOW.md) -11. [`platform/docs/PLUGIN_INVENTORY.md`](platform/docs/PLUGIN_INVENTORY.md) -12. [`platform/docs/DEGRADATION.md`](platform/docs/DEGRADATION.md) +9. [`platform/docs/POLICY_ENGINE_AND_WRAPPERS.md`](platform/docs/POLICY_ENGINE_AND_WRAPPERS.md) +10. [`platform/docs/CI_AND_SECURITY.md`](platform/docs/CI_AND_SECURITY.md) +11. [`platform/docs/DEVFLOW.md`](platform/docs/DEVFLOW.md) +12. [`platform/docs/PLUGIN_INVENTORY.md`](platform/docs/PLUGIN_INVENTORY.md) +13. [`platform/docs/DEGRADATION.md`](platform/docs/DEGRADATION.md) ## Repository map diff --git a/platform/docs/CI_AND_SECURITY.md b/platform/docs/CI_AND_SECURITY.md index 0256d121..7f892c44 100644 --- a/platform/docs/CI_AND_SECURITY.md +++ b/platform/docs/CI_AND_SECURITY.md @@ -41,7 +41,7 @@ any required lane does not complete successfully. ## Fresh-host acceptance -`.github/workflows/fresh-host-acceptance.yml` exercises the real bootstrap, setup, service activation, and repo-local sidecar/browser-lab flows on hosted Linux and macOS runners. +`.github/workflows/fresh-host-acceptance.yml` exercises the real bootstrap, setup, service activation, and repo-local sidecar/browser-lab flows on hosted Linux and macOS runners. It delegates the reusable execution lane to `.github/workflows/fresh-host-core.yml`. - Each run writes a GitHub job summary with the runner label, runtime provider, cache toggles, phase timings, and the effective hosted macOS Colima sizing. @@ -61,6 +61,7 @@ sidecars and browser-lab mutable data live in Docker-managed volumes instead of macOS path without changing the required PR gate. - The workflow stays declarative by delegating runtime setup, image warming, diagnostics, and summary generation to executable helper scripts under `tests/scripts/`. Hosted macOS image warming restores a cached Docker image archive when available, then verifies compose image availability with bounded retries and heartbeat logging as a fallback. +- `.github/workflows/nightly.yml` warms the fresh-host caches before it calls the reusable fresh-host core lane for the scheduled validation sweep. - Repository workflow contract tests verify that shell steps invoking `tests/scripts/*.py` either call an explicit Python interpreter or target an executable script, so nightly cache warming cannot silently regress on file mode drift. diff --git a/tests/suites/contracts/repo/test_docs_parity.py b/tests/suites/contracts/repo/test_docs_parity.py index 649fe6c2..a5f92fef 100644 --- a/tests/suites/contracts/repo/test_docs_parity.py +++ b/tests/suites/contracts/repo/test_docs_parity.py @@ -81,6 +81,20 @@ def test_operator_docs_surface_codebase_context_commands() -> None: assert "clawops context pack" not in text +def test_readme_entrypoints_surface_launch_readiness_docs() -> None: + readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + + assert "platform/docs/ARCHITECTURE.md" in readme + assert "platform/docs/PRODUCTION_READINESS_CHECKLIST.md" in readme + assert "platform/docs/SECURITY_MODEL.md" in readme + assert "platform/docs/SECRETS_AND_ENV.md" in readme + assert "platform/docs/POLICY_ENGINE_AND_WRAPPERS.md" in readme + assert "platform/docs/CI_AND_SECURITY.md" in readme + assert "platform/docs/DEVFLOW.md" in readme + assert "platform/docs/PLUGIN_INVENTORY.md" in readme + assert "platform/docs/DEGRADATION.md" in readme + + def test_operator_docs_surface_platform_verification_commands() -> None: quickstart = (REPO_ROOT / "QUICKSTART.md").read_text(encoding="utf-8") readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") @@ -201,8 +215,17 @@ def test_operator_docs_surface_repo_memory_and_skill_commands() -> None: assert "clawops repo doctor" in repo_doc assert "clawops worktree list" in repo_doc assert "ci-gate.yml" in ci_doc + assert "compatibility-matrix.yml" in ci_doc + assert "harness.yml" in ci_doc + assert "fresh-host-acceptance.yml" in ci_doc + assert "fresh-host-core.yml" in ci_doc + assert "security.yml" in ci_doc + assert "nightly.yml" in ci_doc + assert "release.yml" in ci_doc assert "CI / Verdict" in ci_doc assert "dependency-submission.yml" in ci_doc + assert "upstream-merge-validation.yml" in ci_doc + assert "devflow-contract.yml" in ci_doc assert "memory-plugin-verification.yml" in ci_doc From 1a6718be95ec78667e5fc30e2011643010017d9e Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 2 Apr 2026 10:08:17 -0300 Subject: [PATCH 6/8] Preserve packaged doc parity after CI/security guidance updates The source CI/security doc gained additional fresh-host workflow context in the launch-readiness documentation pass, so the packaged asset copy must mirror it byte-for-byte to keep packaged-runtime parity checks green. Constraint: Packaged platform docs must stay byte-identical to platform/docs counterparts Rejected: Leave packaged copy stale and rely on source docs at runtime | breaks packaged-runtime parity contract Confidence: high Scope-risk: narrow Reversibility: clean Directive: Mirror every source doc edit under src/clawops/assets/platform/docs in the same patch Tested: uv run --project . pytest -q tests/suites/contracts/repo/test_packaged_runtime_assets.py tests/suites/contracts/repo/test_docs_parity.py tests/suites/contracts/repo/test_ci_workflow_surfaces.py tests/suites/unit/ci/test_security_workflow.py tests/suites/unit/clawops/test_approval_dispatch.py tests/suites/unit/clawops/test_op_journal.py Tested: uv run --project . pyright src/clawops/approval_dispatch.py src/clawops/op_journal.py tests/scripts/security_workflow.py tests/utils/helpers/_ci_workflows/security.py tests/utils/helpers/ci_workflows.py tests/suites/unit/ci/test_security_workflow.py tests/suites/unit/clawops/test_approval_dispatch.py tests/suites/unit/clawops/test_op_journal.py Tested: uv run --project . mypy src/clawops/approval_dispatch.py src/clawops/op_journal.py tests/scripts/security_workflow.py tests/utils/helpers/_ci_workflows/security.py tests/utils/helpers/ci_workflows.py tests/suites/unit/ci/test_security_workflow.py tests/suites/unit/clawops/test_approval_dispatch.py tests/suites/unit/clawops/test_op_journal.py Tested: uv run --project . ruff check src/clawops/approval_dispatch.py src/clawops/op_journal.py tests/scripts/security_workflow.py tests/utils/helpers/_ci_workflows/security.py tests/utils/helpers/ci_workflows.py tests/suites/unit/ci/test_security_workflow.py tests/suites/unit/clawops/test_approval_dispatch.py tests/suites/unit/clawops/test_op_journal.py tests/suites/contracts/repo/test_ci_workflow_surfaces.py tests/suites/contracts/repo/test_docs_parity.py Tested: uv run --project . python -m compileall -q src tests --- src/clawops/assets/platform/docs/CI_AND_SECURITY.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/clawops/assets/platform/docs/CI_AND_SECURITY.md b/src/clawops/assets/platform/docs/CI_AND_SECURITY.md index 0256d121..7f892c44 100644 --- a/src/clawops/assets/platform/docs/CI_AND_SECURITY.md +++ b/src/clawops/assets/platform/docs/CI_AND_SECURITY.md @@ -41,7 +41,7 @@ any required lane does not complete successfully. ## Fresh-host acceptance -`.github/workflows/fresh-host-acceptance.yml` exercises the real bootstrap, setup, service activation, and repo-local sidecar/browser-lab flows on hosted Linux and macOS runners. +`.github/workflows/fresh-host-acceptance.yml` exercises the real bootstrap, setup, service activation, and repo-local sidecar/browser-lab flows on hosted Linux and macOS runners. It delegates the reusable execution lane to `.github/workflows/fresh-host-core.yml`. - Each run writes a GitHub job summary with the runner label, runtime provider, cache toggles, phase timings, and the effective hosted macOS Colima sizing. @@ -61,6 +61,7 @@ sidecars and browser-lab mutable data live in Docker-managed volumes instead of macOS path without changing the required PR gate. - The workflow stays declarative by delegating runtime setup, image warming, diagnostics, and summary generation to executable helper scripts under `tests/scripts/`. Hosted macOS image warming restores a cached Docker image archive when available, then verifies compose image availability with bounded retries and heartbeat logging as a fallback. +- `.github/workflows/nightly.yml` warms the fresh-host caches before it calls the reusable fresh-host core lane for the scheduled validation sweep. - Repository workflow contract tests verify that shell steps invoking `tests/scripts/*.py` either call an explicit Python interpreter or target an executable script, so nightly cache warming cannot silently regress on file mode drift. From 90ee0c010896ae1e13573f7effe2577317bdf763 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 2 Apr 2026 10:24:48 -0300 Subject: [PATCH 7/8] Make recovery smoke deterministic when OpenClaw CLI is present The security lane recovery smoke failed in CI because OpenClaw CLI verification rejected the disposable archive format. The helper now forces the strongclaw_recovery tar fallback path during the smoke cycle so backup/verify/restore evidence remains stable across runner environments, and unit coverage now locks that behavior. Constraint: Recovery smoke must run in generic CI runners where OpenClaw CLI behavior can differ from fallback tar semantics Rejected: Require OpenClaw-manifest-complete backup fixtures in the smoke helper | adds brittle environment coupling to a lightweight gate Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep recovery smoke self-contained and environment-agnostic; do not depend on host OpenClaw backup manifest conventions Tested: uv run --project . ruff check tests/utils/helpers/_ci_workflows/security.py tests/suites/unit/ci/test_security_workflow.py Tested: uv run --project . pyright tests/utils/helpers/_ci_workflows/security.py tests/suites/unit/ci/test_security_workflow.py Tested: uv run --project . mypy tests/utils/helpers/_ci_workflows/security.py tests/suites/unit/ci/test_security_workflow.py Tested: uv run --project . pytest -q tests/suites/unit/ci/test_security_workflow.py tests/suites/contracts/repo/test_ci_workflow_surfaces.py Tested: uv run --project . python3 ./tests/scripts/security_workflow.py run-recovery-smoke --tmp-root /tmp --- .../suites/unit/ci/test_security_workflow.py | 43 +++++++++++++++++++ tests/utils/helpers/_ci_workflows/security.py | 37 +++++++++++++--- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/tests/suites/unit/ci/test_security_workflow.py b/tests/suites/unit/ci/test_security_workflow.py index d0daf6d2..80ee0ea3 100644 --- a/tests/suites/unit/ci/test_security_workflow.py +++ b/tests/suites/unit/ci/test_security_workflow.py @@ -203,6 +203,49 @@ def fake_restore_backup(target: Path, *, destination: Path, home_dir: Path) -> P assert seen_calls[2][0] == "restore" +def test_run_recovery_smoke_forces_tar_fallback_when_openclaw_is_available( + test_context: TestContext, + tmp_path: Path, +) -> None: + """Recovery smoke should bypass OpenClaw CLI verification in CI helper mode.""" + archive_path = tmp_path / "archive.tar.gz" + seen_openclaw_resolution: list[str | None] = [] + + def fake_which(command: str, *_args: object, **_kwargs: object) -> str | None: + if command == "openclaw": + return "/usr/local/bin/openclaw" + return None + + def fake_create_backup(*, home_dir: Path) -> Path: + seen_openclaw_resolution.append(security_helpers.recovery_helpers.shutil.which("openclaw")) + archive_path.write_text("archive", encoding="utf-8") + return archive_path + + def fake_verify_backup(target: Path, *, home_dir: Path) -> Path: + seen_openclaw_resolution.append(security_helpers.recovery_helpers.shutil.which("openclaw")) + return target + + def fake_restore_backup(target: Path, *, destination: Path, home_dir: Path) -> Path: + seen_openclaw_resolution.append(security_helpers.recovery_helpers.shutil.which("openclaw")) + marker = destination / ".openclaw" / "logs" / "smoke.log" + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text("restored\n", encoding="utf-8") + return destination + + test_context.patch.patch_object( + security_helpers.recovery_helpers.shutil, + "which", + new=fake_which, + ) + test_context.patch.patch_object(security_helpers, "create_backup", new=fake_create_backup) + test_context.patch.patch_object(security_helpers, "verify_backup", new=fake_verify_backup) + test_context.patch.patch_object(security_helpers, "restore_backup", new=fake_restore_backup) + + ci_workflows.run_recovery_smoke(tmp_root=tmp_path) + + assert seen_openclaw_resolution == [None, None, None] + + def test_security_workflow_main_dispatches_write_summary( test_context: TestContext, tmp_path: Path, diff --git a/tests/utils/helpers/_ci_workflows/security.py b/tests/utils/helpers/_ci_workflows/security.py index 37ec8543..6c437b8d 100644 --- a/tests/utils/helpers/_ci_workflows/security.py +++ b/tests/utils/helpers/_ci_workflows/security.py @@ -2,11 +2,15 @@ from __future__ import annotations +import contextlib import json +import os import xml.etree.ElementTree as ET -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from pathlib import Path +from typing import Any +import clawops.strongclaw_recovery as recovery_helpers from clawops.platform_verify import verify_channels from clawops.strongclaw_recovery import create_backup, restore_backup, verify_backup from tests.utils.helpers._ci_workflows.common import ( @@ -169,14 +173,37 @@ def run_recovery_smoke(*, tmp_root: Path) -> None: marker_path.write_text("recovery smoke marker\n", encoding="utf-8") (state_dir / "settings.json").write_text('{"ok":true}\n', encoding="utf-8") - archive_path = create_backup(home_dir=home_dir) - verified_archive = verify_backup(archive_path, home_dir=home_dir) + with _force_tar_fallback_for_recovery(): + archive_path = create_backup(home_dir=home_dir) + verified_archive = verify_backup(archive_path, home_dir=home_dir) - restore_destination = resolved_tmp_root / "recovery-restore" - restore_backup(verified_archive, destination=restore_destination, home_dir=home_dir) + restore_destination = resolved_tmp_root / "recovery-restore" + restore_backup(verified_archive, destination=restore_destination, home_dir=home_dir) restored_marker = restore_destination / ".openclaw" / "logs" / "smoke.log" if not restored_marker.exists(): raise CiWorkflowError( "recovery smoke failed: restored marker missing after backup/verify/restore cycle" ) + + +@contextlib.contextmanager +def _force_tar_fallback_for_recovery() -> Iterator[None]: + """Temporarily force strongclaw_recovery helpers down the tar fallback path.""" + original_which = recovery_helpers.shutil.which + recovery_shutil: Any = recovery_helpers.shutil + + def _without_openclaw( + command: str, + mode: int = os.F_OK | os.X_OK, + path: str | None = None, + ) -> str | None: + if command == "openclaw": + return None + return original_which(command, mode, path) + + recovery_shutil.which = _without_openclaw + try: + yield + finally: + recovery_shutil.which = original_which From bb4a04a911fbea1d7a4fba4616b2fdd192365e08 Mon Sep 17 00:00:00 2001 From: Juan Sugg Date: Thu, 2 Apr 2026 10:32:54 -0300 Subject: [PATCH 8/8] Run security helper commands inside the project runtime The security workflow invoked tests/scripts/security_workflow.py with the host python interpreter, which bypassed the uv-managed environment and caused dependency/import failures in CI. Scripted security checks now run through uv so channel verification, recovery smoke, and SARIF fallback generation execute with the same project runtime contract as local verification. Constraint: Workflow helper scripts import project modules that depend on uv-managed environment packages Rejected: Keep plain python3 invocations and trim helper imports | hides environment drift and weakens workflow-thin helper reuse Confidence: high Scope-risk: narrow Reversibility: clean Directive: Any tests/scripts helper that imports project modules must run via uv in GitHub Actions Tested: uv run --project . pytest -q tests/suites/contracts/repo/test_ci_workflow_surfaces.py tests/suites/unit/ci/test_security_workflow.py Tested: actionlint .github/workflows/security.yml --- .github/workflows/security.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4de3e043..cd27f6b8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -46,13 +46,13 @@ jobs: run: sudo apt-get update && sudo apt-get install --yes shellcheck - run: uv sync --locked - name: Verify channels rollout contract - run: python3 ./tests/scripts/security_workflow.py verify-channels-contract --repo-root . + run: uv run --project . python3 ./tests/scripts/security_workflow.py verify-channels-contract --repo-root . - name: Run recovery backup/restore smoke - run: python3 ./tests/scripts/security_workflow.py run-recovery-smoke --tmp-root "${RUNNER_TEMP}" + run: uv run --project . python3 ./tests/scripts/security_workflow.py run-recovery-smoke --tmp-root "${RUNNER_TEMP}" - name: Run repository quality gate run: uv run python -m clawops supply-chain --repo-root . quality-gate - name: Coverage summary - run: python3 ./tests/scripts/security_workflow.py write-coverage-summary --coverage-file coverage.xml --summary-file "${GITHUB_STEP_SUMMARY}" + run: uv run --project . python3 ./tests/scripts/security_workflow.py write-coverage-summary --coverage-file coverage.xml --summary-file "${GITHUB_STEP_SUMMARY}" - name: Install Semgrep CLI run: python3 -m pip install --disable-pip-version-check "semgrep==1.156.0" - name: Run Semgrep @@ -61,14 +61,14 @@ jobs: env: GITLEAKS_VERSION: "8.28.0" GITLEAKS_SHA256: "a65b5253807a68ac0cafa4414031fd740aeb55f54fb7e55f386acb52e6a840eb" - run: python3 ./tests/scripts/security_workflow.py install-gitleaks --version "${GITLEAKS_VERSION}" --sha256 "${GITLEAKS_SHA256}" --runner-temp "${RUNNER_TEMP}" --github-path-file "${GITHUB_PATH}" + run: uv run --project . python3 ./tests/scripts/security_workflow.py install-gitleaks --version "${GITLEAKS_VERSION}" --sha256 "${GITLEAKS_SHA256}" --runner-temp "${RUNNER_TEMP}" --github-path-file "${GITHUB_PATH}" - name: Run gitleaks run: gitleaks git --no-banner --no-color --exit-code 1 --log-level warn --redact - name: Install syft CLI env: SYFT_VERSION: "v1.42.2" SYFT_SHA256: "1d3cc98b13ce3dfb6083ef42f64f1033e40d7dea292e8ea85ed1cf88efb2f542" - run: python3 ./tests/scripts/security_workflow.py install-syft --version "${SYFT_VERSION}" --sha256 "${SYFT_SHA256}" --runner-temp "${RUNNER_TEMP}" --github-path-file "${GITHUB_PATH}" + run: uv run --project . python3 ./tests/scripts/security_workflow.py install-syft --version "${SYFT_VERSION}" --sha256 "${SYFT_SHA256}" --runner-temp "${RUNNER_TEMP}" --github-path-file "${GITHUB_PATH}" - name: Generate SBOM run: syft dir:. -o spdx-json=sbom.spdx.json - uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # 0.35.0 @@ -79,7 +79,7 @@ jobs: trivy-config: security/trivy/trivy.yaml - name: Preserve historical code-scanning categories if: always() - run: python3 ./tests/scripts/security_workflow.py write-empty-sarif --output "${RUNNER_TEMP}/empty-security.sarif" --information-uri "https://github.com/jsugg/strongclaw" + run: uv run --project . python3 ./tests/scripts/security_workflow.py write-empty-sarif --output "${RUNNER_TEMP}/empty-security.sarif" --information-uri "https://github.com/jsugg/strongclaw" - uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4 if: always() with: