From a942ab9df22fb0e58be67b1941fed38c43026f32 Mon Sep 17 00:00:00 2001 From: saitej123 Date: Thu, 13 Aug 2026 22:12:41 +0530 Subject: [PATCH] Gate HITL actions and memory writes through the control plane. POST /v1/action, /facts, /documents, and /memory/search were left off the require_control list, so an anonymous caller could approve a parked run or inject tenant memory. Co-authored-by: Cursor --- s16code/routes.py | 6 +++--- s16code/ui/routes.py | 7 ++++--- tests/test_control_plane_auth.py | 10 +++++++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/s16code/routes.py b/s16code/routes.py index ee09f81..9234723 100644 --- a/s16code/routes.py +++ b/s16code/routes.py @@ -339,19 +339,19 @@ async def complete_waiting_job(body: CompletionBody, request: Request): "run": result, "channel_delivery": channel_delivery} -@router.post("/facts") +@router.post("/facts", dependencies=[Depends(require_control)]) async def fact(body: FactBody, request: Request): return request.app.state.runtime.remember_fact(text=body.text, scope=body.scope(), source_uri=body.source_uri, source_author=body.source_author, principal=Principal("gateway", "gateway"), supersedes_id=body.supersedes_id) -@router.post("/documents") +@router.post("/documents", dependencies=[Depends(require_control)]) async def document(body: IndexBody, request: Request): return request.app.state.runtime.index_document(text=body.text, source_uri=body.source_uri, scope=body.scope(), source_author=body.source_author) -@router.post("/memory/search") +@router.post("/memory/search", dependencies=[Depends(require_control)]) async def memory_search(body: SearchBody, request: Request): hits = request.app.state.runtime.memory.recall(body.query, body.scope(), kinds=body.kinds, limit=body.limit) diff --git a/s16code/ui/routes.py b/s16code/ui/routes.py index 564f2f0..80e718d 100644 --- a/s16code/ui/routes.py +++ b/s16code/ui/routes.py @@ -6,7 +6,7 @@ GET /v1/runs/{id}/events AG-UI event stream over SSE GET /v1/runs/{id}/composed the interface the agent composed for a run POST /v1/validate validate an arbitrary surface (injection wall) - POST /v1/action a validated user action (approve/reject/rerun) + POST /v1/action a validated user action (approve/reject/rerun); control token GET /s/{id} the render client, pointed at a run The data source is the runtime's own graph, read in-process off @@ -20,7 +20,7 @@ import json from pathlib import Path -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse, StreamingResponse from pydantic import BaseModel, Field @@ -29,6 +29,7 @@ from .hitl import PendingAction, decide_resume from .surface import build_run_surface from .validator import validate_surface +from s16code.auth import require_control router = APIRouter() _CLIENT = Path(__file__).parent / "client" / "index.html" @@ -152,7 +153,7 @@ class ActionBody(BaseModel): pending_summary: str = "" -@router.post("/v1/action") +@router.post("/v1/action", dependencies=[Depends(require_control)]) async def action(body: ActionBody, request: Request): try: node = request.app.state.runtime.graph.snapshot(body.run_id).nodes[body.node_id] diff --git a/tests/test_control_plane_auth.py b/tests/test_control_plane_auth.py index cbbfcb0..b0c5ebc 100644 --- a/tests/test_control_plane_auth.py +++ b/tests/test_control_plane_auth.py @@ -3,7 +3,11 @@ A subscription carries `allowed_side_effects` and a budget: it is the object the whole session's security argument rests on. An unauthenticated write there hands an anonymous caller the authority to decide what the agent may do and how much it -may spend. Starting a run and resuming a parked node both spend money too. +may spend. Starting a run and resuming a parked node both spend money too. So do +HITL approve/reject (`POST /v1/action`) and writing tenant memory +(`POST /v1/agent/facts`, `/documents`). Cross-tenant recall +(`POST /v1/agent/memory/search`) is a read, but the tenant lives in the JSON +body, so it is gated the same way. The shape being guarded against is `if expected and not compare_digest(...)`, which reads like a check and behaves like an open door whenever the variable is @@ -20,6 +24,10 @@ "occurred_at": "2026-08-05T09:00:00Z"}), ("post", "/v1/agent/runs", {"prompt": "hello", "tenant_id": "t"}), ("post", "/v1/agent/runs/run-1/resume", {}), + ("post", "/v1/action", {"run_id": "run-1", "node_id": "gate", "action": "approve"}), + ("post", "/v1/agent/facts", {"tenant_id": "t", "text": "injected", "source_uri": "attacker://x"}), + ("post", "/v1/agent/documents", {"tenant_id": "t", "text": "injected", "source_uri": "attacker://x"}), + ("post", "/v1/agent/memory/search", {"tenant_id": "t", "query": "secrets"}), ]