Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,17 @@ jobs:
- run: python scripts/generate_examples.py
- run: git diff --exit-code -- examples
- run: python -m unittest discover -s tests -v

action:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./
with:
envelopes: |
examples/approval-envelope.json
records: |
approval-record examples/webauthn-approval-record.json
resolution-record examples/resolution-record.json
audit-events: examples/audit-events.json
audit-snapshot: examples/audit-snapshot.json
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## 3.1.0

Approval paths that fail closed, approvals that can be withdrawn until dispatch, and
records you can verify in your own pipeline.

### Added

- `ApprovalPolicySnapshot.outcome`: a closed `require_approval`, `auto_approve`, or
`deny` result. Auto approval requires an explicit `auto_approve` outcome under a
policy that allows it; a `deny` outcome ends the request as `policy_failed` and
never reaches a provider.
- Revocation of an approved request before consumption: `revoked` status,
`approval_revoked` terminal and dispatch reasons, a `revocation` record with actor,
time, and reason, an `approval.revoked` audit event, and a complete
[revoked lifecycle](examples/revoked-envelope.json).
- `ResolutionRecord.agent_feedback` for refusals: reason code, redacted message,
requested payload changes, and questions, returned with
`revise_and_resubmit` and `new_proposal`.
- An `mcp_elicitation` approval channel with a [profile](docs/mcp-elicitation.md) for
MCP servers that collect decisions through form-mode elicitation. Approved and
rejected elicitation decisions name a present, authenticated human.
- Strict reference payload schemas for `crm.update_record`, `ticket.create`,
`db.update_row`, `api.call`, and `n8n.trigger_workflow`, with example payloads.
- `x-review-target` on action schemas: the review snapshot target must equal those
members of the reviewed arguments for every action type.
- Validator inputs for your own files: `--envelope`, `--record KIND FILE`,
`--schema-file`, `--audit-events` with `--audit-snapshot` and `--anchor`, and
`--json`, with exit status 0, 1, or 2.
- Set checks across envelopes: unique proposal, request, dispatch, and audit-stream
identities, one approval request per call, and one action per idempotency key.
- Offline verification of exported audit ranges, including partial exports anchored
to a retained checkpoint hash.
- A GitHub Action (`action.yml`) that runs the validator in any repository.
- Portable conformance vectors in `tests/release-3.1.json`.

### Compatibility

Every addition is optional on existing records. Schema IDs and `schema_version` move
to `3.1.0`; continue validating stored v3.0 records with the v3.0.0 schemas and do
not relabel them. Records produced under 3.1 that use `email.send` must cite the
v3.1.0 payload schema ID and digest.

## 3.0.0

This release enforces the approval and execution boundaries across complete success,
Expand Down
37 changes: 34 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Nine boundaries make the gate real:
If the agent still has the side-effect credential, the approval path remains optional
from the agent's point of view.

## Contracts in v3.0.0
## Contracts in v3.1.0

| Contract | Purpose |
|---|---|
Expand All @@ -69,9 +69,9 @@ from the agent's point of view.
| [`audit-event.schema.json`](schemas/audit-event.schema.json) | Redacted lifecycle evidence with actor, event time, ingestion time, and hash chain |
| [`audit-snapshot.schema.json`](schemas/audit-snapshot.schema.json) | Completeness manifest for one exported audit-event set |
| [`approval-envelope.schema.json`](schemas/approval-envelope.schema.json) | Portable bundle plus cross-contract profile for a completed lifecycle |
| [`actions/email.send.schema.json`](schemas/actions/email.send.schema.json) | Strict reference for composing action-specific payload contracts |
| [`actions/*.schema.json`](schemas/actions/) | Strict payload references for `email.send`, `crm.update_record`, `ticket.create`, `db.update_row`, `api.call`, and `n8n.trigger_workflow`, each naming the fields the approver must see as the target |

Schema IDs are pinned to the `v3.0.0` release. Pin a released ID in production; do not
Schema IDs are pinned to the `v3.1.0` release. Pin a released ID in production; do not
resolve schemas from the mutable default branch.

## Quick start
Expand Down Expand Up @@ -108,12 +108,43 @@ Recovery retains the unknown attempt, a linked reconciliation record, and a boun
retry with fresh authority. The portable vectors in
[`tests/hardening.json`](tests/hardening.json) cover these boundaries.

