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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to the **Eagle Mem** project are documented here.
4
4
 
5
5
  ---
6
6
 
7
+ ## v4.13.0 Full-Spectrum Security & Reliability Hardening
8
+
9
+ A six-lens fix-in-place review of the whole codebase (security, data integrity, reliability, token economy, code quality, architecture). 32 files hardened, 6 new regression suites, full smoke suite green. No behavioral surface for normal sessions changed — recall and capture are byte-for-byte the same; what changed is the failure, concurrency, and trust behavior underneath.
10
+
11
+ **Security**
12
+ - **Unattended workers no longer default to full access (highest severity).** Orchestration workers previously spawned `--sandbox danger-full-access` / `--permission-mode dontAsk` on prompts assembled from DB-stored lane text — a stored-prompt-injection path to unattended arbitrary execution. New `[orchestration] worker_autonomy` defaults to **safe** (`workspace-write` / `on-request` / `acceptEdits`); `danger` is now an explicit opt-in.
13
+ - **LLM inputs are redacted, not just outputs.** Transcript excerpts sent to providers (including remote APIs via fallback) and persisted to the enrich job file are now run through `eagle_redact` before send/persist; `recall_events.prompt_snippet` is redacted before insert; the Bash command summary is redacted *before* truncation so a boundary-split secret can't leak.
14
+ - **Antigravity binary-hijack fix.** The Python hook resolves `bin/eagle-mem` and hook scripts from its install dir instead of `os.getcwd()`, and validates `SESSION_ID`.
15
+ - Prompts passed to provider CLIs now go over stdin (not `ps`-visible argv); `logs show|tail` canonicalizes paths (realpath) before the runs-root containment check; the Codex hook passes event names via jq `--arg`; the dead `[redaction] extra_patterns` knob is now wired into `eagle_redact`.
16
+
17
+ **Data integrity & database**
18
+ - **Fail-open `SQLITE_BUSY` reads fixed.** Standalone `sqlite3` reads (statusline, tasks, updater, project-identity lookups) now set `busy_timeout` so a momentary lock waits instead of being misread as "no data."
19
+ - **Mod-tracker data-loss & lock-wedge fixed.** Stale-lock TTL reclaim, atomic mv-based pending drain (a concurrent append is never lost), and the edit-history append is now locked. Verified by a 40-parallel-writer regression test.
20
+ - Summary `project` is sticky on conflict for agent-authored rows (hook-path project drift can no longer rekey them); added `eagle_db_strict` (`.bail on`) for fail-fast callers; observation dedup runs in `BEGIN IMMEDIATE`.
21
+
22
+ **Reliability / self-healing**
23
+ - **Auto-scan no longer self-blocks for 24h on failure.** A short-lived *in-flight* marker now debounces concurrent spawns, while the durable *freshness* marker is set only on genuine success — a crashed or output-less scan retries instead of being suppressed for a day.
24
+ - **`eagle_events` retention.** The hook-observability table (≈99k unbounded rows in practice) is now pruned by age (30 days) at SessionEnd. `pending_feature_verifications` is deliberately left un-pruned (expiring it would let an unverified change ship).
25
+
26
+ **Token economy**
27
+ - **SessionStart injection ceiling.** A generous global budget (`context_budget.sessionstart_chars`, default 24000 chars / ~6K tokens) drops whole low-priority sections from the bottom when the recall body is pathologically large, always keeping overview/recent/memories + capture instructions and never splitting a section. Normal-sized recall is emitted unchanged.
28
+
29
+ **Code quality**
30
+ - **Test runner no longer aborts at the first failure.** `((errors++))` returns exit 1 when the count is 0, so under `set -euo pipefail` the first failing check killed the suite before it could report the count; switched to `errors=$((errors + 1))`.
31
+
32
+ Full findings report (every item marked fixed-with-its-test or proposed) lives at `docs/reviews/2026-06-10-full-spectrum-hardening.md`, including the propose-only architecture backlog (`common.sh` decomposition, orchestrate lifecycle, curator→guardrail provenance, Grok/bare-shell gate parity).
33
+
34
+ ---
35
+
7
36
  ## v4.12.1 Deploy-Path Fixes for v4.12.0
8
37
 
9
38
  Two install/update-path bugs that defeated or destabilized the v4.12.0 rollout:
package/README.md CHANGED
@@ -13,6 +13,8 @@ Eagle Mem turns AI coding sessions into compounding project knowledge. It gives
13
13
 
14
14
  **v4.10.5 and onward focuses on Graph Memory, Dream Cycle Curation, OpenCode, Grok, Google Antigravity support, and Compaction Survival:** OpenCode users get a global local plugin plus linked skills, Grok users get first-class skill linking and `eagle-mem grok-bootstrap`, and Antigravity users get native Python SDK hook integration via `google_antigravity_hook.py`. Claude Code, Codex, OpenCode, and Antigravity receive the deepest automatic lifecycle support through hooks or plugins; Grok currently uses the shared CLI and skill workflow.
15
15
 
