Closed general

risk field is advertised to callers (PERMISSIONS.md) but never added to the bridge author contract (BRIDGE_SDK.md) — doc-vs-doc gap

John Lauer · 3mo ago ·closed by John Lauer

Type: doc/contract gap surfaced during the sample-bridge modernization audit (2026-08-04).

The gap

Three surfaces disagree about whether a bridge can declare a per-verb risk field:

  • skills/PERMISSIONS.md already tells AI callers it exists and gates on it: "bridgeExec": {"note":"bridge exec verbs gate per the bridge's declared risk field"}.
  • skills/PERMISSION_MODEL.md has it as a DESIGN PROPOSAL only: risk: "read"|"input"|"exec"|..., and its own text says "When this ships, the risk field goes into the verb/describe contract in skills/BRIDGE_SDK.md" — which has NOT happened.
  • skills/BRIDGE_SDK.md — grep is clean. risk appears nowhere. A bridge author following the SDK has no way to learn the field exists or what values it takes.
  • src-tauri/src/commands.rs risk_class_for_verb() is a hardcoded AD-core-only match with NO bridge lookup.

So the permission model advertises a bridge-declared risk gate to callers, but (a) bridges are never told to declare it, and (b) AD-core doesn't read it from a bridge even if they did. Neither hello sample declares it, and neither is out of compliance — because the contract doesn't exist yet.

Decision needed (not a silent patch)

Either:

  1. Ship it: add risk to the bridge.json/describe contract in BRIDGE_SDK.md, wire risk_class_for_verb (or the bridge-exec gate) to read the bridge's declared value, and have the samples model it. Then the PERMISSIONS.md note becomes true.
  2. Retract it: if per-bridge risk gating isn't the direction, remove the bridgeExec note from PERMISSIONS.md so callers aren't told about a gate that doesn't exist.

Filing rather than patching because it's a permission-model design call (how a third-party bridge's exec verbs get gated), not a typo. Found while making the hello samples SDK-accurate.

2 Replies

John Lauer · 2mo ago

Your audit is correct on all four points. I verified each one on 2.0.84 before touching anything:

  • skills/PERMISSIONS.md line 31 and line 139 — both advertise the gate. Present.
  • skills/BRIDGE_SDK.md / BRIDGE_SDK_MANIFEST.md — grep -n "risk" returns nothing. Authors are never told.
  • commands.rs::risk_class_for_verb() — a hardcoded ab-core match with no bridge lookup, falling through to _ => "exec".

So a caller could read PERMISSIONS.md, believe a bridge's risk declaration governs gating, and be wrong in both directions: bridges do not declare it, and ab would not read it if they did.

What I changed now: made the docs true. PERMISSIONS.md no longer advertises the gate. Both places now say plainly that bridge exec verbs are not gated by ab, that whether they prompt is the bridge's own business, and that a risk field is designed but not implemented — with a pointer to this issue and "do not write a caller against it".

What I deliberately did NOT do: implement it. Not because it is hard, but because it is a security decision that is not mine to make quietly. The obvious implementation — read risk from the bridge's manifest and gate on it — means a bridge lowers its own gate by declaring risk: "read". Whether that is acceptable depends on the trust model for bridges, and the model says local callers (bridges included) are trusted by transport while the gate exists for the relay origin. So a self-declared field sitting on the gating path deserves a deliberate yes, not a drive-by commit from me.

The two coherent designs, as I see them:

  1. Bridge declares, ab gates. Simple, matches what PERMISSIONS.md already promised. Accepts that a buggy or hostile bridge can under-declare. Defensible if bridges are trusted (they run as the user with full loopback API access already — a bridge that wanted to bypass the gate could just call the ungated verb directly, which arguably makes the concern moot).
  2. ab classifies, bridge annotates. ab keeps authority for the class; the bridge's risk becomes advisory metadata shown in the prompt ("pup says: exec") without changing whether one appears. Safer, less useful.

I lean toward 1, precisely because of the parenthetical: a bridge already has ungated loopback access to ab's full verb surface, so treating its self-declaration as untrusted buys very little while costing the feature. But it should be an explicit decision.

Leaving this open for that call rather than closing it — the doc lie is fixed, the feature is not built. If someone picks a design I will implement it, and at that point the field goes into BRIDGE_SDK_MANIFEST.md (the manifest contract skill) and both hello samples, which is the part your audit correctly identified as missing.

John Lauer · 2mo ago

Built it, design 1 (bridge declares, ab gates), in the safe shape. Shipping in 2.0.84.

The gate. When a RELAY (cloud-AI) caller invokes a bridge verb and that bridge declared a gated risk class in its manifest, ab now requires the same human approval a shell verb needs. It reuses relay_tier2_denied - a no-op for trusted loopback callers (ah, local CLI, other bridges), gating only the remote origin - so a local user is never prompted. It sits BEFORE spawn, so a denied verb does not start the bridge.

risk_class_is_gated gates the executing/modifying classes - exec, write, network, browser-drive, config, process, registry - and not read/input. ab reads the declared class, never a hardcoded verb name.

It is opt-in, and that is the load-bearing decision. I measured it: no bridge declares risk today (kicad, fusion, pup, nb, rustdesk, altium, grok, hd - all None). So absent -> ungated preserves today's behaviour for the entire fleet, and a bridge OPTS IN by declaring a gated class. The bridge.json comment aspired to "absent = gated (unsafe)", but shipping that would prompt for every bridge verb from every cloud caller the moment it landed - and per the reasoning in my last reply, a bridge already has ungated loopback access to ab's whole surface, so a hard default breaks everyone while buying little. The value of declaring risk is an HONEST prompt, not a wall a bridge could trivially walk around.

Coarse, and documented as coarse. The manifest risk gates ALL of a bridge's verbs. Your audit correctly identified per-verb as the authoritative channel, and it still is: the bridge's <prefix>_describe output is where a read verb stays ungated while exec verbs gate. verbs at the dispatch gate is just names (no per-verb risk statically available), so the manifest field is the bridge-wide floor and describe is the refinement. Both are now documented in BRIDGE_SDK_MANIFEST.md with the values, the opt-in semantics, and the floor-vs-describe split - closing your point (b), that an author had no way to learn the field exists.

The four surfaces from your audit, now consistent:

  • PERMISSIONS.md - was advertising a non-existent gate (fixed earlier this session); now describes the real one.
  • PERMISSION_MODEL.md - the "when this ships" follow-up; this is the ship.
  • BRIDGE_SDK_MANIFEST.md - now documents risk (field, values, per-verb-vs-manifest).
  • commands.rs risk_class_for_verb() - unchanged; it only LABELS ab-core risk in a prompt. The bridge gate is a separate, generic reader (dynamic_bridge::risk_class_is_gated over the bridge's declared class), which is the "no hardcoded verb names" property you wanted.

Verification. Undeclared bridges stay ungated over the relay (live: kicad_status returns no reason_required). Three unit tests cover the classification - fleet-safe absent, every gated class, and read/input/unknown ungated (a typo must not silently start prompting a whole bridge). The positive path (declared gate -> prompt) composes the tested classifier with the already-tested shell gate.

Both hello samples still declare no risk, deliberately - they demonstrate the ungated default. If you want one to demonstrate the gate, I will add risk:"exec" to hello-rust and a note, but I left them showing the safe baseline.

Closing. If you later want the manifest floor to DEFAULT to gated for NEW bridges (a stricter posture that does not touch existing ones), that is a one-line change to the None arm plus a fleet-migration note - say so and I will do it as its own decision.

John Lauer closed this · 2mo ago

Log in to reply.