eagle-mem 4.12.0 → 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.
@@ -203,6 +203,20 @@ orchestrate_worker_effort() {
203
203
  esac
204
204
  }
205
205
 
206
+ # Worker autonomy level. Defaults to "safe" so spawned workers cannot run with
207
+ # unattended full-access sandbox/approval settings on prompts assembled from
208
+ # DB-stored lane descriptions (stored-prompt-injection surface). Set
209
+ # [orchestration] worker_autonomy = "danger" to opt back into the previous
210
+ # full-access behavior.
211
+ orchestrate_worker_autonomy() {
212
+ local mode
213
+ mode=$(eagle_config_get "orchestration" "worker_autonomy" "safe")
214
+ case "$mode" in
215
+ danger|danger-full-access|full) echo "danger" ;;
216
+ *) echo "safe" ;;
217
+ esac
218
+ }
219
+
206
220
  orchestrate_require_worker_cli() {
207
221
  case "$1" in
208
222
  codex)
@@ -775,6 +789,22 @@ orchestrate_worker_run_script() {
775
789
  local complete_note="Worker exited 0; log: $log_path"
776
790
  local block_note="Worker exited non-zero; log: $log_path"
777
791
 
792
+ # Autonomy gating: full-access unattended execution is opt-in (see
793
+ # orchestrate_worker_autonomy). The default "safe" mode keeps a sandbox and
794
+ # approval/permission gate in place because lane prompts come from
795
+ # DB-stored descriptions (stored-prompt-injection surface).
796
+ local autonomy codex_sandbox codex_approval claude_permission
797
+ autonomy=$(orchestrate_worker_autonomy)
798
+ if [ "$autonomy" = "danger" ]; then
799
+ codex_sandbox="danger-full-access"
800
+ codex_approval='approval_policy="never"'
801
+ claude_permission="dontAsk"
802
+ else
803
+ codex_sandbox="workspace-write"
804
+ codex_approval='approval_policy="on-request"'
805
+ claude_permission="acceptEdits"
806
+ fi
807
+
778
808
  {
779
809
  echo '#!/usr/bin/env bash'
780
810
  echo 'set +e'
@@ -786,12 +816,12 @@ orchestrate_worker_run_script() {
786
816
  printf 'export EAGLE_ORCHESTRATION_LANE=%q\n' "$lane_key"
787
817
  printf 'export EAGLE_ORCHESTRATION_WORKTREE=%q\n' "$worktree"
788
818
  if [ "$worker_agent" = "codex" ]; then
789
- printf 'codex exec --cd %q --model %q -c %q -c %q --sandbox danger-full-access --output-last-message %q - < %q\n' \
790
- "$worktree" "$worker_model" "$effort_config" 'approval_policy="never"' "$last_message_path" "$prompt_file"
819
+ printf 'codex exec --cd %q --model %q -c %q -c %q --sandbox %q --output-last-message %q - < %q\n' \
820
+ "$worktree" "$worker_model" "$effort_config" "$codex_approval" "$codex_sandbox" "$last_message_path" "$prompt_file"
791
821
  else
792
822
  printf 'prompt=$(cat %q)\n' "$prompt_file"
793
- printf 'claude -p --model %q --effort %q --permission-mode dontAsk --output-format text "$prompt"\n' \
794
- "$worker_model" "$worker_effort"
823
+ printf 'claude -p --model %q --effort %q --permission-mode %q --output-format text "$prompt"\n' \
824
+ "$worker_model" "$worker_effort" "$claude_permission"
795
825
  fi
796
826
  echo 'rc=$?'
797
827
  printf 'printf "%%s\\n" "$rc" > %q\n' "$exit_path"
@@ -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
 
@@ -68,6 +72,14 @@ run_check "Trust Surfaces (DB integrity, JSON errors, statusline)" "bash \"$SCRI
68
72
  run_check "Recall Observability (UserPromptSubmit recall event)" "bash \"$SCRIPTS_DIR/../tests/test_recall_observability.sh\""
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\""
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\""
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\""
71
83
 
72
84
  echo ""
73
85
  if [ "$errors" -eq 0 ]; then
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Regression: eagle_patch_claude_md must rewrite an outdated
4
+ # Eagle Mem capture section to the CLI-first doctrine.
5
+ #
6
+ # Guards against the v4.12.0 bug where the detection used
7
+ # `grep -qF 'request: \[what user asked\] | completed:'` — under
8
+ # grep -F the backslashes are literal, so it never matched the
9
+ # real `[what user asked]` text and the section was never updated,
10
+ # silently defeating the clean-capture feature on every install.
11
+ # ═══════════════════════════════════════════════════════════
12
+ set -uo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
15
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
16
+
17
+ pass=0
18
+ fail=0
19
+ ok() { echo " ok: $1"; pass=$((pass+1)); }
20
+ bad() { echo " FAIL: $1"; fail=$((fail+1)); }
21
+
22
+ run_case() {
23
+ local label="$1" body="$2"
24
+ local tmp; tmp=$(mktemp -d)
25
+ mkdir -p "$tmp/.claude"
26
+ printf '%s\n' "$body" > "$tmp/.claude/CLAUDE.md"
27
+
28
+ ( export HOME="$tmp"; . "$REPO_DIR/lib/common.sh" >/dev/null 2>&1; eagle_patch_claude_md >/dev/null 2>&1 )
29
+
30
+ if grep -qF 'session save --session-id' "$tmp/.claude/CLAUDE.md"; then
31
+ ok "$label: CLI-first doctrine present after patch"
32
+ else
33
+ bad "$label: section was NOT rewritten to CLI-first"
34
+ fi
35
+ if [ "$(grep -c '## Eagle Mem — Persistent Memory' "$tmp/.claude/CLAUDE.md")" = "1" ]; then
36
+ ok "$label: exactly one Eagle Mem section (no duplication)"
37
+ else
38
+ bad "$label: section count != 1"
39
+ fi
40
+ rm -rf "$tmp"
41
+ }
42
+
43
+ # Case 1: old <eagle-summary>-emit section with surrounding user content.
44
+ run_case "old-eagle-summary" '# Custom global instructions
45
+
46
+ Pre-section user content.
47
+
48
+ ---
49
+
50
+ ## Eagle Mem — Persistent Memory
51
+
52
+ **Rule:** Before your final response, emit an `<eagle-summary>` block.
53
+
54
+ ```
55
+ <eagle-summary>
56
+ request: [what user asked] | completed: [what shipped]
57
+ </eagle-summary>
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Behavioral Rules
63
+
64
+ Post-section user content that must survive.'
65
+
66
+ # Verify surrounding content survives the rewrite (separate explicit check).
67
+ TMP=$(mktemp -d); mkdir -p "$TMP/.claude"
68
+ printf '%s\n' '# Custom
69
+
70
+ KEEP_BEFORE_MARKER
71
+
72
+ ---
73
+
74
+ ## Eagle Mem — Persistent Memory
75
+
76
+ **Rule:** emit an `<eagle-summary>` block.
77
+
78
+ ---
79
+
80
+ ## Behavioral Rules
81
+
82
+ KEEP_AFTER_MARKER' > "$TMP/.claude/CLAUDE.md"
83
+ ( export HOME="$TMP"; . "$REPO_DIR/lib/common.sh" >/dev/null 2>&1; eagle_patch_claude_md >/dev/null 2>&1 )
84
+ grep -q 'KEEP_BEFORE_MARKER' "$TMP/.claude/CLAUDE.md" && ok "preserve: content before section kept" || bad "preserve: clobbered content before section"
85
+ grep -q 'KEEP_AFTER_MARKER' "$TMP/.claude/CLAUDE.md" && ok "preserve: content after section kept" || bad "preserve: clobbered content after section"
86
+ grep -q 'emit an `<eagle-summary>` block' "$TMP/.claude/CLAUDE.md" && bad "preserve: stale emit instruction still present" || ok "preserve: stale emit instruction removed"
87
+
88
+ # Idempotency: rewriting again must not add a second section.
89
+ before=$(cksum "$TMP/.claude/CLAUDE.md" | awk '{print $1}')
90
+ ( export HOME="$TMP"; . "$REPO_DIR/lib/common.sh" >/dev/null 2>&1; eagle_patch_claude_md >/dev/null 2>&1 )
91
+ [ "$(grep -c '## Eagle Mem — Persistent Memory' "$TMP/.claude/CLAUDE.md")" = "1" ] && ok "idempotent: still one section after 2nd run" || bad "idempotent: section duplicated on 2nd run"
92
+ rm -rf "$TMP"
93
+
94
+ echo ""
95
+ echo "test_claude_md_capture_doctrine: $pass passed, $fail failed"
96
+ [ "$fail" -eq 0 ] || exit 1
@@ -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 ]