Skip to content

Refuse a blocked agent policy decision at the agent output ingest route - #680

Merged
samrusani merged 3 commits into
mainfrom
fix/ingest-output-policy
Oct 26, 2026
Merged

samrusani merged 3 commits into
mainfrom
fix/ingest-output-policy

Conversation

@samrusani

@samrusani samrusani commented Oct 25, 2026 •

Copy link
Copy Markdown
Owner

What changed

POST /v0/vnext/agents/ingest-output asked the agent policy for a decision, recorded it in metadata, and wrote a source and a needs_review artifact (and a candidate memory when propose_memory was set) whatever the decision said. A request the policy blocked answered HTTP 201 while its body said blocked. POST /v0/vnext/sources answers HTTP 403 for the same key, and alice_vnext_ingest_agent_output and alicebot vnext agents ingest-output already refused.

  • The route returns the refusal that POST /v0/vnext/sources returns (HTTP 403, agent policy blocked this action, the policy decision) from inside the connection, so the policy events of the refusal commit. It writes no source, chunk, artifact, provenance link or memory.
  • Credential material in the title, the content or the source refs raised CaptureCredentialRefused, which the route did not catch, and answered HTTP 500. It now answers HTTP 400 with the route's invalid-request text, repeats none of the request and writes nothing.
  • VNextConnectorService.ingest_agent_output refuses a recorded decision that is blocked before it writes anything (it raises AgentPolicyBlockedError), so a later caller that forgets the check gets the refusal and no rows. A call with no decision, and the decisions of the command line, stay as they were.
  • A unit test reads every function under routers/ and fails when a call of a helper that returns a decision (_vnext_policy_checked, _vnext_exact_resource_policy, _vnext_operator_gate, evaluate_agent_policy) does not act on a blocked one.
  • tests/integration/operator_route_vault.py: the owner vault's setup call to this route now declares a profile. It relied on the route writing for a keyless call that names an agent_id and no profile, which is a read_only_agent.

A keyless call that names an agent_id other than hermes and openclaw and declares no profile is a read_only_agent, so this route now refuses it, as POST /v0/vnext/sources, the MCP tool and the command line do.

Call sites of helpers that return a decision

Every other call under routers/ already acts on a blocked decision, and the new test keeps it so: vnext_memories.py (sources, memory review twice, memory proposal through propose_memory, memory audit, the source operator gate), vnext_projects.py (open loop, scheduler configure, run now, run due, pause and resume), vnext_review.py (five report and candidate generators, queue task), vnext_retrieval.py (context pack, artifact trace), vnext_changes.py, and _vnext_authorized_artifact, which raises.

Tests

  • tests/integration/test_ingest_output_policy_postgres.py (PostgreSQL): a read_only_agent key, a key whose domains exclude the requested domain, a key whose sensitivity labels exclude the request, and a read-only key bound to a project each get 403 and no new row in sources, chunks, artifacts, provenance links or memories, with the policy events stored; a key bound to one project that asks for another is refused; a trusted_local_agent key and a keyless call that declares a profile which may write still get 201 and their rows; credential material in the source refs, the content or the title is 400, writes nothing and is not echoed.
  • tests/unit/test_agent_output_blocked_decision.py: the service refusal, including a decision taken from the policy engine and compared field for field.
  • tests/unit/test_router_policy_decisions_are_acted_on.py: the scan, and its checks on source text that does and does not act.
  • scripts/derived_label_mutations.json: seven new cases (the route check, the service refusal, the credential catch, and the refusal moved below validation or made to lose its reasons), the cases of the three changed files re-pinned, and one stale case repaired (its test had been renamed, so it never ran).

Upgrade Overview

Protected Areas

  • memory schema
  • evidence pipeline
  • trust rules
  • promotion logic
  • continuity APIs

Compatibility Impact

POST /v0/vnext/agents/ingest-output now answers HTTP 403 for a request its agent policy blocks and writes no source, chunk, artifact, provenance link, memory or embedding job for it (the refusal's policy events and the agent identity are still recorded, as on every request), as POST /v0/vnext/sources, the MCP tool and the command line already do. A keyless call that names an agent_id other than hermes and openclaw without declaring a profile is a read_only_agent and is refused; a caller that relied on that call writing must declare a profile that may write or use a key with one. Credential material in the request answers HTTP 400 instead of HTTP 500. Responses for allowed requests are unchanged.

Migration / Rollout

No schema change, no migration and no backfill. The change applies as soon as the server runs this code.

Operator Action

Check that agents which send agent output through this route either use a key whose profile may write or, on a keyless install, declare such a profile. Rows written before the upgrade for requests the policy blocked carry "decision": "blocked" in their stored policy_decision metadata, so an operator who wants to review them can find them there.

Validation

tests/integration/test_ingest_output_policy_postgres.py (22 tests on PostgreSQL), tests/unit/test_agent_output_blocked_decision.py (9), tests/unit/test_router_policy_decisions_are_acted_on.py (14); 21 of the 45 fail on main and all pass here. The full unit suite and the route's existing integration tests pass, and the mutation cases of the changed files are killed.

Rollback

Revert this commit. The route then writes blocked requests again and answers HTTP 500 for credential material; no data needs to be changed back.

POST /v0/vnext/agents/ingest-output asked the agent policy for a decision
and passed it to the connector service, which recorded it in metadata and
wrote a source and a needs_review artifact whatever it said. A request the
policy blocked (a read_only_agent key, a key whose domains exclude the
requested domain, a call with no key that declares no profile) answered
HTTP 201 and stored its rows while the body said blocked. The route now
returns the same refusal as POST /v0/vnext/sources from inside the
connection, so the policy events commit and nothing else is written.

Credential material in the title, the content or the source refs raised
CaptureCredentialRefused, which the route did not catch, and answered HTTP
500. It answers HTTP 400 with the route's invalid request text, repeats none
of the request and writes nothing.

VNextConnectorService.ingest_agent_output also refuses a blocked decision
before it writes, so a caller that forgets the check gets the refusal and no
rows. A unit test reads every function under routers/ and fails when a call
of a helper that returns a decision does not act on a blocked one.
A refused request writes no source, chunk, artifact, provenance link, memory or embedding job. It stores the policy events of the refusal and records the agent identity, as any request does. The CHANGELOG pins of the mutation manifest are refreshed for the new wording.
@samrusani
samrusani marked this pull request as ready for review October 26, 2026 07:40
@samrusani
samrusani merged commit 912bcff into main Oct 26, 2026
34 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant