Skip to content

Commit bf86d96

Browse files
johnteeecursoragent
andcommitted
Add HTTP signature relay for WAN multi-sig (P4.3b).
Introduce SignatureRelayServer/Client with bearer auth, wire federated broadcast/collect and MultiSigQuorumConfig relay URLs, and expose `teaagent sync signature-relay` CLI. Includes integration tests and governance-gate coverage. Constraint: terminate TLS at reverse proxy; non-loopback bind requires tokens. Tested: tests/test_signature_relay.py, tests/test_federated_sync.py (25 passed); pre-commit smoke pytest (106 passed). Confidence: high Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3a85773 commit bf86d96

14 files changed

Lines changed: 922 additions & 23 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ jobs:
192192
run: pytest tests/test_tranche_b_governance.py -k "plan" -v
193193

194194
- name: Run Phase 5 unit tests
195-
run: pytest tests/test_phase5_context_bus.py tests/test_phase5_workflow_engine.py tests/test_phase5_jit_approval_server.py tests/test_federated_sync.py tests/test_remediation_p1_p2.py -v
195+
run: pytest tests/test_phase5_context_bus.py tests/test_phase5_workflow_engine.py tests/test_phase5_jit_approval_server.py tests/test_federated_sync.py tests/test_signature_relay.py tests/test_remediation_p1_p2.py -v
196196

197197
- name: Run Phase 4-5 acceptance and adversarial governance tests
198198
run: pytest tests/acceptance/test_consensus_flow.py tests/acceptance/test_sandbox_enhancement_flow.py tests/test_governance_adversarial_runtime.py tests/test_skill_executor.py -v

docs/adr/0008-p4-strategic-posture.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ CVE-2026-23949 on `jaraco.context`).
3434
- File-based multi-sig remains **experimental** (not production WAN transport).
3535
- **P4 tranche:** optional `TEAAGENT_FEDERATED_SIGNATURE_TOKEN` — signature JSON
3636
files must carry matching `auth_token` when the env var is set.
37-
- **Future:** HTTP webhook channel with Bearer token (same shape as vote relay);
38-
reuse `surface_auth` token files.
37+
- **P4.3b (shipped):** HTTP signature relay (`teaagent sync signature-relay serve`,
38+
`SignatureRelayClient`, `MultiSigQuorumConfig.peer_relay_urls` /
39+
`local_relay_base_url`). Bearer tokens reuse relay token files / env
40+
(`TEAAGENT_SIGNATURE_RELAY_TOKEN`, `TEAAGENT_RELAY_TOKEN`).
3941

4042
### 4. `jaraco.context` (CVE-2026-23949)
4143

@@ -46,7 +48,7 @@ CVE-2026-23949 on `jaraco.context`).
4648
## Consequences
4749

4850
- Operators on NFS must not run concurrent TeaAgent writers on the same JSONL paths.
49-
- WAN MCP and federated multi-sig require reverse proxy + env tokens until HTTP P2P ships.
51+
- WAN MCP requires reverse proxy TLS; multi-sig uses signature relay + bearer tokens.
5052
- Dependabot alert #10 should clear once GitHub rescans `uv.lock` at 6.1.2+.
5153

5254
## Alternatives considered

docs/http-surface-auth.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,32 @@ For remote clients:
9898
|----------|--------|
9999
| `TEAAGENT_FEDERATED_SIGNATURE_TOKEN` | When set, P2P signature files under `.teaagent/pending_approvals/` must include matching `auth_token` |
100100

101-
File-based multi-sig remains experimental; WAN deployments should use relay/control-plane
102-
Bearer tokens until HTTP P2P transport ships (ADR 0008).
101+
### Signature relay (WAN multi-sig)
102+
103+
```bash
104+
# Collector (requester workspace)
105+
teaagent sync signature-relay serve --api-token-file .teaagent/relay-tokens.json --port 8791
106+
107+
# Peer submits signature (after receiving approval request)
108+
export TEAAGENT_SIGNATURE_RELAY_TOKEN=...
109+
teaagent sync signature-relay submit \
110+
--relay-url https://requester.example:8791 \
111+
--request-id <id> --peer-id peer-1 --signature "<ssh-blob>"
112+
```
113+
114+
Configure `MultiSigQuorumConfig.local_relay_base_url` and `peer_relay_urls` in policy,
115+
or set `TEAAGENT_SIGNATURE_RELAY_TOKEN` / `TEAAGENT_RELAY_TOKEN` for HTTP client auth.
116+
117+
API:
118+
119+
| Method | Path | Purpose |
120+
|--------|------|---------|
121+
| POST | `/api/v1/approval-requests` | Peer receives approval request |
122+
| POST | `/api/v1/approval-signatures` | Peer submits signature to collector |
123+
| GET | `/api/v1/approval-signatures?request_id=` | Collector polls signatures |
124+
125+
File-based multi-sig remains available for local dev; production WAN should use the
126+
HTTP relay behind TLS termination (ADR 0008).
103127

