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
373 changes: 337 additions & 36 deletions approval-gate/Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion approval-gate/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ async-trait = "0.1"
# `RegisterTriggerType::trigger_request_format`.
schemars = "0.8"
base64 = "0.22"
regex = "1"

[features]
# Test-only seam: exposes `testkit` (engine bootstrap for the engine-backed
Expand All @@ -45,4 +46,4 @@ testkit = []
approval-gate = { path = ".", features = ["testkit"] }
futures = "0.3"
uuid = { version = "1", features = ["v4"] }
tempfile = "3"
harness = { path = "../harness" }
62 changes: 24 additions & 38 deletions approval-gate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ worker:

1. **The gate** — `approval::gate`, a `pre_trigger` hook the worker binds
itself at startup on the harness's `harness::hook::pre-trigger` trigger
type. It evaluates per-session mode, allow-lists, and the yaml policy, and
answers `continue`, `deny`, or `hold`.
type. It evaluates per-session mode, allow-lists, and inline config
`rules`, and answers `continue`, `deny`, or `hold`.
2. **The decision plane** — `approval::resolve` plus the per-session settings
RPCs (`set_mode`, `add_always_allow`, `approve_always`, …). Human/console
only.
Expand All @@ -17,9 +17,10 @@ worker:
notification workers and UIs bind to.

The worker keeps **no resolved-approval history**: a record exists only while
a call is held; every record has an explicit deletion path and a cron sweep as
GC backstop. The transcript's `function_result` and the `pending_resolved`
event are the audit trail.
a call is held; every record has an explicit deletion path (resolve, turn
abort, session delete). The transcript's `function_result` and the
`pending_resolved` event are the audit trail. Holds do not expire — they wait
until a human resolves or the turn/session is purged.

## Standalone caveat

Expand All @@ -40,10 +41,6 @@ exercises the harness surface against in-process fakes until harness 1.0 lands.
iii worker add approval-gate
```

The sweep needs the engine's cron worker: `iii worker add iii-cron`. Without
it the expiry backstop never fires (the harness pending sweep — once it
exists — remains the second backstop).

## Quickstart

```bash
Expand Down Expand Up @@ -76,22 +73,19 @@ unchanged from the proven implementation):
2. mode `full` → allow
3. `approved_always` hit → allow (**every** mode — remembered human decisions)
4. mode `auto` **and** `always_allow` hit → allow (dormant under `manual`)
5. fall through to `policy::check_permissions` (5s budget):
`allow` → allow · `deny` → deny · `needs_approval` → **hold** ·
unparseable reply → hold · transport failure/timeout → **deny**
(`gate_unavailable` — fail closed, never an unattended hold)
5. fall through to configuration **`rules`** (first match wins):
`allow` → allow · `deny` → deny · no match → **hold**

No `policy::check_permissions` worker deployed? Every non-short-circuited
call is denied as `gate_unavailable`. Run a trivial policy worker (e.g.
"everything `needs_approval`") or lean on `always_allow_seed` / per-session
modes.
When the configuration entry omits `rules`, the gate denies only this
worker's own `approval::*` surface; every other call **holds**. On startup the
worker seeds/backfills the stored entry so `rules` are editable in the console.

## Custom trigger types

| Type | Fires | Payload |
|---|---|---|
| `approval::pending-created` | a call was held and its inbox record written (async, off the hot path) | `PendingApprovalRecord & { status: "pending" }` — redacted args, session context, expiry: self-sufficient for notification copy |
| `approval::pending-resolved` | a pending call left the inbox (exactly once per record) | ids + `outcome: "allow" \| "deny" \| "timeout" \| "aborted"`, operator `reason` on deny |
| `approval::pending-created` | a call was held and its inbox record written (async, off the hot path) | `PendingApprovalRecord & { status: "pending" }` — redacted args, session context: self-sufficient for notification copy |
| `approval::pending-resolved` | a pending call left the inbox (exactly once per record) | ids + `outcome: "allow" \| "deny" \| "aborted"`, operator `reason` on deny |

