Skip to content

fix(tools): enforce the autonomy gate before memory_forget reads the store - #437

Merged
sulthannauval merged 1 commit into
mainfrom
fix/memory-forget-gate-ordering
Aug 8, 2026
Merged

fix(tools): enforce the autonomy gate before memory_forget reads the store#437
sulthannauval merged 1 commit into
mainfrom
fix/memory-forget-gate-ordering

Conversation

@sulthannauval

Copy link
Copy Markdown
Member

Replaces #433 — same commit, same diff. That PR was closed automatically when its branch was renamed from fix/085-memory-forget-gate-ordering to this one; no review had happened on it.

Summary

  • Problem: 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 via resolve_unique_entry, which reads the whole store and, on a phrase matching more than one entry, returns an error naming the candidates.
  • Why it matters: a refused caller was answered out of memory contents instead of being told it was refused. Nothing was deleted — the gate did stop the mutation, just late — but a blocked caller was told "be more specific", an instruction to retry a call it can never complete; a full list() ran on every call the policy had already decided to refuse; and the rate-limit path was 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.
  • What changed: argument-shape validation stays first (a malformed call is the model's mistake whether or not the caller may act), the gate runs next, and contains resolves only for a permitted call.
  • What did NOT change (scope boundary): what resolve_unique_entry reveals on an ambiguous match is untouched — naming the candidates is what makes the error actionable for a permitted caller, and forget_by_ambiguous_contains_is_rejected pins it. No new ToolOperation granularity so a resolve counts as a Read: YAGNI, no caller needs that distinction today.

Measured against the previous behaviour:

ReadOnly + {"contains": "deploy"}
-> 'deploy' matches 2 memories (b, a); be more specific or address one by key

memory_store was checked for the same shape and does not have it — it already enforces before resolving its replaces selector. The plan called for fixing both if both were affected; only one was.

Honest framing of severity: this is not a privilege escalation. ReadOnly permits ToolOperation::Read, so memory_recall is 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 label (risk: low|medium|high): risk: low
  • Size label: auto-managed
  • Scope labels: tool, security, memory
  • Module labels: tool: memory_forget
  • Contributor tier label: auto-managed
  • If any auto-label is incorrect, note requested correction: —

Change Metadata

  • Change type: bug
  • Primary scope: security

Linked Issue

Validation Evidence (required)

cargo fmt --all -- --check                                              # clean
cargo +1.92.0 clippy --locked --all-targets -- -D clippy::correctness    # clean
BASE_SHA=<main> ./scripts/ci/rust_strict_delta_gate.sh                  # no blocking issues on changed lines
cargo test --lib -- memory_forget                                        # 13 passed, 0 failed (9 existing unchanged)

Clippy is run under 1.92.0 because that is what .github/workflows/ci-run.yml pins; 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 says read-only mode and does not say be more specific
    • forget_by_contains_blocked_when_rate_limited — ambiguous phrase, asserts Rate limit exceeded
    • forget_by_contains_blocked_in_readonly_mode — unique match; plain coverage, does not discriminate
    • a_refused_forget_does_not_read_memory — a counting Memory proving a refused call performs zero reads, with a permitted control so the counter is not vacuous

    All 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 test was not run (disk-bound on this machine); memory_forget was run instead.

Security Impact (required)

  • New permissions/capabilities? No — this narrows what a refused call does, it does not widen anything.
  • New external network calls? No
  • Secrets/tokens handling changed? No
  • File system access scope changed? No

Describe risk and mitigation: a ReadOnly or rate-limited caller previously received a memory-derived error naming matching keys. As noted above this is not an escalation (memory_recall is permitted to the same caller), but a refused call now returns the refusal and reads nothing.

Privacy and Data Hygiene (required)

  • Data-hygiene status: pass
  • Redaction/anonymization notes: none needed — fixtures use the deploy runbook / the deploy schedule.
  • Neutral wording confirmation: confirmed.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

One behaviour change visible to a model: memory_forget with contains under a refusing policy now returns the policy error rather than the ambiguity error. That is the fix.

Human Verification (required)

  • Verified scenarios: ReadOnly + ambiguous contains → refusal, both entries survive; ReadOnly + unique contains → refusal, entry survives; rate-limited + ambiguous containsRate limit exceeded; a refused call reads the store zero times, while a permitted one reads it at least once.
  • Edge cases checked: neither selector still returns a hard anyhow::Err (not a ToolResult) so the "model called this wrong" vs "call refused" distinction is preserved — forget_missing_key asserts it and is unchanged; both selectors together still returns "not both" before the gate.
  • What was not verified: no live agent run against a real provider with a ReadOnly profile. All evidence is unit-level against the tool.

Side Effects / Blast Radius (required)

  • Affected subsystems: tools/memory_forget only. One function body reordered.
  • Potential unintended effects: none identified — the gate takes a fixed ToolOperation::Act and a literal tool name, so it depends on nothing the selector match produces. That is what makes this a pure reordering.
  • Guardrails: a_refused_forget_does_not_read_memory fails if a future edit moves resolution back above the gate.

Agent Collaboration Notes (recommended)

  • Agent tools used: Claude Code.
  • Workflow/plan summary: plans/085-memory-forget-gate-runs-after-reading-memory.md.
  • Verification focus: the plan's suggested unique-match test was found not to discriminate (it passes against the unfixed code too), so the discriminating cases were switched to ambiguous phrases and a counting-mock test was added to assert the "no work on a refused call" property directly. The unique-match test is kept as coverage and labelled as such.
  • Confirmation: naming + architecture boundaries followed (AGENTS.md + CONTRIBUTING.md) — yes.

Rollback Plan (required)

  • Fast rollback: git revert ab7647c. Single commit, one function body.
  • Feature flags: none.
  • Observable failure symptoms: memory_forget returning "not both" or "Missing 'key' or 'contains'" where a policy refusal was expected, or an agent looping on memory_forget retries.

Risks and Mitigations

  • Risk: a local enum Selector was introduced inside execute to split validation from resolution without a match arm that cannot be reached.

    • Mitigation: the alternative shapes both needed an 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 contains path changes, so an agent trained against the old string would see different text.

    • Mitigation: that is the defect being fixed — the old string instructed a retry that could never succeed.

🤖 Generated with Claude Code

…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.
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

PR intake checks found warnings (non-blocking)

Fast safe checks found advisory issues. CI lint/test/build gates still enforce merge quality.

  • Incomplete required PR template fields: summary problem, summary why it matters, summary what changed, validation commands, security risk/mitigation, privacy status, rollback plan

Action items:

  1. Complete required PR template sections/fields.
  2. Remove tabs, trailing whitespace, and merge conflict markers from added lines.
  3. Re-run local checks before pushing:
    • ./scripts/ci/rust_quality_gate.sh
    • ./scripts/ci/rust_strict_delta_gate.sh
    • ./scripts/ci/docs_quality_gate.sh

Run logs: https://github.com/RantAI-dev/RantAIClaw/actions/runs/31260474038

Detected blocking line issues (sample):

  • none

Detected advisory line issues (sample):

  • none

@github-actions github-actions Bot added tool Auto scope: src/tools/** changed. size: S Auto size: 81-250 non-doc changed lines. risk: high Auto risk: security/runtime/gateway/tools/workflows. distinguished contributor Contributor with 50+ merged PRs. tool: memory_forget Auto module: tool/memory_forget changed. and removed tool Auto scope: src/tools/** changed. labels Aug 8, 2026
@sulthannauval
sulthannauval merged commit ccfdf1d into main Aug 8, 2026
34 checks passed
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.
@sulthannauval
sulthannauval deleted the fix/memory-forget-gate-ordering branch August 8, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

distinguished contributor Contributor with 50+ merged PRs. risk: high Auto risk: security/runtime/gateway/tools/workflows. size: S Auto size: 81-250 non-doc changed lines. tool: memory_forget Auto module: tool/memory_forget changed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant