fix(tools): enforce the autonomy gate before memory_forget reads the store - #437
Merged
Merged
Conversation
…store
`MemoryForgetTool::execute` resolved its selector first and consulted
`enforce_tool_operation` second. The `key` selector needs no lookup, so the
ordering was invisible there — but `contains` resolves by calling
`resolve_unique_entry`, which reads the whole store and, on a phrase matching
more than one entry, returns an error naming the candidates.
A refused caller was therefore answered out of memory contents instead of being
told it was refused:
ReadOnly + {"contains": "deploy"}
-> 'deploy' matches 2 memories (b, a); be more specific or address one by key
Nothing was deleted — the gate did stop the mutation, just late. What the
ordering cost:
- a blocked caller is told "be more specific", an instruction to retry a call it
can never complete
- a full `list()` runs on every call the policy has already decided to refuse
- the rate-limit path is worse than the read-only one, because
`enforce_tool_operation` is what *records* the action, so that work happened
outside anything the limiter accounts for
Both existing gate tests used the `key` selector, so this half of the surface
was never covered.
- split selector validation from selector resolution: the argument-shape check
(neither/both) stays first, since a malformed call is the model's mistake
whether or not the caller may act
- run the gate between them, so `contains` resolves only for a permitted call
- add four tests: read-only and rate-limited via `contains` (using an ambiguous
phrase, which is what made the gate invisible), a unique-match variant, and a
counting `Memory` proving a refused call performs no read at all — with a
permitted control so the counter is not vacuous
`memory_store` was checked for the same shape and does not have it: it enforces
before resolving its `replaces` selector.
Verified: 13 memory_forget tests pass (9 existing unchanged), `cargo fmt`
clean, `cargo +1.92.0 clippy --locked --all-targets -- -D clippy::correctness`
clean.
PR intake checks found warnings (non-blocking)Fast safe checks found advisory issues. CI lint/test/build gates still enforce merge quality.
Action items:
Run logs: https://github.com/RantAI-dev/RantAIClaw/actions/runs/31260474038 Detected blocking line issues (sample):
Detected advisory line issues (sample):
|
sulthannauval
added a commit
that referenced
this pull request
Aug 8, 2026
Rebasing onto main brought in #437's four new `memory_forget` gate tests, which construct `MemoryForgetTool::new` with two arguments. This branch gives that constructor a third — the workspace path it needs to re-project `MEMORY.md`. Git merged the two changes without a conflict, because they touch different regions of the same file, and GitHub reported the PR MERGEABLE/CLEAN. It did not compile: error[E0061]: this function takes 3 arguments but 2 arguments were supplied --> src/tools/memory_forget.rs:395:20 (and :420, :448, :525, :537) - pass `tmp.path()` at the four `test_mem()`-backed sites, renaming `_tmp` to `tmp` as the rest of this module already does - give the `CountingMemory` test its own `TempDir`; that mock's `name()` is "counting", so the projection is a no-op there, but the constructor still needs a real path rather than a fabricated one Verified on the rebased branch: 388 lib tests pass across `memory::`, both memory tools, `api_v1`, `tui::commands::memory` and `agent::agent`; integration binaries `memory_comparison` 7, `memory_restart` 14, `migrate_legacy` 10, `profile_lifecycle` 17, `compat_v041_to_v050` 2; `cargo fmt` clean; `cargo +1.92.0 clippy --locked --all-targets -- -D clippy::correctness` clean.
sulthannauval
added a commit
that referenced
this pull request
Aug 8, 2026
… CLI's (#440) * fix(memory): re-project MEMORY.md on every memory write, not just the CLI's `MEMORY.md` is injected into every system prompt unconditionally (`agent/prompt.rs`), and on the sqlite and lucid backends it is a *projection* of the `core` rows rather than the store itself. Nothing re-projected on its own: `refresh_projection` lived privately in `memory/cli.rs` and was called from three places, all in that file. A grep across `src/tools/`, `src/tui/` and `src/gateway/` for `project_core_memories|refresh_projection` returned nothing. So a core memory deleted through the agent's own tool, the TUI, or the HTTP API was removed from the authoritative store and stayed in the file that reaches the model: the prompt-injected file still holds the forgotten entry: <!-- rantaiclaw:memory:begin --> - rotation_note: staging credentials rotate weekly <!-- rantaiclaw:memory:end --> The store side was wrong in the mirror image: a `core` memory written mid-session was not in the injected file at all. The comment at `memory/mod.rs` claiming "a memory stored mid-session lands in the file now" was only ever true on the CLI path. The projection is otherwise rebuilt only at backend construction. For `rantaiclaw run` that is the next process; for the gateway and the TUI — both long-lived — it is the rest of the process lifetime, and a new session started inside that process reads the stale file. - move `refresh_projection` into `memory/snapshot.rs` as a shared `pub fn` - gate it on `memory.name()` rather than on config. Same decision by construction, and it asks the caller for a workspace path instead of a `Config` — which the tools do not hold. `MarkdownMemory` still skips it (that backend owns `MEMORY.md` directly), as do `postgres` and `none` - call it after every successful mutation on all six surfaces: `memory_forget`, `memory_store`, the TUI's `/memory add` and `/memory remove`, and the API's `memory_create` and `memory_delete` - thread `workspace_dir` into the two memory tools. `all_tools_with_runtime` already had it; `memory_flush_tools` (the pre-compaction flush, which writes memory through the same tools) takes it from the agent Not done, deliberately: no write-through inside `Memory::forget`/`store`. That would put filesystem work behind a trait every test mock implements, and `MarkdownMemory` would recurse into its own file. Ten tests added. Eight were confirmed to fail against the previous behaviour by stubbing `refresh_projection` to a no-op and re-running — one per call site plus the shared helper. The other two assert the gate holds (markdown and `none` must not be projected) and pass either way by design, as does `a_blocked_store_does_not_touch_the_projection`. `state_with_real_memory` in the gateway tests now points `config.workspace_dir` at the same TempDir as the store; without that the projection lands where the test cannot see it. Verified: 371 tests pass across `memory::`, both memory tools, `api_v1`, `tui::commands::memory` and `agent::agent`; `cargo fmt` clean; `cargo +1.92.0 clippy --locked --all-targets -- -D clippy::correctness` clean. * fix(tools): pass workspace_dir to the gate tests that landed with #437 Rebasing onto main brought in #437's four new `memory_forget` gate tests, which construct `MemoryForgetTool::new` with two arguments. This branch gives that constructor a third — the workspace path it needs to re-project `MEMORY.md`. Git merged the two changes without a conflict, because they touch different regions of the same file, and GitHub reported the PR MERGEABLE/CLEAN. It did not compile: error[E0061]: this function takes 3 arguments but 2 arguments were supplied --> src/tools/memory_forget.rs:395:20 (and :420, :448, :525, :537) - pass `tmp.path()` at the four `test_mem()`-backed sites, renaming `_tmp` to `tmp` as the rest of this module already does - give the `CountingMemory` test its own `TempDir`; that mock's `name()` is "counting", so the projection is a no-op there, but the constructor still needs a real path rather than a fabricated one Verified on the rebased branch: 388 lib tests pass across `memory::`, both memory tools, `api_v1`, `tui::commands::memory` and `agent::agent`; integration binaries `memory_comparison` 7, `memory_restart` 14, `migrate_legacy` 10, `profile_lifecycle` 17, `compat_v041_to_v050` 2; `cargo fmt` clean; `cargo +1.92.0 clippy --locked --all-targets -- -D clippy::correctness` clean.
sulthannauval
added a commit
that referenced
this pull request
Aug 8, 2026
Four memory fixes, all already on `main` (#437, #438, #439, #440). Patch rather than minor: none of them adds a CLI surface or changes an API contract. - forgetting a core memory now removes it from the prompt-injected `MEMORY.md` too; only the CLI re-projected before, so a delete through the tool, the TUI or the HTTP API left the entry reaching the model until the process restarted - the `markdown` backend replaces instead of appending, and `forget` sweeps every file it owns rather than stopping at the first match - `memory stats` labels a capped category breakdown and reports a `count()` failure as `unavailable` instead of `0` - `memory_forget` consults the autonomy gate before resolving `contains`, so a refused call is told it was refused rather than answered from memory contents Release gates, per `docs/contributing/release-process.md`: cargo test --locked --test schema_drift --test config_migration_roundtrip # 5 passed cargo test --locked --lib config::migrations # 19 passed cargo test --locked --lib sessions::migrations # 2 passed `schema_drift` passes without a snapshot update, so the config schema did not move: no migration, and the release rolls back cleanly. `Cargo.lock` regenerated with `cargo check --offline`; the diff is the one version line.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
MemoryForgetTool::executeresolved its selector first and consultedenforce_tool_operationsecond. Thekeyselector needs no lookup, so the ordering was invisible there — butcontainsresolves viaresolve_unique_entry, which reads the whole store and, on a phrase matching more than one entry, returns an error naming the candidates.list()ran on every call the policy had already decided to refuse; and the rate-limit path was worse than the read-only one, becauseenforce_tool_operationis what records the action, so that work happened outside anything the limiter accounts for.containsresolves only for a permitted call.resolve_unique_entryreveals on an ambiguous match is untouched — naming the candidates is what makes the error actionable for a permitted caller, andforget_by_ambiguous_contains_is_rejectedpins it. No newToolOperationgranularity so a resolve counts as aRead: YAGNI, no caller needs that distinction today.Measured against the previous behaviour:
memory_storewas checked for the same shape and does not have it — it already enforces before resolving itsreplacesselector. The plan called for fixing both if both were affected; only one was.Honest framing of severity: this is not a privilege escalation.
ReadOnlypermitsToolOperation::Read, somemory_recallis available to the same caller and returns memory content directly — the error message reveals nothing the caller could not already ask for. What was wrong is the ordering, the misleading refusal message, and the uncounted work.Label Snapshot (required)
risk: low|medium|high):risk: lowtool,security,memorytool: memory_forgetChange Metadata
bugsecurityLinked Issue
Validation Evidence (required)
Clippy is run under
1.92.0because that is what.github/workflows/ci-run.ymlpins; the local default toolchain is 1.97 and reports a large pre-existing diagnostic set that CI does not gate on.Evidence provided: four new tests. The pre-fix behaviour was captured directly by a probe (output quoted above) before any code changed. The discriminating cases use an ambiguous phrase, because with a unique match the old ordering also produced the read-only message — a unique-match test alone would not distinguish fixed from unfixed:
forget_by_ambiguous_contains_reports_the_gate_not_the_ambiguity— asserts the error saysread-only modeand does not saybe more specificforget_by_contains_blocked_when_rate_limited— ambiguous phrase, assertsRate limit exceededforget_by_contains_blocked_in_readonly_mode— unique match; plain coverage, does not discriminatea_refused_forget_does_not_read_memory— a countingMemoryproving a refused call performs zero reads, with a permitted control so the counter is not vacuousAll four existing gate tests (
:224,:245,:263,:165) pass unchanged — the plan required that a reordering not need them edited.If any command is intentionally skipped: full-workspace
cargo testwas not run (disk-bound on this machine);memory_forgetwas run instead.Security Impact (required)
Describe risk and mitigation: a
ReadOnlyor rate-limited caller previously received a memory-derived error naming matching keys. As noted above this is not an escalation (memory_recallis permitted to the same caller), but a refused call now returns the refusal and reads nothing.Privacy and Data Hygiene (required)
passthe deploy runbook/the deploy schedule.Compatibility / Migration
One behaviour change visible to a model:
memory_forgetwithcontainsunder a refusing policy now returns the policy error rather than the ambiguity error. That is the fix.Human Verification (required)
ReadOnly+ ambiguouscontains→ refusal, both entries survive;ReadOnly+ uniquecontains→ refusal, entry survives; rate-limited + ambiguouscontains→Rate limit exceeded; a refused call reads the store zero times, while a permitted one reads it at least once.anyhow::Err(not aToolResult) so the "model called this wrong" vs "call refused" distinction is preserved —forget_missing_keyasserts it and is unchanged; both selectors together still returns"not both"before the gate.ReadOnlyprofile. All evidence is unit-level against the tool.Side Effects / Blast Radius (required)
tools/memory_forgetonly. One function body reordered.ToolOperation::Actand a literal tool name, so it depends on nothing the selector match produces. That is what makes this a pure reordering.a_refused_forget_does_not_read_memoryfails if a future edit moves resolution back above the gate.Agent Collaboration Notes (recommended)
plans/085-memory-forget-gate-runs-after-reading-memory.md.AGENTS.md+CONTRIBUTING.md) — yes.Rollback Plan (required)
git revert ab7647c. Single commit, one function body.memory_forgetreturning"not both"or"Missing 'key' or 'contains'"where a policy refusal was expected, or an agent looping onmemory_forgetretries.Risks and Mitigations
Risk: a local
enum Selectorwas introduced insideexecuteto split validation from resolution without amatcharm that cannot be reached.unreachable!()or a duplicated error message. §7.3 asks for no panics in the runtime path, and §3.1 asks for explicit match branches and typed values; a three-line local enum satisfies both. It is private to the function.Risk: the refusal message a model sees on the
containspath changes, so an agent trained against the old string would see different text.🤖 Generated with Claude Code