diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 200608d5..cd27f6b8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -45,10 +45,14 @@ 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: uv run --project . python3 ./tests/scripts/security_workflow.py verify-channels-contract --repo-root . + - name: Run recovery backup/restore smoke + 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 @@ -57,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 @@ -75,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: diff --git a/README.md b/README.md index ae5611aa..ccefa1bb 100644 --- a/README.md +++ b/README.md @@ -22,9 +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/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/SECURITY_MODEL.md`](platform/docs/SECURITY_MODEL.md) +8. [`platform/docs/SECRETS_AND_ENV.md`](platform/docs/SECRETS_AND_ENV.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 61fc0039..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. @@ -112,6 +113,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/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/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/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/CI_AND_SECURITY.md b/src/clawops/assets/platform/docs/CI_AND_SECURITY.md index 61fc0039..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. @@ -112,6 +113,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/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/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 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/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/contracts/repo/test_docs_parity.py b/tests/suites/contracts/repo/test_docs_parity.py index 6e92ff06..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") @@ -112,6 +126,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: @@ -198,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 @@ -230,6 +256,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") diff --git a/tests/suites/unit/ci/test_security_workflow.py b/tests/suites/unit/ci/test_security_workflow.py index f3390737..80ee0ea3 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,108 @@ 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_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, @@ -201,3 +304,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/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) diff --git a/tests/utils/helpers/_ci_workflows/security.py b/tests/utils/helpers/_ci_workflows/security.py index e21ff89a..6c437b8d 100644 --- a/tests/utils/helpers/_ci_workflows/security.py +++ b/tests/utils/helpers/_ci_workflows/security.py @@ -2,11 +2,17 @@ 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 ( CiWorkflowError, append_github_path, @@ -135,3 +141,69 @@ 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") + + 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) + + 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 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",