chore: sync the deployed pin with main (consult_memory + alias-aware pricing) - #9
Conversation
Registers a read-only consult_memory tool (toolset: memory_consult) that a scoped sub-agent uses to fetch allow-listed note bodies from Supabase and distill them for the main agent's query. Gated via check_fn so it is only available on the memory-chat turn (memory_context contextvar set); every other chat path is unaffected. Co-authored-by: Cursor <cursoragent@cursor.com>
…-agent Co-authored-by: Cursor <cursoragent@cursor.com>
…'s org (close cross-org IDOR) The forwarded allowed_note_ids is client-influenced, so it can't be the tenant boundary. Derive the org (app.users.client_id) server-side from the trusted user_id contextvar and constrain the fetch with .eq(client_id, org); fail closed if the org can't be resolved. A forged allow-list with another org's note ids now matches zero rows. Co-authored-by: Cursor <cursoragent@cursor.com>
|
The cross-org defense was a single .eq("client_id", org_id) predicate
with no test behind it. Removing that one line silently turns the tool
into a cross-tenant read, since the service-role client bypasses RLS and
allowed_note_ids rides the request. Adds a fake Supabase that really
filters, so the isolation tests fail if the predicate ever goes away —
verified by deleting it, which fails 4 tests including foreign note
content reaching the sub-agent prompt.
Fixes two things the tests exposed:
- _resolve_user_org used raw users.client_id, but the web app builds the
allow-list from getActiveMembership().orgId ?? user.clientId
(app/api/chat/memory/route.ts, which warns about exactly this). The
two filters intersect to nothing for any user whose active org differs
from their home org, so memory silently returned "nothing relevant".
The selection is honored only when it maps to a live membership —
active_org_id is user-writable, so an unvalidated coalesce would let a
removed member keep reading their old org.
- Note truncation was silent at both caps (200 ids, 60k chars), so a
partial corpus was indistinguishable from a complete one. Both now warn
with counts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review pass — isolation coverage + two fixesReviewed as what this PR actually does: the alias-pricing fix ( The main gap: no test behind the tenant boundaryThe entire cross-org defense was one predicate — Added Two defects the tests exposed1. Org derivation disagreed with the web app. Now resolves the acting org the same way. Importantly the selection is honored only when it maps to a live membership: 2. Truncation was silent at both caps (200 ids, 60k chars). A partial corpus was indistinguishable from a complete one, so the sub-agent could confidently report "nothing relevant" about a note that was simply never fetched. Both paths now warn with counts. Deliberately not changed
Verification8 new tests pass. Full suite: 697 failed / 5020 passed, against a baseline of 697 / 5012 measured at One test initially passed alone but failed in the full suite: it asserted through
|
GurneeshBudhiraja
left a comment
There was a problem hiding this comment.
Code review — PR #9 chore: sync the deployed pin with main (consult_memory + alias-aware pricing)
Overview
Despite the "sync the pin" framing, the diff against main is net-new code: it registers the consult_memory tool (memory_consult toolset), a scoped read-only "memory sub-agent" that distills allow-listed app.user_notes bodies for the main agent on the memory-chat route. Four files: tools/memory_consult_tool.py (+538), tests/tools/test_memory_consult_scope.py (+305), plus one-line registrations in model_tools.py and toolsets.py. I read the tool end-to-end and verified the two live integration points (agent.auxiliary_client.call_llm, tools.registry). No correctness or security regressions found; the design is careful and well-tested. One tenant-boundary question worth confirming, and a few minor robustness notes.
Strengths (verified, not boilerplate)
- Tenant isolation is real and tested. The cross-org boundary is a single
.eq("client_id", org_id)predicate in_fetch_allowed_notes, derived server-side from the trusteduser_idcontextvar (never the client payload). The test suite uses a filtering fake Supabase (not a call-spy), so deleting that predicate actually fails the tests — including one that asserts foreign note content never reaches the sub-agent prompt. - Fail-closed org resolution.
_resolve_user_orgreturnsNoneon missing user / DB error, andconsult_memoryaborts the fetch entirely rather than running unscoped (memory_consult_tool.py:730)._has_active_membershipalso fails closed. active_org_idis validated, not trusted. A stale/self-selectedactive_org_idonly grants access when backed by a liveactivemembership, otherwise it falls back to home org — covered bytest_stale_active_org_without_membership_falls_back_to_home_org. This correctly mirrors the web app'sgetActiveMembership().orgId ?? user.clientId.- Truncation is visible. Both caps (
_MAX_NOTES=200,_MAX_TOTAL_CHARS=60k) emitlogger.warningwith counts, so a partial corpus isn't silently mistaken for a complete one. call_llmintegration is correct. Signature atauxiliary_client.py:1429accepts exactly the kwargs passed (provider/model/keyword-onlymessages/temperature/max_tokens/timeout) and returns.choices[0].message.contentas the tool expects.- No model-supplied identifiers. user_id / org / note_ids all come from contextvars; the tool only takes a
querystring. Injection is mitigated by the "treat notes as DATA, not instructions" system prompt and a defensive JSON parser.
Findings
🟡 Within-org over-read via a forged same-org allow-list — confirm the note model. _fetch_allowed_notes (memory_consult_tool.py:554-588) filters only by .in_("id", ids) + .eq("client_id", org_id) using the service-role client, with no per-user filter. The forwarded allowed_note_ids is explicitly documented as client-influenced. So the org predicate stops cross-org reads (well covered), but within the same org a caller who forges the allow-list can fetch any app.user_notes row belonging to that org — including notes authored by other users. Note that the sibling tools/user_notes_tool.py reads the same table with a per-user .eq("user_id", …) + RLS session context, i.e. it treats user_notes rows as per-user private. If that table also holds personal notes (not just org-shared hierarchical Company/Team/Project notes), a same-org user could surface a teammate's private note through consult_memory. This is by design if every in-scope note is intended to be org-readable — please confirm that assumption, or add a context_node_id in scope_node_ids / authorship constraint. Not a regression (already deployed via the pin), but it's the one boundary the tests don't pin down.
🟢 Hardcoded provider="openrouter" bypasses the auxiliary fallback chain (memory_consult_tool.py:771). call_llm supports task-based auto-resolution across OpenRouter → Nous → custom → Codex → Anthropic, but pinning the provider means that if OPENROUTER_API_KEY is absent in a given deployment the sub-agent always errors (caught, returns {"error": ...}) instead of falling back. Also, a MEMORY_SUBAGENT_MODEL override set to a non-OpenRouter slug would mismatch the hardcoded provider. Consider passing task=/leaving provider None so it follows the standard resolution chain.
🟢 Corpus truncation order is nondeterministic (_build_notes_block, memory_consult_tool.py:597-609). When the 60k budget is exceeded, notes are dropped in whatever order Supabase returned them (.in_ has no .order()), so which notes survive isn't relevance- or priority-ranked and can vary run to run. The warning is good; consider an explicit order (e.g. by path) for reproducibility.
🟢 Minor: dead columns in the select. _fetch_allowed_notes selects description, internal_summary but _build_notes_block only uses content and path. Harmless, just extra payload.
Verdict
Solid, defensively written, and unusually well-tested for a tool of this size — approvable as-is. The only thing I'd want an explicit answer on before considering it fully closed is the 🟡 within-org read model (is every user_notes row in an org meant to be readable by any org member via memory?). The rest are minor robustness nits. Agreeing with the PR body: this isn't new production risk — it makes main honest about already-deployed code.
Why
maindoes not reflect what we actually deploy. Hermes pins this submodule at1bc6ffe7, the tip ofdev/gurneesh/agent-memory— a branch that was 8 commits behind main while carrying 3 commits main never received.Consequences of that split:
consult_memoryruns in production but isn't on main. The 475-line tool, its Modal logging, and the cross-org IDOR fix (1bc6ffe7) are all live via the pin, yet invisible onmain. Anyone readingmainsees code we don't run.de5039e2(alias-aware price catalog lookup) never shipped, 17 days after it merged. That's a live cost-tracking bug: model ids with:nitro/:free/ dashed / dated suffixes missed the exact-match lookup, soestimate_usage_costreturnedunknownand usage rows were written withcost_usdNULL. Dev chat is running:nitroids today.This merges
origin/maininto the pinned commit — clean, no conflicts — so one commit has both. Hermes then pins the result (KNOWIDEA-Tech/hermes#97).What lands on main
The 3
consult_memorycommits. Everything else here is already on main.Worth being explicit: this is not new production risk — that code has been deployed all along via the pin. This makes
mainhonest about it.Note
test_first_person_facts_rescued_to_third_personin hermes fails against this branch, but also fails identically at the old pin. It needs_normalize_first_person, which lives only ondev/aman/chat-memory-fixes(PR #6, still open). Not a regression from this merge; merging #6 is what fixes it.🤖 Generated with Claude Code