Skip to content

Completions: a job callback may not answer a human approval - #29

Open
nishanthvonteddu wants to merge 1 commit into
theschoolofai:mainfrom
nishanthvonteddu:fix/completion-token-cannot-approve
Open

nishanthvonteddu wants to merge 1 commit into
theschoolofai:mainfrom
nishanthvonteddu:fix/completion-token-cannot-approve

Conversation

@nishanthvonteddu

Copy link
Copy Markdown

A holder of the completion token could satisfy a human approval gate.

What breaks

S16_COMPLETION_TOKEN is deliberately separate from the control token. auth.py says why: the holder "should be able to complete work it was given without also being able to write subscriptions."

But /completions takes event_type from the request body and passes it straight through:

completion = runtime.graph.complete_waiting(
    body.handle, body.event_type, body.payload, success=body.success
)

complete_waiting matches on (handle, event_type). Nothing checks what is parked on that handle. So a job service that sends event_type: "approval.received" satisfies a request_approval node — answering, with a machine credential, a question that was asked of a person.

Handles are not secret: GET /v1/agent/runs/{id} publishes them in each waiting node.

How to see it break

Park a run on request_approval, read the handle from GET /v1/agent/runs/{id}, then:

POST /v1/agent/completions      Authorization: Bearer $S16_COMPLETION_TOKEN
{"handle": "<handle>", "event_type": "approval.received", "success": true}

Before this change: 200, and the gate is released.

The fix

complete_waiting gains refuse_skills; the route passes the human_gate family. Which capability parks for a person stays a property of the capability rather than a name this route memorises — the same way the channel reply path already decides it.

The refusal is a 403 and the node stays waiting, rather than a silent no-op that would read like an unknown handle.

Test

test_a_job_callback_cannot_satisfy_a_human_approval — parks a real approval, attempts the completion with the completion token, asserts 403 and that the gate is still waiting. Fails before, passes after.

`S16_COMPLETION_TOKEN` is deliberately separate from the control token so a
remote job service can report the work it was handed without also gaining
authority. auth.py says so: the holder "should be able to complete work it
was given without also being able to write subscriptions."

But `/completions` takes `event_type` from the caller and passes it
straight to `complete_waiting`, which matches on `(handle, event_type)`.
Parked handles are published by `GET /v1/agent/runs/{id}`, so a holder who
reads one could send `approval.received` and satisfy a `request_approval`
node -- answering, with a machine credential, a question that was asked of
a person.

`complete_waiting` now takes `refuse_skills`, and the route passes the
`human_gate` family. Which capability parks for a person stays a property
of the capability rather than a name this route memorises, matching how
the channel reply path already decides the same thing.

The refusal is a 403 and the node stays waiting, rather than a silent
no-op that would read like an unknown handle.
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