Skip to content

Commit f16231a

Browse files
committed
security: verify deploy manifest at startup
1 parent eaf7fd0 commit f16231a

11 files changed

Lines changed: 382 additions & 3 deletions

.env.schema

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ BAUDBOT_AGENT_HOME=/home/baudbot_agent
181181
# @sensitive=false @type=string
182182
BAUDBOT_SOURCE_DIR=
183183

184+
# ── Startup Integrity ────────────────────────────────────────────────────────
185+
186+
# Startup deploy-manifest integrity mode: off | warn | strict
187+
# @sensitive=false @type=string
188+
BAUDBOT_STARTUP_INTEGRITY_MODE=warn
189+
184190
# ── Bridge ───────────────────────────────────────────────────────────────────
185191

186192
# Local HTTP API port for outbound Slack messages

CONFIGURATION.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,18 @@ Set during `setup.sh` / `baudbot install` via env vars:
166166
| `IDLE_COMPACT_THRESHOLD_PCT` | Context usage % to trigger compaction (10–90) | `25` |
167167
| `IDLE_COMPACT_ENABLED` | Set to `0`, `false`, or `no` to disable idle compaction | enabled |
168168

169+
### Startup Integrity
170+
171+
| Variable | Description | Default |
172+
|----------|-------------|---------|
173+
| `BAUDBOT_STARTUP_INTEGRITY_MODE` | Startup manifest verification mode: `off`, `warn`, `strict` | `warn` |
174+
175+
On startup, Baudbot verifies deployed runtime files against `~/.pi/agent/baudbot-manifest.json` and records the result in `~/.pi/agent/manifest-integrity-status.json`.
176+
177+
- `warn`: log high-severity warnings but continue startup
178+
- `strict`: fail startup on missing/mismatched files or unreadable manifest
179+
- `off`: skip verification (not recommended)
180+
169181
### Bridge
170182

171183
| Variable | Description | Default |

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Baudbot is built for utility **and** containment:
152152
- isolated `baudbot_agent` Unix user (no general sudo)
153153
- per-UID firewall controls + process isolation
154154
- source/runtime separation with deploy manifests
155+
- startup deploy-manifest integrity verification (`warn`/`strict` modes)
155156
- read-only protection for security-critical files
156157
- session log hygiene (startup redaction + retention pruning)
157158
- layered tool and shell guardrails (policy/guidance layer, not sole containment)

bin/deploy.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ if [ "$DRY_RUN" -eq 0 ]; then
8383
cp --no-preserve=ownership "$BAUDBOT_SRC/start.sh" "$STAGE_DIR/start.sh"
8484
mkdir -p "$STAGE_DIR/bin"
8585
mkdir -p "$STAGE_DIR/bin/lib"
86-
for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh; do
86+
for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh verify-manifest.sh; do
8787
[ -f "$BAUDBOT_SRC/bin/$script" ] && cp --no-preserve=ownership "$BAUDBOT_SRC/bin/$script" "$STAGE_DIR/bin/$script"
8888
done
8989
[ -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
249249
as_agent mkdir -p "$BAUDBOT_HOME/runtime/bin"
250250
as_agent mkdir -p "$BAUDBOT_HOME/runtime/bin/lib"
251251

252-
for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh; do
252+
for script in harden-permissions.sh redact-logs.sh prune-session-logs.sh verify-manifest.sh; do
253253
if [ -f "$STAGE_DIR/bin/$script" ]; then
254254
as_agent cp "$STAGE_DIR/bin/$script" "$BAUDBOT_HOME/runtime/bin/$script"
255255
as_agent chmod u+x "$BAUDBOT_HOME/runtime/bin/$script"

bin/doctor.sh

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,38 @@ else
304304
fi
305305
fi
306306

307+
INTEGRITY_STATUS_FILE="$BAUDBOT_INTEGRITY_STATUS_FILE"
308+
if [ -f "$INTEGRITY_STATUS_FILE" ]; then
309+
integrity_status="$(jq -r '.status // "unknown"' "$INTEGRITY_STATUS_FILE" 2>/dev/null || echo "unknown")"
310+
integrity_checked_at="$(jq -r '.checked_at // "unknown"' "$INTEGRITY_STATUS_FILE" 2>/dev/null || echo "unknown")"
311+
integrity_missing="$(jq -r '.missing_files // 0' "$INTEGRITY_STATUS_FILE" 2>/dev/null || echo "0")"
312+
integrity_mismatches="$(jq -r '.hash_mismatches // 0' "$INTEGRITY_STATUS_FILE" 2>/dev/null || echo "0")"
313+
314+
case "$integrity_status" in
315+
pass)
316+
pass "startup manifest integrity passed ($integrity_checked_at)"
317+
;;
318+
warn)
319+
warn "startup manifest integrity reported issues at $integrity_checked_at ($integrity_missing missing, $integrity_mismatches mismatched)"
320+
;;
321+
fail)
322+
fail "startup manifest integrity failed at $integrity_checked_at ($integrity_missing missing, $integrity_mismatches mismatched)"
323+
;;
324+
skipped)
325+
warn "startup manifest integrity check is disabled/skipped"
326+
;;
327+
*)
328+
warn "startup manifest integrity status unknown (file: $INTEGRITY_STATUS_FILE)"
329+
;;
330+
esac
331+
else
332+
if [ "$IS_ROOT" -ne 1 ] && [ -d "$BAUDBOT_HOME/.pi/agent" ]; then
333+
warn "cannot verify startup manifest integrity status as non-root (run: sudo baudbot doctor)"
334+
else
335+
warn "startup manifest integrity status file missing ($INTEGRITY_STATUS_FILE)"
336+
fi
337+
fi
338+
307339
# ── Security ─────────────────────────────────────────────────────────────────
308340

