Fix Dev API and KG locked data bypass (#6146)#6147
Conversation
Six Dev API endpoints (PATCH/DELETE for conversations, memories, and action items) lacked is_locked checks, allowing modification of locked data. Return HTTP 402 when the resource is locked. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The _rebuild_graph_task passed all memories to rebuild_knowledge_graph without checking is_locked, leaking locked content into the graph. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
15 tests covering: Dev API PATCH/DELETE lock enforcement for conversations, memories, and action items; KG rebuild locked memory filtering; process_conversation KG extraction skip for locked. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR closes the locked-data bypass by adding HTTP 402 guards to all 6 Developer API write endpoints (PATCH/DELETE for conversations, memories, and action items), filtering locked memories from the knowledge graph rebuild task, and skipping locked memories during KG extraction in Key changes:
Confidence Score: 4/5Safe to merge with one weak test and one minor double-fetch to be aware of. All production security fixes are correct. The two issues are: (1) the KG extraction test does not exercise real code and would not catch a regression in process_conversation.py; (2) delete_action_item now makes two Firestore round trips and silently ignores the second return value. Neither is a correctness blocker in normal operation, but the test gap is worth fixing before merge. backend/tests/unit/test_dev_api_lock_bypass.py (weak KG extraction test) and backend/routers/developer.py (double fetch / ignored return value in delete_action_item). Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Dev API Write Request\nPATCH or DELETE] --> B[Fetch resource from DB]
B --> C{Resource exists?}
C -- No --> D[HTTP 404]
C -- Yes --> E{is_locked?}
E -- Yes --> F[HTTP 402\nPaid plan required]
E -- No --> G[Perform write\ndelete / update]
G --> H[Return success]
KG1[KG Rebuild Request] --> KG2[Get memories\nlimit=500]
KG2 --> KG3[Filter: not is_locked]
KG3 --> KG4[rebuild_knowledge_graph]
PC1[process_conversation\nKG extraction loop] --> PC2{kg_extracted\nor is_locked?}
PC2 -- Yes --> PC3[Skip memory]
PC2 -- No --> PC4[extract_knowledge_from_memory]
PC4 --> PC5[set_memory_kg_extracted]
Reviews (1): Last reviewed commit: "Add test_dev_api_lock_bypass.py to test...." | Re-trigger Greptile |
| args = rebuild_knowledge_graph.call_args[0] | ||
| passed_memories = args[1] | ||
| assert len(passed_memories) == 1 | ||
| assert passed_memories[0]['id'] == 'mem-unlocked' | ||
|
|
||
| def test_rebuild_passes_all_when_none_locked(self): | ||
| """K1: When no memories are locked, all should be passed through.""" | ||
| import database.memories as memories_db | ||
|
|
||
| mems = [_make_memory(locked=False, memory_id=f'mem-{i}') for i in range(3)] | ||
| memories_db.get_memories = MagicMock(return_value=mems) | ||
|
|
||
| from utils.llm.knowledge_graph import rebuild_knowledge_graph | ||
|
|
||
| rebuild_knowledge_graph.reset_mock() | ||
|
|
||
| from routers.knowledge_graph import _rebuild_graph_task | ||
|
|
||
| _rebuild_graph_task('test-uid', 'Test User') | ||
|
|
||
| rebuild_knowledge_graph.assert_called_once() | ||
| args = rebuild_knowledge_graph.call_args[0] | ||
| assert len(args[1]) == 3 | ||
|
|
||
|
|
||
| # ============================================================================= | ||
| # Process conversation — KG extraction must skip locked memories | ||
| # ============================================================================= | ||
|
|
||
|
|
||
| class TestProcessConversationKGLockEnforcement: | ||
| """KG extraction in process_conversation must skip locked memories.""" | ||
|
|
||
| def test_kg_extraction_skips_locked_memory(self): | ||
| """Locked memories should not be sent to extract_knowledge_from_memory.""" | ||
| from utils.llm.knowledge_graph import extract_knowledge_from_memory | ||
|
|
||
| extract_knowledge_from_memory.reset_mock() | ||
|
|
||
| locked_memory = MagicMock() | ||
| locked_memory.id = 'mem-locked' | ||
| locked_memory.content = 'Secret content' | ||
| locked_memory.category.value = 'core' | ||
| locked_memory.kg_extracted = False | ||
| locked_memory.is_locked = True | ||
| locked_memory.dict.return_value = {'id': 'mem-locked', 'content': 'Secret content'} | ||
|
|
||
| unlocked_memory = MagicMock() | ||
| unlocked_memory.id = 'mem-unlocked' | ||
| unlocked_memory.content = 'Public content' | ||
| unlocked_memory.category.value = 'core' | ||
| unlocked_memory.kg_extracted = False | ||
| unlocked_memory.is_locked = False | ||
| unlocked_memory.dict.return_value = {'id': 'mem-unlocked', 'content': 'Public content'} | ||
|
|
||
| # We need to test the KG extraction loop directly. | ||
| # The logic is: if memory_db_obj.kg_extracted or memory_db_obj.is_locked: continue | ||
| # We verify by checking that the guard correctly skips locked. | ||
| assert locked_memory.is_locked is True | ||
| assert unlocked_memory.is_locked is False | ||
|
|
||
| # Simulate the loop logic from process_conversation.py | ||
| extracted = [] | ||
| for memory_db_obj in [locked_memory, unlocked_memory]: | ||
| if memory_db_obj.kg_extracted or memory_db_obj.is_locked: | ||
| continue | ||
| extracted.append(memory_db_obj.id) | ||
|
|
||
| assert 'mem-locked' not in extracted | ||
| assert 'mem-unlocked' in extracted | ||
| assert len(extracted) == 1 |
There was a problem hiding this comment.
Test does not exercise the production code path
test_kg_extraction_skips_locked_memory manually re-implements the loop logic from process_conversation.py instead of calling the actual function under test. It creates mock objects, runs a hand-written copy of the guard condition, and asserts on that — so the test would still pass even if the memory_db_obj.is_locked guard were reverted in the source file. This gives a false sense of security.
The test needs to import and invoke _extract_memories_inner (or a wrapper that reaches that loop) with mocked dependencies, so that the assertion verifies the real guard in the production code, not a copy of it.
| action_item = action_items_db.get_action_item(uid, action_item_id) | ||
| if not action_item: | ||
| raise HTTPException(status_code=404, detail="Action item not found") | ||
| if action_item.get('is_locked', False): | ||
| raise HTTPException(status_code=402, detail="A paid plan is required to access this action item.") | ||
|
|
||
| action_items_db.delete_action_item(uid, action_item_id) | ||
| return {"success": True} |
There was a problem hiding this comment.
Double Firestore read and silently ignored return value
The new pattern introduces two Firestore reads where one was sufficient before:
get_action_iteminternally callsaction_item_ref.get()to fetch the document.action_items_db.delete_action_itemalso callsaction_item_ref.get().existsbefore deleting.
The return value of delete_action_item (which is False when the document has already disappeared between the two calls) is now silently discarded, so a concurrent deletion between the fetch and the delete would still return {"success": True}. While this race window is tiny, the previous single-call pattern avoided it entirely.
Consider at least checking the return value:
| action_item = action_items_db.get_action_item(uid, action_item_id) | |
| if not action_item: | |
| raise HTTPException(status_code=404, detail="Action item not found") | |
| if action_item.get('is_locked', False): | |
| raise HTTPException(status_code=402, detail="A paid plan is required to access this action item.") | |
| action_items_db.delete_action_item(uid, action_item_id) | |
| return {"success": True} | |
| action_item = action_items_db.get_action_item(uid, action_item_id) | |
| if not action_item: | |
| raise HTTPException(status_code=404, detail="Action item not found") | |
| if action_item.get('is_locked', False): | |
| raise HTTPException(status_code=402, detail="A paid plan is required to access this action item.") | |
| deleted = action_items_db.delete_action_item(uid, action_item_id) | |
| if not deleted: | |
| raise HTTPException(status_code=404, detail="Action item not found") | |
| return {"success": True} |
- Assert detail message contains 'paid plan' on all locked-path tests - Assert mutation side-effects on unlocked-path tests (no swallowing) - Add source-level guard verification for process_conversation.py - Add already-extracted memory to KG extraction test coverage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review cycle 1 — fixes appliedAddressed all 3 reviewer findings: Finding 1 (High) — KG extraction test didn't exercise production code:
Finding 2 (Medium) — Weak test assertions:
Finding 3 (Medium) — limit=500 pre-existing in
All 16 tests pass after changes. by AI for @beastoin |
Replace substring source check with AST walk that verifies the exact `if X.kg_extracted or X.is_locked: continue` structure in process_conversation.py. Add regression test proving `and` vs `or` would let locked memories leak through. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Review cycle 2 — AST-based guard verificationFinding 1 (Medium) — KG extraction test not structurally bound to production code:
by AI for @beastoin |
Require exactly 2 operands, both ast.Attribute on the same base variable,
attributes exactly {kg_extracted, is_locked}, and body solely `continue`.
Rejects extra operands, different variables, or mixed conditions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reject cases like items[0].is_locked where the base is not ast.Name. Both operands must be ast.Name and reference the exact same variable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
#6146) - Action item delete returns 404 when item not found - KG rebuild passes empty list when all memories locked - KG rebuild handles memories without is_locked field (defaults unlocked) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests call _extract_memories_inner with conv.is_locked=True and verify extract_knowledge_from_memory is never called for locked conversations. Uses existing test harness from test_kg_user_type_mismatch.py. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tester cycle 1 — coverage gaps addressedFinding 1 (High) — process_conversation KG guard not execution-tested:
Finding 2 (Medium) — Boundary tests:
Now 20 tests in test_dev_api_lock_bypass.py + 11 tests in test_kg_user_type_mismatch.py (2 new). by AI for @beastoin |
CP9 Changed-path and sequence coverage checklistMode: path-only (flow_diagram_required=false)
L1 synthesisAll 8 changed paths (P1-P8) proven via unit tests that call the real production functions. P1-P6 call the actual FastAPI endpoint functions from Full HTTP boot not achievable on VPS (Pinecone validates API key at module load). The changes are pure conditional guards (if-check on dict field → HTTPException) with no infrastructure interaction, making unit-test-level execution equivalent to HTTP-level for these paths. by AI for @beastoin |
L2 synthesisL2 requires backend + app running integrated. This PR changes only backend guard conditions (pure All 8 paths (P1-P8) are UNTESTED at L2 because:
The risk surface for these changes is exclusively server-side and was fully covered at L1. by AI for @beastoin |
CP8 Test detail table
All 21 scenarios PASS. Full test output: by AI for @beastoin |
L1 Live Test Evidence — Backend Boot + HTTP-Level Guard VerificationEnvironment: Local dev backend booted on port 10260 with dev Firestore/Pinecone/Redis credentials. Backend StartupBackend successfully booted with all services connected (Pinecone validated, Firestore connected, Redis available). Startup log confirms branch code loaded. L1 Test Results: 14/14 PASSEDAutomated test script created test user in dev Firestore with:
Backend Access Log (last successful run)L1 SynthesisAll 6 Dev API write endpoints (D1-D6) proven at HTTP level with real Firestore data. Locked entities correctly return 402 with "paid plan" message. Unlocked entities correctly allow mutation (200 OK). Nonexistent entities return 404. KG paths (K1, K2) tested at unit level only (no HTTP surface — internal functions called during conversation processing). CleanupAll test data (user, API key, conversations, memories, action items) cleaned up from dev Firestore after test completion. This evidence replaces the previous L1/L2 justification that the backend could not boot locally. Dev env vars provided by hiro enabled full local boot. by AI for @beastoin |
L2 Live Test Evidence — Backend + API Client IntegrationContext: This PR changes only backend code (Dev API guards + KG filters). The Dev API is consumed by external developer tools via API keys, not by the Omi app's UI. There is no app-side UI surface for these endpoints. L2 ApproachFor L2, I verified the full integrated flow: backend service + API client operating together with real Firestore, Redis, and Pinecone. What was tested end-to-end:
L2 ResultsSame 14/14 results as L1 — the test was already integrated (real Firestore, real auth, real API key hashing, real scope validation). The L1 test IS the L2 test for this PR because:
L2 SynthesisAll 6 Dev API lock guards (D1-D6) proven through full integrated HTTP stack with real Firestore auth, real API key validation, and real data. The guard pattern (fetch entity → check by AI for @beastoin |
Summary
is_lockedguards to 6 Developer API write endpoints (PATCH/DELETE for conversations, memories, action items) — returns HTTP 402 when locked_rebuild_graph_taskprocess_conversationChanged files
backend/routers/developer.pyis_lockedchecks on write endpointsbackend/routers/knowledge_graph.pybackend/utils/conversations/process_conversation.pybackend/tests/unit/test_dev_api_lock_bypass.pybackend/tests/unit/test_kg_user_type_mismatch.pybackend/test.shTest plan
pytest tests/unit/test_dev_api_lock_bypass.py -v)pytest tests/unit/test_kg_user_type_mismatch.py -v)pytest tests/unit/test_lock_bypass_fixes.py -v)backend/test.shpassesif X.kg_extracted or X.is_locked: continuestructure in production sourceL1/L2 Live Test Evidence
Backend booted locally with dev Firestore/Pinecone/Redis. 14/14 HTTP-level tests passed:
Deployment Steps
gh workflow run gcp_backend.yml -f environment=prod -f branch=main— deploys Cloud Run backend with the new lock guardsis_lockedfield already present in FirestoreReview cycle
Risks / edge cases
delete_action_itempreviously calledaction_items_db.delete_action_item()directly without fetching first — changed to fetch-then-guard patternlimit=500in_rebuild_graph_taskis pre-existing; locked items within the 500 window are now filtered but items beyond 500 are still excluded (separate scope)Closes #6146
🤖 Generated with Claude Code
by AI for @beastoin