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.
@@ -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"
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # Phase 3 reliability hardening regressions:
3
+ # A. Auto-scan/index in-flight vs freshness marker separation — a crashed or
4
+ # output-less job must NOT block retry for a day (only a genuine success
5
+ # sets the freshness marker; the foreground touch is a short-lived debounce).
6
+ # B. eagle_events retention — the hook-observability table is pruned by age.
7
+ set -euo pipefail
8
+
9
+ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
10
+ tmp_dir=$(mktemp -d "$ROOT_DIR/.tmp-reliability.XXXXXX")
11
+ trap 'rm -rf "$tmp_dir"' EXIT
12
+
13
+ export HOME="$tmp_dir/home"
14
+ export EAGLE_MEM_DIR="$tmp_dir/eagle-mem"
15
+ mkdir -p "$HOME" "$EAGLE_MEM_DIR"
16
+
17
+ . "$ROOT_DIR/lib/common.sh"
18
+ "$ROOT_DIR/db/migrate.sh" >/dev/null
19
+ . "$ROOT_DIR/lib/db.sh"
20
+ . "$ROOT_DIR/lib/hooks-sessionstart.sh"
21
+
22
+ pass=0; fail=0
23
+ ok() { echo " ok: $1"; pass=$((pass+1)); }
24
+ bad() { echo " FAIL: $1"; fail=$((fail+1)); }
25
+ assert() { if eval "$1"; then ok "$2"; else bad "$2"; fi; }
26
+ assert_not() { if eval "$1"; then bad "$2"; else ok "$2"; fi; }
27
+
28
+ project="test/reliability"
29
+
30
+ # ── A. In-flight vs freshness ───────────────────────────────────────────────
31
+ # Crashed/output-less job: only the in-flight marker exists, never the freshness one.
32
+ _eagle_state_touch "scan-inflight" "$project"
33
+ assert_not '_eagle_state_fresh "scan" "$project" 1' \
34
+ "A1: in-flight-only does NOT satisfy the 1-day freshness gate (retry not blocked for a day)"
35
+ assert '_eagle_state_inflight_fresh "scan" "$project" 15' \
36
+ "A2: a recent in-flight marker debounces concurrent spawns"
37
+
38
+ # Genuine success sets the freshness marker.
39
+ _eagle_state_touch "scan" "$project"
40
+ assert '_eagle_state_fresh "scan" "$project" 1' \
41
+ "A3: a success-set freshness marker satisfies the freshness gate"
42
+
43
+ # A stale in-flight marker (job died long ago) must age out so retry reopens.
44
+ stale_inflight=$(_eagle_state_file "index-inflight" "$project")
45
+ mkdir -p "$(dirname "$stale_inflight")"
46
+ : > "$stale_inflight"
47
+ touch -t 202001010000 "$stale_inflight"
48
+ assert_not '_eagle_state_inflight_fresh "index" "$project" 15' \
49
+ "A4: a stale in-flight marker is not 'fresh' — retry is allowed after the debounce window"
50
+
51
+ # ── B. eagle_events retention ───────────────────────────────────────────────
52
+ p1=$(eagle_sql_escape "$project")
53
+ p2=$(eagle_sql_escape "other/project")
54
+ eagle_db "INSERT INTO eagle_events (project, session_id, agent, event_type, status, created_at)
55
+ VALUES ('$p1','s-old','codex','hook_started','ok', strftime('%Y-%m-%dT%H:%M:%fZ','now','-60 days'));" >/dev/null
56
+ eagle_db "INSERT INTO eagle_events (project, session_id, agent, event_type, status, created_at)
57
+ VALUES ('$p1','s-new','codex','hook_started','ok', strftime('%Y-%m-%dT%H:%M:%fZ','now'));" >/dev/null
58
+ eagle_db "INSERT INTO eagle_events (project, session_id, agent, event_type, status, created_at)
59
+ VALUES ('$p2','s-old2','codex','hook_started','ok', strftime('%Y-%m-%dT%H:%M:%fZ','now','-60 days'));" >/dev/null
60
+
61
+ eagle_prune_events 30 "$project"
62
+ old_p1=$(eagle_db "SELECT COUNT(*) FROM eagle_events WHERE project='$p1' AND session_id='s-old';")
63
+ new_p1=$(eagle_db "SELECT COUNT(*) FROM eagle_events WHERE project='$p1' AND session_id='s-new';")
64
+ old_p2=$(eagle_db "SELECT COUNT(*) FROM eagle_events WHERE project='$p2' AND session_id='s-old2';")
65
+ [ "$old_p1" = "0" ] && ok "B1: old event (60d) pruned for the target project" || bad "B1: old event not pruned (got $old_p1)"
66
+ [ "$new_p1" = "1" ] && ok "B2: recent event retained" || bad "B2: recent event lost (got $new_p1)"
67
+ [ "$old_p2" = "1" ] && ok "B3: project filter — other project's old event untouched" || bad "B3: project filter leaked (got $old_p2)"
68
+
69
+ eagle_prune_events 30
70
+ old_p2b=$(eagle_db "SELECT COUNT(*) FROM eagle_events WHERE project='$p2' AND session_id='s-old2';")
71
+ [ "$old_p2b" = "0" ] && ok "B4: unscoped prune removes other project's old event" || bad "B4: unscoped prune missed it (got $old_p2b)"
72
+
73
+ echo ""
74
+ echo "test_reliability_retention: $pass passed, $fail failed"
75
+ [ "$fail" -eq 0 ] || exit 1
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════
3
+ # Regression: scripts/test.sh must NOT abort mid-run when a
4
+ # single check fails.
5
+ #
6
+ # Guards against the `set -e` masking bug where the failure
7
+ # accumulator used `((errors++))`. Under `set -euo pipefail`,
8
+ # `((errors++))` returns exit status 1 when the pre-increment
9
+ # value is 0 (post-increment evaluates the old, falsy value),
10
+ # so the FIRST failing check aborted the entire runner —
11
+ # skipping every later check and the failure-count summary.
12
+ # The fix uses the assignment form `errors=$((errors + 1))`,
13
+ # which always returns 0.
14
+ # ═══════════════════════════════════════════════════════════
15
+ set -uo pipefail
16
+
17
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
19
+ TEST_SH="$REPO_DIR/scripts/test.sh"
20
+
21
+ pass=0
22
+ fail=0
23
+ ok() { echo " ok: $1"; pass=$((pass+1)); }
24
+ bad() { echo " FAIL: $1"; fail=$((fail+1)); }
25
+
26
+ # 1. The buggy idiom must be gone from the runner's executable code.
27
+ # Match only non-comment lines so the explanatory comment that names
28
+ # the old idiom does not trip the check.
29
+ if grep -vE '^\s*#' "$TEST_SH" | grep -qE '\(\(errors\+\+\)\)'; then
30
+ bad "scripts/test.sh still uses ((errors++)) in code (aborts under set -e)"
31
+ else
32
+ ok "scripts/test.sh no longer uses ((errors++)) in code"
33
+ fi
34
+ if grep -qF 'errors=$((errors + 1))' "$TEST_SH"; then
35
+ ok "scripts/test.sh uses the safe assignment form"
36
+ else
37
+ bad "scripts/test.sh missing safe assignment accumulator"
38
+ fi
39
+
40
+ # 2. Behavioral proof: replicate the runner's exact accumulation loop
41
+ # under `set -euo pipefail`, force the FIRST check to fail, and
42
+ # confirm execution reaches the end with a correct count.
43
+ result=$(
44
+ set -euo pipefail
45
+ errors=0
46
+ run_check() {
47
+ if eval "$2" >/dev/null 2>&1; then
48
+ :
49
+ else
50
+ errors=$((errors + 1))
51
+ fi
52
+ }
53
+ run_check "first-fails" "false" # errors goes 0 -> 1
54
+ run_check "second-ok" "true"
55
+ run_check "third-fails" "false" # errors goes 1 -> 2
56
+ echo "REACHED_END errors=$errors"
57
+ )
58
+ status=$?
59
+
60
+ if [ "$status" -eq 0 ] && printf '%s' "$result" | grep -qF 'REACHED_END errors=2'; then
61
+ ok "runner reaches summary after failures with correct count (errors=2)"
62
+ else
63
+ bad "runner aborted early or miscounted (status=$status, out='$result')"
64
+ fi
65
+
66
+ # 3. Sanity: the old idiom genuinely aborts (proves the test is meaningful).
67
+ # Run as a separate `bash` process so `set -e` is fully active (a `|| ...`
68
+ # on a subshell would disable `set -e` inside it and hide the abort).
69
+ control_tmp=$(mktemp)
70
+ cat > "$control_tmp" <<'CTRL'
71
+ set -euo pipefail
72
+ errors=0
73
+ ((errors++)) # pre-value 0 -> command returns 1 -> set -e aborts here
74
+ echo "SHOULD_NOT_PRINT"
75
+ CTRL
76
+ control_out=$(bash "$control_tmp" 2>/dev/null); control_status=$?
77
+ rm -f "$control_tmp"
78
+ if [ "$control_status" -ne 0 ] && ! printf '%s' "$control_out" | grep -qF 'SHOULD_NOT_PRINT'; then
79
+ ok "control: ((errors++)) from 0 aborts under set -e (bug is real)"
80
+ else
81
+ bad "control: ((errors++)) did not abort (status=$control_status) — test premise invalid"
82
+ fi
83
+
84
+ echo ""
85
+ echo "test_test_runner_no_abort: $pass passed, $fail failed"
86
+ [ "$fail" -eq 0 ] || exit 1