Version 3.1 adds a fail-closed policy outcome (`require_approval`, `auto_approve`, or
`deny`; anything else is recorded as `deny`), [revocation](examples/revoked-envelope.json)
of an approved request until the moment it is consumed, structured `agent_feedback` on
refusals so the agent can revise instead of re-asking, an `mcp_elicitation` channel
([profile](docs/mcp-elicitation.md)), and review-target binding for every action type.

[`tests/canonicalization.json`](tests/canonicalization.json) is a language-neutral
RFC 8785 corpus with exact canonical UTF-8 bytes and SHA-256 results. Use it to prove
that implementations in different languages bind the same document to the same hash.
[`tests/adversarial.json`](tests/adversarial.json) exercises deceptive identities,
display controls, metadata injection, redaction boundaries, and review tampering.

## Validate your own records

The reference validator checks your envelopes, standalone records, and audit exports
with the same rules it applies to this repository:

```bash
python3 scripts/validate_contracts.py --envelope out/envelope.json
python3 scripts/validate_contracts.py --envelope a.json --envelope b.json
python3 scripts/validate_contracts.py --record approval-record out/approval.json --json
python3 scripts/validate_contracts.py --audit-events out/audit.jsonl --audit-snapshot out/snapshot.json
```

Several envelopes are also checked as a set: one approval request per call, and one
action per idempotency key. Audit exports verify offline, including a range that
continues from a retained checkpoint hash. In CI, use the bundled action:

```yaml
- uses: renezander030/agent-approval-gate@v3.1.0
with:
envelopes: out/envelope.json
```