309341
echo ""

bin/lib/paths-common.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,15 @@ bb_init_paths() {
5454
: "${BAUDBOT_AGENT_SETTINGS_FILE:=$BAUDBOT_AGENT_DIR/settings.json}"
5555
: "${BAUDBOT_VERSION_FILE:=$BAUDBOT_AGENT_DIR/baudbot-version.json}"
5656
: "${BAUDBOT_MANIFEST_FILE:=$BAUDBOT_AGENT_DIR/baudbot-manifest.json}"
57+
: "${BAUDBOT_INTEGRITY_STATUS_FILE:=$BAUDBOT_AGENT_DIR/manifest-integrity-status.json}"
5758
: "${BAUDBOT_ENV_FILE:=$BAUDBOT_AGENT_HOME/.config/.env}"
5859

5960
bb_refresh_release_paths
6061

6162
export BAUDBOT_AGENT_USER BAUDBOT_AGENT_HOME BAUDBOT_HOME
6263
export BAUDBOT_RUNTIME_DIR BAUDBOT_PI_DIR BAUDBOT_AGENT_DIR
6364
export BAUDBOT_AGENT_EXT_DIR BAUDBOT_AGENT_SKILLS_DIR BAUDBOT_AGENT_SETTINGS_FILE
64-
export BAUDBOT_VERSION_FILE BAUDBOT_MANIFEST_FILE BAUDBOT_ENV_FILE
65+
export BAUDBOT_VERSION_FILE BAUDBOT_MANIFEST_FILE BAUDBOT_INTEGRITY_STATUS_FILE BAUDBOT_ENV_FILE
6566
export BAUDBOT_RELEASE_ROOT BAUDBOT_RELEASES_DIR BAUDBOT_CURRENT_LINK BAUDBOT_PREVIOUS_LINK
6667
export BAUDBOT_SOURCE_URL_FILE BAUDBOT_SOURCE_BRANCH_FILE
6768
}

