From e8b93db1e1e21ce13597f2d330b602e87dc2a429 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Apr 2026 11:41:50 +0100 Subject: [PATCH] Harden dev agent deploy SSH host verification --- .gitignore | 2 + .../subsystems/deployment-installability.md | 72 ++++++++++--------- .../v6/internal/subsystems/registry.json | 3 + scripts/dev-deploy-agent.sh | 32 +++++---- .../dev_runtime_governance_test.py | 26 +++++++ .../ssh_host_key_policy_test.py | 65 +++++++++++++++++ 6 files changed, 155 insertions(+), 45 deletions(-) create mode 100644 scripts/release_control/ssh_host_key_policy_test.py diff --git a/.gitignore b/.gitignore index 0be39a1da..a27ee6904 100644 --- a/.gitignore +++ b/.gitignore @@ -231,6 +231,7 @@ scripts/release_control/* !scripts/release_control/generate_platform_support_frontend_module.py !scripts/release_control/control_plane_audit.py !scripts/release_control/control_plane_audit_test.py +!scripts/release_control/dev_runtime_governance_test.py !scripts/release_control/documentation_currentness_test.py !scripts/release_control/format_staged_go.py !scripts/release_control/format_staged_go_test.py @@ -266,6 +267,7 @@ scripts/release_control/* !scripts/release_control/subsystem_contracts_test.py !scripts/release_control/subsystem_lookup.py !scripts/release_control/subsystem_lookup_test.py +!scripts/release_control/ssh_host_key_policy_test.py !scripts/release_control/work_claim.py !scripts/release_control/work_claim_test.py .agent/ diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6e8ea43db..d0b57f259 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -54,38 +54,39 @@ server-side update execution surfaces. 32. `scripts/clean-mock-alerts.sh` 33. `scripts/com.pulse.hot-dev.plist.template` 34. `scripts/dev-check.sh` -35. `scripts/dev-launchd-setup.sh` -36. `scripts/dev-launchd-wrapper.sh` -37. `scripts/hot-dev-bg.sh` -38. `scripts/hot-dev.sh` -39. `scripts/install-container-agent.sh` -40. `scripts/install.ps1` -41. `scripts/install.sh` -42. `scripts/pulse-auto-update.sh` -43. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py` -44. `scripts/release_control/record_rc_to_ga_rehearsal.py` -45. `scripts/release_control/release_promotion_policy_support.py` -46. `scripts/release_control/resolve_release_promotion.py` -47. `scripts/release_ldflags.sh` -48. `scripts/run_demo_public_browser_smoke.sh` -49. `scripts/demo_public_browser_smoke.cjs` -50. `scripts/run_hosted_staging_smoke.sh` -51. `scripts/trigger-release-dry-run.sh` -52. `scripts/trigger-release.sh` -53. `scripts/toggle-mock.sh` -54. `deploy/helm/pulse/` -55. `tests/integration/playwright.config.ts` -56. `tests/integration/QUICK_START.md` -57. `tests/integration/README.md` -58. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` -59. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` -60. `tests/integration/scripts/hosted-tenant-runtime.mjs` -61. `tests/integration/scripts/managed-dev-runtime.mjs` -62. `tests/integration/scripts/relay-mobile-token-helper.go` -63. `tests/integration/tests/helpers.ts` -64. `tests/integration/tests/runtime-defaults.ts` -65. `docker-compose.yml` -66. `scripts/install-docker.sh` +35. `scripts/dev-deploy-agent.sh` +36. `scripts/dev-launchd-setup.sh` +37. `scripts/dev-launchd-wrapper.sh` +38. `scripts/hot-dev-bg.sh` +39. `scripts/hot-dev.sh` +40. `scripts/install-container-agent.sh` +41. `scripts/install.ps1` +42. `scripts/install.sh` +43. `scripts/pulse-auto-update.sh` +44. `scripts/release_control/internal/record_rc_to_ga_rehearsal.py` +45. `scripts/release_control/record_rc_to_ga_rehearsal.py` +46. `scripts/release_control/release_promotion_policy_support.py` +47. `scripts/release_control/resolve_release_promotion.py` +48. `scripts/release_ldflags.sh` +49. `scripts/run_demo_public_browser_smoke.sh` +50. `scripts/demo_public_browser_smoke.cjs` +51. `scripts/run_hosted_staging_smoke.sh` +52. `scripts/trigger-release-dry-run.sh` +53. `scripts/trigger-release.sh` +54. `scripts/toggle-mock.sh` +55. `deploy/helm/pulse/` +56. `tests/integration/playwright.config.ts` +57. `tests/integration/QUICK_START.md` +58. `tests/integration/README.md` +59. `tests/integration/scripts/bootstrap-hosted-mobile-onboarding.mjs` +60. `tests/integration/scripts/hosted-mobile-token-runtime.mjs` +61. `tests/integration/scripts/hosted-tenant-runtime.mjs` +62. `tests/integration/scripts/managed-dev-runtime.mjs` +63. `tests/integration/scripts/relay-mobile-token-helper.go` +64. `tests/integration/tests/helpers.ts` +65. `tests/integration/tests/runtime-defaults.ts` +66. `docker-compose.yml` +67. `scripts/install-docker.sh` ## Shared Boundaries @@ -100,7 +101,7 @@ server-side update execution surfaces. 2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml` 3. Add or change shell installer, Docker bootstrap installer, Windows installer, container-agent installer, repo-root compose defaults, or auto-update script behavior through `scripts/install.sh`, `scripts/install-docker.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, `docker-compose.yml`, and `scripts/pulse-auto-update.sh` 4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts` -5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` +5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `scripts/dev-deploy-agent.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md` 6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` That release-promotion boundary also owns prerelease note packet lineage: shipped RC notes must remain historically accurate, the top-level @@ -527,6 +528,11 @@ to be listening. Changes to `scripts/hot-dev.sh` and `scripts/hot-dev-bg.sh` must therefore stay on their own direct dev-runtime orchestration proof path instead of piggybacking on installer proof coverage for unrelated deployment scripts. +That same dev-runtime helper boundary also owns trusted-host behavior for the +developer agent deploy wrapper: `scripts/dev-deploy-agent.sh` may TOFU new SSH +targets, but it must persist host keys in a known_hosts file and fail closed +on host-key changes instead of disabling verification with +`StrictHostKeyChecking=no`. That same dev-runtime orchestration boundary also owns watcher stability for the managed local stack: `scripts/hot-dev.sh` may only rebuild the backend for runtime Go sources, not `*_test.go` churn, and it must suppress `pulse` binary diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 67af00dd0..697e82c73 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -2441,6 +2441,7 @@ "scripts/com.pulse.hot-dev.plist.template", "scripts/demo_public_browser_smoke.cjs", "scripts/dev-check.sh", + "scripts/dev-deploy-agent.sh", "scripts/dev-launchd-setup.sh", "scripts/dev-launchd-wrapper.sh", "scripts/hot-dev-bg.sh", @@ -2666,6 +2667,7 @@ "scripts/clean-mock-alerts.sh", "scripts/com.pulse.hot-dev.plist.template", "scripts/dev-check.sh", + "scripts/dev-deploy-agent.sh", "scripts/dev-launchd-setup.sh", "scripts/dev-launchd-wrapper.sh", "scripts/hot-dev-bg.sh", @@ -2681,6 +2683,7 @@ "allow_same_subsystem_tests": false, "test_prefixes": [], "exact_files": [ + "scripts/release_control/ssh_host_key_policy_test.py", "scripts/tests/test-hot-dev-bg.sh", "scripts/tests/test-toggle-mock.sh", "tests/integration/tests/16-dev-runtime-recovery.spec.ts" diff --git a/scripts/dev-deploy-agent.sh b/scripts/dev-deploy-agent.sh index f3597bbde..1d6aed60d 100755 --- a/scripts/dev-deploy-agent.sh +++ b/scripts/dev-deploy-agent.sh @@ -16,7 +16,15 @@ GOARCH="${GOARCH:-amd64}" GOOS="${GOOS:-linux}" # SSH options -SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" +# Default to TOFU for new hosts while still failing closed on host-key changes. +SSH_KNOWN_HOSTS_FILE="${PULSE_DEV_SSH_KNOWN_HOSTS_FILE:-$HOME/.ssh/known_hosts}" +SSH_OPTS=( + -o BatchMode=yes + -o StrictHostKeyChecking=accept-new + -o UpdateHostKeys=yes + -o ConnectTimeout=10 + -o "UserKnownHostsFile=$SSH_KNOWN_HOSTS_FILE" +) # Remote paths REMOTE_AGENT_PATH="/usr/local/bin/pulse-agent" @@ -76,7 +84,7 @@ for host in "${HOSTS[@]}"; do log_info "Deploying to $host..." # Check if host is reachable and get architecture - HOST_ARCH=$(ssh $SSH_OPTS "$host" "uname -m" 2>/dev/null || echo "unknown") + HOST_ARCH=$(ssh "${SSH_OPTS[@]}" "$host" "uname -m" 2>/dev/null || echo "unknown") if [ "$HOST_ARCH" == "unknown" ]; then log_error "Cannot connect to $host or determine architecture - skipping" FAILED_HOSTS+=("$host") @@ -102,11 +110,11 @@ for host in "${HOSTS[@]}"; do # Stop the service log_info " Stopping pulse-agent..." - ssh $SSH_OPTS "$host" "sudo systemctl stop $REMOTE_SERVICE 2>/dev/null || pkill -f pulse-agent || true" + ssh "${SSH_OPTS[@]}" "$host" "sudo systemctl stop $REMOTE_SERVICE 2>/dev/null || pkill -f pulse-agent || true" # Copy the binary log_info " Copying binary..." - if ! scp $SSH_OPTS "$BINARY_PATH" "$host:/tmp/pulse-agent-new"; then + if ! scp "${SSH_OPTS[@]}" "$BINARY_PATH" "$host:/tmp/pulse-agent-new"; then log_error " Failed to copy binary to $host" FAILED_HOSTS+=("$host") continue @@ -114,7 +122,7 @@ for host in "${HOSTS[@]}"; do # Install the binary log_info " Installing binary..." - if ! ssh $SSH_OPTS "$host" "sudo mv /tmp/pulse-agent-new $REMOTE_AGENT_PATH && sudo chmod +x $REMOTE_AGENT_PATH"; then + if ! ssh "${SSH_OPTS[@]}" "$host" "sudo mv /tmp/pulse-agent-new $REMOTE_AGENT_PATH && sudo chmod +x $REMOTE_AGENT_PATH"; then log_error " Failed to install binary on $host" FAILED_HOSTS+=("$host") continue @@ -123,25 +131,25 @@ for host in "${HOSTS[@]}"; do # Start the service log_info " Starting pulse-agent..." # Try systemd first, then Unraid go.d script, then manual start via existing scripts - if ! ssh $SSH_OPTS "$host" "sudo systemctl start $REMOTE_SERVICE 2>/dev/null"; then - if ssh $SSH_OPTS "$host" "test -f /boot/config/go.d/pulse-agent.sh" 2>/dev/null; then + if ! ssh "${SSH_OPTS[@]}" "$host" "sudo systemctl start $REMOTE_SERVICE 2>/dev/null"; then + if ssh "${SSH_OPTS[@]}" "$host" "test -f /boot/config/go.d/pulse-agent.sh" 2>/dev/null; then log_info " Using Unraid startup script..." - ssh $SSH_OPTS "$host" "bash /boot/config/go.d/pulse-agent.sh" >/dev/null 2>&1 - elif ssh $SSH_OPTS "$host" "test -f /etc/init.d/pulse-agent" 2>/dev/null; then + ssh "${SSH_OPTS[@]}" "$host" "bash /boot/config/go.d/pulse-agent.sh" >/dev/null 2>&1 + elif ssh "${SSH_OPTS[@]}" "$host" "test -f /etc/init.d/pulse-agent" 2>/dev/null; then log_info " Using init.d script..." - ssh $SSH_OPTS "$host" "sudo /etc/init.d/pulse-agent start" >/dev/null 2>&1 + ssh "${SSH_OPTS[@]}" "$host" "sudo /etc/init.d/pulse-agent start" >/dev/null 2>&1 fi fi # Verify it's running sleep 2 - if ssh $SSH_OPTS "$host" "pgrep -x pulse-agent >/dev/null 2>&1"; then + if ssh "${SSH_OPTS[@]}" "$host" "pgrep -x pulse-agent >/dev/null 2>&1"; then log_success " Agent deployed and running on $host" SUCCESS_HOSTS+=("$host") else # Try one last ditch effort: run it via the background helper if we can find it log_warn " Agent not running, checking logs..." - ssh $SSH_OPTS "$host" "tail -n 5 /var/log/pulse-agent.log /boot/logs/pulse-agent.log 2>/dev/null" | log_warn + ssh "${SSH_OPTS[@]}" "$host" "tail -n 5 /var/log/pulse-agent.log /boot/logs/pulse-agent.log 2>/dev/null" | log_warn FAILED_HOSTS+=("$host") fi done diff --git a/scripts/release_control/dev_runtime_governance_test.py b/scripts/release_control/dev_runtime_governance_test.py index 987633bc1..6897a01db 100644 --- a/scripts/release_control/dev_runtime_governance_test.py +++ b/scripts/release_control/dev_runtime_governance_test.py @@ -37,6 +37,32 @@ class DevRuntimeGovernanceTest(unittest.TestCase): match["verification_requirement"]["exact_files"], ) + def test_lookup_maps_dev_agent_deploy_helper_to_installability(self) -> None: + result = lookup_paths(["scripts/dev-deploy-agent.sh"]) + self.assertEqual(result["unowned_runtime_files"], []) + self.assertEqual( + {entry["subsystem"] for entry in result["impacted_subsystems"]}, + {"deployment-installability"}, + ) + + file_entry = result["files"][0] + self.assertEqual(file_entry["classification"], "runtime") + self.assertEqual( + {match["subsystem"] for match in file_entry["matches"]}, + {"deployment-installability"}, + ) + match = file_entry["matches"][0] + self.assertEqual( + match["contract"], + "docs/release-control/v6/internal/subsystems/deployment-installability.md", + ) + self.assertEqual(match["lane_context"]["lane_id"], "L1") + self.assertEqual(match["verification_requirement"]["id"], "dev-runtime-orchestration") + self.assertIn( + "scripts/release_control/ssh_host_key_policy_test.py", + match["verification_requirement"]["exact_files"], + ) + def test_lookup_maps_integration_browser_default_surfaces_to_installability(self) -> None: result = lookup_paths( [ diff --git a/scripts/release_control/ssh_host_key_policy_test.py b/scripts/release_control/ssh_host_key_policy_test.py new file mode 100644 index 000000000..bac980fce --- /dev/null +++ b/scripts/release_control/ssh_host_key_policy_test.py @@ -0,0 +1,65 @@ +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[2] +TEXT_RUNTIME_SUFFIXES = (".go", ".py", ".sh", ".yml", ".yaml") +ALLOWED_STRICT_HOST_KEY_CHECKING_NO = { + "internal/hostagent/cluster_sensors.go", +} +ALLOWED_USER_KNOWN_HOSTS_DEV_NULL = { + "internal/hostagent/cluster_sensors.go", +} + + +def iter_runtime_files(): + for base in (REPO_ROOT / "internal", REPO_ROOT / "scripts", REPO_ROOT / ".github" / "workflows"): + if not base.exists(): + continue + for path in base.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(REPO_ROOT).as_posix() + if not relative.endswith(TEXT_RUNTIME_SUFFIXES): + continue + if relative.endswith(("_test.go", "_test.py", ".md")): + continue + if relative.startswith("scripts/installtests/") or relative.startswith("scripts/tests/"): + continue + yield path, relative + + +class SSHHostKeyPolicyTest(unittest.TestCase): + def test_runtime_paths_do_not_disable_ssh_host_key_verification(self) -> None: + strict_host_key_checking_no = [] + user_known_hosts_dev_null = [] + + for path, relative in iter_runtime_files(): + content = path.read_text(encoding="utf-8") + if "StrictHostKeyChecking=no" in content: + strict_host_key_checking_no.append(relative) + if "UserKnownHostsFile=/dev/null" in content: + user_known_hosts_dev_null.append(relative) + + self.assertEqual( + sorted(strict_host_key_checking_no), + sorted(ALLOWED_STRICT_HOST_KEY_CHECKING_NO), + f"unexpected StrictHostKeyChecking=no runtime surfaces: {strict_host_key_checking_no}", + ) + self.assertEqual( + sorted(user_known_hosts_dev_null), + sorted(ALLOWED_USER_KNOWN_HOSTS_DEV_NULL), + f"unexpected UserKnownHostsFile=/dev/null runtime surfaces: {user_known_hosts_dev_null}", + ) + + def test_dev_agent_deploy_uses_tofu_instead_of_disabling_host_checks(self) -> None: + script = (REPO_ROOT / "scripts" / "dev-deploy-agent.sh").read_text(encoding="utf-8") + + self.assertNotIn("StrictHostKeyChecking=no", script) + self.assertIn("StrictHostKeyChecking=accept-new", script) + self.assertIn("UpdateHostKeys=yes", script) + self.assertIn("UserKnownHostsFile=$SSH_KNOWN_HOSTS_FILE", script) + + +if __name__ == "__main__": + unittest.main()