eagle-mem 4.12.1 → 4.13.0

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.
@@ -104,6 +104,11 @@ save_session() {
104
104
  summary="$2"
105
105
  shift 2
106
106
  ;;
107
+ --summary-stdin)
108
+ # Read the summary from stdin so it is not visible in `ps`.
109
+ summary="$(cat)"
110
+ shift
111
+ ;;
107
112
  --request)
108
113
  require_value "$1" "${2:-}"
109
114
  request="$2"
@@ -65,7 +65,11 @@ eagle_mem_statusline_stats() {
65
65
  project_scope=$(eagle_recall_project_scope_from_cwd "${current_dir:-$project_dir}" "$project_key")
66
66
  project_condition=$(eagle_sql_project_scope_condition "project" "$project_scope")
67
67
 
68
- stats=$("$sqlite_bin" "$em_db" "SELECT
68
+ # busy_timeout so a momentary SQLITE_BUSY (this is the hottest standalone
69
+ # query during live sessions) waits for the lock instead of exiting non-zero,
70
+ # which would otherwise escalate to an integrity-status mislabel below.
71
+ stats=$("$sqlite_bin" "$em_db" "PRAGMA busy_timeout=10000;
72
+ SELECT
69
73
  COUNT(*) || '|' ||
70
74
  (SELECT COUNT(*) FROM agent_memories WHERE $project_condition) || '|' ||
71
75
  COALESCE(MAX(COALESCE(last_activity_at, started_at)), 'never')
package/scripts/tasks.sh CHANGED
@@ -153,10 +153,13 @@ tasks_list() {
153
153
  in_progress)
154
154
  icon="${CYAN}>${RESET}"
155
155
  marker=" ${CYAN}[in_progress]${RESET}"
156
- # Staleness check for discipline
156
+ # Staleness check for discipline. Route through eagle_db (busy_timeout
157
+ # + FTS5-capable sqlite3) and escape $updated_at — it is a DB-read value
158
+ # interpolated back into SQL, so a quote would be second-order injection.
157
159
  if [ -n "$updated_at" ]; then
158
- local age_days
159
- age_days=$(echo "SELECT (julianday('now') - julianday('$updated_at'))" | sqlite3 "$EAGLE_MEM_DB" 2>/dev/null | cut -d. -f1)
160
+ local age_days updated_at_sql
161
+ updated_at_sql=$(eagle_sql_escape "$updated_at")
162
+ age_days=$(eagle_db "SELECT (julianday('now') - julianday('$updated_at_sql'));" 2>/dev/null | cut -d. -f1)
160
163
  if [ -n "$age_days" ] && [ "$age_days" -gt 7 ]; then
161
164
  marker+=" ${RED}[STALE - ${age_days}d]${RESET}"
162
165
  fi
package/scripts/test.sh CHANGED
@@ -25,7 +25,11 @@ run_check() {
25
25
  eagle_ok "$name"
26
26
  else
27
27
  eagle_fail "$name"
28
- ((errors++))
28
+ # Assignment form (not ((errors++))) so a failing check does not abort
29
+ # the whole runner under `set -e`: ((errors++)) returns exit 1 when the
30
+ # pre-increment value is 0, killing the suite at the first failure and
31
+ # skipping the failure-count summary below.
32
+ errors=$((errors + 1))
29
33
  fi
30
34
  }
31
35
 
@@ -69,7 +73,13 @@ run_check "Recall Observability (UserPromptSubmit recall event)" "bash \"$SCRIPT
69
73
  run_check "Eagle Event Log (hook/action observability)" "bash \"$SCRIPTS_DIR/../tests/test_eagle_events.sh\""
70
74
  run_check "Dashboard Surface (local HTML memory view)" "bash \"$SCRIPTS_DIR/../tests/test_dashboard.sh\""
71
75
  run_check "Clean Session Capture (capture_source, fill-only upsert, no clobber)" "bash \"$SCRIPTS_DIR/../tests/test_clean_session_capture.sh\""
76
+ run_check "Context Budget (SessionStart injection ceiling: normal unchanged, pathological capped + logged)" "bash \"$SCRIPTS_DIR/../tests/test_context_budget.sh\""
72
77
  run_check "CLAUDE.md Capture Doctrine (installer rewrites outdated section)" "bash \"$SCRIPTS_DIR/../tests/test_claude_md_capture_doctrine.sh\""
78
+ run_check "Redaction Coverage (provider input, recall events, enrich job, autonomy, log paths)" "bash \"$SCRIPTS_DIR/../tests/test_redaction_coverage.sh\""
79
+ run_check "Data Integrity Hardening (migrate idempotency, SQL escaping, summary precedence)" "bash \"$SCRIPTS_DIR/../tests/test_data_integrity_hardening.sh\""
80
+ run_check "Mod-Tracker Concurrency (lock TTL, no lost append, observation dedup race)" "bash \"$SCRIPTS_DIR/../tests/test_mod_tracker_concurrency.sh\""
81
+ run_check "Reliability Retention (scan in-flight vs freshness, eagle_events prune)" "bash \"$SCRIPTS_DIR/../tests/test_reliability_retention.sh\""
82
+ run_check "Test Runner No-Abort (failing check does not kill the suite under set -e)" "bash \"$SCRIPTS_DIR/../tests/test_test_runner_no_abort.sh\""
73
83
 
74
84
  echo ""
75
85
  if [ "$errors" -eq 0 ]; then
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Eagle Mem — SessionStart injection budget regression suite
4
+ # Proves the global recall-injection ceiling:
5
+ # (a) normal-sized recall is emitted UNCHANGED (no trimming)
6
+ # (b) a pathologically large recall is capped at the budget,
7
+ # drops whole low-priority sections from the bottom, keeps the
8
+ # highest-priority top sections, and LOGS a trim (observable)
9
+ # ═══════════════════════════════════════════════════════════
10
+ set -euo pipefail
11
+
12
+ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
13
+ tmp_dir=$(mktemp -d)
14
+ trap 'rm -rf "$tmp_dir"' EXIT
15
+
16
+ fail() { echo "FAIL: $1" >&2; exit 1; }
17
+
18
+ assert_eq() {
19
+ [ "$1" = "$2" ] || fail "$3 (expected='$2' actual='$1')"
20
+ }
21
+
22
+ assert_contains() {
23
+ case "$1" in *"$2"*) ;; *) fail "$3 (missing: $2)" ;; esac
24
+ }
25
+
26
+ assert_not_contains() {
27
+ case "$1" in *"$2"*) fail "$3 (unexpectedly present: $2)" ;; esac
28
+ }
29
+
30
+ . "$ROOT_DIR/lib/common.sh"
31
+
32
+ trim_count_file="$tmp_dir/.trim-count"
33
+ export EAGLE_INJECT_TRIM_COUNT="$trim_count_file"
34
+
35
+ # ─── Budget helper: sane default + floor against self-defeating values ──
36
+ default_budget=$(eagle_sessionstart_inject_budget)
37
+ [ "$default_budget" -ge 4000 ] 2>/dev/null \
38
+ || fail "default budget unexpectedly small ($default_budget)"
39
+
40
+ floored=$(EAGLE_MEM_DIR="$tmp_dir" bash -c "
41
+ . '$ROOT_DIR/lib/common.sh'
42
+ mkdir -p '$tmp_dir'
43
+ printf '[context_budget]\nsessionstart_chars = 10\n' > '$tmp_dir/config.toml'
44
+ eagle_sessionstart_inject_budget
45
+ ")
46
+ [ "$floored" -ge 4000 ] 2>/dev/null \
47
+ || fail "tiny configured budget was not floored ($floored)"
48
+
49
+ # ─── (a) Normal-sized recall — emitted UNCHANGED ──────────────────────
50
+ normal_body="=== Eagle Mem: Project Overview ===
51
+ A small project overview that fits comfortably.
52
+ === Eagle Mem: Recent Recall ===
53
+ One recent session summary.
54
+ === Eagle Mem: Stored Memories ===
55
+ - [project][claude] Some memory: short description (today)
56
+ === Eagle Mem: Core Files ===
57
+ - main.sh
58
+ === Eagle Mem: Working Set ===
59
+ - app.ts (2 edits)"
60
+
61
+ normal_out=$(printf '%s' "$normal_body" | eagle_trim_inject_body 24000)
62
+ normal_dropped=$(cat "$trim_count_file")
63
+
64
+ assert_eq "$normal_out" "$normal_body" "normal recall was modified by the budget"
65
+ assert_eq "$normal_dropped" "0" "normal recall reported a non-zero trim"
66
+
67
+ # ─── (b) Pathological recall — capped, low-priority dropped, top kept ──
68
+ # Build a body whose top section is small but whose trailing sections are
69
+ # huge, so the ceiling must engage.
70
+ huge=$(head -c 9000 /dev/zero | tr '\0' 'x')
71
+ big_body="=== Eagle Mem: Project Overview ===
72
+ KEEP_OVERVIEW_MARKER top-priority overview must survive.
73
+ === Eagle Mem: Recent Recall ===
74
+ $huge
75
+ === Eagle Mem: Tasks ===
76
+ $huge
77
+ === Eagle Mem: Core Files ===
78
+ $huge
79
+ === Eagle Mem: Working Set ===
80
+ $huge"
81
+
82
+ budget=12000
83
+ big_out=$(printf '%s' "$big_body" | eagle_trim_inject_body "$budget")
84
+ big_dropped=$(cat "$trim_count_file")
85
+
86
+ [ "${#big_out}" -le "$budget" ] 2>/dev/null \
87
+ || fail "trimmed body still exceeds budget (${#big_out} > $budget)"
88
+ [ "$big_dropped" -gt 0 ] 2>/dev/null \
89
+ || fail "pathological recall did not report any trimmed sections"
90
+ assert_contains "$big_out" "KEEP_OVERVIEW_MARKER" \
91
+ "top-priority Overview section was dropped"
92
+ assert_not_contains "$big_out" "=== Eagle Mem: Working Set ===" \
93
+ "lowest-priority Working Set section survived past the budget"
94
+
95
+ # ─── Termination safety: a single oversized first section is kept whole ──
96
+ oversized=$(head -c 20000 /dev/zero | tr '\0' 'y')
97
+ solo_body="=== Eagle Mem: Project Overview ===
98
+ $oversized"
99
+ solo_out=$(printf '%s' "$solo_body" | eagle_trim_inject_body 5000)
100
+ solo_dropped=$(cat "$trim_count_file")
101
+ assert_contains "$solo_out" "=== Eagle Mem: Project Overview ===" \
102
+ "oversized lone section was discarded instead of kept whole"
103
+ assert_eq "$solo_dropped" "0" "oversized lone section reported a phantom trim"
104
+
105
+ # A body with no section markers is returned verbatim (no infinite loop).
106
+ nomarker_out=$(printf '%s' "$oversized" | eagle_trim_inject_body 5000)
107
+ assert_eq "${#nomarker_out}" "${#oversized}" "marker-less body was altered"
108
+
109
+ # ─── Observable trim: the hook logs a WARN when it trims ──────────────
110
+ # The hook engages the ceiling and logs a WARN with the dropped-section count
111
+ # whenever it trims; assert that observable surface exists in the hook.
112
+ grep -q 'SessionStart: injection over budget' "$ROOT_DIR/hooks/session-start.sh" \
113
+ || fail "session-start.sh missing observable over-budget WARN log"
114
+ grep -q 'trimmed .* low-priority section' "$ROOT_DIR/hooks/session-start.sh" \
115
+ || fail "session-start.sh WARN log does not report trimmed section count"
116
+
117
+ echo "PASS: SessionStart injection budget (normal unchanged, pathological capped + logged)"
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Eagle Mem — Data integrity hardening regression test
4
+ # Covers:
5
+ # - migrate.sh idempotency (re-run is a no-op, _migrations unchanged)
6
+ # - SQL escaping units (single quotes, FTS metachars, GLOB/LIKE patterns
7
+ # stored verbatim and queryable — no injection, no crash)
8
+ # - Summary precedence: eagle_insert_summary vs _fill_only — capture_source
9
+ # AND project stickiness on agent rows (finding #8 clobber)
10
+ # ═══════════════════════════════════════════════════════════
11
+ set -uo pipefail
12
+
13
+ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
14
+ tmp_dir=$(mktemp -d)
15
+ trap 'rm -rf "$tmp_dir"' EXIT
16
+
17
+ export EAGLE_MEM_DIR="$tmp_dir/em"
18
+ export EAGLE_AGENT_SOURCE="claude-code"
19
+ export EAGLE_MEM_DISABLE_HOOKS=1
20
+ mkdir -p "$EAGLE_MEM_DIR"
21
+
22
+ pass=0; fail=0
23
+ ok() { echo " ok: $1"; pass=$((pass+1)); }
24
+ bad() { echo " FAIL: $1" >&2; fail=$((fail+1)); }
25
+
26
+ bash "$ROOT_DIR/db/migrate.sh" >/dev/null 2>&1
27
+
28
+ . "$ROOT_DIR/lib/common.sh"
29
+ . "$ROOT_DIR/lib/db.sh"
30
+
31
+ # ── migrate.sh idempotency ──────────────────────────────────
32
+ mig_before=$(eagle_db "SELECT COUNT(*) || ':' || COALESCE(MAX(id),0) FROM _migrations;")
33
+ mig_out=$(bash "$ROOT_DIR/db/migrate.sh" 2>&1); mig_rc=$?
34
+ [ "$mig_rc" -eq 0 ] && ok "migrate 2nd run exits 0" || bad "migrate 2nd run rc=$mig_rc out=$mig_out"
35
+ case "$mig_out" in
36
+ *applied:*) bad "migrate 2nd run re-applied a migration: $mig_out" ;;
37
+ *) ok "migrate 2nd run applied nothing" ;;
38
+ esac
39
+ mig_after=$(eagle_db "SELECT COUNT(*) || ':' || COALESCE(MAX(id),0) FROM _migrations;")
40
+ [ "$mig_before" = "$mig_after" ] && ok "_migrations unchanged on 2nd run ($mig_after)" || bad "_migrations drifted: $mig_before -> $mig_after"
41
+
42
+ # ── SQL escaping units ──────────────────────────────────────
43
+ # Each payload is stored verbatim via eagle_insert_summary (escaped) and read
44
+ # back; a quote/metachar that broke out of the literal would error or truncate.
45
+ SID_Q="esc-quote-001"
46
+ eagle_upsert_session "$SID_Q" "esc/proj" "$tmp_dir" "" "test" "claude-code"
47
+ quote_payload="it's a \"trap\"; DROP TABLE summaries;-- '' or 1=1"
48
+ eagle_insert_summary "$SID_Q" "esc/proj" "$quote_payload" "" "" "" "" "[]" "[]" "" "" "" "" "claude-code" "agent"
49
+ got_q=$(eagle_db "SELECT request FROM summaries WHERE session_id='$SID_Q';")
50
+ [ "$got_q" = "$quote_payload" ] && ok "single-quote/SQL-injection payload stored verbatim" || bad "quote payload mangled -> '$got_q'"
51
+ # summaries table still present (no DROP executed)
52
+ have_tbl=$(eagle_db "SELECT name FROM sqlite_master WHERE type='table' AND name='summaries';")
53
+ [ "$have_tbl" = "summaries" ] && ok "summaries table intact after injection attempt" || bad "summaries table missing — injection executed"
54
+
55
+ SID_F="esc-fts-002"
56
+ eagle_upsert_session "$SID_F" "esc/proj" "$tmp_dir" "" "test" "claude-code"
57
+ fts_payload='NEAR("foo" bar)* AND col:val OR -baz {set}'
58
+ eagle_insert_summary "$SID_F" "esc/proj" "$fts_payload" "" "" "" "" "[]" "[]" "" "" "" "" "claude-code" "agent"
59
+ got_f=$(eagle_db "SELECT request FROM summaries WHERE session_id='$SID_F';")
60
+ [ "$got_f" = "$fts_payload" ] && ok "FTS-metachar payload stored verbatim" || bad "FTS payload mangled -> '$got_f'"
61
+ # FTS search over a benign token must not crash even though row has metachars
62
+ srch=$(eagle_search_summaries "foo" "" 5 2>&1); srch_rc=$?
63
+ [ "$srch_rc" -eq 0 ] && ok "FTS search over metachar corpus did not crash (rc=0)" || bad "FTS search crashed rc=$srch_rc: $srch"
64
+
65
+ SID_G="esc-glob-003"
66
+ eagle_upsert_session "$SID_G" "esc/proj" "$tmp_dir" "" "test" "claude-code"
67
+ glob_payload='100% OFF _now_ [a-z]* path\to\file'
68
+ eagle_insert_observation "$SID_G" "esc/proj" "Bash" "$glob_payload" "[]" "[]"
69
+ got_g=$(eagle_db "SELECT tool_input_summary FROM observations WHERE session_id='$SID_G';")
70
+ [ "$got_g" = "$glob_payload" ] && ok "GLOB/LIKE-metachar payload stored verbatim in observations" || bad "glob payload mangled -> '$got_g'"
71
+
72
+ # guardrail field escaping (single quote)
73
+ if declare -F eagle_add_guardrail >/dev/null 2>&1; then
74
+ eagle_add_guardrail "esc/proj" "don't touch '; DELETE FROM guardrails;--" "*.sh" "test" >/dev/null 2>&1
75
+ grc=$(eagle_db "SELECT rule FROM guardrails WHERE project='esc/proj' ORDER BY id DESC LIMIT 1;")
76
+ case "$grc" in *"don't touch"*) ok "guardrail rule with quote stored verbatim" ;; *) bad "guardrail rule mangled -> '$grc'" ;; esac
77
+ gcount=$(eagle_db "SELECT name FROM sqlite_master WHERE type='table' AND name='guardrails';")
78
+ [ "$gcount" = "guardrails" ] && ok "guardrails table intact after injection attempt" || bad "guardrails table dropped — injection executed"
79
+ else
80
+ ok "eagle_add_guardrail not present — skipped (non-fatal)"
81
+ fi
82
+
83
+ # ── Summary precedence / clobber (finding #8) ───────────────
84
+ # An agent-authored row must NOT have its project rekeyed by a later hook-path
85
+ # writer that arrives with a drifted project key.
86
+ SID_P="precedence-004"
87
+ eagle_upsert_session "$SID_P" "real/project" "$tmp_dir" "" "test" "claude-code"
88
+ eagle_insert_summary "$SID_P" "real/project" "agent req" "" "" "agent done" "" "[]" "[]" "" "agent decision" "" "" "claude-code" "agent"
89
+ [ "$(eagle_db "SELECT capture_source FROM summaries WHERE session_id='$SID_P';")" = "agent" ] && ok "P: capture_source=agent after agent insert" || bad "P: capture_source not agent"
90
+ [ "$(eagle_db "SELECT project FROM summaries WHERE session_id='$SID_P';")" = "real/project" ] && ok "P: project=real/project after agent insert" || bad "P: project wrong"
91
+
92
+ # Later hook writer with a DRIFTED project key + heuristic capture_source.
93
+ eagle_insert_summary "$SID_P" "DRIFTED/wrong-key" "" "" "" "" "" "[]" "[]" "" "" "" "" "claude-code" "hook"
94
+ proj_after=$(eagle_db "SELECT project FROM summaries WHERE session_id='$SID_P';")
95
+ [ "$proj_after" = "real/project" ] && ok "P: agent row project NOT clobbered by drifted hook write" || bad "P: project clobbered -> '$proj_after'"
96
+ [ "$(eagle_db "SELECT capture_source FROM summaries WHERE session_id='$SID_P';")" = "agent" ] && ok "P: capture_source stays agent" || bad "P: capture_source downgraded"
97
+ [ "$(eagle_db "SELECT completed FROM summaries WHERE session_id='$SID_P';")" = "agent done" ] && ok "P: agent completed preserved" || bad "P: completed clobbered"
98
+
99
+ # Contrast: a hook-authored row (capture_source != agent) SHOULD still accept a
100
+ # project correction (the normal drift-repair path must not regress).
101
+ SID_H="precedence-005"
102
+ eagle_upsert_session "$SID_H" "hookproj/a" "$tmp_dir" "" "test" "claude-code"
103
+ eagle_insert_summary "$SID_H" "hookproj/a" "hook req" "" "" "hook done" "" "[]" "[]" "" "" "" "" "claude-code" "hook"
104
+ eagle_insert_summary "$SID_H" "hookproj/b" "" "" "" "" "" "[]" "[]" "" "" "" "" "claude-code" "hook"
105
+ proj_h=$(eagle_db "SELECT project FROM summaries WHERE session_id='$SID_H';")
106
+ [ "$proj_h" = "hookproj/b" ] && ok "H: hook row project STILL repairable (no regression)" || bad "H: hook project not updated -> '$proj_h'"
107
+
108
+ # fill_only must never change project or downgrade capture_source on agent row
109
+ eagle_insert_summary_fill_only "$SID_P" "ANOTHER/drift" "" "" "fill learned" "" "" "[]" "[]" "" "" "" "" "claude-code" "enrich"
110
+ [ "$(eagle_db "SELECT project FROM summaries WHERE session_id='$SID_P';")" = "real/project" ] && ok "P: fill_only did not change project" || bad "P: fill_only changed project"
111
+ case "$(eagle_db "SELECT learned FROM summaries WHERE session_id='$SID_P';")" in *"fill learned"*) ok "P: fill_only filled empty learned gap" ;; *) bad "P: fill_only did not fill learned" ;; esac
112
+
113
+ echo ""
114
+ echo "test_data_integrity_hardening: $pass passed, $fail failed"
115
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Eagle Mem — mod-tracker concurrency regression test (finding #6)
4
+ # Asserts:
5
+ # 1. Many parallel writers never wedge the lock (no leaked *.lock dir).
6
+ # 2. A pending append created DURING a drain is NOT lost (drains via mv, so
7
+ # only captured files are deleted; concurrent appends survive).
8
+ # 3. A stale lock dir (older than TTL) is reclaimed, not wedged forever.
9
+ # 4. The edit-history writer is lock-guarded and loses no append.
10
+ # ═══════════════════════════════════════════════════════════
11
+ set -uo pipefail
12
+
13
+ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
14
+ tmp_dir=$(mktemp -d)
15
+ trap 'rm -rf "$tmp_dir"' EXIT
16
+
17
+ export EAGLE_MEM_DIR="$tmp_dir/em"
18
+ export EAGLE_AGENT_SOURCE="claude-code"
19
+ export EAGLE_MEM_DISABLE_HOOKS=1
20
+ export EAGLE_MOD_LOCK_TTL=2
21
+ mkdir -p "$EAGLE_MEM_DIR"
22
+
23
+ pass=0; fail=0
24
+ ok() { echo " ok: $1"; pass=$((pass+1)); }
25
+ bad() { echo " FAIL: $1" >&2; fail=$((fail+1)); }
26
+
27
+ . "$ROOT_DIR/lib/common.sh"
28
+
29
+ # Pull the tracker functions out of post-tool-use.sh without running the hook
30
+ # body. State-machine awk: capture the TTL assignment plus each named function
31
+ # (header at column 0 through its closing `}` at column 0). No overlapping
32
+ # ranges, so no duplicated lines.
33
+ fn_src="$tmp_dir/tracker_fns.sh"
34
+ awk '
35
+ /^EAGLE_MOD_LOCK_TTL=/ { print; next }
36
+ /^(eagle_acquire_dir_lock|eagle_track_modified_path|eagle_track_edit_history_path)\(\) \{/ { capture=1 }
37
+ capture { print }
38
+ capture && /^}$/ { capture=0 }
39
+ ' "$ROOT_DIR/hooks/post-tool-use.sh" > "$fn_src"
40
+ bash -n "$fn_src" || { bad "extracted tracker fns have syntax errors"; cat "$fn_src" >&2; echo "$pass passed, $fail failed"; exit 1; }
41
+ . "$fn_src"
42
+ declare -F eagle_track_modified_path >/dev/null && ok "loaded eagle_track_modified_path" || { bad "could not load tracker fns"; echo "$pass passed, $fail failed"; exit 1; }
43
+ declare -F eagle_acquire_dir_lock >/dev/null && ok "loaded eagle_acquire_dir_lock" || bad "could not load lock helper"
44
+
45
+ SID="conc-aaa111bbb222"
46
+ mod_dir="$EAGLE_MEM_DIR/mod-tracker"
47
+ mod_file="$mod_dir/$SID"
48
+ mod_lock="${mod_file}.lock"
49
+
50
+ # ── 1. Parallel writers, no wedge ──────────────────────────
51
+ N=40
52
+ for i in $(seq 1 "$N"); do
53
+ eagle_track_modified_path "/p/file_${i}.txt" "$SID" &
54
+ done
55
+ wait
56
+ [ ! -d "$mod_lock" ] && ok "1: lock dir not leaked after $N parallel writers" || bad "1: lock dir wedged"
57
+ # Whatever is committed must be valid paths from our set (no corruption).
58
+ committed=$(cat "$mod_file" 2>/dev/null | wc -l | tr -d ' ')
59
+ [ "$committed" -ge 1 ] && ok "1: committed file has $committed line(s)" || bad "1: committed file empty"
60
+ bad_lines=$(grep -vE '^/p/file_[0-9]+\.txt$' "$mod_file" 2>/dev/null | wc -l | tr -d ' ')
61
+ [ "$bad_lines" = "0" ] && ok "1: no corrupted/interleaved lines in committed file" || bad "1: $bad_lines corrupted lines"
62
+
63
+ # ── 2. Append during drain is NOT lost ─────────────────────
64
+ # Hold the lock ourselves, create a pending file, snapshot starts, then a NEW
65
+ # pending append lands. The drain must mv-capture only pre-existing pending
66
+ # files and never delete the late append.
67
+ rm -rf "$mod_dir"; mkdir -p "$mod_dir"
68
+ printf '%s\n' "/p/early.txt" > "${mod_file}.pending.111"
69
+ # Simulate: acquire lock, mv-drain existing pending, but a concurrent writer
70
+ # adds a brand-new pending file before we rm. Reproduce the exact sequence the
71
+ # fixed code uses.
72
+ mkdir "$mod_lock"
73
+ # drain step (mv existing pending out of the way)
74
+ for pf in "${mod_file}".pending.*; do
75
+ [ -e "$pf" ] || continue
76
+ mv "$pf" "${pf}.draining.$$" 2>/dev/null || true
77
+ done
78
+ # CONCURRENT appender lands a new pending file AFTER our snapshot/mv:
79
+ printf '%s\n' "/p/late.txt" > "${mod_file}.pending.999"
80
+ # finish drain: build committed from draining files only, delete draining only
81
+ { cat "$mod_file" 2>/dev/null; for d in "${mod_file}".pending.*.draining.*; do [ -e "$d" ] && cat "$d"; done; } | tail -3 > "${mod_file}.tmp"
82
+ mv "${mod_file}.tmp" "$mod_file"
83
+ rm -f "${mod_file}".pending.*.draining.* 2>/dev/null || true
84
+ rmdir "$mod_lock"
85
+ # The late append must still be present as a pending file (not deleted).
86
+ [ -f "${mod_file}.pending.999" ] && ok "2: concurrent append during drain survived (not deleted)" || bad "2: late append LOST"
87
+ grep -q "/p/early.txt" "$mod_file" && ok "2: early pending drained into committed file" || bad "2: early pending lost"
88
+
89
+ # ── 3. Stale lock reclaim ──────────────────────────────────
90
+ rm -rf "$mod_dir"; mkdir -p "$mod_dir"
91
+ mkdir "$mod_lock"
92
+ # Age the lock past TTL (EAGLE_MOD_LOCK_TTL=2s) by backdating its mtime.
93
+ touch -t "$(date -v-1H '+%Y%m%d%H%M.%S' 2>/dev/null || date -d '1 hour ago' '+%Y%m%d%H%M.%S')" "$mod_lock" 2>/dev/null || true
94
+ # A new writer should reclaim the stale lock and succeed (not hang/fail).
95
+ eagle_track_modified_path "/p/after_stale.txt" "$SID"
96
+ [ ! -d "$mod_lock" ] && ok "3: stale lock reclaimed and released" || bad "3: stale lock not reclaimed"
97
+ grep -q "/p/after_stale.txt" "$mod_file" && ok "3: write after stale-lock reclaim recorded" || bad "3: write lost after stale reclaim"
98
+
99
+ # ── 4. edit-history writer is locked and loses nothing ─────
100
+ edit_dir="$EAGLE_MEM_DIR/edit-tracker"
101
+ edit_file="$edit_dir/$SID"
102
+ rm -rf "$edit_dir"
103
+ M=40
104
+ for i in $(seq 1 "$M"); do
105
+ eagle_track_edit_history_path "/e/edit_${i}.txt" "$SID" &
106
+ done
107
+ wait
108
+ [ ! -d "${edit_file}.lock" ] && ok "4: edit-history lock not leaked" || bad "4: edit-history lock wedged"
109
+ edit_count=$(sort -u "$edit_file" 2>/dev/null | grep -cE '^/e/edit_[0-9]+\.txt$' || echo 0)
110
+ [ "$edit_count" = "$M" ] && ok "4: all $M edit-history appends present (append-only, none lost)" || bad "4: only $edit_count/$M edit-history appends present"
111
+ bad_e=$(grep -vE '^/e/edit_[0-9]+\.txt$' "$edit_file" 2>/dev/null | wc -l | tr -d ' ')
112
+ [ "$bad_e" = "0" ] && ok "4: no torn/interleaved edit-history lines" || bad "4: $bad_e torn lines"
113
+
114
+ # ── 5. Observation dedup is race-safe (finding #7) ─────────
115
+ # Two concurrent identical observations must dedupe to ONE row. The fixed code
116
+ # wraps the check-then-insert in BEGIN IMMEDIATE so the second writer blocks,
117
+ # then sees the first row via NOT EXISTS.
118
+ . "$ROOT_DIR/lib/db.sh"
119
+ bash "$ROOT_DIR/db/migrate.sh" >/dev/null 2>&1
120
+ OSID="obs-dedup-7777"
121
+ eagle_upsert_session "$OSID" "obs/proj" "$tmp_dir" "" "test" "claude-code"
122
+ for i in $(seq 1 12); do
123
+ eagle_insert_observation "$OSID" "obs/proj" "Bash" "ls -la /tmp" "[]" "[]" &
124
+ done
125
+ wait
126
+ obs_rows=$(eagle_db "SELECT COUNT(*) FROM observations WHERE session_id='$OSID' AND tool_input_summary='ls -la /tmp';")
127
+ [ "$obs_rows" = "1" ] && ok "5: 12 concurrent identical observations deduped to 1 row" || bad "5: dedup race produced $obs_rows rows (expected 1)"
128
+
129
+ # eagle_db_strict exists and aborts a multi-statement script on first error.
130
+ if declare -F eagle_db_strict >/dev/null; then
131
+ ok "5: eagle_db_strict variant present"
132
+ # A failing first statement must prevent the second from running.
133
+ eagle_db_strict "INSERT INTO nonexistent_table_xyz VALUES (1); INSERT INTO observations (session_id, project, tool_name) VALUES ('strict-canary','obs/proj','Bash');" >/dev/null 2>&1 || true
134
+ canary=$(eagle_db "SELECT COUNT(*) FROM observations WHERE session_id='strict-canary';")
135
+ [ "$canary" = "0" ] && ok "5: eagle_db_strict bailed before 2nd statement" || bad "5: eagle_db_strict did not bail ($canary canary rows)"
136
+ else
137
+ bad "5: eagle_db_strict missing"
138
+ fi
139
+
140
+ echo ""
141
+ echo "test_mod_tracker_concurrency: $pass passed, $fail failed"
142
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env bash
2
+ # Regression coverage for the Phase 1 security hardening:
3
+ # - secrets are redacted BEFORE leaving the machine (LLM provider input,
4
+ # enrich job file) and before persistence (recall_events, observations)
5
+ # - configured [redaction] extra_patterns are actually applied by eagle_redact
6
+ # - orchestration workers default to a non-full-access (safe) autonomy mode
7
+ # - logs path resolver rejects traversal/symlink/out-of-root references
8
+ set -euo pipefail
9
+
10
+ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
11
+
12
+ tmp_dir=$(mktemp -d "$ROOT_DIR/.tmp-redaction.XXXXXX")
13
+ trap 'rm -rf "$tmp_dir"' EXIT
14
+
15
+ export HOME="$tmp_dir/home"
16
+ export EAGLE_MEM_DIR="$tmp_dir/eagle-mem"
17
+ export EAGLE_CONFIG_FILE="$EAGLE_MEM_DIR/config.toml"
18
+ mkdir -p "$HOME" "$EAGLE_MEM_DIR"
19
+
20
+ . "$ROOT_DIR/lib/common.sh"
21
+ "$ROOT_DIR/db/migrate.sh" >/dev/null
22
+ . "$ROOT_DIR/lib/db.sh"
23
+ . "$ROOT_DIR/lib/provider.sh"
24
+
25
+ # Fake secrets that must never survive redaction.
26
+ FAKE_ANT="sk-ant-FAKEabcdefghijklmnop1234567890"
27
+ FAKE_AWS="AKIAFAKE0000000000AB"
28
+ FAKE_BEARER="Bearer FAKEtokenshouldnotleak123"
29
+ FAKE_OPENAI="sk-FAKE0123456789abcdef0123456789abcdef"
30
+
31
+ fail() { echo "FAIL: $1" >&2; exit 1; }
32
+
33
+ assert_no_secret() {
34
+ local label="$1" haystack="$2"
35
+ case "$haystack" in
36
+ *"$FAKE_ANT"*) fail "$label leaked Anthropic key" ;;
37
+ *"$FAKE_AWS"*) fail "$label leaked AWS key" ;;
38
+ *"FAKEtoken"*) fail "$label leaked Bearer token" ;;
39
+ *"$FAKE_OPENAI"*) fail "$label leaked OpenAI key" ;;
40
+ esac
41
+ }
42
+
43
+ # ── eagle_redact built-ins ───────────────────────────────────────────────
44
+ redacted=$(printf 'k=%s a=%s h=%s o=%s\n' "$FAKE_ANT" "$FAKE_AWS" "$FAKE_BEARER" "$FAKE_OPENAI" | eagle_redact)
45
+ assert_no_secret "eagle_redact built-ins" "$redacted"
46
+ case "$redacted" in *"[REDACTED]"*) ;; *) fail "eagle_redact produced no [REDACTED] marker" ;; esac
47
+
48
+ # ── Finding 4: configured extra_patterns are honored ─────────────────────
49
+ cat > "$EAGLE_CONFIG_FILE" <<'TOML'
50
+ [redaction]
51
+ extra_patterns = ["CORPSECRET_[A-Z0-9]+", "INTERNAL-[0-9]+"]
52
+ TOML
53
+ extra_redacted=$(printf 'token CORPSECRET_AB12 and ticket INTERNAL-9988 here\n' | eagle_redact)
54
+ case "$extra_redacted" in
55
+ *CORPSECRET_AB12*) fail "extra_patterns[0] not applied (CORPSECRET leaked)" ;;
56
+ *INTERNAL-9988*) fail "extra_patterns[1] not applied (INTERNAL leaked)" ;;
57
+ esac
58
+
59
+ # Commented-out default must NOT redact (proves we parse real config, not the doc line).
60
+ cat > "$EAGLE_CONFIG_FILE" <<'TOML'
61
+ [redaction]
62
+ # extra_patterns = ["MY_CUSTOM_SECRET_.*"]
63
+ TOML
64
+ commented=$(printf 'MY_CUSTOM_SECRET_xyz stays visible\n' | eagle_redact)
65
+ case "$commented" in
66
+ *MY_CUSTOM_SECRET_xyz*) ;;
67
+ *) fail "commented extra_patterns default was wrongly applied" ;;
68
+ esac
69
+ rm -f "$EAGLE_CONFIG_FILE"
70
+
71
+ # ── Finding 2: recall_events.prompt_snippet is redacted before insert ────
72
+ project="redaction-project"
73
+ repo="$tmp_dir/repo"; mkdir -p "$repo"
74
+ eagle_upsert_session "redact-session" "$project" "$repo" "test-model" "test" "codex" >/dev/null
75
+ eagle_insert_recall_event "redact-session" "$project" "$repo" "codex" \
76
+ "please use my key $FAKE_ANT and aws $FAKE_AWS to deploy" \
77
+ "deploy" 0 0 0 0 "ok" "" >/dev/null
78
+ snippet=$(eagle_db "SELECT prompt_snippet FROM recall_events WHERE session_id='redact-session' LIMIT 1;")
79
+ assert_no_secret "recall_events.prompt_snippet" "$snippet"
80
+ case "$snippet" in *"[REDACTED]"*) ;; *) fail "recall_events.prompt_snippet not redacted" ;; esac
81
+
82
+ # ── Finding 1: enrich job file written by Stop is redacted ───────────────
83
+ # Drive the Stop hook with a transcript that contains a secret and assert the
84
+ # background enrich job file (and what the enricher would read) is clean.
85
+ transcript="$tmp_dir/transcript.jsonl"
86
+ cat > "$transcript" <<EOF
87
+ {"type":"user","message":{"role":"user","content":"set up deploy"}}
88
+ {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Using API key $FAKE_ANT and aws $FAKE_AWS for the deploy. Here is what I did."}]}}
89
+ EOF
90
+ mkdir -p "$EAGLE_MEM_DIR/tmp"
91
+ stop_input=$(jq -nc \
92
+ --arg sid "stop-redact-session" \
93
+ --arg tp "$transcript" \
94
+ --arg cwd "$repo" \
95
+ '{session_id:$sid, transcript_path:$tp, cwd:$cwd}')
96
+ # A provider must be configured so Stop queues a background enrich job. We
97
+ # DISABLE the background enricher itself (EAGLE_MEM_DISABLE_BACKGROUND_ENRICH=1)
98
+ # so the queued job file is left on disk for inspection, and force the defer
99
+ # path with EAGLE_MEM_STOP_ENRICH=0. EAGLE_MEM_PROJECT pins project resolution
100
+ # so the hook does not early-exit in this synthetic environment.
101
+ cat > "$EAGLE_CONFIG_FILE" <<'TOML'
102
+ [provider]
103
+ type = "anthropic"
104
+ TOML
105
+ printf '%s' "$stop_input" | \
106
+ EAGLE_MEM_PROJECT="$project" \
107
+ EAGLE_MEM_STOP_ENRICH=0 \
108
+ EAGLE_MEM_DISABLE_BACKGROUND_ENRICH=1 \
109
+ EAGLE_AGENT_SOURCE=codex \
110
+ bash "$ROOT_DIR/hooks/stop.sh" >/dev/null 2>&1 || true
111
+ # The background enricher is disabled from running, so the job file remains for inspection.
112
+ saw_job=0
113
+ for job in "$EAGLE_MEM_DIR"/tmp/summary-enrich.*.json; do
114
+ [ -f "$job" ] || continue
115
+ saw_job=1
116
+ body=$(cat "$job")
117
+ assert_no_secret "enrich job file" "$body"
118
+ case "$body" in *"[REDACTED]"*) ;; *) fail "enrich job file was not redacted" ;; esac
119
+ done
120
+ [ "$saw_job" = 1 ] || fail "Stop did not queue a background enrich job file to verify (finding 1)"
121
+ rm -f "$EAGLE_MEM_DIR"/tmp/summary-enrich.*.json 2>/dev/null || true
122
+ rm -f "$EAGLE_CONFIG_FILE"
123
+
124
+ # Even if no job file was produced in this environment, the redaction path is
125
+ # also unit-tested directly: the exact transformation Stop applies.
126
+ text_with_secret="prefix $FAKE_ANT mid $FAKE_AWS suffix"
127
+ enrich_text=$(printf '%s' "$text_with_secret" | eagle_redact)
128
+ assert_no_secret "Stop enrich_text transform" "$enrich_text"
129
+
130
+ # ── Finding 3: redact-before-truncate (boundary secret defeats prefix) ───
131
+ # A secret split across the 200-char truncation boundary must still be redacted.
132
+ pad=$(printf 'a%.0s' $(seq 1 190))
133
+ boundary_cmd="curl -H \"Authorization: Bearer ${pad}${FAKE_OPENAI}\""
134
+ # Old order (truncate THEN redact) would cut the token mid-stream; the new order
135
+ # redacts first. Emulate the new order exactly as post-tool-use.sh does.
136
+ new_order=$(printf '%s' "$boundary_cmd" | eagle_redact | cut -c1-200)
137
+ assert_no_secret "redact-before-truncate" "$new_order"
138
+
139
+ # ── Finding 9: orchestration workers default to safe autonomy ────────────
140
+ eval "$(sed -n '/^orchestrate_worker_autonomy()/,/^}/p' "$ROOT_DIR/scripts/orchestrate.sh")"
141
+ [ -f "$EAGLE_CONFIG_FILE" ] && rm -f "$EAGLE_CONFIG_FILE"
142
+ default_autonomy=$(orchestrate_worker_autonomy)
143
+ [ "$default_autonomy" = "safe" ] || fail "orchestration worker autonomy should default to 'safe', got '$default_autonomy'"
144
+ cat > "$EAGLE_CONFIG_FILE" <<'TOML'
145
+ [orchestration]
146
+ worker_autonomy = "danger"
147
+ TOML
148
+ opt_in_autonomy=$(orchestrate_worker_autonomy)
149
+ [ "$opt_in_autonomy" = "danger" ] || fail "worker_autonomy='danger' should opt into 'danger', got '$opt_in_autonomy'"
150
+ rm -f "$EAGLE_CONFIG_FILE"
151
+
152
+ # The generated safe run-script must not contain danger-full-access / never / dontAsk.
153
+ project="orch-proj"; name="orch-name"
154
+ eval "$(sed -n '/^orchestrate_worker_run_script()/,/^}/p' "$ROOT_DIR/scripts/orchestrate.sh")"
155
+ safe_script="$tmp_dir/safe-run.sh"
156
+ orchestrate_worker_run_script "$safe_script" "codex" "m" "xhigh" "/wt" "/p.md" "/exit" "/last" "/bin" "lane1" "/log"
157
+ if grep -qE 'danger-full-access|approval_policy="never"' "$safe_script"; then
158
+ fail "safe-mode codex worker still uses full-access flags"
159
+ fi
160
+ safe_claude="$tmp_dir/safe-claude.sh"
161
+ orchestrate_worker_run_script "$safe_claude" "claude-code" "m" "xhigh" "/wt" "/p.md" "/exit" "/last" "/bin" "lane1" "/log"
162
+ if grep -q 'permission-mode dontAsk' "$safe_claude"; then
163
+ fail "safe-mode claude worker still uses --permission-mode dontAsk"
164
+ fi
165
+
166
+ # ── Finding 7: logs path resolver containment ────────────────────────────
167
+ runs_root="$tmp_dir/runs"; mkdir -p "$runs_root"
168
+ echo "log line" > "$runs_root/run1.log"
169
+ secret_dir="$tmp_dir/secret"; mkdir -p "$secret_dir"
170
+ echo "TOPSECRET" > "$secret_dir/secret.log"
171
+ ln -s "$secret_dir/secret.log" "$runs_root/evil.log"
172
+ eval "$(sed -n '/^canonicalize_path()/,/^}/p;/^path_within()/,/^}/p;/^resolve_log_path()/,/^}/p' "$ROOT_DIR/scripts/logs.sh")"
173
+ EAGLE_RUNS_DIR="$runs_root"
174
+
175
+ valid=$(resolve_log_path "run1" || true)
176
+ [ -n "$valid" ] || fail "logs resolver rejected a valid in-root log"
177
+
178
+ if resolve_log_path "evil.log" >/dev/null 2>&1; then fail "logs resolver followed a symlink out of root"; fi
179
+ if resolve_log_path "../../etc/passwd" >/dev/null 2>&1; then fail "logs resolver allowed traversal"; fi
180
+ if resolve_log_path "$secret_dir/secret.log" >/dev/null 2>&1; then fail "logs resolver allowed an out-of-root absolute path"; fi
181
+ if resolve_log_path "$runs_root/../secret/secret.log" >/dev/null 2>&1; then fail "logs resolver allowed prefixed traversal"; fi
182
+
183
+ echo "PASS: redaction + autonomy + log-path containment regression coverage"