bin/security-audit.sh

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,36 @@ else
289289
finding "WARN" "No deploy manifest found — cannot verify integrity" \
290290
"Run deploy.sh to generate"
291291
fi
292+
293+
if [ -f "$BAUDBOT_INTEGRITY_STATUS_FILE" ]; then
294+
status_value="$(jq -r '.status // "unknown"' "$BAUDBOT_INTEGRITY_STATUS_FILE" 2>/dev/null || echo "unknown")"
295+
status_checked_at="$(jq -r '.checked_at // "unknown"' "$BAUDBOT_INTEGRITY_STATUS_FILE" 2>/dev/null || echo "unknown")"
296+
status_missing="$(jq -r '.missing_files // 0' "$BAUDBOT_INTEGRITY_STATUS_FILE" 2>/dev/null || echo "0")"
297+
status_mismatches="$(jq -r '.hash_mismatches // 0' "$BAUDBOT_INTEGRITY_STATUS_FILE" 2>/dev/null || echo "0")"
298+
299+
case "$status_value" in
300+
pass)
301+
ok "Last startup integrity check passed ($status_checked_at)"
302+
;;
303+
warn)
304+
finding "WARN" "Last startup integrity check reported issues" \
305+
"$status_checked_at — missing: $status_missing, mismatched: $status_mismatches"
306+
;;
307+
fail)
308+
finding "CRITICAL" "Last startup integrity check failed" \
309+
"$status_checked_at — missing: $status_missing, mismatched: $status_mismatches"
310+
;;
311+
skipped)
312+
finding "WARN" "Last startup integrity check was skipped/disabled" "$status_checked_at"
313+
;;
314+
*)
315+
finding "INFO" "Startup integrity status unknown" "$BAUDBOT_INTEGRITY_STATUS_FILE"
316+
;;
317+
esac
318+
else
319+
finding "WARN" "No startup integrity status found" \
320+
"Expected: $BAUDBOT_INTEGRITY_STATUS_FILE (restart agent after deploy)"
321+
fi
292322
echo ""
293323

294324
# ── Secrets in readable files ────────────────────────────────────────────────