See [`docs/contracts.md`](docs/contracts.md#validating-your-own-records) for every option
and exit status.

## Optional passkey proof

Authenticated web approvals may carry a `webauthn-es256` signature. Its challenge is
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.0.0
3.1.0
60 changes: 60 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Agent approval gate conformance
description: Validate approval envelopes, standalone records, and audit exports against the agent-approval-gate contracts.
branding:
icon: check-circle
color: green
inputs:
envelopes:
description: Newline-separated paths to complete approval envelopes. Two or more are also checked for shared identities.
required: false
default: ""
records:
description: Newline-separated "KIND PATH" pairs, for example "approval-record out/approval.json".
required: false
default: ""
schema-files:
description: Newline-separated paths to trusted local payload schemas for custom action types.
required: false
default: ""
audit-events:
description: Exported audit events (.json array or .jsonl).
required: false
default: ""
audit-snapshot:
description: AuditSnapshot manifest for the exported audit events.
required: false
default: ""
anchor:
description: Retained hash of the event preceding a partial audit export.
required: false
default: ""
runs:
using: composite
steps:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- shell: bash
run: python -m pip install --quiet --requirement "$GITHUB_ACTION_PATH/requirements-dev.txt"
- shell: bash
env:
INPUT_ENVELOPES: ${{ inputs.envelopes }}
INPUT_RECORDS: ${{ inputs.records }}
INPUT_SCHEMA_FILES: ${{ inputs.schema-files }}
INPUT_AUDIT_EVENTS: ${{ inputs.audit-events }}
INPUT_AUDIT_SNAPSHOT: ${{ inputs.audit-snapshot }}
INPUT_ANCHOR: ${{ inputs.anchor }}
run: |
set -euo pipefail
args=()
while IFS= read -r line; do [ -n "$line" ] && args+=(--envelope "$line"); done <<< "$INPUT_ENVELOPES"
while IFS= read -r line; do [ -n "$line" ] && args+=(--schema-file "$line"); done <<< "$INPUT_SCHEMA_FILES"
while read -r kind path; do [ -n "${kind:-}" ] && args+=(--record "$kind" "$path"); done <<< "$INPUT_RECORDS"
[ -n "$INPUT_AUDIT_EVENTS" ] && args+=(--audit-events "$INPUT_AUDIT_EVENTS")
[ -n "$INPUT_AUDIT_SNAPSHOT" ] && args+=(--audit-snapshot "$INPUT_AUDIT_SNAPSHOT")
[ -n "$INPUT_ANCHOR" ] && args+=(--anchor "$INPUT_ANCHOR")
if [ ${#args[@]} -eq 0 ]; then
echo "::error::Give at least one of envelopes, records, or audit-events with audit-snapshot."
exit 2
fi
python "$GITHUB_ACTION_PATH/scripts/validate_contracts.py" "${args[@]}"
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ path without a fabricated approval or invocation. See the version 3 boundary rul
| Telegram | Medium | Very low | Sign high-risk records and bind callback nonce |
| Email | Low | High | Sign high-risk records; resist forwarding and replay |
| n8n form | Medium | Low | Keep the request identity and callback validation outside the form fields |
| MCP elicitation | Medium | Very low | Form-mode clients only; a client auto-decline is not a human rejection ([profile](mcp-elicitation.md)) |
| Policy | Machine | Very low | Allowed only when the immutable policy snapshot permits auto approval |

A UI control that an automation-capable agent can click is not, by itself, a human
Expand Down
98 changes: 96 additions & 2 deletions docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

## Versioning

The release version and every schema's `schema_version` are `3.0.0`. Schema `$id`
values resolve through the immutable `v3.0.0` tag. A consumer may cache schemas by
The release version and every schema's `schema_version` are `3.1.0`. Schema `$id`
values resolve through the immutable `v3.1.0` tag. A consumer may cache schemas by
`$id`; it must not replace a cached release schema with content from `main`.

- Patch releases may clarify descriptions and add compatible examples or tests.
Expand Down Expand Up @@ -138,6 +138,60 @@ with the record. Decision actor and time agree with the approval; witness times
with the facts they record. Capturing the envelope cannot precede its contained
evidence. Rehashing a false event never makes it a valid witness.

## Version 3.1 additions

### Policy outcome

`ApprovalPolicySnapshot.outcome` records the evaluator's decision as exactly one of
`require_approval`, `auto_approve`, or `deny`. Map every other evaluator result,
including a non-boolean, missing, unknown, or errored result, to `deny` before writing
the snapshot. Never treat "no opinion" as permission.

- `auto_approve` requires `allow_auto_approval: true`, and every `auto_approved`
decision requires an explicit `auto_approve` outcome.
- `deny` ends the request as `failed` with `terminal_reason: "policy_failed"`. It cannot
carry an approving decision or reach provider invocation.

### Revocation before consumption

An approved request may become `revoked` until it is consumed. The transition is a
compare-and-set from `approved` to `revoked`; it must fail once consumption won.
The request names the withdrawn decision in `decision_record_id` and records
`revocation.revoked_at`, `revoked_by`, and a `reason_code`. Its revision includes both
the decision and the revocation transition. Revocation must precede request expiry, an
`approval.revoked` audit event must witness the same actor and time, and a revoked
request cannot invoke a provider. A dispatcher that observes the revocation records
`not_dispatched` with `reason: "approval_revoked"`. The approval record and its
resolution receipt stay unchanged; a new action needs a new proposal.

### Agent feedback

A refusal resolution (`rejected`, `expired`, or `cancelled`) may carry
`agent_feedback` with a closed `reason_code`, a redacted `message`, optional
`requested_changes` (JSON Pointers inside `/payload` with a note), and optional
`questions`. Requested changes or questions require
`disposition: "revise_and_resubmit"` and `retry_policy: "new_proposal"`. Feedback
informs the next proposal; it never reopens the decision or grants execution. When
the `resolution.emitted` event carries a `reason_code`, it must equal the feedback
reason.

### Reference action catalog and review targets

Every built-in `action_type` has a strict reference payload schema in
`schemas/actions/`. Each schema names its action in `x-action-type` and the payload
members the approver must see as the target in `x-review-target`. The validator
requires `ReviewSnapshot.content.target` to equal exactly those members of the
reviewed arguments, for built-in and trusted custom schemas alike. Deployments copy a
schema next to their dispatcher, adapt it, publish it under an immutable ID, and pass
it to the validator as a trusted local file.

### Cross-envelope identities

One envelope proves one lifecycle. A deployment's set of envelopes must also keep
proposal, request, dispatch, and audit-stream identities unique, admit at most one
approval request per tenant and `call_id`, and bind each tenant `idempotency_key` to a
single original action hash. Pass several envelopes to the validator to check the set.

## WebAuthn approval proof

`webauthn-es256` is an optional signature profile for authenticated web approvals.
Expand Down Expand Up @@ -249,6 +303,43 @@ Hash linkage proves ordering only for the events presented. Pair an export with
event-array digest, and terminal event hash. Retain a checkpoint outside the mutable
event store when rewrite detection must survive compromise of that store.

## Validating your own records

The reference validator checks this repository's corpus when run without arguments,
and your records when given files:

```bash
python scripts/validate_contracts.py --envelope out/envelope.json
python scripts/validate_contracts.py --envelope a.json --envelope b.json # also checks the set
python scripts/validate_contracts.py --record approval-record out/approval.json
python scripts/validate_contracts.py --envelope out/envelope.json \
--schema-file schemas/crm.update_record.schema.json # trusted custom schema
python scripts/validate_contracts.py --audit-events export.jsonl \
--audit-snapshot snapshot.json [--anchor sha256:...] # offline audit export
```

Inputs are parsed with duplicate-property and non-finite-number refusal. `--json`
prints one result object. Exit status is `0` when every input passed, `1` when a
contract refused an input, and `2` when an input could not be read or parsed.

An audit export may be a contiguous range. A range that starts after sequence 1 must
name the preceding event hash, and `--anchor` checks it against the hash you retained
outside the event store. The snapshot's count, bounds, digest, and terminal hash must
match the exported range exactly.

The repository's `action.yml` runs the same checks in GitHub Actions:

```yaml
- uses: renezander030/agent-approval-gate@v3.1.0
with:
envelopes: |
out/envelope.json
records: |
approval-record out/approval.json
audit-events: out/audit.jsonl
audit-snapshot: out/audit-snapshot.json
```

## Conformance corpus

`tests/conformance.json` contains positive examples and lifecycle mutations for
Expand All @@ -262,3 +353,6 @@ arguments, forged review hashes, invalid WebAuthn binding, origin drift, counter
rollback, and signature tampering. `tests/canonicalization.json` is the
language-neutral byte-and-digest corpus. Together the files can drive validators in
any implementation language.

`tests/release-3.1.json` adds portable vectors for policy outcomes, revocation, agent
feedback, the MCP elicitation channel, and the reference action schemas.
60 changes: 60 additions & 0 deletions docs/mcp-elicitation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# MCP elicitation profile

An MCP server that gates side effects can ask the person at the MCP client to decide
through `elicitation/create`. The `mcp_elicitation` channel records that decision in the
same `ApprovalRecord` as any other channel. The elicitation is a notification and answer
surface; the approval request, review snapshot, policy, and dispatcher stay on the
server side of the trust boundary.

## When the channel is available

Offer the channel only when the client declared form-mode elicitation in its
`initialize` capabilities. A URL-only declaration does not count. If the client cannot
elicit, do not notify through this channel: record the channel entry with
`delivery_status: "failed"` and use another allowed channel, or let the request end as
`failed` with `terminal_reason: "delivery_failed"`.

Never expose approve or reject as a tool the model can call with a decision argument.
The model may ask the server to collect a decision; only the elicitation answer from the
client decides.

## Request

1. Persist the `ApprovalRequest` and its `ReviewSnapshot` before sending the
elicitation, exactly as for any other channel.
2. Render the snapshot `content` (action type, target, complete arguments, risk, expiry,
and policy summary) into the elicitation `message`. Include the snapshot
`content_hash` so the answer can be bound to what was shown.
3. Request a single boolean field in `requestedSchema`, for example
`{"type": "object", "properties": {"approve": {"type": "boolean"}}, "required": ["approve"]}`.
Do not ask the person to type the decision as free text.
4. Add a `channels` entry with `channel: "mcp_elicitation"`, the hash of the client
session identity as `target_hash`, and `delivery_status: "delivered"` once the client
accepted the request for display.

## Answer mapping

| Elicitation result | Approval record |
|---|---|
| `accept` with `approve: true` | `decision: "approved"`, human actor, `human_present: true` |
| `accept` with `approve: false` | `decision: "rejected"`, human actor, `human_present: true` |
| `decline` from the person | `decision: "rejected"`, human actor, `human_present: true` |
| `cancel` | no decision; the request stays `pending` until another answer or expiry |
| client declined without showing the form | no decision; mark the channel `failed` |

A client that answers `decline` without presenting the form to a person has not
rejected anything. Recording it as a human rejection would let a client configuration
decide on the person's behalf. The schema therefore requires an approved or rejected
`mcp_elicitation` record to name a human actor who was present and authenticated through
`authenticated_session`, `channel_identity`, or `signed_callback`.

## Validation

The reference validator refuses an `mcp_elicitation` decision when the request has no
delivered `mcp_elicitation` channel entry, and the envelope schema requires a signature
on an approved high-risk action decided through this channel, as it does for email and
Telegram. Prefer the `web` channel with the WebAuthn profile for high-risk actions that
need independently attributable proof.

The answer must still pass every ordinary check: the request revision, nonce, action
hash, review content hash, policy evaluation ID, quorum, and separation of duties.
6 changes: 6 additions & 0 deletions examples/actions/api.call.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"service": "stripe",
"operation": "refunds.create",
"method": "POST",
"body": {"charge": "ch_3P0example", "amount": 4900, "reason": "requested_by_customer"}
}
Loading
Loading