Binding config (both types): `{ session_id?, metadata? }` — `metadata` is a
subset-equality match against the record's denormalized `session_metadata`,
Expand All @@ -103,24 +97,17 @@ restart, reconcile with one `approval::list-pending` call.
The whole config — runtime wiring **and** deployment approval defaults — lives
in the single engine configuration entry **`approval-gate`** (operator-edited
via the console's Configuration screen; reactive reload, no polling). There is
**no committed `config.yaml`**; defaults are seeded into the entry on first
registration.
**no committed `config.yaml`**. On first boot the worker seeds the entry with
the built-in defaults (including `rules`) so the editor is pre-filled; existing
stored values are never overwritten except to add a missing `rules` field.

```jsonc
{
"hook": { // harness::hook::pre-trigger binding (re-bound live on change)
"functions": ["*"], // pre_trigger globs the gate consults on
"timeout_ms": 5000,
"on_error": "fail_closed"
},
"sweep_expression": "0 * * * * *", // 6-field cron for the expiry sweep (re-bound live on change)
"policy_timeout_ms": 5000, // per-call budgets (hot-reloadable)
"session_fetch_timeout_ms": 1000,
"state_timeout_ms": 5000,
"harness_timeout_ms": 10000,
"default_mode": "manual", // manual | auto | full — sessions with no stored settings
"always_allow_seed": [], // auto-mode trust profile (function ids / globs)
"pending_timeout_ms": 1800000 // hold deadline; drives expires_at (default 30 min)
"rules": [ // first match wins; no match → hold
"!approval::*",
{ "function": "state::get", "action": "allow", "modes": ["auto"] }
]
}
```

Expand All @@ -129,9 +116,7 @@ schema and fetches the authoritative value at startup, and a failed
register/fetch aborts boot (the gate must run on a known, authoritative policy
surface, never a guessed one). When no value is stored yet the built-in
defaults above are seeded and used. **Every field hot-reloads on
`configuration::set` — nothing requires a restart**: `hook` and
`sweep_expression` re-bind their triggers live (register the new binding, then
unregister the old); the rest swap the in-memory snapshot.
`configuration::set` — nothing requires a restart**.

## Agent exposure

Expand All @@ -154,8 +139,9 @@ cargo clippy --all-targets --all-features -- -D warnings

The integration suite spawns a real engine (`III_ENGINE_BIN` or `iii` on
PATH) with `configuration` + `iii-state`, registers the production surface
in-process, and fakes the not-yet-built siblings
(`policy::check_permissions`, `harness::function::resolve`, `session::get`).
in-process, and fakes sibling RPCs where noted (`session::get`). With the
harness binary available, `tests/harness_integration.rs` additionally boots the
real harness worker for cross-worker hold / sweep / resolve checks.

## Architecture documentation

Expand Down
12 changes: 6 additions & 6 deletions approval-gate/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ opening the source.
|---|---|---|
| [internals.md](internals.md) | Maintainers of this worker | You are changing approval-gate itself: the evaluation order, the pending-record lifecycle, the emit gate, redaction, configuration reload. |
| [integration.md](integration.md) | Authors of other workers / clients | You are building something that calls `approval::*` or binds its trigger types — the console, a notification worker, the harness (once its hook surface lands). This file is the handoff contract. |
| [permissions-source.md](permissions-source.md) | Operators / integrators | You need to know where permission truth lives and how harness and the console consume the single `approval-gate` rules list. |

The unit suites beside each module and the engine-backed scenarios in
[../tests/integration.rs](../tests/integration.rs) are the executable
Expand All @@ -24,17 +25,17 @@ rows, and the exactly-once emission contract are all pinned by tests.
approval-gate decides, for one function call at a time, whether a human must
be involved — and routes the human's answer back to the parked turn. It is a
`pre_trigger` hook (`approval::gate`) that answers `continue` / `deny` /
`hold` from a per-session permission model (mode + two allow-lists) with a
yaml-policy fallback; a decision plane (`approval::resolve` + settings RPCs,
`hold` from a per-session permission model (mode + two allow-lists) with an
inline config-`rules` fallback; a decision plane (`approval::resolve` + settings RPCs,
human/console-only); and an **ephemeral** pending inbox (state scope
`approval_pending`, two custom trigger types) that exists only while calls
are held. It never executes the held function itself — on allow it asks the
harness to release the call through its own trigger pipeline
(`harness::function::resolve`, `action: "execute"`); on deny/timeout it
(`harness::function::resolve`, `action: "execute"`); on deny it
delivers an `is_error` result. No decision history is kept: the transcript
and the `pending_resolved` event are the audit trail, and every state record
this worker writes has an explicit deletion path plus a cron sweep as GC
backstop.
this worker writes has an explicit deletion path (resolve, turn abort,
session delete). Holds do not expire.

```mermaid
flowchart LR
Expand All @@ -47,7 +48,6 @@ flowchart LR
R -- "delete (emit gate)" --> S
R -. "pending_resolved" .-> N
C[(configuration entry\napproval-gate)] -. "reactive reload" .-> G
CR[cron ~60s] --> SW[approval::sweep] --> S
```

## Vocabulary
Expand Down
29 changes: 10 additions & 19 deletions approval-gate/architecture/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ operational contract).
| `approval::approve-always` | console (human-only) | Per-session grant honoured in **every** mode; call it right before `resolve { decision: "allow" }` for an "Approve always" button. |
| `approval::get-settings` | console | Effective settings + `source: "stored" \| "defaults"`. Never writes. |
| `approval::clear-settings` | console | Drop the stored record; revert to deployment defaults. |
| `approval::on-config-change` / `on-session-deleted` / `on-turn-completed` / `approval::sweep` | trigger handlers | Internal — never call directly. |
| `approval::on-config-change` / `on-session-deleted` / `on-turn-completed` | trigger handlers | Internal — never call directly. |

Errors use `code: message` with codes `approval/invalid_payload`,
`approval/state_unavailable`, `approval/harness_unavailable`. An unknown
Expand All @@ -43,7 +43,7 @@ hook returns `hold` — never on the trigger hot path.

Payload: the `PendingApprovalRecord` plus `status: "pending"` — ids
(`session_id`, `turn_id`, `function_call_id`, `function_id`), redacted
`arguments_excerpt`, `pending_at` / `expires_at`, denormalized
`arguments_excerpt`, `pending_at`, denormalized
`session_title` / `session_description` / `session_metadata` (omitted when
session-manager was unreachable at hold time), sub-agent `depth`.
Self-sufficient for notification copy — no follow-up reads needed, and safe
Expand All @@ -53,7 +53,7 @@ to forward to push/Slack payloads (arguments are redacted and clipped).

A pending call left the inbox. Emitted **exactly once per record** — your
badge-clearing logic can trust it. Payload: ids plus
`outcome: "allow" | "deny" | "timeout" | "aborted"`, operator `reason` (deny
`outcome: "allow" | "deny" | "aborted"`, operator `reason` (deny
only), `session_metadata`, `resolved_at`.

### Binding config (both types)
Expand All @@ -76,7 +76,7 @@ sequenceDiagram
participant UI as console
participant N as notify worker
H->>AG: approval::gate (pre_trigger hook)
AG-->>H: { decision: "hold", pending_timeout_ms }
AG-->>H: { decision: "hold", pending_timeout_ms: 0 }
AG--)N: approval::pending-created
UI->>AG: approval::resolve { decision: "allow" }
AG->>H: harness::function::resolve { action: "execute" }
Expand All @@ -96,8 +96,8 @@ assumes, faked today by `tests/integration.rs`:

- **`harness::hook::pre-trigger` trigger type.** The worker binds
`approval::gate` at startup with
`{ functions, timeout_ms, on_error: "fail_closed" }` from the `hook` block of
its `approval-gate` configuration entry. The hook is an ordinary registered
`{ functions: ["*"], timeout_ms: 5000, on_error: "fail_closed" }` at worker
startup (fixed — not in the configuration entry). The hook is an ordinary registered
function: the harness invokes it synchronously and treats the return value as
`HookOutput`.
- **`harness::function::resolve`** accepting
Expand All @@ -116,24 +116,15 @@ gated sets a broad trigger policy and lets the gate hold/deny.

## Deployment notes

- **Sweep requires `iii-cron`** (`iii worker add iii-cron`). The binding
config key is `expression` (6-field cron, default `"0 * * * * *"`).
- **Policy worker** (`policy::check_permissions`): soft dependency with a
sharp consequence — absent, every non-short-circuited call denies as
`gate_unavailable`. Deploy a trivial "everything needs_approval" policy
worker or rely on modes/allow-lists.
- **session-manager** (soft): provides hold-time context and the
`session::deleted` cascade. Without it, records carry no session context
and settings cleanup relies on `approval::clear-settings`.
- **Configuration (required)**: the worker's entire config — the `hook`
binding, `sweep_expression`, the per-call `*_timeout_ms` budgets, and the
approval defaults (`default_mode`, `always_allow_seed`, `pending_timeout_ms`)
— lives in the `approval-gate` configuration entry; there is **no
- **Configuration (required)**: the worker's config — the approval defaults
(`default_mode`, `rules`) — lives in the `approval-gate` configuration entry; there is **no
`config.yaml`**. It is a required boot dependency: a failed register/fetch
aborts startup. `configuration::set` replaces the **whole** value —
read-merge-write to edit one field. Every field hot-reloads (no restart):
`hook` and `sweep_expression` re-bind their triggers live; the rest swap the
in-memory snapshot.
read-merge-write to edit one field. When `rules` is omitted, the built-in
shipped defaults apply. Every field hot-reloads via snapshot swap.

## What not to do

Expand Down
Loading
Loading