104128
## Reverse proxy templates
105129

docs/plans/remediation-roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Principle: **smallest verifiable step** per phase; no big-bang refactors.
6565
| P4.1 | JSONL / NFS posture + migration ADR | ✅ tranche | [ADR 0008](../adr/0008-p4-strategic-posture.md), threat-model NFS row |
6666
| P4.2 | MCP TLS via reverse proxy (no native TLS) | ✅ documented | [http-surface-auth.md](../http-surface-auth.md), ADR 0008 |
6767
| P4.3 | File P2P signature `auth_token` when `TEAAGENT_FEDERATED_SIGNATURE_TOKEN` set | ✅ shipped | `federated_sync.py`, `security_env.py` |
68-
| P4.3b | HTTP P2P multi-sig channel | 🔲 future | ADR 0008 — reuse relay bearer shape |
68+
| P4.3b | HTTP P2P multi-sig channel | ✅ shipped | `signature_relay.py`, `teaagent sync signature-relay` |
6969
| P4.4 | `jaraco.context` CVE-2026-23949 (Dependabot #10) | ✅ constrained | `pyproject.toml` `jaraco-context>=6.1.0`, `selftest` version check |
7070
| P4.5 | Async `collect_approval_signatures` unit tests | ✅ shipped | `tests/test_federated_sync.py` |
7171

docs/threat-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ This document maps threats to mitigations and verification. It complements [tool
2525
| Context Bus SQLite lock contention / transaction leaks | Medium | Per-thread SQLite connections (`threading.local`); `timeout=5.0` on connect; WAL pragmas on each new connection; `_execute_with_retry` with exponential backoff (5 retries) + generation-based reconnect (per-thread only); explicit `conn.rollback()` on write failure; `cleanup_old_deltas` scoped to `workflow_id` | `tests/test_phase5_context_bus.py` (incl. parallel publish + workflow-scoped cleanup), `tests/test_remediation_p1_p2.py` ||
2626
| Federated sync state corruption on crash | Medium | `atomic_write_text` + file lock on `federated_sync_state.json`; lock on pending changes | `tests/test_federated_sync.py` | File-based multi-sig quorum still experimental |
2727
| JIT approval server race on approve/reject | Medium | `threading.Lock` on `_requests` / `_pending_events` | `tests/test_phase5_jit_approval_server.py` | Approve from thread without running event loop still drops SSE broadcast |
28-
| Asyncio event loop starvation from synchronous P2P approval polling | High | `collect_approval_signatures` is `async def` with `asyncio.sleep`; blocking I/O uses `run_in_executor`; `_collect_peer_signatures` dispatches via `run_coroutine_threadsafe` or `asyncio.run()` | `tests/test_federated_sync.py` (`test_collect_approval_signatures_async_non_blocking`, quorum/dedup) ||
28+
| Asyncio event loop starvation from synchronous P2P approval polling | High | `collect_approval_signatures` is `async def` with `asyncio.sleep`; blocking I/O uses `run_in_executor`; HTTP WAN path uses `SignatureRelayClient` + bearer auth | `tests/test_federated_sync.py`, `tests/test_signature_relay.py` ||
2929
| Shell normalization bypass via brace expansion / process substitution | High | Multi-pass `_normalize_shell_arg` now handles `{a,b}` expansion, `<()` process substitution, and non-string/non-list fallback | `tests/test_policy.py` | Catches `/pr{od,oduction}`, `<(echo /prod)`, dict-type command args |
3030
| Protected directory bypass via alternate write tools | High | `workspace_write_*` tool pattern + `.git*` argument pattern covers all write tools and subdirectory contents | `tests/test_policy.py`, `tests/test_file_policy.py` | Previously only `workspace_write_file` was covered |
3131
| Swarm hang / undetected thread deadlock | High | `ThreadPoolExecutor.as_completed(timeout=...)` with partial result collection; `Subagent` tracks `is_running`/`last_heartbeat`; `_heartbeat_monitor_loop` uses thread-ref liveness instead of defunct PID-based `is_process_alive`; heartbeat hangs merged into swarm `results` | `tests/test_swarm.py`, `tests/test_remediation_p1_p2.py` | Previously heartbeat monitor checked parent PID (always alive) — now detects actual thread hangs |

teaagent/cli/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@
171171
status_short_command,
172172
sync_export,
173173
sync_import,
174+
sync_signature_relay_serve_command,
175+
sync_signature_submit_command,
174176
sync_status,
175177
tool_inspect_command,
176178
tool_lint_command,
@@ -310,6 +312,8 @@ def build_parser() -> argparse.ArgumentParser:
310312
'experiment_cancel': experiment_cancel,
311313
'sync_export': sync_export,
312314
'sync_import': sync_import,
315+
'sync_signature_relay_serve': sync_signature_relay_serve_command,
316+
'sync_signature_submit': sync_signature_submit_command,
313317
'sync_status': sync_status,
314318
'replay_list': replay_list,
315319
'replay_steps': replay_steps,

teaagent/cli/_handlers/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@
213213
from ._sync import (
214214
sync_export,
215215
sync_import,
216+
sync_signature_relay_serve_command,
217+
sync_signature_submit_command,
216218
sync_status,
217219
)
218220
from ._tool import tool_inspect_command, tool_lint_command, tool_list_command
@@ -365,6 +367,8 @@
365367
'experiment_select',
366368
'sync_export',
367369
'sync_import',
370+
'sync_signature_relay_serve_command',
371+
'sync_signature_submit_command',
368372
'sync_status',
369373
'replay_list',
370374
'replay_steps',

teaagent/cli/_handlers/_sync.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import json
77
import logging
88
import sqlite3
9+
import ssl
910
from pathlib import Path
1011

1112
from teaagent.federated_sync import FederatedGraphSync, SyncAck
@@ -209,3 +210,83 @@ def sync_status(args: argparse.Namespace) -> int:
209210
}
210211
)
211212
return 0
213+
214+
215+
def _signature_relay_ssl_context(
216+
args: argparse.Namespace,
217+
) -> ssl.SSLContext | None:
218+
from teaagent.tls_server import build_server_ssl_context
219+
220+
if not getattr(args, 'tls_cert', None):
221+
return None
222+
client_ca = (
223+
Path(args.tls_client_ca) if getattr(args, 'tls_client_ca', None) else None
224+
)
225+
return build_server_ssl_context(
226+
cert_file=Path(args.tls_cert),
227+
key_file=Path(args.tls_key),
228+
client_ca_file=client_ca,
229+
)
230+
231+
232+
def sync_signature_relay_serve_command(args: argparse.Namespace) -> int:
233+
"""Serve HTTP relay for WAN multi-sig approval signatures."""
234+
from pathlib import Path
235+
236+
from teaagent.http_rate_limit import TokenRateLimiter
237+
from teaagent.signature_relay import SignatureRelayServer
238+
from teaagent.surface_auth import load_surface_auth_policy
239+
240+
token_file = (
241+
Path(args.api_token_file) if getattr(args, 'api_token_file', None) else None
242+
)
243+
policy = load_surface_auth_policy(
244+
api_token=getattr(args, 'api_token', None),
245+
api_token_file=token_file,
246+
relay_mode=True,
247+
)
248+
rate_limiter = None
249+
rate_limit_calls = int(getattr(args, 'rate_limit_calls', 0))
250+
if rate_limit_calls > 0:
251+
rate_limiter = TokenRateLimiter(
252+
max_calls=rate_limit_calls,
253+
window_seconds=float(getattr(args, 'rate_limit_window', 60.0)),
254+
)
255+
try:
256+
relay = SignatureRelayServer(
257+
host=args.host,
258+
port=args.port,
259+
auth_policy=policy,
260+
ssl_context=_signature_relay_ssl_context(args),
261+
rate_limiter=rate_limiter,
262+
)
263+
except ValueError as exc:
264+
print_json({'ok': False, 'error': str(exc)})
265+
return 1
266+
relay.serve_blocking()
267+
return 0
268+
269+
270+
def sync_signature_submit_command(args: argparse.Namespace) -> int:
271+
"""POST an approval signature to a remote signature relay."""
272+
from teaagent.signature_relay import SignatureRelayClient
273+
274+
client = SignatureRelayClient(api_token=getattr(args, 'api_token', None))
275+
submit_url = (getattr(args, 'submit_url', None) or '').strip()
276+
if not submit_url:
277+
relay_url = (getattr(args, 'relay_url', None) or '').strip()
278+
if not relay_url:
279+
print_json({'ok': False, 'error': 'provide --submit-url or --relay-url'})
280+
return 1
281+
submit_url = f'{relay_url.rstrip("/")}/api/v1/approval-signatures'
282+
result = client.post_signature(
283+
submit_url,
284+
{
285+
'request_id': args.request_id,
286+
'peer_id': args.peer_id,
287+
'signature': args.signature,
288+
'ssh_key_id': getattr(args, 'ssh_key_id', None),
289+
},
290+
)
291+
print_json(result)
292+
return 0 if result.get('ok') else 1

