@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. package/ui/templates/dashboard.html +1202 -0
@@ -0,0 +1,753 @@
1
+ #!/usr/bin/env bash
2
+ set -uo pipefail
3
+
4
+ json_escape() {
5
+ # Escape backslashes first, then quotes, then control chars for valid JSON
6
+ # Finding 5: Handle control characters (\n, \r, \t, C0)
7
+ local s="$1"
8
+ s="${s//\\/\\\\}"
9
+ s="${s//\"/\\\"}"
10
+ s="${s//$'\n'/\\n}"
11
+ s="${s//$'\r'/\\r}"
12
+ s="${s//$'\t'/\\t}"
13
+ printf '%s' "$s"
14
+ }
15
+ compute_sha256() {
16
+ # P1-Bug2 fix: Single helper for sha256sum with fallback to shasum
17
+ # Reads from stdin, outputs hex hash
18
+ local hash_bin
19
+ if command -v sha256sum >/dev/null 2>&1; then
20
+ hash_bin="sha256sum"
21
+ elif command -v shasum >/dev/null 2>&1; then
22
+ hash_bin="shasum -a 256"
23
+ else
24
+ printf 'ERROR: sha256sum or shasum not found in PATH
25
+ ' >&2
26
+ return 1
27
+ fi
28
+ $hash_bin | awk '{print $1}'
29
+ }
30
+
31
+
32
+ acquire_audit_lock() {
33
+ # Finding 1: Mkdir-based atomic lock for audit log write safety
34
+ local lock_dir="$1"
35
+ local timeout=300
36
+ local start_time
37
+ start_time=$(date +%s)
38
+
39
+ while true; do
40
+ if mkdir "$lock_dir" 2>/dev/null; then
41
+ # Acquired lock successfully
42
+ echo "$$" > "$lock_dir/pid"
43
+ return 0
44
+ fi
45
+
46
+ # Check if lock is stale (>timeout seconds old)
47
+ if [ -f "$lock_dir/pid" ]; then
48
+ local lock_time
49
+ lock_time=$(stat -c %Y "$lock_dir" 2>/dev/null || stat -f %m "$lock_dir" 2>/dev/null || echo 0)
50
+ local current_time
51
+ current_time=$(date +%s)
52
+ if [ $((current_time - lock_time)) -gt $timeout ]; then
53
+ # Stale lock; force reclaim atomically
54
+ rm -rf "$lock_dir" 2>/dev/null
55
+ mkdir "$lock_dir" 2>/dev/null && echo "$$" > "$lock_dir/pid" && return 0
56
+ fi
57
+ fi
58
+
59
+ # Check timeout
60
+ if [ $(($(date +%s) - start_time)) -gt 10 ]; then
61
+ # Lock holder is stuck; give up after 10s
62
+ return 1
63
+ fi
64
+
65
+ sleep 0.1
66
+ done
67
+ }
68
+
69
+ release_audit_lock() {
70
+ # P0 fix: Release the lock directory only if we own it (pid matches)
71
+ local lock_dir="$1"
72
+ if [ -f "$lock_dir/pid" ]; then
73
+ local lock_pid
74
+ lock_pid=$(cat "$lock_dir/pid" 2>/dev/null || echo "")
75
+ if [ "$lock_pid" = "$$" ]; then
76
+ rm -rf "$lock_dir" 2>/dev/null
77
+ fi
78
+ fi
79
+ }
80
+
81
+ get_previous_hash() {
82
+ # Finding 6: Fallback for missing sha256sum, fail loudly if unavailable
83
+ local audit_log="$1"
84
+ if [ ! -f "$audit_log" ] || [ ! -s "$audit_log" ]; then
85
+ printf 'GENESIS'
86
+ return 0
87
+ fi
88
+
89
+ local hash_bin
90
+ if command -v sha256sum >/dev/null 2>&1; then
91
+ hash_bin="sha256sum"
92
+ elif command -v shasum >/dev/null 2>&1; then
93
+ hash_bin="shasum -a 256"
94
+ else
95
+ printf 'ERROR: sha256sum or shasum not found in PATH\n' >&2
96
+ return 1
97
+ fi
98
+
99
+ tail -n 1 "$audit_log" | tr -d '\n' | $hash_bin | awk '{print $1}'
100
+ }
101
+
102
+ get_next_seq() {
103
+ # Finding 2: Get monotonically increasing sequence number
104
+ local audit_log="$1"
105
+ if [ ! -f "$audit_log" ] || [ ! -s "$audit_log" ]; then
106
+ echo 1
107
+ return 0
108
+ fi
109
+
110
+ local last_seq
111
+ last_seq=$(tail -n 1 "$audit_log" | python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('seq', 0))" 2>/dev/null || echo 0)
112
+ echo $((last_seq + 1))
113
+ }
114
+
115
+ verify_audit_log() {
116
+ # P1-Bug1 fix: Acquire write lock while reading/verifying sidecar
117
+ # Prevents false truncation positive during concurrent appends
118
+ # Finding 2: Include truncation detection via tail hash sidecar and seq field
119
+ local audit_log="$1"
120
+ if [ ! -f "$audit_log" ]; then
121
+ printf 'Error: Audit log not found at %s
122
+ ' "$audit_log" >&2
123
+ return 1
124
+ fi
125
+
126
+ if [ ! -s "$audit_log" ]; then
127
+ printf 'Audit log is empty or does not exist
128
+ '
129
+ return 0
130
+ fi
131
+
132
+ local state_dir
133
+ state_dir=$(dirname "$audit_log")
134
+ local lock_dir="$state_dir/.audit-log-lock"
135
+ local tail_hash_file="$state_dir/.audit-tail-hash"
136
+
137
+ # P1-Bug1: Acquire lock before reading sidecar to prevent race
138
+ if ! acquire_audit_lock "$lock_dir"; then
139
+ printf 'Warning: Could not acquire lock for verification; skipping sidecar check
140
+ ' >&2
141
+ fi
142
+
143
+ local line_num=0
144
+ local prev_line=""
145
+ local expected_hash=""
146
+ local actual_prev_hash=""
147
+ local prev_seq=0
148
+
149
+ while IFS= read -r line; do
150
+ line_num=$((line_num + 1))
151
+
152
+ if [ $line_num -eq 1 ]; then
153
+ expected_hash="GENESIS"
154
+ else
155
+ expected_hash=$(printf '%s' "$prev_line" | compute_sha256)
156
+ fi
157
+
158
+ actual_prev_hash=$(printf '%s' "$line" | python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('prev_hash', 'MISSING'))" 2>/dev/null)
159
+
160
+ if [ "$actual_prev_hash" = "MISSING" ]; then
161
+ release_audit_lock "$lock_dir"
162
+ printf 'Error: Line %d missing prev_hash field
163
+ ' "$line_num" >&2
164
+ return 1
165
+ fi
166
+
167
+ if [ "$actual_prev_hash" != "$expected_hash" ]; then
168
+ release_audit_lock "$lock_dir"
169
+ printf 'Error: Hash chain broken at line %d
170
+ ' "$line_num" >&2
171
+ printf ' Expected prev_hash: %s
172
+ ' "$expected_hash" >&2
173
+ printf ' Actual prev_hash: %s
174
+ ' "$actual_prev_hash" >&2
175
+ return 1
176
+ fi
177
+
178
+ # Check seq monotonicity
179
+ local current_seq
180
+ current_seq=$(printf '%s' "$line" | python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('seq', 0))" 2>/dev/null || echo 0)
181
+ if [ "$current_seq" -le "$prev_seq" ] && [ $line_num -gt 1 ]; then
182
+ release_audit_lock "$lock_dir"
183
+ printf 'Error: Sequence number not monotonic at line %d (prev: %d, current: %d)
184
+ ' "$line_num" "$prev_seq" "$current_seq" >&2
185
+ return 1
186
+ fi
187
+ prev_seq=$current_seq
188
+
189
+ prev_line="$line"
190
+ done < "$audit_log"
191
+
192
+ # Check truncation via tail hash anchor (within lock)
193
+ if [ -f "$tail_hash_file" ]; then
194
+ local stored_tail_hash
195
+ stored_tail_hash=$(head -n 1 "$tail_hash_file" 2>/dev/null)
196
+ local actual_tail_hash
197
+ actual_tail_hash=$(tail -n 1 "$audit_log" | tr -d '
198
+ ' | compute_sha256)
199
+
200
+ if [ "$stored_tail_hash" != "$actual_tail_hash" ]; then
201
+ release_audit_lock "$lock_dir"
202
+ printf 'TRUNCATION SUSPECTED: Tail hash mismatch (stored: %s, actual: %s)
203
+ ' "$stored_tail_hash" "$actual_tail_hash" >&2
204
+ return 1
205
+ fi
206
+ fi
207
+
208
+ release_audit_lock "$lock_dir"
209
+
210
+ if [ $line_num -gt 0 ]; then
211
+ printf 'Audit log verification OK (%d entries)
212
+ ' "$line_num"
213
+ fi
214
+ return 0
215
+ }
216
+
217
+ check_branch_policy() {
218
+ # Parse git pre-push stdin to check if any remote-ref targets main or master
219
+ # Format: <local-ref> <local-sha> <remote-ref> <remote-sha>
220
+ # This catches attempts like: git push origin HEAD:main (even from feature branch)
221
+ # Finding 3: Handle tty mode and final line without trailing newline
222
+ if [ -t 0 ]; then
223
+ # Running interactively on a tty; skip stdin processing with note
224
+ # but still check current branch as fallback
225
+ :
226
+ else
227
+ # Not a tty; read stdin normally
228
+ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha || [ -n "$local_ref" ]; do
229
+ # Skip empty lines
230
+ if [ -z "$remote_ref" ]; then
231
+ continue
232
+ fi
233
+
234
+ # Block if attempting to push to main or master
235
+ if [ "$remote_ref" = "refs/heads/main" ] || [ "$remote_ref" = "refs/heads/master" ]; then
236
+ return 1
237
+ fi
238
+ done
239
+ fi
240
+
241
+ # If no protected branch in stdin, also check current branch as fallback
242
+ # (for safety, in case stdin is empty or hook runs without git pre-push)
243
+ local current_branch
244
+ current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
245
+
246
+ if [ "$current_branch" = "main" ] || [ "$current_branch" = "master" ]; then
247
+ return 1
248
+ fi
249
+
250
+ return 0
251
+ }
252
+
253
+ check_secret_scan() {
254
+ local scan_bin
255
+ if command -v python >/dev/null 2>&1; then
256
+ scan_bin="python"
257
+ elif command -v python3 >/dev/null 2>&1; then
258
+ scan_bin="python3"
259
+ else
260
+ scan_bin="python"
261
+ fi
262
+
263
+ local aesop_root="${AESOP_ROOT:-$HOME/aesop}"
264
+ local scan_script="$aesop_root/tools/secret_scan.py"
265
+
266
+ if [ ! -f "$scan_script" ]; then
267
+ # Scanner not found: log event and warn to stderr, but don't block (fail-open)
268
+ log_event "secret_scan_unavailable"
269
+ printf 'Warning: secret_scan.py not found at %s\n' "$scan_script" >&2
270
+ return 0
271
+ fi
272
+
273
+ # Run scanner and capture output; surface ALLOWED-DOC lines to stderr for visibility
274
+ local scan_output
275
+ scan_output=$("$scan_bin" "$scan_script" --staged 2>&1)
276
+ local exit_code=$?
277
+
278
+ # Surface all output including ALLOWED-DOC findings to stderr
279
+ if [ -n "$scan_output" ]; then
280
+ printf '%s\n' "$scan_output" >&2
281
+ fi
282
+
283
+ return $exit_code
284
+ }
285
+
286
+ log_event() {
287
+ # Finding 1 & 2: Acquire lock before read-modify-append, add seq field, update sidecar
288
+ local event_type="$1"
289
+ local aesop_root="${AESOP_ROOT:-$HOME/aesop}"
290
+ local state_dir="$aesop_root/state"
291
+ local audit_log="$state_dir/SECURITY-AUDIT.log"
292
+ local lock_dir="$state_dir/.audit-log-lock"
293
+ local ts
294
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
295
+ local repo_name
296
+ repo_name=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || echo 'unknown')")
297
+ local user
298
+ user=$(git config user.name 2>/dev/null || echo "unknown")
299
+
300
+ mkdir -p "$state_dir" 2>/dev/null
301
+
302
+ # Acquire write lock
303
+ if ! acquire_audit_lock "$lock_dir"; then
304
+ # Log write blocked by lock; fail-open (don't block push)
305
+ return 0
306
+ fi
307
+
308
+ local prev_hash
309
+ prev_hash=$(get_previous_hash "$audit_log")
310
+ local seq
311
+ seq=$(get_next_seq "$audit_log")
312
+
313
+ printf '{"seq":%d,"prev_hash":"%s","ts":"%s","repo":"%s","event":"%s","user":"%s"}\n' "$seq" "$prev_hash" "$ts" "$repo_name" "$(json_escape "$event_type")" "$(json_escape "$user")" >> "$audit_log" 2>/dev/null
314
+
315
+ # Update tail hash sidecar
316
+ if [ -s "$audit_log" ]; then
317
+ tail -n 1 "$audit_log" | tr -d '\n' | compute_sha256 > "$state_dir/.audit-tail-hash" 2>/dev/null
318
+ fi
319
+
320
+ release_audit_lock "$lock_dir"
321
+ }
322
+
323
+ log_block() {
324
+ # Finding 1 & 2: Acquire lock before read-modify-append, add seq field, update sidecar
325
+ local reason="$1"
326
+ local aesop_root="${AESOP_ROOT:-$HOME/aesop}"
327
+ local state_dir="$aesop_root/state"
328
+ local audit_log="$state_dir/SECURITY-AUDIT.log"
329
+ local lock_dir="$state_dir/.audit-log-lock"
330
+ local ts
331
+ ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
332
+ local repo_name
333
+ repo_name=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || echo 'unknown')")
334
+ local user
335
+ user=$(git config user.name 2>/dev/null || echo "unknown")
336
+
337
+ mkdir -p "$state_dir" 2>/dev/null
338
+
339
+ # Acquire write lock
340
+ if ! acquire_audit_lock "$lock_dir"; then
341
+ # Log write blocked by lock; fail-open (don't block push)
342
+ return 0
343
+ fi
344
+
345
+ local prev_hash
346
+ prev_hash=$(get_previous_hash "$audit_log")
347
+ local seq
348
+ seq=$(get_next_seq "$audit_log")
349
+
350
+ printf '{"seq":%d,"prev_hash":"%s","ts":"%s","repo":"%s","event":"push_blocked","reason":"%s","user":"%s"}\n' "$seq" "$prev_hash" "$ts" "$repo_name" "$(json_escape "$reason")" "$(json_escape "$user")" >> "$audit_log" 2>/dev/null
351
+
352
+ # Update tail hash sidecar
353
+ if [ -s "$audit_log" ]; then
354
+ tail -n 1 "$audit_log" | tr -d '\n' | compute_sha256 > "$state_dir/.audit-tail-hash" 2>/dev/null
355
+ fi
356
+
357
+ release_audit_lock "$lock_dir"
358
+ }
359
+
360
+ run_test_mode() {
361
+ local test_passed=0
362
+ local test_failed=0
363
+ local tmpdir
364
+ tmpdir=$(mktemp -d)
365
+ trap "rm -rf '$tmpdir'" EXIT
366
+
367
+ printf '\n=== Test 1: Branch policy check ===\n'
368
+ (
369
+ cd "$tmpdir" || exit 1
370
+ git init -q
371
+ git config user.email "test@example.com"
372
+ git config user.name "Test User"
373
+ echo "dummy" > file.txt
374
+ git add file.txt
375
+ git commit -q -m "initial"
376
+ git checkout -q -b main 2>/dev/null || git branch -M main
377
+
378
+ if check_branch_policy; then
379
+ printf 'FAIL: Should have blocked on main branch\n'
380
+ exit 1
381
+ fi
382
+ printf 'PASS: Correctly blocked on main branch\n'
383
+ )
384
+ if [ $? -eq 0 ]; then
385
+ test_passed=$((test_passed + 1))
386
+ else
387
+ test_failed=$((test_failed + 1))
388
+ fi
389
+
390
+ printf '\n=== Test 2: Branch policy allows feature branches ===\n'
391
+ (
392
+ cd "$tmpdir" || exit 1
393
+ git checkout -q -b feature/test 2>/dev/null
394
+
395
+ if check_branch_policy; then
396
+ printf 'PASS: Correctly allowed feature branch\n'
397
+ else
398
+ printf 'FAIL: Should have allowed feature branch\n'
399
+ exit 1
400
+ fi
401
+ )
402
+ if [ $? -eq 0 ]; then
403
+ test_passed=$((test_passed + 1))
404
+ else
405
+ test_failed=$((test_failed + 1))
406
+ fi
407
+
408
+ printf '\n=== Test 3: Audit log format ===\n'
409
+ (
410
+ export AESOP_ROOT="$tmpdir/aesop"
411
+ mkdir -p "$AESOP_ROOT/state"
412
+ log_block "test_reason"
413
+
414
+ if [ ! -f "$AESOP_ROOT/state/SECURITY-AUDIT.log" ]; then
415
+ printf 'FAIL: Audit log not created\n'
416
+ exit 1
417
+ fi
418
+
419
+ audit_line=$(tail -n 1 "$AESOP_ROOT/state/SECURITY-AUDIT.log")
420
+ if ! printf '%s' "$audit_line" | python3 -m json.tool >/dev/null 2>&1; then
421
+ printf 'FAIL: Audit log entry is not valid JSON\n'
422
+ printf 'Entry: %s\n' "$audit_line"
423
+ exit 1
424
+ fi
425
+
426
+ if printf '%s' "$audit_line" | grep -q '"event":"push_blocked"'; then
427
+ printf 'PASS: Audit log entry valid JSON with correct event type\n'
428
+ else
429
+ printf 'FAIL: Audit log entry missing correct event\n'
430
+ exit 1
431
+ fi
432
+ )
433
+ if [ $? -eq 0 ]; then
434
+ test_passed=$((test_passed + 1))
435
+ else
436
+ test_failed=$((test_failed + 1))
437
+ fi
438
+
439
+ printf '\n=== Test 4: JSON escaping with special characters ===\n'
440
+ (
441
+ export AESOP_ROOT="$tmpdir/aesop"
442
+ mkdir -p "$AESOP_ROOT/state"
443
+ git config user.name 'John "Jack" Doe'
444
+ log_block "reason_with_backslash\\test"
445
+
446
+ if [ ! -f "$AESOP_ROOT/state/SECURITY-AUDIT.log" ]; then
447
+ printf 'FAIL: Audit log not created for special chars test\n'
448
+ exit 1
449
+ fi
450
+
451
+ audit_line=$(tail -n 1 "$AESOP_ROOT/state/SECURITY-AUDIT.log")
452
+ if ! printf '%s' "$audit_line" | python3 -m json.tool >/dev/null 2>&1; then
453
+ printf 'FAIL: JSON with escaped chars is invalid\n'
454
+ printf 'Entry: %s\n' "$audit_line"
455
+ exit 1
456
+ fi
457
+
458
+ if printf '%s' "$audit_line" | grep -q 'John.*Jack.*Doe'; then
459
+ printf 'PASS: JSON correctly escapes quotes in user names\n'
460
+ else
461
+ printf 'FAIL: JSON escaping incomplete\n'
462
+ exit 1
463
+ fi
464
+ )
465
+ if [ $? -eq 0 ]; then
466
+ test_passed=$((test_passed + 1))
467
+ else
468
+ test_failed=$((test_failed + 1))
469
+ fi
470
+
471
+ printf '\n=== Test 5: stdin handling (git hook compatibility) ===\n'
472
+ (
473
+ cd "$tmpdir" || exit 1
474
+ git checkout -q feature/test 2>/dev/null
475
+
476
+ # Simulate git pre-push stdin with ref info
477
+ local_sha=$(git rev-parse HEAD 2>/dev/null || echo "0000000")
478
+ printf '%s\n' "refs/heads/feature/test $local_sha refs/heads/feature/test 0000000000000000000000000000000000000000" | {
479
+ if check_branch_policy >/dev/null 2>&1; then
480
+ printf 'PASS: Hook accepts stdin without choking\n'
481
+ else
482
+ printf 'FAIL: Hook failed with stdin input\n'
483
+ exit 1
484
+ fi
485
+ }
486
+ ) || {
487
+ printf 'FAIL: stdin test exited with error\n'
488
+ test_failed=$((test_failed + 1))
489
+ }
490
+ if [ $? -eq 0 ]; then
491
+ test_passed=$((test_passed + 1))
492
+ fi
493
+
494
+ printf '\n=== Test 6: stdin refspec bypass detection (HEAD:main) ===\n'
495
+ (
496
+ cd "$tmpdir" || exit 1
497
+ git checkout -q feature/test 2>/dev/null || git checkout -q -b feature/bypass_test 2>/dev/null
498
+
499
+ # Simulate git pre-push stdin for: git push origin HEAD:main
500
+ # This is an explicit refspec that pushes to main even though local HEAD is feature/test
501
+ # The fixed check_branch_policy MUST block this by checking remote-ref in stdin
502
+ local_sha=$(git rev-parse HEAD 2>/dev/null || echo "0000000")
503
+ remote_main_sha="0000000000000000000000000000000000000000"
504
+
505
+ # Stdin format: <local-ref> <local-sha> <remote-ref> <remote-sha>
506
+ # For "git push origin HEAD:main": refs/heads/feature/test <sha> refs/heads/main 0000...
507
+ printf '%s\n' "refs/heads/feature/test $local_sha refs/heads/main $remote_main_sha" | {
508
+ if check_branch_policy >/dev/null 2>&1; then
509
+ printf 'FAIL: Should have blocked push to main via stdin refspec\n'
510
+ exit 1
511
+ else
512
+ printf 'PASS: Correctly blocked push to main via stdin refspec\n'
513
+ fi
514
+ }
515
+ )
516
+ if [ $? -eq 0 ]; then
517
+ test_passed=$((test_passed + 1))
518
+ else
519
+ test_failed=$((test_failed + 1))
520
+ fi
521
+
522
+ printf '\n=== Test 7: Secret scan unavailable logs audit event ===\n'
523
+ (
524
+ export AESOP_ROOT="$tmpdir/aesop_no_scanner"
525
+ mkdir -p "$AESOP_ROOT/state"
526
+
527
+ # Capture stderr to verify warning is printed
528
+ stderr_output=$( { check_secret_scan; } 2>&1 1>/dev/null )
529
+ exit_code=$?
530
+
531
+ # Should return 0 (fail-open)
532
+ if [ "$exit_code" -ne 0 ]; then
533
+ printf 'FAIL: check_secret_scan should return 0 when scanner missing (fail-open)\n'
534
+ exit 1
535
+ fi
536
+
537
+ # Should have logged a "secret_scan_unavailable" event
538
+ if [ ! -f "$AESOP_ROOT/state/SECURITY-AUDIT.log" ]; then
539
+ printf 'FAIL: Audit log not created when scanner is unavailable\n'
540
+ exit 1
541
+ fi
542
+
543
+ audit_line=$(tail -n 1 "$AESOP_ROOT/state/SECURITY-AUDIT.log")
544
+
545
+ # Verify JSON is valid
546
+ if ! printf '%s' "$audit_line" | python3 -m json.tool >/dev/null 2>&1; then
547
+ printf 'FAIL: Audit log entry is not valid JSON\n'
548
+ printf 'Entry: %s\n' "$audit_line"
549
+ exit 1
550
+ fi
551
+
552
+ # Verify event type is "secret_scan_unavailable"
553
+ if ! printf '%s' "$audit_line" | grep -q '"event":"secret_scan_unavailable"'; then
554
+ printf 'FAIL: Audit log entry missing correct event type\n'
555
+ printf 'Expected event: "secret_scan_unavailable"\n'
556
+ printf 'Entry: %s\n' "$audit_line"
557
+ exit 1
558
+ fi
559
+
560
+ # Verify a warning was printed to stderr
561
+ if ! printf '%s' "$stderr_output" | grep -q -i 'unavailable\|missing\|not found'; then
562
+ printf 'FAIL: No warning message printed to stderr\n'
563
+ printf 'stderr was: %s\n' "$stderr_output"
564
+ exit 1
565
+ fi
566
+
567
+ printf 'PASS: Secret scan unavailable logged with event and stderr warning\n'
568
+ )
569
+ if [ $? -eq 0 ]; then
570
+ test_passed=$((test_passed + 1))
571
+ else
572
+ test_failed=$((test_failed + 1))
573
+ fi
574
+
575
+ printf '\n=== Test 8: Hash-chain audit log (GENESIS first event) ===\n'
576
+ (
577
+ export AESOP_ROOT="$tmpdir/aesop_hashchain"
578
+ mkdir -p "$AESOP_ROOT/state"
579
+ log_block "test_block_1"
580
+
581
+ if [ ! -f "$AESOP_ROOT/state/SECURITY-AUDIT.log" ]; then
582
+ printf 'FAIL: Audit log not created\n'
583
+ exit 1
584
+ fi
585
+
586
+ audit_line=$(tail -n 1 "$AESOP_ROOT/state/SECURITY-AUDIT.log")
587
+ if ! printf '%s' "$audit_line" | grep -q '"prev_hash":"GENESIS"'; then
588
+ printf 'FAIL: First event should have prev_hash=GENESIS\n'
589
+ printf 'Entry: %s\n' "$audit_line"
590
+ exit 1
591
+ fi
592
+
593
+ if ! printf '%s' "$audit_line" | python3 -m json.tool >/dev/null 2>&1; then
594
+ printf 'FAIL: Hash-chained entry is not valid JSON\n'
595
+ printf 'Entry: %s\n' "$audit_line"
596
+ exit 1
597
+ fi
598
+
599
+ printf 'PASS: First event has GENESIS prev_hash and valid JSON\n'
600
+ )
601
+ if [ $? -eq 0 ]; then
602
+ test_passed=$((test_passed + 1))
603
+ else
604
+ test_failed=$((test_failed + 1))
605
+ fi
606
+
607
+ printf '\n=== Test 9: Hash-chain builds across 2+ events ===\n'
608
+ (
609
+ export AESOP_ROOT="$tmpdir/aesop_hashchain2"
610
+ mkdir -p "$AESOP_ROOT/state"
611
+ log_block "test_block_1"
612
+ log_block "test_block_2"
613
+
614
+ if [ ! -f "$AESOP_ROOT/state/SECURITY-AUDIT.log" ]; then
615
+ printf 'FAIL: Audit log not created\n'
616
+ exit 1
617
+ fi
618
+
619
+ line_count=$(wc -l < "$AESOP_ROOT/state/SECURITY-AUDIT.log")
620
+ if [ "$line_count" -ne 2 ]; then
621
+ printf 'FAIL: Expected 2 audit log entries, got %d\n' "$line_count"
622
+ exit 1
623
+ fi
624
+
625
+ line1=$(head -n 1 "$AESOP_ROOT/state/SECURITY-AUDIT.log")
626
+ line2=$(tail -n 1 "$AESOP_ROOT/state/SECURITY-AUDIT.log")
627
+
628
+ if ! printf '%s' "$line1" | grep -q '"prev_hash":"GENESIS"'; then
629
+ printf 'FAIL: First event should have prev_hash=GENESIS\n'
630
+ exit 1
631
+ fi
632
+
633
+ if ! printf '%s' "$line2" | grep -q '"prev_hash":'; then
634
+ printf 'FAIL: Second event should have prev_hash field\n'
635
+ exit 1
636
+ fi
637
+
638
+ line1_hash=$(printf '%s' "$line1" | tr -d '\n' | compute_sha256)
639
+ line2_prev=$(printf '%s' "$line2" | python3 -c "import sys, json; print(json.load(sys.stdin).get('prev_hash', ''))")
640
+
641
+ if [ "$line1_hash" != "$line2_prev" ]; then
642
+ printf 'FAIL: Second event prev_hash does not match first line hash\n'
643
+ printf 'Expected: %s\n' "$line1_hash"
644
+ printf 'Got: %s\n' "$line2_prev"
645
+ exit 1
646
+ fi
647
+
648
+ printf 'PASS: Hash chain builds correctly across 2 events\n'
649
+ )
650
+ if [ $? -eq 0 ]; then
651
+ test_passed=$((test_passed + 1))
652
+ else
653
+ test_failed=$((test_failed + 1))
654
+ fi
655
+
656
+ printf '\n=== Test 10: verify-audit-log passes on intact chain ===\n'
657
+ (
658
+ export AESOP_ROOT="$tmpdir/aesop_verify"
659
+ mkdir -p "$AESOP_ROOT/state"
660
+ log_block "entry_1"
661
+ log_block "entry_2"
662
+ log_event "entry_3"
663
+
664
+ if [ ! -f "$AESOP_ROOT/state/SECURITY-AUDIT.log" ]; then
665
+ printf 'FAIL: Audit log not created\n'
666
+ exit 1
667
+ fi
668
+
669
+ if verify_audit_log "$AESOP_ROOT/state/SECURITY-AUDIT.log" >/dev/null 2>&1; then
670
+ printf 'PASS: Verification passed on intact chain\n'
671
+ else
672
+ printf 'FAIL: Verification should pass on intact chain\n'
673
+ exit 1
674
+ fi
675
+ )
676
+ if [ $? -eq 0 ]; then
677
+ test_passed=$((test_passed + 1))
678
+ else
679
+ test_failed=$((test_failed + 1))
680
+ fi
681
+
682
+ printf '\n=== Test 11: verify-audit-log detects tampered line ===\n'
683
+ (
684
+ export AESOP_ROOT="$tmpdir/aesop_tamper"
685
+ mkdir -p "$AESOP_ROOT/state"
686
+ log_block "entry_1"
687
+ log_block "entry_2"
688
+ log_block "entry_3"
689
+
690
+ if [ ! -f "$AESOP_ROOT/state/SECURITY-AUDIT.log" ]; then
691
+ printf 'FAIL: Audit log not created\n'
692
+ exit 1
693
+ fi
694
+
695
+ sed -i '2s/"reason":"[^"]*"/"reason":"TAMPERED"/g' "$AESOP_ROOT/state/SECURITY-AUDIT.log"
696
+
697
+ if ! verify_audit_log "$AESOP_ROOT/state/SECURITY-AUDIT.log" >/dev/null 2>&1; then
698
+ printf 'PASS: Verification detected tampered middle line\n'
699
+ else
700
+ printf 'FAIL: Verification should detect tampering\n'
701
+ exit 1
702
+ fi
703
+ )
704
+ if [ $? -eq 0 ]; then
705
+ test_passed=$((test_passed + 1))
706
+ else
707
+ test_failed=$((test_failed + 1))
708
+ fi
709
+
710
+ printf '\n=== Test Results ===\n'
711
+ printf 'PASSED: %d\n' "$test_passed"
712
+ printf 'FAILED: %d\n' "$test_failed"
713
+
714
+ if [ "$test_failed" -eq 0 ]; then
715
+ printf '\nAll 11 tests passed.\n'
716
+ return 0
717
+ else
718
+ printf '\nSome tests failed.\n'
719
+ return 1
720
+ fi
721
+ }
722
+
723
+ main() {
724
+ if [ "${1:-}" = "--test" ]; then
725
+ run_test_mode
726
+ exit $?
727
+ fi
728
+
729
+ if [ "${1:-}" = "--verify-audit-log" ]; then
730
+ local audit_log="${2:-${AESOP_ROOT:-$HOME/aesop}/state/SECURITY-AUDIT.log}"
731
+ verify_audit_log "$audit_log"
732
+ exit $?
733
+ fi
734
+
735
+ # git pre-push provides ref info on stdin, pass it to check_branch_policy
736
+ if ! check_branch_policy; then
737
+ printf 'Error: Push to main/master is blocked by policy\n' >&2
738
+ log_block "push_to_protected_branch"
739
+ exit 1
740
+ fi
741
+
742
+ if ! check_secret_scan; then
743
+ printf 'Error: Secret scan failed. Push blocked.\n' >&2
744
+ log_block "secret_scan_failure"
745
+ exit 1
746
+ fi
747
+
748
+ exit 0
749
+ }
750
+
751
+ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
752
+ main "$@"
753
+ fi