fix(tools): enforce the autonomy gate before memory_forget reads the store - #433
Closed
sulthannauval wants to merge 1 commit into
Closed
fix(tools): enforce the autonomy gate before memory_forget reads the store#433sulthannauval wants to merge 1 commit into
sulthannauval wants to merge 1 commit into
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/31258423705 Detected blocking line issues (sample):
Detected advisory line issues (sample):
|
Member
Author
|
Superseded by #437 — identical commit. Closed automatically when the branch was renamed to |
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