teaagent/cli/_misc_parsers.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def register(
8484
handlers.get('sync_export'),
8585
handlers.get('sync_import'),
8686
handlers.get('sync_status'),
87+
handlers.get('sync_signature_relay_serve'),
88+
handlers.get('sync_signature_submit'),
8789
)
8890
_replay(
8991
subparsers,
@@ -888,6 +890,8 @@ def _sync(
888890
export_handler: Optional[Callable],
889891
import_handler: Optional[Callable],
890892
status_handler: Optional[Callable],
893+
signature_relay_serve_handler: Optional[Callable] = None,
894+
signature_submit_handler: Optional[Callable] = None,
891895
) -> None:
892896
"""Register sync subcommands."""
893897
sync_parser = subparsers.add_parser(
@@ -944,6 +948,53 @@ def _sync(
944948
)
945949
status_cmd.set_defaults(func=status_handler or _deprecation_warning)
946950

951+
sig_relay = subs.add_parser(
952+
'signature-relay',
953+
help='HTTP relay for WAN multi-sig approval requests and signatures',
954+
)
955+
sig_subs = sig_relay.add_subparsers(
956+
dest='signature_relay_command', required=True, help='Signature relay commands'
957+
)
958+
sig_serve = sig_subs.add_parser('serve', help='Start signature relay HTTP server')
959+
sig_serve.add_argument('--host', default='127.0.0.1')
960+
sig_serve.add_argument('--port', type=int, default=8791)
961+
sig_serve.add_argument('--api-token', help='Bearer token for relay requests')
962+
sig_serve.add_argument('--api-token-file', help='JSON relay token file')
963+
sig_serve.add_argument('--tls-cert', help='TLS certificate PEM')
964+
sig_serve.add_argument('--tls-key', help='TLS private key PEM')
965+
sig_serve.add_argument('--tls-client-ca', help='Client CA PEM for mTLS')
966+
sig_serve.add_argument(
967+
'--rate-limit-calls',
968+
type=int,
969+
default=120,
970+
help='Max POSTs per token per window (0 disables)',
971+
)
972+
sig_serve.add_argument(
973+
'--rate-limit-window',
974+
type=float,
975+
default=60.0,
976+
help='Rate limit window seconds',
977+
)
978+
sig_serve.set_defaults(func=signature_relay_serve_handler or _deprecation_warning)
979+
980+
sig_submit = sig_subs.add_parser(
981+
'submit', help='Submit a peer signature to a signature relay'
982+
)
983+
sig_submit.add_argument(
984+
'--relay-url', help='Relay base URL if --submit-url omitted'
985+
)
986+
sig_submit.add_argument(
987+
'--submit-url',
988+
default='',
989+
help='Full POST URL (defaults to {relay-url}/api/v1/approval-signatures)',
990+
)
991+
sig_submit.add_argument('--request-id', required=True)
992+
sig_submit.add_argument('--peer-id', required=True)
993+
sig_submit.add_argument('--signature', required=True)
994+
sig_submit.add_argument('--ssh-key-id', help='SSH key id metadata')
995+
sig_submit.add_argument('--api-token', help='Bearer token')
996+
sig_submit.set_defaults(func=signature_submit_handler or _deprecation_warning)
997+
947998

948999
def _replay(
9491000
subparsers: argparse._SubParsersAction, # type: ignore[type-arg]

0 commit comments

Comments
 (0)