diff --git a/.env.schema b/.env.schema index 827538c..c479dee 100644 --- a/.env.schema +++ b/.env.schema @@ -181,6 +181,12 @@ BAUDBOT_AGENT_HOME=/home/baudbot_agent # @sensitive=false @type=string BAUDBOT_SOURCE_DIR= +# ── Startup Integrity ──────────────────────────────────────────────────────── + +# Startup deploy-manifest integrity mode: off | warn | strict +# @sensitive=false @type=string +BAUDBOT_STARTUP_INTEGRITY_MODE=warn + # ── Bridge ─────────────────────────────────────────────────────────────────── # Local HTTP API port for outbound Slack messages diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 4149793..f3b9138 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -166,6 +166,18 @@ Set during `setup.sh` / `baudbot install` via env vars: | `IDLE_COMPACT_THRESHOLD_PCT` | Context usage % to trigger compaction (10–90) | `25` | | `IDLE_COMPACT_ENABLED` | Set to `0`, `false`, or `no` to disable idle compaction | enabled | +### Startup Integrity + +| Variable | Description | Default | +|----------|-------------|---------| +| `BAUDBOT_STARTUP_INTEGRITY_MODE` | Startup manifest verification mode: `off`, `warn`, `strict` | `warn` | + +On startup, Baudbot verifies deployed runtime files against `~/.pi/agent/baudbot-manifest.json` and records the result in `~/.pi/agent/manifest-integrity-status.json`. + +- `warn`: log high-severity warnings but continue startup +- `strict`: fail startup on missing/mismatched files or unreadable manifest +- `off`: skip verification (not recommended) + ### Bridge | Variable | Description | Default | diff --git a/README.md b/README.md index d4cd7d0..caa264d 100644 --- a/README.md +++ b/README.md @@ -152,11 +152,12 @@ Baudbot is built for utility **and** containment: - isolated `baudbot_agent` Unix user (no general sudo) - per-UID firewall controls + process isolation - source/runtime separation with deploy manifests +- startup deploy-manifest integrity verification (`warn`/`strict` modes) - read-only protection for security-critical files - session log hygiene (startup redaction + retention pruning) - layered tool and shell guardrails (policy/guidance layer, not sole containment) -See [SECURITY.md](SECURITY.md) for full threat model, trust boundaries, and known risks. In particular: tool/shell guards are defense-in-depth policy layers; hard containment comes from OS/runtime boundaries. +See [SECURITY.md](SECURITY.md) for full threat model, trust boundaries, and known risks. In particular: many controls here are defense-in-depth (helpful for drift prevention/detection and keeping healthy agents on-task), while hard containment comes from OS/runtime boundaries. ## Documentation diff --git a/SECURITY.md b/SECURITY.md index 93922be..b1ef00b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -67,6 +67,8 @@ Live execution runs from release snapshots under `/opt/baudbot`. Primary hard boundaries are runtime permissions, user isolation, and release-based deployment. If local source isolation is also enforced, admin can re-deploy from source to restore expected state. +> **Important scope note:** Many controls in this document are defense-in-depth and can be bypassed by a sufficiently capable or compromised rogue agent operating within its allowed user permissions. They are still valuable because they reduce accidental drift, surface tampering quickly, and help a non-compromised agent stay aligned with intended workflows. + ## User Model | User | Role | Sudo | Groups | diff --git a/bin/deploy.sh b/bin/deploy.sh index 10de7b5..3c9068e 100755 --- a/bin/deploy.sh +++ b/bin/deploy.sh @@ -83,7 +83,7 @@ if [ "$DRY_RUN" -eq 0 ]; then cp --no-preserve=ownership "$BAUDBOT_SRC/start.sh" "$STAGE_DIR/start.sh" mkdir -p "$STAGE_DIR/bin" mkdir -p "$STAGE_DIR/bin/lib" - for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh; do + for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh verify-manifest.sh; do [ -f "$BAUDBOT_SRC/bin/$script" ] && cp --no-preserve=ownership "$BAUDBOT_SRC/bin/$script" "$STAGE_DIR/bin/$script" done [ -f "$BAUDBOT_SRC/bin/lib/runtime-node.sh" ] && cp --no-preserve=ownership "$BAUDBOT_SRC/bin/lib/runtime-node.sh" "$STAGE_DIR/bin/lib/runtime-node.sh" @@ -249,7 +249,7 @@ if [ "$DRY_RUN" -eq 0 ]; then as_agent mkdir -p "$BAUDBOT_HOME/runtime/bin" as_agent mkdir -p "$BAUDBOT_HOME/runtime/bin/lib" - for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh; do + for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh verify-manifest.sh; do if [ -f "$STAGE_DIR/bin/$script" ]; then as_agent cp "$STAGE_DIR/bin/$script" "$BAUDBOT_HOME/runtime/bin/$script" as_agent chmod u+x "$BAUDBOT_HOME/runtime/bin/$script" diff --git a/bin/doctor.sh b/bin/doctor.sh index c4fdb67..48dfd80 100755 --- a/bin/doctor.sh +++ b/bin/doctor.sh @@ -304,6 +304,38 @@ else fi fi +INTEGRITY_STATUS_FILE="$BAUDBOT_INTEGRITY_STATUS_FILE" +if [ -f "$INTEGRITY_STATUS_FILE" ]; then + integrity_status="$(jq -r '.status // "unknown"' "$INTEGRITY_STATUS_FILE" 2>/dev/null || echo "unknown")" + integrity_checked_at="$(jq -r '.checked_at // "unknown"' "$INTEGRITY_STATUS_FILE" 2>/dev/null || echo "unknown")" + integrity_missing="$(jq -r '.missing_files // 0' "$INTEGRITY_STATUS_FILE" 2>/dev/null || echo "0")" + integrity_mismatches="$(jq -r '.hash_mismatches // 0' "$INTEGRITY_STATUS_FILE" 2>/dev/null || echo "0")" + + case "$integrity_status" in + pass) + pass "startup manifest integrity passed ($integrity_checked_at)" + ;; + warn) + warn "startup manifest integrity reported issues at $integrity_checked_at ($integrity_missing missing, $integrity_mismatches mismatched)" + ;; + fail) + fail "startup manifest integrity failed at $integrity_checked_at ($integrity_missing missing, $integrity_mismatches mismatched)" + ;; + skipped) + warn "startup manifest integrity check is disabled/skipped" + ;; + *) + warn "startup manifest integrity status unknown (file: $INTEGRITY_STATUS_FILE)" + ;; + esac +else + if [ "$IS_ROOT" -ne 1 ] && [ -d "$BAUDBOT_HOME/.pi/agent" ]; then + warn "cannot verify startup manifest integrity status as non-root (run: sudo baudbot doctor)" + else + warn "startup manifest integrity status file missing ($INTEGRITY_STATUS_FILE)" + fi +fi + # ── Security ───────────────────────────────────────────────────────────────── echo "" diff --git a/bin/lib/paths-common.sh b/bin/lib/paths-common.sh index 92afa54..b9ee0d3 100644 --- a/bin/lib/paths-common.sh +++ b/bin/lib/paths-common.sh @@ -54,6 +54,7 @@ bb_init_paths() { : "${BAUDBOT_AGENT_SETTINGS_FILE:=$BAUDBOT_AGENT_DIR/settings.json}" : "${BAUDBOT_VERSION_FILE:=$BAUDBOT_AGENT_DIR/baudbot-version.json}" : "${BAUDBOT_MANIFEST_FILE:=$BAUDBOT_AGENT_DIR/baudbot-manifest.json}" + : "${BAUDBOT_INTEGRITY_STATUS_FILE:=$BAUDBOT_AGENT_DIR/manifest-integrity-status.json}" : "${BAUDBOT_ENV_FILE:=$BAUDBOT_AGENT_HOME/.config/.env}" bb_refresh_release_paths @@ -61,7 +62,7 @@ bb_init_paths() { export BAUDBOT_AGENT_USER BAUDBOT_AGENT_HOME BAUDBOT_HOME export BAUDBOT_RUNTIME_DIR BAUDBOT_PI_DIR BAUDBOT_AGENT_DIR export BAUDBOT_AGENT_EXT_DIR BAUDBOT_AGENT_SKILLS_DIR BAUDBOT_AGENT_SETTINGS_FILE - export BAUDBOT_VERSION_FILE BAUDBOT_MANIFEST_FILE BAUDBOT_ENV_FILE + export BAUDBOT_VERSION_FILE BAUDBOT_MANIFEST_FILE BAUDBOT_INTEGRITY_STATUS_FILE BAUDBOT_ENV_FILE export BAUDBOT_RELEASE_ROOT BAUDBOT_RELEASES_DIR BAUDBOT_CURRENT_LINK BAUDBOT_PREVIOUS_LINK export BAUDBOT_SOURCE_URL_FILE BAUDBOT_SOURCE_BRANCH_FILE } diff --git a/bin/security-audit.sh b/bin/security-audit.sh index bbe4cc6..7d2a1bb 100755 --- a/bin/security-audit.sh +++ b/bin/security-audit.sh @@ -289,6 +289,36 @@ else finding "WARN" "No deploy manifest found — cannot verify integrity" \ "Run deploy.sh to generate" fi + +if [ -f "$BAUDBOT_INTEGRITY_STATUS_FILE" ]; then + status_value="$(jq -r '.status // "unknown"' "$BAUDBOT_INTEGRITY_STATUS_FILE" 2>/dev/null || echo "unknown")" + status_checked_at="$(jq -r '.checked_at // "unknown"' "$BAUDBOT_INTEGRITY_STATUS_FILE" 2>/dev/null || echo "unknown")" + status_missing="$(jq -r '.missing_files // 0' "$BAUDBOT_INTEGRITY_STATUS_FILE" 2>/dev/null || echo "0")" + status_mismatches="$(jq -r '.hash_mismatches // 0' "$BAUDBOT_INTEGRITY_STATUS_FILE" 2>/dev/null || echo "0")" + + case "$status_value" in + pass) + ok "Last startup integrity check passed ($status_checked_at)" + ;; + warn) + finding "WARN" "Last startup integrity check reported issues" \ + "$status_checked_at — missing: $status_missing, mismatched: $status_mismatches" + ;; + fail) + finding "CRITICAL" "Last startup integrity check failed" \ + "$status_checked_at — missing: $status_missing, mismatched: $status_mismatches" + ;; + skipped) + finding "WARN" "Last startup integrity check was skipped/disabled" "$status_checked_at" + ;; + *) + finding "INFO" "Startup integrity status unknown" "$BAUDBOT_INTEGRITY_STATUS_FILE" + ;; + esac +else + finding "WARN" "No startup integrity status found" \ + "Expected: $BAUDBOT_INTEGRITY_STATUS_FILE (restart agent after deploy)" +fi echo "" # ── Secrets in readable files ──────────────────────────────────────────────── diff --git a/bin/test.sh b/bin/test.sh index ef2c940..312abe2 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -76,6 +76,7 @@ run_shell_tests() { run "safe-bash wrapper" bash bin/baudbot-safe-bash.test.sh run "log redaction" bash bin/redact-logs.test.sh run "log pruning" bash bin/prune-session-logs.test.sh + run "manifest integrity" bash bin/verify-manifest.test.sh run "config flow" bash bin/config.test.sh run "deploy lib helpers" bash bin/lib/deploy-common.test.sh run "doctor lib helpers" bash bin/lib/doctor-common.test.sh diff --git a/bin/verify-manifest.sh b/bin/verify-manifest.sh new file mode 100755 index 0000000..c6504da --- /dev/null +++ b/bin/verify-manifest.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# Verify deployed runtime files against ~/.pi/agent/baudbot-manifest.json. +# +# Modes: +# off - skip verification (exit 0) +# warn - log warnings on mismatch (exit 0) +# strict - fail on mismatch (exit 1) + +set -euo pipefail + +MODE="${BAUDBOT_STARTUP_INTEGRITY_MODE:-warn}" +MANIFEST_FILE="${BAUDBOT_MANIFEST_FILE:-$HOME/.pi/agent/baudbot-manifest.json}" +STATUS_FILE="${BAUDBOT_INTEGRITY_STATUS_FILE:-$HOME/.pi/agent/manifest-integrity-status.json}" +AGENT_HOME="${BAUDBOT_HOME:-$HOME}" +RELEASE_ROOT="${BAUDBOT_CURRENT_LINK:-/opt/baudbot/current}" + +# Expected mutable content that should not block startup if present in a manifest. +EXCLUDE_REGEX='^\.pi/agent/(sessions|memory|logs)/|\.log$' + +mkdir -p "$(dirname "$STATUS_FILE")" + +write_status() { + local status="$1" + local checked_files="$2" + local skipped_files="$3" + local missing_files="$4" + local hash_mismatches="$5" + + cat >"$STATUS_FILE" </dev/null || true +} + +case "$MODE" in + off|warn|strict) ;; + *) + echo "⚠️ Unknown BAUDBOT_STARTUP_INTEGRITY_MODE='$MODE' (expected: off|warn|strict). Falling back to warn." >&2 + MODE="warn" + ;; +esac + +if [ "$MODE" = "off" ]; then + echo "Startup integrity check disabled (BAUDBOT_STARTUP_INTEGRITY_MODE=off)." + write_status "skipped" 0 0 0 0 + exit 0 +fi + +if [ ! -f "$MANIFEST_FILE" ]; then + echo "⚠️ Deploy manifest not found: $MANIFEST_FILE" >&2 + write_status "warn" 0 0 0 0 + if [ "$MODE" = "strict" ]; then + echo "❌ Startup integrity verification failed (missing manifest, strict mode)." >&2 + exit 1 + fi + exit 0 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "⚠️ jq not found; cannot parse deploy manifest for startup integrity check." >&2 + write_status "warn" 0 0 0 0 + if [ "$MODE" = "strict" ]; then + echo "❌ Startup integrity verification failed (jq missing, strict mode)." >&2 + exit 1 + fi + exit 0 +fi + +if ! jq -e '.files and (.files | type == "object")' "$MANIFEST_FILE" >/dev/null 2>&1; then + echo "⚠️ Invalid manifest format (missing .files object): $MANIFEST_FILE" >&2 + write_status "warn" 0 0 0 0 + if [ "$MODE" = "strict" ]; then + echo "❌ Startup integrity verification failed (invalid manifest, strict mode)." >&2 + exit 1 + fi + exit 0 +fi + +checked_files=0 +skipped_files=0 +missing_files=0 +hash_mismatches=0 + +while IFS=$'\t' read -r rel_path expected_hash; do + [ -n "$rel_path" ] || continue + + if [[ "$rel_path" =~ $EXCLUDE_REGEX ]]; then + skipped_files=$((skipped_files + 1)) + continue + fi + + if [[ "$rel_path" == release/* ]]; then + full_path="$RELEASE_ROOT/${rel_path#release/}" + else + full_path="$AGENT_HOME/$rel_path" + fi + + checked_files=$((checked_files + 1)) + + if [ ! -f "$full_path" ]; then + echo "⚠️ Missing file from manifest: $rel_path ($full_path)" >&2 + missing_files=$((missing_files + 1)) + continue + fi + + actual_hash=$(sha256sum "$full_path" | awk '{print $1}') + if [ "$actual_hash" != "$expected_hash" ]; then + echo "⚠️ Hash mismatch: $rel_path" >&2 + hash_mismatches=$((hash_mismatches + 1)) + fi +done < <(jq -r '.files | to_entries[] | [.key, .value] | @tsv' "$MANIFEST_FILE") + +if [ "$missing_files" -eq 0 ] && [ "$hash_mismatches" -eq 0 ]; then + echo "✅ Startup integrity check passed ($checked_files files, $skipped_files skipped)." + write_status "pass" "$checked_files" "$skipped_files" 0 0 + exit 0 +fi + +total_issues=$((missing_files + hash_mismatches)) +echo "⚠️ Startup integrity check found $total_issues issue(s) ($missing_files missing, $hash_mismatches hash mismatch)." >&2 + +if [ "$MODE" = "strict" ]; then + write_status "fail" "$checked_files" "$skipped_files" "$missing_files" "$hash_mismatches" + echo "❌ Strict mode enabled; refusing to start." >&2 + exit 1 +fi + +write_status "warn" "$checked_files" "$skipped_files" "$missing_files" "$hash_mismatches" +exit 0 diff --git a/bin/verify-manifest.test.sh b/bin/verify-manifest.test.sh new file mode 100755 index 0000000..cff4e32 --- /dev/null +++ b/bin/verify-manifest.test.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# Tests for verify-manifest.sh + +set -euo pipefail + +SCRIPT="$(dirname "$0")/verify-manifest.sh" +PASS=0 +FAIL=0 + +TMPDIR=$(mktemp -d) +cleanup() { rm -rf "$TMPDIR"; } +trap cleanup EXIT + +pass() { + echo " PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo " FAIL: $1" + FAIL=$((FAIL + 1)) +} + +expect_eq() { + local desc="$1" + local actual="$2" + local expected="$3" + if [ "$actual" = "$expected" ]; then + pass "$desc" + else + fail "$desc (expected '$expected', got '$actual')" + fi +} + +hash_file() { + sha256sum "$1" | awk '{print $1}' +} + +make_manifest() { + local manifest_file="$1" + local home_dir="$2" + local release_dir="$3" + + local ext_file="$home_dir/.pi/agent/extensions/test.ts" + local runtime_file="$home_dir/runtime/bin/helper.sh" + local bridge_file="$release_dir/slack-bridge/bridge.mjs" + local log_file="$home_dir/.pi/agent/logs/bridge.log" + + cat >"$manifest_file" < "$HOME1/.pi/agent/extensions/test.ts" +printf '#!/bin/bash\necho helper\n' > "$HOME1/runtime/bin/helper.sh" +printf 'export const bridge = true;\n' > "$RELEASE1/slack-bridge/bridge.mjs" +printf 'mutable log\n' > "$HOME1/.pi/agent/logs/bridge.log" + +MANIFEST1="$HOME1/.pi/agent/baudbot-manifest.json" +STATUS1="$HOME1/.pi/agent/manifest-integrity-status.json" +make_manifest "$MANIFEST1" "$HOME1" "$RELEASE1" + +BAUDBOT_HOME="$HOME1" \ +BAUDBOT_CURRENT_LINK="$RELEASE1" \ +BAUDBOT_MANIFEST_FILE="$MANIFEST1" \ +BAUDBOT_INTEGRITY_STATUS_FILE="$STATUS1" \ +BAUDBOT_STARTUP_INTEGRITY_MODE="warn" \ +bash "$SCRIPT" >/dev/null + +expect_eq "matching manifest passes" "$(status_field "$STATUS1" status)" "pass" +expect_eq "mutable log path was skipped" "$(status_field "$STATUS1" skipped_files)" "1" + +echo "" +echo "Test: warn mode does not fail startup" +printf 'tampered\n' > "$HOME1/.pi/agent/extensions/test.ts" + +BAUDBOT_HOME="$HOME1" \ +BAUDBOT_CURRENT_LINK="$RELEASE1" \ +BAUDBOT_MANIFEST_FILE="$MANIFEST1" \ +BAUDBOT_INTEGRITY_STATUS_FILE="$STATUS1" \ +BAUDBOT_STARTUP_INTEGRITY_MODE="warn" \ +bash "$SCRIPT" >/dev/null + +expect_eq "warn mode records warn status" "$(status_field "$STATUS1" status)" "warn" + +echo "" +echo "Test: strict mode fails on mismatch" +set +e +BAUDBOT_HOME="$HOME1" \ +BAUDBOT_CURRENT_LINK="$RELEASE1" \ +BAUDBOT_MANIFEST_FILE="$MANIFEST1" \ +BAUDBOT_INTEGRITY_STATUS_FILE="$STATUS1" \ +BAUDBOT_STARTUP_INTEGRITY_MODE="strict" \ +bash "$SCRIPT" >/dev/null 2>&1 +rc=$? +set -e + +if [ "$rc" -ne 0 ]; then + pass "strict mode exits non-zero on mismatch" +else + fail "strict mode should exit non-zero on mismatch" +fi +expect_eq "strict mode records fail status" "$(status_field "$STATUS1" status)" "fail" + +echo "" +echo "Test: off mode skips verification" +BAUDBOT_HOME="$HOME1" \ +BAUDBOT_CURRENT_LINK="$RELEASE1" \ +BAUDBOT_MANIFEST_FILE="$MANIFEST1" \ +BAUDBOT_INTEGRITY_STATUS_FILE="$STATUS1" \ +BAUDBOT_STARTUP_INTEGRITY_MODE="off" \ +bash "$SCRIPT" >/dev/null + +expect_eq "off mode records skipped status" "$(status_field "$STATUS1" status)" "skipped" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi + +echo "ALL PASSED" diff --git a/start.sh b/start.sh index e64558e..74fe8c8 100755 --- a/start.sh +++ b/start.sh @@ -45,6 +45,18 @@ umask 077 # Redact any secrets that leaked into retained session logs ~/runtime/bin/redact-logs.sh 2>/dev/null || true +# Verify deployed runtime integrity against deploy manifest. +# Modes: off | warn | strict (default: warn) +INTEGRITY_MODE="${BAUDBOT_STARTUP_INTEGRITY_MODE:-warn}" +if [ -x "$HOME/runtime/bin/verify-manifest.sh" ]; then + if ! BAUDBOT_STARTUP_INTEGRITY_MODE="$INTEGRITY_MODE" "$HOME/runtime/bin/verify-manifest.sh"; then + echo "❌ Startup integrity verification failed (mode: $INTEGRITY_MODE). Refusing to start." + exit 1 + fi +else + echo "⚠️ Startup integrity verifier missing at ~/runtime/bin/verify-manifest.sh" +fi + # Clean stale session sockets from previous runs SOCKET_DIR="$HOME/.pi/session-control" if [ -d "$SOCKET_DIR" ]; then