From 713cb21886f663e370f8f802a11ecf0405ab72ce Mon Sep 17 00:00:00 2001 From: ttmuse Date: Thu, 12 Nov 2026 16:26:25 -0500 Subject: [PATCH 01/10] examples(muse-discord): add README.md --- examples/muse-discord/README.md | 147 ++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 examples/muse-discord/README.md diff --git a/examples/muse-discord/README.md b/examples/muse-discord/README.md new file mode 100644 index 0000000..a1a5508 --- /dev/null +++ b/examples/muse-discord/README.md @@ -0,0 +1,147 @@ +# Reference client: Muse as a caller, Discord as the upstream + +This example implements the caller side of issue #6: a Muse custom-connector +helper that reaches Discord only through auth-proxy, so every outbound call is +policy-checked, audit-logged, and — once `require_approval` from issue #5 +exists — held for the owner's approval before it executes. + +It is a reference, not a deployment. Nothing here touches the proxy core. + +## Pieces + +```mermaid +flowchart LR + subgraph agent["Muse (agent host)"] + H["connector helper
(caller.py shape)"] + end + subgraph owner["Owner-controlled host"] + P["auth-proxy
policy / swap / audit"] + R["registrar
issues + revokes surrogates"] + end + A["approval plane
owner's device (#5)"] + D["discord.com"] + + H -->|"caller key in path,
surrogate in header"| P + R -->|"register sha256(surrogate)"| P + P -.->|"write parked (#5)"| A + A -.->|"approve / deny"| P + P -->|"real bot token swapped in"| D +``` + +## Files + +| File | What it is | +|---|---| +| `identity.toml` | Example proxy config: `muse` identity, `discord` route, credential reference, grants. | +| `caller.py` | Reference caller (Python, stdlib only). Drop-in shape for a connector helper. | +| `mock_proxy.py` | Stand-in proxy implementing the caller-facing contract, including the proposed approval flow. No real Discord call ever happens. | +| `test_caller.py` | End-to-end tests of the caller against the mock: allow, park → approved, park → denied, park → expired, bad surrogate. | + +## Wire protocol (as implemented by the proxy today) + +From `crates/ap-client` and `crates/ap-proxy/src/pipeline.rs`: + +- Requests are sent to `{base}/ap//` in + origin form. The caller key is `apk_` + 64 lowercase hex; the config stores + only its sha256. +- The session surrogate is `aps_` + 64 lowercase hex, sent in the route's + surrogate header (default `authorization`) as `Bearer `. The + proxy registry holds `sha256(surrogate)` per session. +- The proxy canonicalizes the request, checks caller key → identity, route, + surrogate registration and the grant (identity × credential × route × + method × path; a path ending in `/` is a prefix), strips every client auth + header, sets exactly one credential header with the real secret, and + forwards upstream. The caller never sees the real credential. + +Approval flow (proposed in #5, implemented here as the reference contract): +a write whose grant requires approval is answered `202` with a `request_id` +instead of being forwarded. The outcome is caller-scoped — +`GET {base}/ap//requests/` — and reports `parked`, +`executed` (with the upstream response), `denied`, or `expired`. `caller.py` +polls it; against a Phase 1 proxy nothing returns `202` and that path never +triggers. + +## Generating keys + +```sh +python3 - <<'EOF' +import hashlib, secrets +key = "apk_" + secrets.token_hex(32) +print("caller key (give to the caller):", key) +print("caller_key_sha256 (config): ", hashlib.sha256(key.encode()).hexdigest()) +print("surrogate (per session): ", "aps_" + secrets.token_hex(32)) +EOF +``` + +## Policy walkthrough (`identity.toml`) + +- **Reads are granted**: `GET` on `/api/v10/channels/` and `/api/v10/guilds/` + (prefixes). This covers reading message history for mention triage. +- **Writes are present but commented out.** Phase 1 policy only allows or + denies, so an enabled write grant would execute immediately, with no + approval. Uncomment the write grants only together with the + `require_approval` action from #5 — or knowingly, accepting immediate + execution. +- Write grants are per channel: the create-message endpoint is one exact + path per channel id. Replies use the same endpoint with a + `message_reference` in the JSON body. Policy cannot see the body; the + approval step is what reviews it. That split is intentional. + +## Known gap: credential scheme (`Bot` vs `Bearer`) + +The forwarder always sets the credential header as `Bearer ` +(`crates/ap-proxy/src/forward.rs`, `credential_value`). Discord bot tokens +must be sent as `Bot `, so a live Discord upstream cannot authenticate +against Phase 1 as shipped — for reads and writes alike. Storing `Bot ` +as the secret does not help; the prefix is prepended unconditionally. + +Minimal change that unblocks this example (not in this PR, it touches the +proxy core): a per-route credential scheme, e.g. `credential_scheme = "Bot"`, +defaulting to `"Bearer"`, validated against a small allowlist. Everything +else in this directory is ready to run against a proxy with that change and, +for writes, with #5. + +## Sessions for a Muse caller + +Issue #6 asks how sessions are issued when the caller is Muse. This example +assumes the registrar split from the session plane design: + +- The registrar is a small component on the owner's device or home host. It + holds the identity's runtime Ed25519 key (`runtime_pubkey` in + `identity.toml`), generates the surrogate per session, and registers its + sha256 with the proxy. The agent receives only the surrogate. +- Revoke is the same lever as for other callers: unregister the surrogate + (Muse disconnect, session end) and it stops working immediately, together + with any requests it has parked (#5). +- The remote-caller transport (Muse's host is not the proxy's host) is the + open part of #6: the caller plane must be reachable by the agent without + being reachable by the world — in the owner's deployment that is a + tailnet-only listener. The proxy itself does not change for this; the + caller key plus registered surrogate remain the authentication. + +## Try it + +```sh +python3 test_caller.py # full contract: allow / approve / deny / expire / refusal +python3 mock_proxy.py & # or run the mock yourself and drive caller.py by hand +AP_BASE=http://127.0.0.1: \ +AP_CALLER_KEY=apk_c6267244721d31df7f7ab84ca4f5d16ad4d3ef9d6d53b06662cd7dac6704c26c \ +AP_SURROGATE=aps_ac5e3eca49b3e957da4c8709180f7f62a4336de3e14e68faf46df0735a016b03 \ +python3 caller.py get-messages 2222222222222222222 5 +``` + +(The key material above is the worked example from this directory; the mock +accepts exactly it.) + +## Security notes + +- The caller key travels in the URL path by design of the session plane; it + is an identifier-grade authenticator that is useless without a registered + surrogate, and vice versa. Treat proxy access logs as carrying it. +- Neither the caller key nor the surrogate can be exchanged for the real + credential: the swap happens only inside the proxy, per request, against a + matching grant. +- Fail closed at every layer you control: an unmatched path, an unregistered + surrogate, or a missing runtime key is a refusal, never a fallback to a + direct upstream call. `caller.py` has no direct-to-Discord code path at + all. From 477a00c5e85be38139d23d4d2f3ad614879f35d2 Mon Sep 17 00:00:00 2001 From: ttmuse Date: Thu, 12 Nov 2026 16:26:26 -0500 Subject: [PATCH 02/10] examples(muse-discord): add identity.toml --- examples/muse-discord/identity.toml | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 examples/muse-discord/identity.toml diff --git a/examples/muse-discord/identity.toml b/examples/muse-discord/identity.toml new file mode 100644 index 0000000..ed11b54 --- /dev/null +++ b/examples/muse-discord/identity.toml @@ -0,0 +1,71 @@ +# Example identity file: Muse as a caller, Discord as the upstream. +# +# This file follows the schema in crates/ap-core/src/config.rs and the layout of +# deploy/k8s/identity-configmap.yaml. Copy it into your deployment, replace the marked values, +# and keep the raw caller key and the bot token out of the file: the config holds only the +# sha256 of the caller key, and the credential is a store reference. +# +# Status of the write path: the POST/PATCH/DELETE grants at the bottom are commented out on +# purpose. Phase 1 policy has only allow/deny, so enabling them today would let the caller +# write immediately. Enable them only together with the require_approval action proposed in +# issue #5, so writes park until the owner approves them. See README.md in this directory. + +version = 1 + +[listen] +proxy = "127.0.0.1:18080" + +[[identity]] +name = "muse" +# sha256 of the example caller key in README.md ("Generating keys"). REPLACE with the sha256 of +# your own generated caller key; the raw key (apk_...) is held by the caller only. +caller_key_sha256 = "653dd71c569fa41e649d86496f07a65cb092ba3c68e8b02ebb8f48a648f401e0" +# runtime_pubkey = "..." # base64 Ed25519 public key of the registrar that opens sessions for +# this identity. For Muse the registrar is a component on the owner's device (issue #6). Hello +# is refused until this is set. + +[[route]] +name = "discord" +host = "discord.com" +# port, error_shape, surrogate_header and credential_header keep their defaults: +# 443, generic, "authorization" and "authorization". +# Known gap: the forwarder always sets the credential header as "Bearer " +# (crates/ap-proxy/src/forward.rs), but Discord bot tokens must be sent as "Bot ". +# A live Discord pilot needs a per-route credential scheme; see README.md, "Known gap". + +[[credential]] +id = "discord-bot" +# Store reference for the bot token, in the same syntax as the kiro example: the file-backed +# store resolves k8s:: to the token material on the proxy host. The token itself +# never appears in this file and is never sent to the caller. +source = "k8s:auth-proxy-discord:bot_token" +routes = ["discord"] + +# Reads: allowed. Path entries ending in "/" are prefixes (crates/ap-core/src/config.rs); +# "/api/v10/channels/" covers channel reads and message history. +[[grant]] +identity = "muse" +credential = "discord-bot" +route = "discord" +methods = ["GET"] +paths = ["/api/v10/channels/", "/api/v10/guilds/"] + +# Writes: DO NOT enable before require_approval (issue #5) exists, unless you accept that +# matching requests execute immediately. Path granularity note: the create-message endpoint +# is one exact path per channel, so one grant per channel the bot may post to. Replies use +# the same endpoint with a message_reference in the JSON body; the policy layer cannot see +# the body, which is exactly what the approval step reviews. +# +# [[grant]] +# identity = "muse" +# credential = "discord-bot" +# route = "discord" +# methods = ["POST"] +# paths = ["/api/v10/channels/1111111111111111111/messages"] # REPLACE with the channel id +# +# [[grant]] +# identity = "muse" +# credential = "discord-bot" +# route = "discord" +# methods = ["PATCH", "DELETE"] +# paths = ["/api/v10/channels/1111111111111111111/messages/"] # prefix: edits/deletes of own messages From 91c9188efe0c8a96db41e05aa15d6b842ac9496b Mon Sep 17 00:00:00 2001 From: ttmuse Date: Thu, 12 Nov 2026 16:26:28 -0500 Subject: [PATCH 03/10] examples(muse-discord): add caller.py --- examples/muse-discord/caller.py | 165 ++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 examples/muse-discord/caller.py diff --git a/examples/muse-discord/caller.py b/examples/muse-discord/caller.py new file mode 100644 index 0000000..86cc3fb --- /dev/null +++ b/examples/muse-discord/caller.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Reference caller: a Muse custom-connector helper talking to auth-proxy. + +This mirrors the wire protocol implemented by crates/ap-client and enforced by +crates/ap-proxy/src/pipeline.rs: + + * The request goes to {AP_BASE}/ap// + in origin form. The caller key (apk_ + 64 lowercase hex) travels in the path; + the proxy config stores only its sha256. + * The session surrogate (aps_ + 64 lowercase hex) travels in the route's + surrogate header (default "authorization") as "Bearer ". + * The caller never holds the real upstream credential. The proxy checks the + caller key, the surrogate registration and the grant, then swaps the + surrogate for the real credential and forwards upstream. + +Approval flow (proposed in issues #5 and #6, NOT yet implemented by the proxy): +a request whose grant says require_approval is answered "202 Accepted" with a +request id instead of being forwarded. The caller polls +{AP_BASE}/ap//requests/ until the parked request is +executed (the upstream response is returned), denied, or expired. Against a +Phase 1 proxy nothing returns 202 and this code path simply never triggers. + +Any connector helper can adopt this shape: keep the operation surface you +already have, change the base URL to the proxy, and attach the two values +above. The example operations below are the Discord ones from issue #6. + +Environment: + AP_BASE proxy base URL (default http://127.0.0.1:18080) + AP_CALLER_KEY apk_... caller key (required) + AP_SURROGATE aps_... surrogate (required) + AP_ROUTE route name (default "discord") + AP_POLL_TIMEOUT seconds to wait for an approval outcome (default 300) + AP_POLL_INTERVAL seconds between polls (default 2) + +Exit codes: 0 executed, 2 proxy refusal or upstream error, 3 approval denied, +4 approval expired, 5 approval wait timed out, 64 usage/config error. +""" + +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request + +KEY_RE = re.compile(r"^apk_[0-9a-f]{64}$") +SURROGATE_RE = re.compile(r"^aps_[0-9a-f]{64}$") + + +def die(message: str, code: int = 64) -> "SystemExit": + print(f"caller: {message}", file=sys.stderr) + raise SystemExit(code) + + +class Caller: + def __init__(self) -> None: + self.base = os.environ.get("AP_BASE", "http://127.0.0.1:18080").rstrip("/") + self.caller_key = os.environ.get("AP_CALLER_KEY", "") + self.surrogate = os.environ.get("AP_SURROGATE", "") + self.route = os.environ.get("AP_ROUTE", "discord") + self.poll_timeout = float(os.environ.get("AP_POLL_TIMEOUT", "300")) + self.poll_interval = float(os.environ.get("AP_POLL_INTERVAL", "2")) + if not KEY_RE.match(self.caller_key): + die("AP_CALLER_KEY must be apk_ followed by 64 lowercase hex characters") + if not SURROGATE_RE.match(self.surrogate): + die("AP_SURROGATE must be aps_ followed by 64 lowercase hex characters") + + def _url(self, path: str) -> str: + return f"{self.base}/ap/{self.caller_key}/{self.route}{path}" + + def _send(self, method: str, url: str, body: dict | None) -> tuple[int, str]: + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", f"Bearer {self.surrogate}") + if data is not None: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.status, resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as err: + return err.code, err.read().decode("utf-8", "replace") + except urllib.error.URLError as err: + die(f"cannot reach the proxy at {self.base}: {err.reason}", 2) + + def request(self, method: str, path: str, body: dict | None = None) -> tuple[int, str]: + """One upstream call through the proxy, following the approval flow.""" + status, text = self._send(method, self._url(path), body) + if status != 202: + return status, text + # Parked for approval (issue #5 contract). Poll for the outcome. + try: + request_id = json.loads(text)["request_id"] + except (ValueError, KeyError): + die(f"proxy answered 202 without a request_id: {text}", 2) + print(f"caller: parked for approval as {request_id}", file=sys.stderr) + return self._await_outcome(request_id) + + def _await_outcome(self, request_id: str) -> tuple[int, str]: + poll_url = f"{self.base}/ap/{self.caller_key}/requests/{request_id}" + deadline = time.monotonic() + self.poll_timeout + while True: + status, text = self._send("GET", poll_url, None) + if status != 200: + return status, text + try: + outcome = json.loads(text) + except ValueError: + die(f"unparsable outcome for {request_id}: {text}", 2) + state = outcome.get("status") + if state == "executed": + result = outcome.get("result") or {} + return int(result.get("status", 200)), json.dumps(result.get("body", "")) + if state == "denied": + print(f"caller: {request_id} was denied by the owner", file=sys.stderr) + raise SystemExit(3) + if state == "expired": + print(f"caller: {request_id} expired without a decision", file=sys.stderr) + raise SystemExit(4) + if time.monotonic() > deadline: + print(f"caller: still parked after {self.poll_timeout:g}s", file=sys.stderr) + raise SystemExit(5) + time.sleep(self.poll_interval) + + +def main(argv: list[str]) -> int: + caller = Caller() + if len(argv) < 2: + die(__doc__.splitlines()[0] + "\nusage: caller.py ...") + command, args = argv[1], argv[2:] + + if command == "get-messages": + if not args: + die("usage: caller.py get-messages [limit]") + limit = args[1] if len(args) > 1 else "20" + status, text = caller.request( + "GET", f"/api/v10/channels/{args[0]}/messages?limit={limit}" + ) + elif command == "send": + if len(args) < 2: + die("usage: caller.py send [--reply-to ]") + payload: dict = {"content": args[1]} + if "--reply-to" in args: + ref = args[args.index("--reply-to") + 1] + payload["message_reference"] = {"message_id": ref} + status, text = caller.request( + "POST", f"/api/v10/channels/{args[0]}/messages", payload + ) + elif command == "request": + if len(args) < 2: + die("usage: caller.py request [json-body]") + body = json.loads(args[2]) if len(args) > 2 else None + status, text = caller.request(args[0].upper(), args[1], body) + else: + die(f"unknown command: {command}") + + if 200 <= status < 300: + print(text) + return 0 + print(f"caller: proxy/upstream answered {status}: {text}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 3ca66c6c5a4f0b86fa44ebf1fe650409b30cd9b6 Mon Sep 17 00:00:00 2001 From: ttmuse Date: Thu, 12 Nov 2026 16:26:29 -0500 Subject: [PATCH 04/10] examples(muse-discord): add mock_proxy.py --- examples/muse-discord/mock_proxy.py | 188 ++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 examples/muse-discord/mock_proxy.py diff --git a/examples/muse-discord/mock_proxy.py b/examples/muse-discord/mock_proxy.py new file mode 100644 index 0000000..0264a05 --- /dev/null +++ b/examples/muse-discord/mock_proxy.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Mock auth-proxy for trying the reference caller without a deployment. + +Implements just enough of the real proxy's caller-facing contract to exercise +caller.py end to end: + + * /ap// addressing and caller-key checking + (sha256 compared, like crates/ap-proxy/src/pipeline.rs), + * the surrogate in "Authorization: Bearer aps_..." form, + * an allow-listed read answered with canned upstream data, + * the approval flow proposed in issues #5/#6: a write is parked with + "202 + request_id", and GET /ap//requests/ + reports parked / executed / denied / expired. + +Simplifications, deliberately: one identity, one route, one registered +surrogate, no canonicalization, no audit, and parked requests live in memory. +The "upstream" is fabricated; no Discord call ever happens, and the mock +"executes" a parked write by inventing the upstream response. + +Test-only admin endpoints (NOT part of the proposed contract; they stand in +for the owner's approval plane): + + POST /_mock/approve/ approve and "execute" the parked write + POST /_mock/deny/ deny it + +Environment: + MOCK_TTL seconds a parked request awaits a decision (default 60) + MOCK_PORT port to bind, 0 = ephemeral (default 0); the bound port is + printed as "MOCK_PORT=" on stdout. +""" + +import hashlib +import json +import os +import re +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# The example key material from README.md. Only digests are kept for the +# caller key, like the real config; the surrogate is registered by digest too. +EXAMPLE_CALLER_KEY = "apk_c6267244721d31df7f7ab84ca4f5d16ad4d3ef9d6d53b06662cd7dac6704c26c" +EXAMPLE_SURROGATE = "aps_ac5e3eca49b3e957da4c8709180f7f62a4336de3e14e68faf46df0735a016b03" +CALLER_SHA256 = hashlib.sha256(EXAMPLE_CALLER_KEY.encode()).hexdigest() +SURROGATE_SHA256 = hashlib.sha256(EXAMPLE_SURROGATE.encode()).hexdigest() +SURROGATE_FORM = re.compile(r"^Bearer (aps_[0-9a-f]{64})$") +CHANNEL_MESSAGES = re.compile(r"^/api/v10/channels/(\d+)/messages$") + +TTL = float(os.environ.get("MOCK_TTL", "60")) +_lock = threading.Lock() +_parked: dict[str, dict] = {} +_counter = [0] + + +def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None: + body = json.dumps(payload).encode() + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): # keep test output clean + pass + + # -- helpers --------------------------------------------------------- + def _read_json(self) -> dict: + length = int(self.headers.get("Content-Length") or 0) + if not length: + return {} + try: + return json.loads(self.rfile.read(length) or b"{}") + except ValueError: + return {} + + def _check_caller(self, caller_key: str) -> bool: + digest = hashlib.sha256(caller_key.encode()).hexdigest() + if digest != CALLER_SHA256: + _json(self, 403, {"code": "caller_rejected"}) + return False + match = SURROGATE_FORM.match(self.headers.get("Authorization") or "") + if not match: + _json(self, 403, {"code": "surrogate_malformed"}) + return False + if hashlib.sha256(match.group(1).encode()).hexdigest() != SURROGATE_SHA256: + _json(self, 403, {"code": "surrogate_unknown"}) + return False + return True + + # -- routing --------------------------------------------------------- + def do_POST(self): # noqa: N802 (http.server naming) + if self.path.startswith("/_mock/"): + return self._admin(self.path) + parts = self.path.split("/", 4) + if len(parts) < 5 or parts[1] != "ap": + return _json(self, 404, {"code": "route_unknown"}) + _empty, _ap, caller_key, route, rest = parts + if not self._check_caller(caller_key): + return + upstream_path = "/" + rest.split("?")[0] + match = CHANNEL_MESSAGES.match(upstream_path) if route == "discord" else None + if not match: + return _json(self, 404, {"code": "route_unknown"}) + body = self._read_json() + with _lock: + _counter[0] += 1 + request_id = f"req_{_counter[0]:04d}" + _parked[request_id] = { + "status": "parked", + "channel_id": match.group(1), + "body": body, + "expires_at": time.monotonic() + TTL, + "result": None, + } + return _json(self, 202, {"request_id": request_id, "status": "parked"}) + + def do_GET(self): # noqa: N802 + parts = self.path.split("/", 4) + if len(parts) < 4 or parts[1] != "ap": + return _json(self, 404, {"code": "route_unknown"}) + caller_key, route = parts[2], parts[3] + if not self._check_caller(caller_key): + return + if route == "requests": + request_id = parts[4] if len(parts) > 4 else "" + return self._outcome(request_id) + if route != "discord" or len(parts) < 5: + return _json(self, 404, {"code": "route_unknown"}) + upstream_path = "/" + parts[4].split("?")[0] + if CHANNEL_MESSAGES.match(upstream_path): + return _json(self, 200, [{ + "id": "1570000000000000000", + "channel_id": upstream_path.split("/")[4], + "content": "canned message from the mock upstream", + "author": {"id": "1499476122885423204", "username": "mock-bot"}, + }]) + return _json(self, 404, {"code": "route_unknown"}) + + # -- approval outcome + test-only admin ------------------------------ + def _outcome(self, request_id: str) -> None: + with _lock: + entry = _parked.get(request_id) + if entry is None: + return _json(self, 404, {"code": "request_unknown"}) + if entry["status"] == "parked" and time.monotonic() > entry["expires_at"]: + entry["status"] = "expired" + payload = {"request_id": request_id, "status": entry["status"]} + if entry["status"] == "executed": + payload["result"] = entry["result"] + return _json(self, 200, payload) + + def _admin(self, path: str) -> None: + bits = path.strip("/").split("/") + if len(bits) != 3 or bits[1] not in ("approve", "deny"): + return _json(self, 404, {"code": "admin_unknown"}) + action, request_id = bits[1], bits[2] + with _lock: + entry = _parked.get(request_id) + if entry is None or entry["status"] != "parked": + return _json(self, 409, {"code": "not_parked"}) + if action == "deny": + entry["status"] = "denied" + else: + entry["status"] = "executed" + entry["result"] = { + "status": 201, + "body": { + "id": "1570000000000000001", + "channel_id": entry["channel_id"], + "content": entry["body"].get("content", ""), + "message_reference": entry["body"].get("message_reference"), + "via": "mock-upstream", + }, + } + return _json(self, 200, {"request_id": request_id, "status": entry["status"]}) + + +def main() -> None: + port = int(os.environ.get("MOCK_PORT", "0")) + server = ThreadingHTTPServer(("127.0.0.1", port), Handler) + print(f"MOCK_PORT={server.server_address[1]}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() From 9c311bb4c30a409644aac83643d9381fa86fc6f0 Mon Sep 17 00:00:00 2001 From: ttmuse Date: Thu, 12 Nov 2026 16:26:31 -0500 Subject: [PATCH 05/10] examples(muse-discord): add test_caller.py --- examples/muse-discord/test_caller.py | 112 +++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 examples/muse-discord/test_caller.py diff --git a/examples/muse-discord/test_caller.py b/examples/muse-discord/test_caller.py new file mode 100644 index 0000000..fde6c44 --- /dev/null +++ b/examples/muse-discord/test_caller.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""End-to-end tests: caller.py against mock_proxy.py. + +Covers the four outcome paths of the caller contract: + + 1. allow-listed read executes immediately (200), + 2. a write parks (202), the owner approves, the caller sees the executed + upstream response, + 3. a write parks, the owner denies, the caller exits 3, + 4. a write parks, nobody decides, the request expires, the caller exits 4, + 5. a bad surrogate is refused by the proxy (exit 2). + +Run: python3 test_caller.py (stdlib only, binds loopback ports only) +""" + +import json +import os +import subprocess +import sys +import time +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +CALLER_KEY = "apk_c6267244721d31df7f7ab84ca4f5d16ad4d3ef9d6d53b06662cd7dac6704c26c" +SURROGATE = "aps_ac5e3eca49b3e957da4c8709180f7f62a4336de3e14e68faf46df0735a016b03" +CHANNEL = "2222222222222222222" + +failures = [] + + +def check(name: str, condition: bool, detail: str = "") -> None: + print(f"{'PASS' if condition else 'FAIL'} {name}" + (f" ({detail})" if detail and not condition else "")) + if not condition: + failures.append(name) + + +def start_mock(ttl: str) -> tuple[subprocess.Popen, str]: + env = dict(os.environ, MOCK_TTL=ttl, MOCK_PORT="0") + proc = subprocess.Popen( + [sys.executable, os.path.join(HERE, "mock_proxy.py")], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, text=True, + ) + line = proc.stdout.readline() + port = line.strip().split("=", 1)[1] + return proc, f"http://127.0.0.1:{port}" + + +def run_caller(base: str, args: list[str], surrogate: str = SURROGATE, + wait: bool = True) -> subprocess.Popen | subprocess.CompletedProcess: + env = dict(os.environ, AP_BASE=base, AP_CALLER_KEY=CALLER_KEY, + AP_SURROGATE=surrogate, AP_POLL_INTERVAL="0.3", AP_POLL_TIMEOUT="15") + proc = subprocess.Popen( + [sys.executable, os.path.join(HERE, "caller.py"), *args], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, text=True, + ) + if wait: + out, err = proc.communicate(timeout=30) + return subprocess.CompletedProcess(args, proc.returncode, out, err) + return proc + + +def admin(base: str, action: str, request_id: str) -> dict: + req = urllib.request.Request(f"{base}/_mock/{action}/{request_id}", data=b"", method="POST") + with urllib.request.urlopen(req, timeout=5) as resp: + return json.loads(resp.read()) + + +def main() -> int: + mock_a, base_a = start_mock("60") + mock_b, base_b = start_mock("1.5") + try: + # 1. allow-listed read + res = run_caller(base_a, ["get-messages", CHANNEL, "5"]) + check("read executes immediately", res.returncode == 0 and "canned message" in res.stdout, + f"rc={res.returncode} out={res.stdout!r} err={res.stderr!r}") + + # 2. park -> approve -> executed + proc = run_caller(base_a, ["send", CHANNEL, "hello via approval", "--reply-to", "1570000000000000000"], wait=False) + time.sleep(1.0) + admin(base_a, "approve", "req_0001") + out, err = proc.communicate(timeout=30) + body = json.loads(out) if out.strip() else {} + check("approved write executes", proc.returncode == 0 + and body.get("content") == "hello via approval" + and body.get("message_reference", {}).get("message_id") == "1570000000000000000", + f"rc={proc.returncode} out={out!r} err={err!r}") + + # 3. park -> deny + proc = run_caller(base_a, ["send", CHANNEL, "never send this"], wait=False) + time.sleep(1.0) + admin(base_a, "deny", "req_0002") + proc.communicate(timeout=30) + check("denied write exits 3", proc.returncode == 3, f"rc={proc.returncode}") + + # 4. park -> expire (short-TTL mock) + res = run_caller(base_b, ["send", CHANNEL, "too slow"]) + check("expired write exits 4", res.returncode == 4, f"rc={res.returncode} err={res.stderr!r}") + + # 5. bad surrogate refused + res = run_caller(base_a, ["get-messages", CHANNEL], surrogate="aps_" + "0" * 64) + check("bad surrogate refused (exit 2)", res.returncode == 2 and "surrogate_unknown" in res.stderr, + f"rc={res.returncode} err={res.stderr!r}") + finally: + mock_a.terminate() + mock_b.terminate() + + print(f"\n{'ALL PASS' if not failures else 'FAILURES: ' + ', '.join(failures)}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) From 328bb9bb5286d782ee9f0bfc5f38e22596158bca Mon Sep 17 00:00:00 2001 From: ttmuse Date: Fri, 13 Nov 2026 21:04:30 -0500 Subject: [PATCH 06/10] fix(examples/muse-discord): address PR review round 1 (F1-F6) --- examples/muse-discord/README.md | 80 ++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/examples/muse-discord/README.md b/examples/muse-discord/README.md index a1a5508..8a9b2d7 100644 --- a/examples/muse-discord/README.md +++ b/examples/muse-discord/README.md @@ -1,11 +1,18 @@ -# Reference client: Muse as a caller, Discord as the upstream - -This example implements the caller side of issue #6: a Muse custom-connector -helper that reaches Discord only through auth-proxy, so every outbound call is -policy-checked, audit-logged, and — once `require_approval` from issue #5 -exists — held for the owner's approval before it executes. - -It is a reference, not a deployment. Nothing here touches the proxy core. +# Proposal + mock: Muse as a caller, Discord as the upstream + +**Scope: this is a proposal plus a mock, not a runnable end-to-end +Muse -> Discord client today.** It sketches the caller side of issue #6 — +a Muse custom-connector helper that would reach Discord only through +auth-proxy, so every outbound call is policy-checked, audit-logged, and — +once `require_approval` from issue #5 exists — held for the owner's +approval before it executes. + +It cannot reach Discord end-to-end as submitted, for reasons outside this +directory: `runtime_pubkey` is unset in `identity.toml` (the session plane +refuses hello until it is set), the forwarder always sends `Bearer` while +Discord bot tokens need `Bot` (see "Known gap" below), and the example +listener is loopback-only while the remote Muse transport is still open +in #6. Nothing here touches the proxy core. ## Pieces @@ -32,10 +39,10 @@ flowchart LR | File | What it is | |---|---| -| `identity.toml` | Example proxy config: `muse` identity, `discord` route, credential reference, grants. | -| `caller.py` | Reference caller (Python, stdlib only). Drop-in shape for a connector helper. | -| `mock_proxy.py` | Stand-in proxy implementing the caller-facing contract, including the proposed approval flow. No real Discord call ever happens. | -| `test_caller.py` | End-to-end tests of the caller against the mock: allow, park → approved, park → denied, park → expired, bad surrogate. | +| `identity.toml` | Example proxy config (with placeholder digests only): `muse` identity, `discord` route, credential reference, grants. | +| `caller.py` | Proposal caller (Python, stdlib only). Shape for a connector helper once #5/#6 settle. | +| `mock_proxy.py` | **Contract sketch for #5, not the real proxy.** Stand-in implementing the sketched caller-facing contract, including the proposed approval flow. It omits canonicalization, policy evaluation, the registry, audit and forwarding. No real Discord call ever happens. | +| `test_caller.py` | Tests of the caller against the mock only: allow, park → approved, park → denied, park → expired, bad surrogate. Green here does not verify the real proxy. | ## Wire protocol (as implemented by the proxy today) @@ -53,13 +60,18 @@ From `crates/ap-client` and `crates/ap-proxy/src/pipeline.rs`: header, sets exactly one credential header with the real secret, and forwards upstream. The caller never sees the real credential. -Approval flow (proposed in #5, implemented here as the reference contract): -a write whose grant requires approval is answered `202` with a `request_id` -instead of being forwarded. The outcome is caller-scoped — +Approval flow (proposed in #5, sketched here — NOT an agreed contract): +a write whose grant requires approval would be answered `202` with a +`request_id` **and the proxy-owned marker header +`X-Auth-Proxy-Approval: parked`** instead of being forwarded. The marker is +what distinguishes a parked request from a legitimate upstream `202`: +`caller.py` treats a bare `202` without that header as an ordinary upstream +response and returns it as-is. The outcome is caller-scoped — `GET {base}/ap//requests/` — and reports `parked`, `executed` (with the upstream response), `denied`, or `expired`. `caller.py` -polls it; against a Phase 1 proxy nothing returns `202` and that path never -triggers. +polls it. This shape (marker, result schema, poll authorization, expiry / +revocation) is still open in #5; once #5 settles it, the caller and the mock +must be re-derived from that spec rather than freezing this sketch. ## Generating keys @@ -82,8 +94,11 @@ EOF approval. Uncomment the write grants only together with the `require_approval` action from #5 — or knowingly, accepting immediate execution. -- Write grants are per channel: the create-message endpoint is one exact - path per channel id. Replies use the same endpoint with a +- Write grants are per channel in prefix form: config validation rejects + non-root grant paths that do not end in `/`, so the grant is + `/api/v10/channels//messages/` — not the exact create-message path. + That prefix also matches sub-paths such as message edits under the same + channel, so scope it knowingly. Replies use the same endpoint with a `message_reference` in the JSON body. Policy cannot see the body; the approval step is what reviews it. That split is intentional. @@ -95,11 +110,12 @@ must be sent as `Bot `, so a live Discord upstream cannot authenticate against Phase 1 as shipped — for reads and writes alike. Storing `Bot ` as the secret does not help; the prefix is prepended unconditionally. -Minimal change that unblocks this example (not in this PR, it touches the -proxy core): a per-route credential scheme, e.g. `credential_scheme = "Bot"`, -defaulting to `"Bearer"`, validated against a small allowlist. Everything -else in this directory is ready to run against a proxy with that change and, -for writes, with #5. +Minimal change that would unblock the read path (not in this PR, it touches +the proxy core): a per-route credential scheme, e.g. +`credential_scheme = "Bot"`, defaulting to `"Bearer"`, validated against a +small allowlist. Even with that change, this example would still need a set +`runtime_pubkey`, a reachable (non-loopback-only) caller transport from #6, +and — for writes — the settled #5 contract before it could run end-to-end. ## Sessions for a Muse caller @@ -122,16 +138,20 @@ assumes the registrar split from the session plane design: ## Try it ```sh -python3 test_caller.py # full contract: allow / approve / deny / expire / refusal -python3 mock_proxy.py & # or run the mock yourself and drive caller.py by hand +python3 test_caller.py # mock-only contract: allow / approve / deny / expire / refusal + # (generates ephemeral keys and passes them to the mock) +python3 mock_proxy.py & # or run the mock yourself: it prints fresh ephemeral + # MOCK_CALLER_KEY / MOCK_SURROGATE values on startup AP_BASE=http://127.0.0.1: \ -AP_CALLER_KEY=apk_c6267244721d31df7f7ab84ca4f5d16ad4d3ef9d6d53b06662cd7dac6704c26c \ -AP_SURROGATE=aps_ac5e3eca49b3e957da4c8709180f7f62a4336de3e14e68faf46df0735a016b03 \ +AP_CALLER_KEY= \ +AP_SURROGATE= \ python3 caller.py get-messages 2222222222222222222 5 ``` -(The key material above is the worked example from this directory; the mock -accepts exactly it.) +(No reusable key material is committed in this directory. Keys are +generated ephemerally — by `test_caller.py` via `secrets.token_hex(32)`, or +by the mock at startup — and `identity.toml` carries only a placeholder +digest.) ## Security notes From ea2d9ee7110987e83bb1e0781fb4bc4702652041 Mon Sep 17 00:00:00 2001 From: ttmuse Date: Fri, 13 Nov 2026 21:04:33 -0500 Subject: [PATCH 07/10] fix(examples/muse-discord): address PR review round 1 (F1-F6) --- examples/muse-discord/caller.py | 125 +++++++++++++++++++++++++------- 1 file changed, 97 insertions(+), 28 deletions(-) diff --git a/examples/muse-discord/caller.py b/examples/muse-discord/caller.py index 86cc3fb..9ac8b8e 100644 --- a/examples/muse-discord/caller.py +++ b/examples/muse-discord/caller.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 -"""Reference caller: a Muse custom-connector helper talking to auth-proxy. +"""Proposal caller: a Muse custom-connector helper talking to auth-proxy. + +Scope: this is a proposal plus mock (see README.md), not a runnable +end-to-end Muse -> Discord client today. The read wire format below +matches the proxy as shipped; the approval flow is a proposed contract +for issue #5 that has NOT been agreed or implemented in the proxy yet. This mirrors the wire protocol implemented by crates/ap-client and enforced by crates/ap-proxy/src/pipeline.rs: @@ -13,16 +18,16 @@ caller key, the surrogate registration and the grant, then swaps the surrogate for the real credential and forwards upstream. -Approval flow (proposed in issues #5 and #6, NOT yet implemented by the proxy): -a request whose grant says require_approval is answered "202 Accepted" with a -request id instead of being forwarded. The caller polls -{AP_BASE}/ap//requests/ until the parked request is -executed (the upstream response is returned), denied, or expired. Against a -Phase 1 proxy nothing returns 202 and this code path simply never triggers. - -Any connector helper can adopt this shape: keep the operation surface you -already have, change the base URL to the proxy, and attach the two values -above. The example operations below are the Discord ones from issue #6. +Approval flow (PROPOSED in issues #5 and #6, NOT yet implemented by the proxy +and NOT yet agreed): a request whose grant says require_approval would be +answered "202 Accepted" ONLY when it also carries the proxy-owned marker +header "X-Auth-Proxy-Approval: parked", together with a request id. The +caller then polls {AP_BASE}/ap//requests/ until the +parked request is executed, denied, or expired. A bare 202 without that +header is treated as an ordinary upstream 202 response, not as a parked +request -- upstream APIs may legitimately return 202 themselves. Once #5 +settles the marker, response schema, poll authorization and expiry / +revocation semantics, this sketch should be re-derived from that spec. Environment: AP_BASE proxy base URL (default http://127.0.0.1:18080) @@ -37,6 +42,7 @@ """ import json +import math import os import re import sys @@ -46,6 +52,9 @@ KEY_RE = re.compile(r"^apk_[0-9a-f]{64}$") SURROGATE_RE = re.compile(r"^aps_[0-9a-f]{64}$") +SNOWFLAKE_RE = re.compile(r"^\d+$") +APPROVAL_HEADER = "x-auth-proxy-approval" +APPROVAL_MARKER = "parked" def die(message: str, code: int = 64) -> "SystemExit": @@ -53,14 +62,56 @@ def die(message: str, code: int = 64) -> "SystemExit": raise SystemExit(code) +def _parse_poll_value(name: str, default: str) -> float: + raw = os.environ.get(name, default) + try: + value = float(raw) + except (TypeError, ValueError): + die(f"{name} must be a finite non-negative number, got {raw!r}") + if not math.isfinite(value) or value < 0: + die(f"{name} must be a finite non-negative number, got {raw!r}") + return value + + +def _require_snowflake(value: str, what: str) -> str: + if not SNOWFLAKE_RE.match(value or ""): + die(f"{what} must be a numeric (snowflake) id, got {value!r}") + return value + + +def _require_limit(value: str) -> str: + if not SNOWFLAKE_RE.match(value or ""): + die(f"limit must be digits only, got {value!r}") + return value + + +def _has_control(text: str) -> bool: + return any(ord(ch) < 32 or ord(ch) == 127 for ch in text) + + +def _validate_internal_path(path: str) -> str: + # Internal call sites may use a query string (e.g. ?limit=), but a + # fragment or control characters are never legitimate. + if not path.startswith("/") or "#" in path or _has_control(path): + die(f"path must be an origin-form path starting with '/' with no fragment or control characters, got {path!r}") + return path + + +def _validate_user_path(path: str) -> str: + # User-supplied `request` paths: no query or fragment injection either. + if not path.startswith("/") or "#" in path or "?" in path or _has_control(path) or " " in path: + die(f"path must be an origin-form path starting with '/' with no query, fragment, space or control characters, got {path!r}") + return path + + class Caller: def __init__(self) -> None: self.base = os.environ.get("AP_BASE", "http://127.0.0.1:18080").rstrip("/") self.caller_key = os.environ.get("AP_CALLER_KEY", "") self.surrogate = os.environ.get("AP_SURROGATE", "") self.route = os.environ.get("AP_ROUTE", "discord") - self.poll_timeout = float(os.environ.get("AP_POLL_TIMEOUT", "300")) - self.poll_interval = float(os.environ.get("AP_POLL_INTERVAL", "2")) + self.poll_timeout = _parse_poll_value("AP_POLL_TIMEOUT", "300") + self.poll_interval = _parse_poll_value("AP_POLL_INTERVAL", "2") if not KEY_RE.match(self.caller_key): die("AP_CALLER_KEY must be apk_ followed by 64 lowercase hex characters") if not SURROGATE_RE.match(self.surrogate): @@ -69,7 +120,7 @@ def __init__(self) -> None: def _url(self, path: str) -> str: return f"{self.base}/ap/{self.caller_key}/{self.route}{path}" - def _send(self, method: str, url: str, body: dict | None) -> tuple[int, str]: + def _send(self, method: str, url: str, body: dict | None) -> tuple[int, dict, str]: data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", f"Bearer {self.surrogate}") @@ -77,22 +128,29 @@ def _send(self, method: str, url: str, body: dict | None) -> tuple[int, str]: req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=30) as resp: - return resp.status, resp.read().decode("utf-8", "replace") + headers = {k.lower(): v for k, v in resp.headers.items()} + return resp.status, headers, resp.read().decode("utf-8", "replace") except urllib.error.HTTPError as err: - return err.code, err.read().decode("utf-8", "replace") + headers = {k.lower(): v for k, v in err.headers.items()} if err.headers else {} + return err.code, headers, err.read().decode("utf-8", "replace") except urllib.error.URLError as err: die(f"cannot reach the proxy at {self.base}: {err.reason}", 2) def request(self, method: str, path: str, body: dict | None = None) -> tuple[int, str]: - """One upstream call through the proxy, following the approval flow.""" - status, text = self._send(method, self._url(path), body) + """One upstream call through the proxy, following the proposed approval flow.""" + _validate_internal_path(path) + status, headers, text = self._send(method, self._url(path), body) if status != 202: return status, text - # Parked for approval (issue #5 contract). Poll for the outcome. + # F1: a bare 202 is NOT a parked request. Only a 202 carrying the + # proxy-owned approval marker header is treated as parked; any + # other 202 is an ordinary upstream response and is returned as-is. + if (headers.get(APPROVAL_HEADER) or "").strip().lower() != APPROVAL_MARKER: + return status, text try: request_id = json.loads(text)["request_id"] - except (ValueError, KeyError): - die(f"proxy answered 202 without a request_id: {text}", 2) + except (ValueError, KeyError, TypeError): + die(f"proxy answered a marked 202 without a request_id: {text}", 2) print(f"caller: parked for approval as {request_id}", file=sys.stderr) return self._await_outcome(request_id) @@ -100,7 +158,7 @@ def _await_outcome(self, request_id: str) -> tuple[int, str]: poll_url = f"{self.base}/ap/{self.caller_key}/requests/{request_id}" deadline = time.monotonic() + self.poll_timeout while True: - status, text = self._send("GET", poll_url, None) + status, _headers, text = self._send("GET", poll_url, None) if status != 200: return status, text try: @@ -132,25 +190,36 @@ def main(argv: list[str]) -> int: if command == "get-messages": if not args: die("usage: caller.py get-messages [limit]") - limit = args[1] if len(args) > 1 else "20" + channel = _require_snowflake(args[0], "channel-id") + limit = _require_limit(args[1]) if len(args) > 1 else "20" status, text = caller.request( - "GET", f"/api/v10/channels/{args[0]}/messages?limit={limit}" + "GET", f"/api/v10/channels/{channel}/messages?limit={limit}" ) elif command == "send": if len(args) < 2: die("usage: caller.py send [--reply-to ]") + channel = _require_snowflake(args[0], "channel-id") payload: dict = {"content": args[1]} if "--reply-to" in args: - ref = args[args.index("--reply-to") + 1] + idx = args.index("--reply-to") + if idx + 1 >= len(args): + die("usage: caller.py send --reply-to (missing message-id)") + ref = _require_snowflake(args[idx + 1], "message-id") payload["message_reference"] = {"message_id": ref} status, text = caller.request( - "POST", f"/api/v10/channels/{args[0]}/messages", payload + "POST", f"/api/v10/channels/{channel}/messages", payload ) elif command == "request": if len(args) < 2: die("usage: caller.py request [json-body]") - body = json.loads(args[2]) if len(args) > 2 else None - status, text = caller.request(args[0].upper(), args[1], body) + path = _validate_user_path(args[1]) + if not re.fullmatch(r"[A-Za-z]+", args[0] or ""): + die(f"method must be alphabetic, got {args[0]!r}") + try: + body = json.loads(args[2]) if len(args) > 2 else None + except ValueError as exc: + die(f"invalid JSON body: {exc}") + status, text = caller.request(args[0].upper(), path, body) else: die(f"unknown command: {command}") From 6ed2f37cfd67ceb736964eb48135f369de473ba7 Mon Sep 17 00:00:00 2001 From: ttmuse Date: Fri, 13 Nov 2026 21:04:36 -0500 Subject: [PATCH 08/10] fix(examples/muse-discord): address PR review round 1 (F1-F6) --- examples/muse-discord/identity.toml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/muse-discord/identity.toml b/examples/muse-discord/identity.toml index ed11b54..2516a8c 100644 --- a/examples/muse-discord/identity.toml +++ b/examples/muse-discord/identity.toml @@ -17,9 +17,10 @@ proxy = "127.0.0.1:18080" [[identity]] name = "muse" -# sha256 of the example caller key in README.md ("Generating keys"). REPLACE with the sha256 of -# your own generated caller key; the raw key (apk_...) is held by the caller only. -caller_key_sha256 = "653dd71c569fa41e649d86496f07a65cb092ba3c68e8b02ebb8f48a648f401e0" +# Placeholder only -- no real or example digest is committed here. REPLACE +# with the sha256 of your own generated caller key (see README.md, +# "Generating keys"); the raw key (apk_...) is held by the caller only. +caller_key_sha256 = "" # runtime_pubkey = "..." # base64 Ed25519 public key of the registrar that opens sessions for # this identity. For Muse the registrar is a component on the owner's device (issue #6). Hello # is refused until this is set. @@ -51,17 +52,22 @@ methods = ["GET"] paths = ["/api/v10/channels/", "/api/v10/guilds/"] # Writes: DO NOT enable before require_approval (issue #5) exists, unless you accept that -# matching requests execute immediately. Path granularity note: the create-message endpoint -# is one exact path per channel, so one grant per channel the bot may post to. Replies use -# the same endpoint with a message_reference in the JSON body; the policy layer cannot see -# the body, which is exactly what the approval step reviews. +# matching requests execute immediately. Path granularity note: config validation +# (crates/ap-core/src/config.rs, valid_path) rejects any non-root grant path that does +# not end in "/", so grants must use the prefix form below -- an exact create-message +# path without the trailing slash would make the proxy refuse to load this config. +# The prefix form also matches sub-paths below messages/ (e.g. message edits and +# deletes under that channel), so scope and review it accordingly: one prefix grant +# per channel the bot may post to. Replies use the same endpoint with a +# message_reference in the JSON body; the policy layer cannot see the body, which +# is exactly what the approval step reviews. # # [[grant]] # identity = "muse" # credential = "discord-bot" # route = "discord" # methods = ["POST"] -# paths = ["/api/v10/channels/1111111111111111111/messages"] # REPLACE with the channel id +# paths = ["/api/v10/channels/1111111111111111111/messages/"] # REPLACE with the channel id; prefix form, trailing "/" required # # [[grant]] # identity = "muse" From d78f4eff0e7e03d1372a6a3374bc6ab43a118607 Mon Sep 17 00:00:00 2001 From: ttmuse Date: Fri, 13 Nov 2026 21:04:38 -0500 Subject: [PATCH 09/10] fix(examples/muse-discord): address PR review round 1 (F1-F6) --- examples/muse-discord/mock_proxy.py | 63 ++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/examples/muse-discord/mock_proxy.py b/examples/muse-discord/mock_proxy.py index 0264a05..8aa884e 100644 --- a/examples/muse-discord/mock_proxy.py +++ b/examples/muse-discord/mock_proxy.py @@ -1,21 +1,37 @@ #!/usr/bin/env python3 -"""Mock auth-proxy for trying the reference caller without a deployment. +"""Mock auth-proxy -- CONTRACT SKETCH for issue #5, NOT the real proxy. -Implements just enough of the real proxy's caller-facing contract to exercise -caller.py end to end: +This mock is a contract sketch for the approval flow proposed in #5/#6. +It is NOT a stand-in that verifies the real proxy: it deliberately omits +canonicalization, policy evaluation, the session registry, audit and +upstream forwarding (see "Simplifications" below). Tests that pass against +this mock therefore say little about the real proxy in crates/ -- they only +exercise caller.py against this sketched contract. Once #5 settles the +approval contract, this mock should be re-derived from that spec, and a +separate test should run the caller against the real proxy. + +Implements just enough of the caller-facing shape to exercise caller.py: * /ap// addressing and caller-key checking (sha256 compared, like crates/ap-proxy/src/pipeline.rs), * the surrogate in "Authorization: Bearer aps_..." form, * an allow-listed read answered with canned upstream data, - * the approval flow proposed in issues #5/#6: a write is parked with - "202 + request_id", and GET /ap//requests/ - reports parked / executed / denied / expired. + * the proposed approval flow: a write is parked with + "202 + X-Auth-Proxy-Approval: parked + request_id", and + GET /ap//requests/ reports + parked / executed / denied / expired. A bare 202 without the marker + header is NOT a parked request (caller.py returns it as upstream data). Simplifications, deliberately: one identity, one route, one registered -surrogate, no canonicalization, no audit, and parked requests live in memory. -The "upstream" is fabricated; no Discord call ever happens, and the mock -"executes" a parked write by inventing the upstream response. +surrogate, no canonicalization, no policy evaluation, no registry, no +audit, and parked requests live in memory. The "upstream" is fabricated; +no Discord call ever happens, and the mock "executes" a parked write by +inventing the upstream response. + +No reusable credential material is committed in this file. The caller key +and surrogate are ephemeral: test_caller.py generates them with +secrets.token_hex(32) and passes them in via the environment; a manual run +generates fresh ones at startup and prints them. Test-only admin endpoints (NOT part of the proposed contract; they stand in for the owner's approval plane): @@ -24,23 +40,26 @@ POST /_mock/deny/ deny it Environment: - MOCK_TTL seconds a parked request awaits a decision (default 60) - MOCK_PORT port to bind, 0 = ephemeral (default 0); the bound port is - printed as "MOCK_PORT=" on stdout. + MOCK_TTL seconds a parked request awaits a decision (default 60) + MOCK_PORT port to bind, 0 = ephemeral (default 0); the bound port is + printed as "MOCK_PORT=" on stdout, followed by the + ephemeral MOCK_CALLER_KEY / MOCK_SURROGATE in use. + MOCK_CALLER_KEY apk_... caller key to accept (default: generated) + MOCK_SURROGATE aps_... surrogate to accept (default: generated) """ import hashlib import json import os import re +import secrets import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -# The example key material from README.md. Only digests are kept for the -# caller key, like the real config; the surrogate is registered by digest too. -EXAMPLE_CALLER_KEY = "apk_c6267244721d31df7f7ab84ca4f5d16ad4d3ef9d6d53b06662cd7dac6704c26c" -EXAMPLE_SURROGATE = "aps_ac5e3eca49b3e957da4c8709180f7f62a4336de3e14e68faf46df0735a016b03" +# Ephemeral key material only -- nothing reusable is committed here. +EXAMPLE_CALLER_KEY = os.environ.get("MOCK_CALLER_KEY") or ("apk_" + secrets.token_hex(32)) +EXAMPLE_SURROGATE = os.environ.get("MOCK_SURROGATE") or ("aps_" + secrets.token_hex(32)) CALLER_SHA256 = hashlib.sha256(EXAMPLE_CALLER_KEY.encode()).hexdigest() SURROGATE_SHA256 = hashlib.sha256(EXAMPLE_SURROGATE.encode()).hexdigest() SURROGATE_FORM = re.compile(r"^Bearer (aps_[0-9a-f]{64})$") @@ -52,11 +71,13 @@ _counter = [0] -def _json(handler: BaseHTTPRequestHandler, status: int, payload: dict) -> None: +def _json(handler: BaseHTTPRequestHandler, status: int, payload, extra_headers: dict | None = None) -> None: body = json.dumps(payload).encode() handler.send_response(status) handler.send_header("Content-Type", "application/json") handler.send_header("Content-Length", str(len(body))) + for key, value in (extra_headers or {}).items(): + handler.send_header(key, value) handler.end_headers() handler.wfile.write(body) @@ -114,7 +135,11 @@ def do_POST(self): # noqa: N802 (http.server naming) "expires_at": time.monotonic() + TTL, "result": None, } - return _json(self, 202, {"request_id": request_id, "status": "parked"}) + # Proposed #5 marker: parked responses carry the proxy-owned header + # in addition to the 202 status, so a bare upstream 202 is not + # mistaken for a parked request. + return _json(self, 202, {"request_id": request_id, "status": "parked"}, + extra_headers={"X-Auth-Proxy-Approval": "parked"}) def do_GET(self): # noqa: N802 parts = self.path.split("/", 4) @@ -181,6 +206,8 @@ def main() -> None: port = int(os.environ.get("MOCK_PORT", "0")) server = ThreadingHTTPServer(("127.0.0.1", port), Handler) print(f"MOCK_PORT={server.server_address[1]}", flush=True) + print(f"MOCK_CALLER_KEY={EXAMPLE_CALLER_KEY}", flush=True) + print(f"MOCK_SURROGATE={EXAMPLE_SURROGATE}", flush=True) server.serve_forever() From 5e2123ffe2686a39bf35fb1b6ae88e55d4369c5f Mon Sep 17 00:00:00 2001 From: ttmuse Date: Fri, 13 Nov 2026 21:04:40 -0500 Subject: [PATCH 10/10] fix(examples/muse-discord): address PR review round 1 (F1-F6) --- examples/muse-discord/test_caller.py | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/examples/muse-discord/test_caller.py b/examples/muse-discord/test_caller.py index fde6c44..e07455e 100644 --- a/examples/muse-discord/test_caller.py +++ b/examples/muse-discord/test_caller.py @@ -1,28 +1,40 @@ #!/usr/bin/env python3 -"""End-to-end tests: caller.py against mock_proxy.py. +"""Tests: caller.py against mock_proxy.py ONLY (contract sketch for #5). -Covers the four outcome paths of the caller contract: +These tests exercise the mock, which is a contract sketch for the proposed +#5 approval flow -- they do NOT run the caller against the real proxy and +therefore do not verify canonicalization, policy evaluation, the session +registry, audit or upstream forwarding in crates/. Green here means the +caller follows the sketched contract, not that the real proxy behaves +the same way. + +Covers the outcome paths of the sketched caller contract: 1. allow-listed read executes immediately (200), - 2. a write parks (202), the owner approves, the caller sees the executed - upstream response, + 2. a write parks (marked 202), the owner approves, the caller sees the + executed upstream response, 3. a write parks, the owner denies, the caller exits 3, 4. a write parks, nobody decides, the request expires, the caller exits 4, - 5. a bad surrogate is refused by the proxy (exit 2). + 5. a bad surrogate is refused by the mock (exit 2). + +No reusable credential material is committed: the caller key and surrogate +below are generated ephemerally per run with secrets.token_hex(32) and +passed to the mock at startup via its environment. Run: python3 test_caller.py (stdlib only, binds loopback ports only) """ import json import os +import secrets import subprocess import sys import time import urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) -CALLER_KEY = "apk_c6267244721d31df7f7ab84ca4f5d16ad4d3ef9d6d53b06662cd7dac6704c26c" -SURROGATE = "aps_ac5e3eca49b3e957da4c8709180f7f62a4336de3e14e68faf46df0735a016b03" +CALLER_KEY = "apk_" + secrets.token_hex(32) +SURROGATE = "aps_" + secrets.token_hex(32) CHANNEL = "2222222222222222222" failures = [] @@ -35,7 +47,8 @@ def check(name: str, condition: bool, detail: str = "") -> None: def start_mock(ttl: str) -> tuple[subprocess.Popen, str]: - env = dict(os.environ, MOCK_TTL=ttl, MOCK_PORT="0") + env = dict(os.environ, MOCK_TTL=ttl, MOCK_PORT="0", + MOCK_CALLER_KEY=CALLER_KEY, MOCK_SURROGATE=SURROGATE) proc = subprocess.Popen( [sys.executable, os.path.join(HERE, "mock_proxy.py")], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, text=True,