bin/test.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ run_shell_tests() {
7676
run "safe-bash wrapper" bash bin/baudbot-safe-bash.test.sh
7777
run "log redaction" bash bin/redact-logs.test.sh
7878
run "log pruning" bash bin/prune-session-logs.test.sh
79+
run "manifest integrity" bash bin/verify-manifest.test.sh
7980
run "config flow" bash bin/config.test.sh
8081
run "deploy lib helpers" bash bin/lib/deploy-common.test.sh
8182
run "doctor lib helpers" bash bin/lib/doctor-common.test.sh

bin/verify-manifest.sh

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/bin/bash
2+
# Verify deployed runtime files against ~/.pi/agent/baudbot-manifest.json.
3+
#
4+
# Modes:
5+
# off - skip verification (exit 0)
6+
# warn - log warnings on mismatch (exit 0)
7+
# strict - fail on mismatch (exit 1)
8+
9+
set -euo pipefail
10+
11+
MODE="${BAUDBOT_STARTUP_INTEGRITY_MODE:-warn}"
12+
MANIFEST_FILE="${BAUDBOT_MANIFEST_FILE:-$HOME/.pi/agent/baudbot-manifest.json}"
13+
STATUS_FILE="${BAUDBOT_INTEGRITY_STATUS_FILE:-$HOME/.pi/agent/manifest-integrity-status.json}"
14+
AGENT_HOME="${BAUDBOT_HOME:-$HOME}"
15+
RELEASE_ROOT="${BAUDBOT_CURRENT_LINK:-/opt/baudbot/current}"
16+
17+
# Expected mutable content that should not block startup if present in a manifest.
18+
EXCLUDE_REGEX='^\.pi/agent/(sessions|memory|logs)/|\.log$'
19+
20+
mkdir -p "$(dirname "$STATUS_FILE")"
21+
22+
write_status() {
23+
local status="$1"
24+
local checked_files="$2"
25+
local skipped_files="$3"
26+
local missing_files="$4"
27+
local hash_mismatches="$5"
28+
29+
cat >"$STATUS_FILE" <<EOF
30+
{
31+
"checked_at": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
32+
"mode": "$MODE",
33+
"status": "$status",
34+
"manifest": "$MANIFEST_FILE",
35+
"checked_files": $checked_files,
36+
"skipped_files": $skipped_files,
37+
"missing_files": $missing_files,
38+
"hash_mismatches": $hash_mismatches
39+
}
40+
EOF
41+
chmod 600 "$STATUS_FILE" 2>/dev/null || true
42+
}
43+
44+
case "$MODE" in
45+
off|warn|strict) ;;
46+
*)
47+
echo "⚠️ Unknown BAUDBOT_STARTUP_INTEGRITY_MODE='$MODE' (expected: off|warn|strict). Falling back to warn." >&2
48+
MODE="warn"
49+
;;
50+
esac
51+
52+
if [ "$MODE" = "off" ]; then
53+
echo "Startup integrity check disabled (BAUDBOT_STARTUP_INTEGRITY_MODE=off)."
54+
write_status "skipped" 0 0 0 0
55+
exit 0
56+
fi
57+
58+
if [ ! -f "$MANIFEST_FILE" ]; then
59+
echo "⚠️ Deploy manifest not found: $MANIFEST_FILE" >&2
60+
write_status "warn" 0 0 0 0
61+
if [ "$MODE" = "strict" ]; then
62+
echo "❌ Startup integrity verification failed (missing manifest, strict mode)." >&2
63+
exit 1
64+
fi
65+
exit 0
66+
fi
67+
68+
if ! command -v jq >/dev/null 2>&1; then
69+
echo "⚠️ jq not found; cannot parse deploy manifest for startup integrity check." >&2
70+
write_status "warn" 0 0 0 0
71+
if [ "$MODE" = "strict" ]; then
72+
echo "❌ Startup integrity verification failed (jq missing, strict mode)." >&2
73+
exit 1
74+
fi
75+
exit 0
76+
fi
77+
78+
if ! jq -e '.files and (.files | type == "object")' "$MANIFEST_FILE" >/dev/null 2>&1; then
79+
echo "⚠️ Invalid manifest format (missing .files object): $MANIFEST_FILE" >&2
80+
write_status "warn" 0 0 0 0
81+
if [ "$MODE" = "strict" ]; then
82+
echo "❌ Startup integrity verification failed (invalid manifest, strict mode)." >&2
83+
exit 1
84+
fi
85+
exit 0
86+
fi
87+
88+
checked_files=0
89+
skipped_files=0
90+
missing_files=0
91+
hash_mismatches=0
92+
93+
while IFS=$'\t' read -r rel_path expected_hash; do
94+
[ -n "$rel_path" ] || continue
95+
96+
if [[ "$rel_path" =~ $EXCLUDE_REGEX ]]; then
97+
skipped_files=$((skipped_files + 1))
98+
continue
99+
fi
100+
101+
if [[ "$rel_path" == release/* ]]; then
102+
full_path="$RELEASE_ROOT/${rel_path#release/}"
103+
else
104+
full_path="$AGENT_HOME/$rel_path"
105+
fi
106+
107+
checked_files=$((checked_files + 1))
108+
109+
if [ ! -f "$full_path" ]; then
110+
echo "⚠️ Missing file from manifest: $rel_path ($full_path)" >&2
111+
missing_files=$((missing_files + 1))
112+
continue
113+
fi
114+
115+
actual_hash=$(sha256sum "$full_path" | awk '{print $1}')
116+
if [ "$actual_hash" != "$expected_hash" ]; then
117+
echo "⚠️ Hash mismatch: $rel_path" >&2
118+
hash_mismatches=$((hash_mismatches + 1))
119+
fi
120+
done < <(jq -r '.files | to_entries[] | [.key, .value] | @tsv' "$MANIFEST_FILE")
121+
122+
if [ "$missing_files" -eq 0 ] && [ "$hash_mismatches" -eq 0 ]; then
123+
echo "✅ Startup integrity check passed ($checked_files files, $skipped_files skipped)."
124+
write_status "pass" "$checked_files" "$skipped_files" 0 0
125+
exit 0
126+
fi
127+
128+
total_issues=$((missing_files + hash_mismatches))
129+
echo "⚠️ Startup integrity check found $total_issues issue(s) ($missing_files missing, $hash_mismatches hash mismatch)." >&2
130+
131+
if [ "$MODE" = "strict" ]; then
132+
write_status "fail" "$checked_files" "$skipped_files" "$missing_files" "$hash_mismatches"
133+
echo "❌ Strict mode enabled; refusing to start." >&2
134+
exit 1
135+
fi
136+
137+
write_status "warn" "$checked_files" "$skipped_files" "$missing_files" "$hash_mismatches"
138+
exit 0

0 commit comments

Comments
 (0)