16
+ **v4.13.0 is a full-spectrum security & reliability hardening:** orchestration workers no longer default to full filesystem access, all LLM inputs (not just outputs) are redacted before they leave the machine, fail-open `SQLITE_BUSY` reads now wait for the lock, the mod-tracker is concurrency-safe, failed background scans retry instead of self-blocking for a day, and SessionStart injection has a token-economy ceiling. Normal recall and capture are unchanged. See [`CHANGELOG.md`](CHANGELOG.md) and the [findings report](docs/reviews/2026-06-10-full-spectrum-hardening.md).
17
+
16
18
  **Website:** [Product](https://eagleisbatman.github.io/eagle-mem/) |
17
19
  [Architecture](https://eagleisbatman.github.io/eagle-mem/architecture.html) |
18
20
  [About](https://eagleisbatman.github.io/eagle-mem/about.html)
@@ -265,6 +267,8 @@ By default Eagle Mem uses the opposite-agent worker model:
265
267
 
266
268
  Worker models, effort, route, and worktree behavior are configurable in `~/.eagle-mem/config.toml` under `[orchestration]`.
267
269
 
270
+ Spawned workers run in a sandbox with an approval/permission gate by default (`worker_autonomy = "safe"`). Because worker prompts are assembled from DB-stored lane descriptions, unattended full filesystem access is opt-in: set `worker_autonomy = "danger"` under `[orchestration]` to run workers with `codex --sandbox danger-full-access` / `claude --permission-mode dontAsk`. Only enable this when you trust the lane descriptions.
271
+
268
272
  ```bash
269
273
  eagle-mem orchestrate init "Ship auth cleanup"
270
274
  eagle-mem orchestrate lane add api --agent codex --desc "API fixes + tests" --validate "npm test"
package/db/migrate.sh CHANGED
@@ -44,7 +44,17 @@ run_migration() {
44
44
 
45
45
  if [ "$already_applied" = "0" ]; then
46
46
  # Strip PRAGMAs from migration body (they can't run inside transactions
47
- # and are already set on every connection via lib/db.sh EAGLE_DB_SETUP)
47
+ # and are already set on every connection via lib/db.sh EAGLE_DB_SETUP).
48
+ #
49
+ # FUTURE-MIGRATION FOOTGUN: EVERY line matching `^\s*PRAGMA ` is removed
50
+ # from the body before it runs inside the BEGIN/COMMIT below. A migration
51
+ # that genuinely needs a persistent/connection PRAGMA (e.g. a one-shot
52
+ # `PRAGMA user_version=N;` or a `PRAGMA foreign_key_check;` validation)
53
+ # will have that line SILENTLY DROPPED — it will appear to succeed while
54
+ # doing nothing. Do not rely on PRAGMA statements inside a migration file;
55
+ # set connection PRAGMAs in EAGLE_DB_SETUP, and run any standalone schema
56
+ # PRAGMA as a separate `"$SQLITE_BIN" "$DB" "PRAGMA ...;"` call outside
57
+ # run_migration. This stripping behavior is intentional — do not remove it.
48
58
  local body
49
59
  body=$(grep -v -E '^[[:space:]]*PRAGMA ' "$file")
50
60
 
@@ -57,3 +57,30 @@ Last verified: 2026-06-10
57
57
 
58
58
  When editing Claude Code hooks, update this file with the new verification date and include the exact hook event names, input fields, and output semantics used by the implementation. When editing brand visibility or statusline behavior, re-read the statusline reference first and update `claude-statusline.json` if the stdin schema changed.
59
59
 
60
+ ### Evidence: data-integrity hardening (2026-06-10)
61
+
62
+ Phase 2 data-integrity hardening touched the PostToolUse hook and the statusline without changing any Claude Code contract — no hook event names, stdin field reads, or stdout/exit semantics changed; `claude-statusline.json` is unaffected. Specifically:
63
+
64
+ - `hooks/post-tool-use.sh`: the `mod-tracker`/`edit-tracker` writers (internal `~/.eagle-mem` state, not Claude I/O) gained a stale-lock TTL reclaim, an atomic mv-based pending drain so a concurrent append is never lost, and a lock around the edit-history append. The PostToolUse stdin parse (`session_id`, `cwd`, `tool_name`, `hook_event_name`, `tool_input`) is unchanged.
65
+ - `scripts/statusline-em.sh`: the hottest standalone stats query now runs `PRAGMA busy_timeout=10000;` so a momentary `SQLITE_BUSY` waits for the lock instead of being misread as a DB-integrity error. Statusline stdin schema and output rendering are unchanged.
66
+
67
+ Covered by `tests/test_mod_tracker_concurrency.sh` and `tests/test_trust_surfaces.sh` (statusline integrity-status path).
68
+
69
+ ### Evidence: reliability/self-healing hardening (2026-06-10)
70
+
71
+ Phase 3 reliability hardening touched SessionStart auto-provisioning and SessionEnd without changing any Claude Code contract — hook event names, stdin field reads, and stdout/exit semantics are unchanged. Specifically:
72
+
73
+ - `lib/hooks-sessionstart.sh` (sourced by SessionStart): auto-scan/auto-index now debounce concurrent spawns with a short-lived in-flight marker and set the durable freshness marker ONLY when the background job genuinely succeeds, so a crashed/output-less scan no longer blocks retry for ~24h. Pure background-state behavior; no change to injected context format or stdin parse.
74
+ - `hooks/session-end.sh`: now also prunes `eagle_events` (hook-observability telemetry) older than 30 days; `pending_feature_verifications` is deliberately left un-pruned (documented inline). SessionEnd input/exit semantics unchanged.
75
+
76
+ Covered by `tests/test_reliability_retention.sh`.
77
+
78
+ ### Evidence: SessionStart injection ceiling (2026-06-10)
79
+
80
+ Phase 4 token-economy hardening added a generous global size ceiling on the recall body that SessionStart injects, without changing any Claude Code contract — the hook still emits the same `additionalContext` via the existing `eagle_emit_context_for_agent` path, with unchanged stdin field reads (`session_id`, `cwd`, `source`, `model`) and exit semantics. Specifically:
81
+
82
+ - `lib/common.sh`: added `eagle_sessionstart_inject_budget` (config key `context_budget.sessionstart_chars`, default 24000 chars / ~6K tokens, floored at 4000) and `eagle_trim_inject_body`, which drops whole `=== Eagle Mem: ...` sections from the END (lowest priority appended last) until the body fits. Sections are never split, so no recall surface is half-emitted, and the top-priority sections (overview, recent recall, memories) always survive.
83
+ - `hooks/session-start.sh`: applies the ceiling to the accumulated recall body for the non-Codex path BEFORE the trailing Active/instructions block is appended, so capture instructions are never trimmed. When it trims it logs an observable `WARN` ("injection over budget … trimmed N low-priority section(s)") and records `sections_trimmed` in the hook observability detail. Normal-sized recall is emitted byte-for-byte unchanged (verified by test).
84
+
85
+ Covered by `tests/test_context_budget.sh`.
86
+
@@ -15,6 +15,7 @@ Last verified: 2026-06-10
15
15
  - `AGENTS.md` is the durable repo-instruction surface for Codex. Closer nested files override earlier guidance because Codex concatenates instruction files from the project root down to the current working directory.
16
16
  - Codex hooks are discovered from active config layers through `hooks.json` or inline `[hooks]` tables in `config.toml`.
17
17
  - `features.hooks` is the canonical feature key. `features.codex_hooks` is a deprecated alias and should not be the primary setting.
18
+ - Hook entries are registered into `hooks.<Event>` via jq. The event name is passed as `--arg` and indexed dynamically (`.hooks[$event]`), never interpolated into the jq program; the emitted `hooks.json` shape is unchanged. Re-verified during the 2026-06-10 security hardening pass (jq injection hardening, secret redaction before provider calls/persistence); no Codex hook contract changed.
18
19
  - `SessionStart`, `PreToolUse`, `PostToolUse`, `PreCompact`, `PostCompact`, `UserPromptSubmit`, `SubagentStop`, and `Stop` run at the documented lifecycle scopes.
19
20
  - `SessionStart` matchers use `startup`, `resume`, `clear`, and `compact`.
20
21
  - `PreToolUse` and `PostToolUse` match tool names, including `Bash`, `apply_patch`, MCP tool names, and aliases such as `Edit` and `Write` for `apply_patch`.
@@ -0,0 +1,90 @@
1
+ # Eagle Mem — Full-Spectrum Review & Hardening (2026-06-10)
2
+
3
+ Branch `review/arch-hardening` (off `main` @ v4.12.1). Six-lens review, fix-in-place.
4
+ **32 files changed (+1275 / −101); 6 new regression suites (registered checks 33 → 39); full `scripts/test.sh` suite green.** Nothing published — release is a separate, user-gated step.
5
+
6
+ | Phase | Lens | Commit |
7
+ |---|---|---|
8
+ | 1 | Security | `be08b31` |
9
+ | 2 | Data integrity & DB | `1fdaa2e` |
10
+ | 3 | Reliability / self-healing | `9451cdd` |
11
+ | 4 | Performance / token economy | `0ef772e` |
12
+ | 5 | Code quality | `d50ba86` |
13
+ | 6 | Architecture / coupling | this document (assessment + proposals) |
14
+
15
+ ---
16
+
17
+ ## Fixed (with the test that proves it)
18
+
19
+ ### Phase 1 — Security (`be08b31`)
20
+ 1. **Unredacted LLM input** — transcript excerpts were sent to providers (remote APIs via fallback) and written to the enrich job file in the clear. Now `eagle_redact` runs on the input before send/persist (`hooks/stop.sh`, `scripts/enrich-summary.sh`).
21
+ 2. **Raw prompt in `recall_events`** — `prompt_snippet` now redacted before insert (`lib/db-observations.sh`).
22
+ 3. **Truncate-before-redact** — Bash command summary now redacted *then* truncated (`hooks/post-tool-use.sh`).
23
+ 4. **Dead `extra_patterns` knob** — `[redaction] extra_patterns` config now wired into `eagle_redact` (`lib/common.sh`).
24
+ 5. **Antigravity binary hijack** — hook resolves `bin/eagle-mem` + hook scripts from the install dir, not `os.getcwd()`; validates `SESSION_ID` (`integrations/google_antigravity_hook.py`).
25
+ 6. **Prompt via argv (`ps`-visible)** — Claude CLI provider + antigravity summary now pass prompts via stdin (`lib/provider.sh`, `scripts/session.sh`).
26
+ 7. **Non-canonical log containment** — `logs.sh` canonicalizes (realpath) before the runs-root containment check.
27
+ 8. **jq program-string interpolation** — codex-hooks event name passed via `--arg` (`lib/codex-hooks.sh`).
28
+ 9. **Unattended full-access workers (highest severity)** — orchestration workers no longer default to `--sandbox danger-full-access` / `dontAsk` on DB-assembled prompts. `[orchestration] worker_autonomy` defaults to **safe** (`workspace-write` / `on-request` / `acceptEdits`); `danger` is explicit opt-in (`scripts/orchestrate.sh`).
29
+ *Proof:* `tests/test_redaction_coverage.sh` (seeds fake secrets; asserts none reach provider input / recall_events / enrich job; asserts safe-mode run-scripts carry no full-access flags).
30
+
31
+ ### Phase 2 — Data integrity & DB (`1fdaa2e`)
32
+ - **Fail-open `sqlite3` guards** — standalone reads now set `busy_timeout` so `SQLITE_BUSY` waits instead of reading as "no data" (`lib/common.sh` ×2, `scripts/statusline-em.sh`, `scripts/tasks.sh`, `lib/updater.sh`); `eagle_project_has_table_row` allowlists its table identifier; `tasks.sh` escapes a DB-read value + uses `eagle_db`.
33
+ - **Mod-tracker data loss / lock wedge** — stale-lock TTL reclaim, atomic mv-based pending drain (no append lost), and the edit-history append is now locked (`hooks/post-tool-use.sh`).
34
+ - **Project clobber** — summary `project` is now sticky on conflict for agent rows (`lib/db-summaries.sh`).
35
+ - **Error masking** — added `eagle_db_strict` (`.bail on`) for fail-fast callers (left `eagle_db` continue-on-error to avoid breaking ~200 callers); observation dedup runs in `BEGIN IMMEDIATE`; the project-repair transaction now logs failure (`lib/db-core.sh`, `lib/db-observations.sh`, `lib/db-sessions.sh`).
36
+ - **PRAGMA-stripping footgun** documented in `db/migrate.sh`.
37
+ *Proof:* `tests/test_data_integrity_hardening.sh` (migrate idempotency, SQL-escaping, summary precedence/clobber) + `tests/test_mod_tracker_concurrency.sh` (40 parallel writers, no lost append, lock reclaim, dedup race).
38
+
39
+ ### Phase 3 — Reliability / self-healing (`9451cdd`)
40
+ - **24h auto-scan block** — the foreground touch now sets a short-lived *in-flight* marker (debounce); the durable *freshness* marker is set only on genuine background success, so a crashed/output-less scan no longer blocks retry for a day (`lib/hooks-sessionstart.sh`).
41
+ - **Unbounded `eagle_events`** (≈99k rows on this machine) — pruned by age (30d) at SessionEnd via new `eagle_prune_events` (`lib/db-events.sh`, `hooks/session-end.sh`).
42
+ - **`pending_feature_verifications`** — deliberately *not* TTL'd (documented; expiring it would let an unverified change ship).
43
+ *Proof:* `tests/test_reliability_retention.sh`.
44
+
45
+ ### Phase 4 — Performance / token economy (`0ef772e`)
46
+ - **SessionStart injection ceiling** — added a generous global budget (`context_budget.sessionstart_chars`, default 24000 chars / ~6K tokens, floored at 4000) that drops whole low-priority sections from the bottom (never splits a section; always keeps overview/recent/memories + capture instructions), logs a trim, and records `sections_trimmed`. Normal recall is byte-for-byte unchanged. The earlier "3 git diffs per tool call" concern was verified overstated (gated behind release-boundary detection).
47
+ *Proof:* `tests/test_context_budget.sh`.
48
+
49
+ ### Phase 5 — Code quality (`d50ba86`)
50
+ - **Test runner aborted at first failure** — `((errors++))` returns 1 when `errors==0`, so under `set -euo pipefail` the first failing check killed the runner before it could report the count. Switched to `errors=$((errors + 1))`. (This is why a failing check earlier showed truncated output.)
51
+ *Proof:* `tests/test_test_runner_no_abort.sh` (incl. a control proving the old form aborts).
52
+ - Audited: all remaining `grep -F` sites are genuinely literal; `feature.sh`'s `changes()` pattern is benign; all shell tests registered; `extra_patterns` confirmed live.
53
+
54
+ ---
55
+
56
+ ## Phase 6 — Architecture assessment (propose only)
57
+
58
+ The system is sound and unusually well-instrumented for a bash project (durable feature gate, hook observability, redaction, FTS recall). The structural risks below are about *coupling and blast radius*, not correctness; none were auto-refactored because they move code across gate-watched files and would destabilize every adapter mid-review.
59
+
60
+ 1. **`lib/common.sh` is a 2k-line nexus.** It owns project-identity resolution, redaction, command classification, release-boundary detection, the token-budget trim, and CLAUDE.md/AGENTS.md patching — and every hook and adapter depends on it. *Proposal:* decompose into focused libs (`lib/identity.sh`, `lib/redact.sh`, `lib/cmd-classify.sh`, `lib/claude-md.sh`) behind the current function names, one module at a time, each with the existing tests as the safety net. High value, medium risk; do it as its own series, not inside a review.
61
+
62
+ 2. **`scripts/orchestrate.sh` (largest script, 1.3k lines) ↔ `bin/eagle-mem` re-entrancy.** Worker lifecycle state is spread across DB rows + run-dir files (`exit_code`, `.done`) + PIDs, with recorded cancellation/resume gaps. Now that autonomy is gated (Phase 1), the next step is a single source of truth for lane state and an explicit cancel/resume contract. *Proposal, separate effort.*
63
+
64
+ 3. **Curator → guardrail → PreToolUse feedback loop.** LLM output (`PROMOTE:` lines, learned command rules) becomes enforcement input that steers future command execution, with only format-level validation. *Proposal:* tag model-derived guardrails/rules with provenance and require confirmation (or a confidence threshold) before they can rewrite commands — closes the loop between the curator and the autonomy work done in Phase 1.
65
+
66
+ 4. **Adapter parity is convention-held; Grok has no hook lifecycle.** Five agents × six events × three transports (native hooks / JS plugin / Python shim) are kept in line only by `docs/agent-compatibility/` + the docs gate. Grok is skills-only — no capture, no guardrails, **no release gate** — and bare-shell pushes also bypass the gate (it lives only in PreToolUse). *Proposal:* either document Grok/bare-shell as explicitly out-of-gate scope, or add a lightweight pre-push wrapper so governance isn't silently skippable.
67
+
68
+ 5. **install.sh / update.sh duplication.** Currently *in sync*, but the hook matchers are duplicated literals across both — the historical drift class. *Proposal (from Phase 5):* extract `eagle_register_claude_hooks` into `lib/hooks.sh` and call it from both.
69
+
70
+ ---
71
+
72
+ ## Deferred proposals (rationale)
73
+
74
+ - **PostToolUse zero-state short-circuit** — skip stale-memory / decision FTS on empty projects; needs a cached count to avoid adding a query (recall-sensitive).
75
+ - **UserPromptSubmit query consolidation** — the 3 FTS queries hit different tables/ranking; UNION would entangle parsing for ~1 process spawn saved. Recommend leaving as-is.
76
+ - **Provider LLM-output cache** — correctness risk (stale enrichment); needs content-hash invalidation.
77
+ - **`tests/test_antigravity_hook.py`** runs only manually — add a guarded python lane to `scripts/test.sh` or document as dev-only.
78
+ - **Dead-function sweep** — ~18 lib functions have no shell caller, but several are public aliases / dynamic-dispatch; needs an owner-confirmed call-graph (incl. `.py`/`.js`) before removal.
79
+
80
+ ---
81
+
82
+ ## Verification
83
+
84
+ - Per phase: `bash scripts/test.sh` fully green; `bash -n` on every touched shell file; `py_compile` + `tests/test_antigravity_hook.py` for the Python hook.
85
+ - New coverage: redaction (provider input / recall_events / enrich job / autonomy / log paths), migration idempotency + SQL escaping + summary precedence, 40-writer mod-tracker concurrency, scan in-flight/freshness + events retention, context budget, test-runner no-abort.
86
+ - The docs gate (`tests/test_agent_compatibility_docs_gate.sh`) was satisfied with truthful dated evidence notes for each phase that touched a sensitive integration file.
87
+
88
+ ## Releasing
89
+
90
+ Not auto-shipped. To release (likely `v4.13.0`): bump + CHANGELOG, clear the feature gate, `npm publish`, `npm install -g eagle-mem@<v>` → `eagle-mem update`, verify the installed `~/.eagle-mem` runtime, then gh-pages + GitHub release.
@@ -17,34 +17,72 @@ LIB_DIR="$SCRIPT_DIR/../lib"
17
17
  input=$(eagle_read_stdin)
18
18
  [ -z "$input" ] && exit 0
19
19
 
20
+ # Stale lock TTL (seconds). A hook killed between mkdir and rmdir would
21
+ # otherwise leak its lock dir forever and wedge every later writer; reclaim a
22
+ # lock dir older than this so the tracker self-heals.
23
+ EAGLE_MOD_LOCK_TTL="${EAGLE_MOD_LOCK_TTL:-30}"
24
+
25
+ # Acquire a mkdir-based lock with stale-lock reclaim. Echoes 0 on success.
26
+ # Returns non-zero (without acquiring) if the lock stays held by a live holder.
27
+ eagle_acquire_dir_lock() {
28
+ local lock_dir="$1" attempt lock_age now mtime
29
+ for attempt in 1 2 3 4 5 6 7 8 9 10; do
30
+ if mkdir "$lock_dir" 2>/dev/null; then
31
+ return 0
32
+ fi
33
+ # Reclaim a stale lock: if the dir is older than the TTL, the holder
34
+ # almost certainly died mid-critical-section. rmdir + retry.
35
+ now=$(date +%s 2>/dev/null) || now=""
36
+ mtime=$(stat -f %m "$lock_dir" 2>/dev/null || stat -c %Y "$lock_dir" 2>/dev/null || echo "")
37
+ if [ -n "$now" ] && [ -n "$mtime" ]; then
38
+ lock_age=$(( now - mtime ))
39
+ if [ "$lock_age" -ge "$EAGLE_MOD_LOCK_TTL" ]; then
40
+ rmdir "$lock_dir" 2>/dev/null || true
41
+ eagle_log "WARN" "PostToolUse: reclaimed stale tracker lock ${lock_dir##*/} (age=${lock_age}s)"
42
+ continue
43
+ fi
44
+ fi
45
+ sleep 0.05
46
+ done
47
+ return 1
48
+ }
49
+
20
50
  eagle_track_modified_path() {
21
51
  local path="$1" sid="$2"
22
52
  [ -n "$path" ] || return 0
23
53
  [ -n "$sid" ] && eagle_validate_session_id "$sid" || return 0
24
54
 
25
- local mod_dir mod_file mod_lock mod_tmp attempt
55
+ local mod_dir mod_file mod_lock mod_tmp pending_file drain_file
26
56
  mod_dir="$EAGLE_MEM_DIR/mod-tracker"
27
57
  mkdir -p "$mod_dir" 2>/dev/null || return 0
28
58
  mod_file="$mod_dir/${sid}"
29
59
  mod_lock="${mod_file}.lock"
30
60
 
31
- for attempt in 1 2 3 4 5 6 7 8 9 10; do
32
- if mkdir "$mod_lock" 2>/dev/null; then
33
- mod_tmp=$(mktemp "${mod_file}.XXXXXX" 2>/dev/null) || mod_tmp="${mod_file}.$$"
34
- (
35
- cat "$mod_file" 2>/dev/null
36
- for pending_file in "${mod_file}".pending.*; do
37
- [ -f "$pending_file" ] && cat "$pending_file" 2>/dev/null
38
- done
39
- printf '%s\n' "$path"
40
- ) | tail -3 > "$mod_tmp"
41
- mv "$mod_tmp" "$mod_file" 2>/dev/null || rm -f "$mod_tmp"
42
- rm -f "${mod_file}".pending.* 2>/dev/null || true
43
- rmdir "$mod_lock" 2>/dev/null || true
44
- return 0
45
- fi
46
- sleep 0.05
47
- done
61
+ if eagle_acquire_dir_lock "$mod_lock"; then
62
+ mod_tmp=$(mktemp "${mod_file}.XXXXXX" 2>/dev/null) || mod_tmp="${mod_file}.$$"
63
+ # Drain pending atomically: mv each pending file to a private .draining
64
+ # name BEFORE reading it. A concurrent append landing in a NEW .pending.*
65
+ # after this glob is simply left for the next lock holder — never deleted
66
+ # mid-flight, so no append is ever lost between snapshot and cleanup.
67
+ for pending_file in "${mod_file}".pending.*; do
68
+ [ -e "$pending_file" ] || continue
69
+ drain_file="${pending_file}.draining.$$"
70
+ mv "$pending_file" "$drain_file" 2>/dev/null || continue
71
+ done
72
+ (
73
+ cat "$mod_file" 2>/dev/null
74
+ # Read every draining file (including any orphaned by a holder that
75
+ # died mid-drain) — we hold the lock, so we are the sole reader.
76
+ for drain_file in "${mod_file}".pending.*.draining.*; do
77
+ [ -e "$drain_file" ] && cat "$drain_file" 2>/dev/null
78
+ done
79
+ printf '%s\n' "$path"
80
+ ) | tail -3 > "$mod_tmp"
81
+ mv "$mod_tmp" "$mod_file" 2>/dev/null || rm -f "$mod_tmp"
82
+ rm -f "${mod_file}".pending.*.draining.* 2>/dev/null || true
83
+ rmdir "$mod_lock" 2>/dev/null || true
84
+ return 0
85
+ fi
48
86
 
49
87
  printf '%s\n' "$path" >> "${mod_file}.pending.$$" 2>/dev/null || true
50
88
  eagle_log "WARN" "PostToolUse: mod-tracker lock busy; queued pending modified file for session=$sid"
@@ -55,10 +93,21 @@ eagle_track_edit_history_path() {
55
93
  [ -n "$path" ] || return 0
56
94
  [ -n "$sid" ] && eagle_validate_session_id "$sid" || return 0
57
95
 
58
- local edit_dir
96
+ local edit_dir edit_file edit_lock
59
97
  edit_dir="$EAGLE_MEM_DIR/edit-tracker"
60
98
  mkdir -p "$edit_dir" 2>/dev/null || return 0
61
- printf '%s\n' "$path" >> "$edit_dir/${sid}" 2>/dev/null || true
99
+ edit_file="$edit_dir/${sid}"
100
+ edit_lock="${edit_file}.lock"
101
+
102
+ # Lock the append so it shares the mod-tracker's serialization discipline:
103
+ # mixing locked and unlocked writers can interleave/overwrite under load.
104
+ # Fall back to a bare append (atomic for small lines) if the lock is wedged.
105
+ if eagle_acquire_dir_lock "$edit_lock"; then
106
+ printf '%s\n' "$path" >> "$edit_file" 2>/dev/null || true
107
+ rmdir "$edit_lock" 2>/dev/null || true
108
+ return 0
109
+ fi
110
+ printf '%s\n' "$path" >> "$edit_file" 2>/dev/null || true
62
111
  }
63
112
 
64
113
  IFS=$'\x1f' read -r session_id cwd tool_name hook_event <<< \
@@ -167,8 +216,10 @@ case "$tool_name" in
167
216
  fi
168
217
  ;;
169
218
  Bash|exec_command|shell_command|unified_exec)
170
- cmd=$(eagle_tool_command_from_json "$input" | cut -c1-200)
171
- cmd=$(echo "$cmd" | eagle_redact)
219
+ # Redact BEFORE truncating: a secret split across the 200-char boundary
220
+ # could otherwise survive because the redaction prefix patterns no longer
221
+ # see the full token.
222
+ cmd=$(eagle_tool_command_from_json "$input" | eagle_redact | cut -c1-200)
172
223
  tool_summary="${tool_name}: $cmd"
173
224
 
174
225
  tool_output=$(echo "$input" | jq -r '.tool_response.stdout // empty' 2>/dev/null)
@@ -47,4 +47,14 @@ eagle_hook_observability_complete 0
47
47
  # Prune observations older than 90 days (keeps DB size bounded)
48
48
  eagle_prune_observations 90 "$project"
49
49
 
50
+ # Prune hook-observability events older than 30 days. This telemetry table is
51
+ # written on every hook fire and was previously never pruned (unbounded growth).
52
+ eagle_prune_events 30 "$project"
53
+
54
+ # NOTE: pending_feature_verifications is deliberately NOT pruned/TTL'd here. A
55
+ # pending verification is an explicit "this changed and was not yet verified or
56
+ # waived" gate; silently expiring it would let an unverified change ship. It is
57
+ # resolved only by `eagle-mem feature verify|waive` (or reconciliation when the
58
+ # diff fingerprint no longer matches), never by age.
59
+
50
60
  exit 0
@@ -620,6 +620,28 @@ Files you were modifying before compact.
620
620
  fi
621
621
  fi
622
622
 
623
+ # ─── Global injection ceiling (safety only — trims pathological cases) ──
624
+ # Recall sections are appended high→low priority (overview, recent, memories,
625
+ # plans, tasks, lanes, pending features, core files, working set). If the body
626
+ # is pathologically large, drop whole low-priority sections from the bottom so
627
+ # the top surfaces survive. The Active/instructions block is appended AFTER
628
+ # this and is never trimmed.
629
+
630
+ inject_budget=$(eagle_sessionstart_inject_budget)
631
+ inject_trimmed=0
632
+ if [ "${#context}" -gt "$inject_budget" ] 2>/dev/null; then
633
+ pre_trim_chars=${#context}
634
+ EAGLE_INJECT_TRIM_COUNT="$EAGLE_MEM_DIR/.inject-trim-count.${session_id}"
635
+ context=$(printf '%s' "$context" | eagle_trim_inject_body "$inject_budget")
636
+ [ -f "$EAGLE_INJECT_TRIM_COUNT" ] && inject_trimmed=$(cat "$EAGLE_INJECT_TRIM_COUNT" 2>/dev/null | tr -d '[:space:]')
637
+ rm -f "$EAGLE_INJECT_TRIM_COUNT" 2>/dev/null
638
+ unset EAGLE_INJECT_TRIM_COUNT
639
+ inject_trimmed=${inject_trimmed:-0}
640
+ if [ "$inject_trimmed" -gt 0 ] 2>/dev/null; then
641
+ eagle_log "WARN" "SessionStart: injection over budget (${pre_trim_chars}>${inject_budget} chars); trimmed ${inject_trimmed} low-priority section(s) for session=$session_id project=$project"
642
+ fi
643
+ fi
644
+
623
645
  # ─── Instructions (compressed) ───────────────────────────
624
646
 
625
647
  if [ "$agent" = "codex" ]; then
@@ -651,7 +673,8 @@ if [ -n "$context" ]; then
651
673
  --arg source_type "$source_type" \
652
674
  --arg recall_scope "$recall_scope" \
653
675
  --argjson injected_chars "${#context}" \
654
- '{source_type:$source_type, recall_scope:$recall_scope, injected_chars:$injected_chars}')"
676
+ --argjson sections_trimmed "${inject_trimmed:-0}" \
677
+ '{source_type:$source_type, recall_scope:$recall_scope, injected_chars:$injected_chars, sections_trimmed:$sections_trimmed}')"
655
678
  eagle_emit_context_for_agent "$agent" "SessionStart" "$context"
656
679
  fi
657
680
  eagle_hook_observability_complete 0
package/hooks/stop.sh CHANGED
@@ -240,7 +240,9 @@ if [ "$needs_enrichment" -eq 1 ]; then
240
240
  defer_enrichment=1
241
241
  eagle_log "INFO" "Stop: LLM enrichment skipped on fast hook path; provider=$provider"
242
242
  elif [ "$provider" != "none" ] && [ -n "$text_content" ]; then
243
- excerpt=$(echo "$text_content" | tail -c 3000)
243
+ # Redact secrets BEFORE the transcript tail is sent to an LLM provider
244
+ # (the fallback chain includes remote Anthropic/OpenAI APIs).
245
+ excerpt=$(echo "$text_content" | tail -c 3000 | eagle_redact)
244
246
 
245
247
  enrich_prompt="Extract facts from this AI coding session. Only include items with clear evidence in the session text. Do NOT invent or repeat example content.
246
248
 
@@ -437,11 +439,14 @@ if [ "$defer_enrichment" -eq 1 ] && [ "${EAGLE_MEM_STOP_BACKGROUND_ENRICH:-1}" =
437
439
  mkdir -p "$EAGLE_MEM_DIR/tmp" 2>/dev/null || true
438
440
  enrich_job=$(mktemp "$EAGLE_MEM_DIR/tmp/summary-enrich.XXXXXX.json" 2>/dev/null)
439
441
  if [ -n "$enrich_job" ]; then
442
+ # Redact secrets BEFORE persisting the transcript to the job file. The
443
+ # background enricher sends this text to an LLM provider (possibly remote).
444
+ enrich_text=$(printf '%s' "$text_content" | eagle_redact)
440
445
  jq -cn \
441
446
  --arg session_id "$session_id" \
442
447
  --arg project "$project" \
443
448
  --arg agent "$agent" \
444
- --arg text "$text_content" \
449
+ --arg text "$enrich_text" \
445
450
  '{session_id:$session_id, project:$project, agent:$agent, text:$text}' > "$enrich_job"
446
451
 
447
452
  enrich_script="$SCRIPT_DIR/../scripts/enrich-summary.sh"
@@ -5,6 +5,7 @@ and tasks, enforce release-boundary guardrails, and survive context compaction.
5
5
  """
6
6
 
7
7
  import os
8
+ import re
8
9
  import sys
9
10
  import json
10
11
  import asyncio
@@ -16,6 +17,34 @@ from typing import Any, Dict, List, Optional
16
17
  logging.basicConfig(level=logging.INFO)
17
18
  logger = logging.getLogger("eagle_mem.antigravity")
18
19
 
20
+ # Trusted install roots, resolved relative to THIS hook file (never os.getcwd()),
21
+ # so an opened/untrusted workspace cannot shadow the real eagle-mem binaries or
22
+ # hook scripts by dropping bin/eagle-mem or hooks/*.sh into the working dir.
23
+ _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
24
+ _EAGLE_HOME = os.path.join(os.path.expanduser("~"), ".eagle-mem")
25
+
26
+
27
+ def _resolve_eagle_mem_bin() -> Optional[str]:
28
+ """Resolve the eagle-mem binary from trusted install roots only."""
29
+ for path in (
30
+ os.path.join(_EAGLE_HOME, "bin", "eagle-mem"),
31
+ os.path.join(_REPO_ROOT, "bin", "eagle-mem"),
32
+ ):
33
+ if os.path.exists(path):
34
+ return path
35
+ return None
36
+
37
+
38
+ def _resolve_hook_script(script_name: str) -> Optional[str]:
39
+ """Resolve an Eagle Mem hook script from trusted install roots only."""
40
+ for path in (
41
+ os.path.join(_EAGLE_HOME, "hooks", script_name),
42
+ os.path.join(_REPO_ROOT, "hooks", script_name),
43
+ ):
44
+ if os.path.exists(path):
45
+ return path
46
+ return None
47
+
19
48
  # --- Import and Mock Fallback for google-antigravity SDK ---
20
49
  try:
21
50
  from google.antigravity import types
@@ -66,26 +95,27 @@ except ImportError:
66
95
 
67
96
  # --- Asynchronous Subprocess Helpers ---
68
97
 
69
- async def run_cmd_async(cmd: List[str]) -> str:
70
- """Runs a shell command asynchronously and returns stdout."""
71
- # Prioritize workspace-local bin/eagle-mem if running from workspace or if it exists in cwd or relative to this script
98
+ async def run_cmd_async(cmd: List[str], stdin_data: Optional[str] = None) -> str:
99
+ """Runs a shell command asynchronously and returns stdout.
100
+
101
+ Resolves the eagle-mem binary from the install dir (next to this hook file),
102
+ never from os.getcwd(), so an opened workspace cannot shadow the real binary.
103
+ Optional stdin_data is piped in so sensitive values stay out of `ps`.
104
+ """
72
105
  if cmd and cmd[0] == "eagle-mem":
73
- local_paths = [
74
- os.path.join(os.getcwd(), "bin", "eagle-mem"),
75
- os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "eagle-mem")
76
- ]
77
- for path in local_paths:
78
- if os.path.exists(path):
79
- cmd = [path] + cmd[1:]
80
- break
106
+ resolved = _resolve_eagle_mem_bin()
107
+ if resolved:
108
+ cmd = [resolved] + cmd[1:]
81
109
 
82
110
  try:
83
111
  proc = await asyncio.create_subprocess_exec(
84
112
  *cmd,
113
+ stdin=asyncio.subprocess.PIPE if stdin_data is not None else None,
85
114
  stdout=asyncio.subprocess.PIPE,
86
115
  stderr=asyncio.subprocess.PIPE
87
116
  )
88
- stdout, stderr = await proc.communicate()
117
+ stdin_bytes = stdin_data.encode('utf-8') if stdin_data is not None else None
118
+ stdout, stderr = await proc.communicate(input=stdin_bytes)
89
119
  if proc.returncode == 0:
90
120
  return stdout.decode('utf-8', errors='ignore')
91
121
  else:
@@ -102,14 +132,11 @@ async def run_cmd_async(cmd: List[str]) -> str:
102
132
 
103
133
  async def run_hook_async(script_name: str, input_data: Dict[str, Any]) -> str:
104
134
  """Runs an Eagle Mem bash hook script asynchronously, piping JSON via stdin."""
105
- # Resolve the physical path of the hook script
106
- # First check ~/.eagle-mem/hooks/, then fall back to workspace hooks/
107
- home_dir = os.path.expanduser("~")
108
- hook_path = os.path.join(home_dir, ".eagle-mem", "hooks", script_name)
109
- if not os.path.exists(hook_path):
110
- hook_path = os.path.join(os.getcwd(), "hooks", script_name)
111
-
112
- if not os.path.exists(hook_path):
135
+ # Resolve the hook script from trusted install roots only (~/.eagle-mem/hooks
136
+ # then the repo's hooks/). Never os.getcwd() an opened workspace could
137
+ # otherwise shadow the real hook with a malicious script.
138
+ hook_path = _resolve_hook_script(script_name)
139
+ if not hook_path:
113
140
  logger.error(f"Eagle Mem hook script not found: {script_name}")
114
141
  return ""
115
142
 
@@ -134,12 +161,19 @@ async def run_hook_async(script_name: str, input_data: Dict[str, Any]) -> str:
134
161
 
135
162
  # --- Utility Helpers ---
136
163
 
164
+ _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
165
+
166
+
137
167
  def get_session_id() -> str:
138
168
  """Retrieves or generates a persistent session ID for the current context."""
139
- # Use environment variables if present (matches Claude/Codex)
169
+ # Use environment variables if present (matches Claude/Codex). Validate the
170
+ # value: the session ID flows into file paths inside the bash hooks, so an
171
+ # unvalidated env var could enable path traversal. Reject anything that is
172
+ # not a UUID/hex-style token and fall back to a generated ID.
140
173
  session_id = os.environ.get("EAGLE_SESSION_ID") or os.environ.get("SESSION_ID")
141
- if not session_id:
142
- # Generate a unique hex session ID
174
+ if not session_id or not _SESSION_ID_RE.match(session_id):
175
+ if session_id:
176
+ logger.warning("Ignoring malformed session ID from environment; generating a new one")
143
177
  import uuid
144
178
  session_id = f"agy-{uuid.uuid4().hex[:16]}"
145
179
  return session_id
@@ -343,13 +377,14 @@ class EagleMemAntigravityHook:
343
377
  # 1. Trigger Stop hook (stop.sh) in background to capture transcript/summary
344
378
  asyncio.create_task(run_hook_async("stop.sh", input_data))
345
379
 
346
- # 2. Concurrently trigger session save CLI in background
380
+ # 2. Concurrently trigger session save CLI in background. Pass the
381
+ # summary via stdin (not argv) so it is not visible in `ps`.
347
382
  summary = final_response[:200] if final_response else ""
348
383
  asyncio.create_task(run_cmd_async([
349
384
  "eagle-mem", "session", "save",
350
- "--summary", summary,
385
+ "--summary-stdin",
351
386
  "--agent", self.agent_name
352
- ]))
387
+ ], stdin_data=summary))
353
388
 
354
389
  async def on_compaction(self, data: Any):
355
390
  """Fires when context is compacted. Queries active tasks and injects them to survive compaction."""
@@ -149,7 +149,11 @@ eagle_patch_codex_hook() {
149
149
 
150
150
  local tmp
151
151
  tmp=$(mktemp)
152
- jq --argjson entry "$entry" ".hooks.${event} = ((.hooks.${event} // []) + [\$entry])" "$hooks_file" > "$tmp" && mv "$tmp" "$hooks_file"
152
+ # Pass $event as data via --arg and index dynamically so the event name is
153
+ # never interpolated into the jq program string (injection-safe even if the
154
+ # caller ever passes an untrusted event name).
155
+ jq --argjson entry "$entry" --arg event "$event" \
156
+ '.hooks[$event] = ((.hooks[$event] // []) + [$entry])' "$hooks_file" > "$tmp" && mv "$tmp" "$hooks_file"
153
157
  chmod 600 "$hooks_file" 2>/dev/null || true
154
158
  [ -n "$description" ] && eagle_ok "$description"
155
159
  }