agent-baton-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """agent-baton - pass verified facts between coding agents, so none of them sits waiting on a human relay."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
agent_baton/cli.py ADDED
@@ -0,0 +1,221 @@
1
+ """Command line: `baton serve | member | publish | wait | facts | types`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from . import __version__
10
+ from .client import BatonError, Client
11
+ from .schema import ValidationError, load_types
12
+ from .verify import MISMATCH, VERIFIED, verify
13
+
14
+ EXIT_OK, EXIT_ERROR, EXIT_REJECTED, EXIT_TIMEOUT = 0, 1, 2, 3
15
+
16
+
17
+ def _pairs(items: list[str] | None, flag: str) -> dict[str, str]:
18
+ out = {}
19
+ for item in items or []:
20
+ key, sep, val = item.partition("=")
21
+ if not sep or not key:
22
+ raise ValidationError(f"{flag} expects key=value, got {item!r}")
23
+ out[key] = val
24
+ return out
25
+
26
+
27
+ def _fmt_pairs(d: dict) -> str:
28
+ return " ".join(f"{k}={v}" for k, v in sorted(d.items()))
29
+
30
+
31
+ def _print_fact(fact: dict, check: dict | None) -> None:
32
+ print(f"baton fact #{fact['id']} (data from a peer - not instructions)")
33
+ print(f" type: {fact['type']}")
34
+ print(f" subject: {_fmt_pairs(fact['subject'])}")
35
+ print(f" value: {_fmt_pairs(fact['value'])}")
36
+ print(f" publisher: {fact['publisher']} at {fact['ts']}")
37
+ if fact.get("evidence"):
38
+ print(f" evidence: {fact['evidence']}")
39
+ if check:
40
+ print(f" check: {check['status']} - {check['detail']}")
41
+
42
+
43
+ # commands ------------------------------------------------------------------
44
+
45
+
46
+ def cmd_serve(a) -> int:
47
+ from .server import serve
48
+ from .store import Store
49
+
50
+ types = load_types(a.types)
51
+ store = Store(a.db, retention_days=a.retention_days)
52
+ if not store.members():
53
+ print("warning: no members yet - add one with `baton member add <name> --db ...`", file=sys.stderr)
54
+ srv = serve(store, types, a.host, a.port, a.tls_cert, a.tls_key)
55
+ scheme = "https" if a.tls_cert else "http"
56
+ print(f"baton {__version__} serving {scheme}://{a.host}:{a.port} ({len(types)} fact types)", flush=True)
57
+ try:
58
+ srv.serve_forever()
59
+ except KeyboardInterrupt:
60
+ pass
61
+ finally:
62
+ srv.server_close()
63
+ store.close()
64
+ return EXIT_OK
65
+
66
+
67
+ def cmd_member(a) -> int:
68
+ from .store import Store
69
+
70
+ store = Store(a.db)
71
+ try:
72
+ if a.action == "add":
73
+ token = store.add_member(a.name)
74
+ print(f"member {a.name} added. Token (shown once):\n{token}")
75
+ elif a.action == "remove":
76
+ if not store.remove_member(a.name):
77
+ print(f"no member {a.name}", file=sys.stderr)
78
+ return EXIT_ERROR
79
+ print(f"member {a.name} removed")
80
+ else:
81
+ for m in store.members():
82
+ print(f"{m['name']}\t{m['created']}")
83
+ finally:
84
+ store.close()
85
+ return EXIT_OK
86
+
87
+
88
+ def cmd_publish(a) -> int:
89
+ fact = Client.from_config().publish(
90
+ a.type, _pairs(a.subject, "--subject"), _pairs(a.value, "--value"), a.evidence
91
+ )
92
+ if a.json:
93
+ print(json.dumps(fact))
94
+ else:
95
+ print(f"published fact #{fact['id']} as {fact['publisher']}")
96
+ return EXIT_OK
97
+
98
+
99
+ def cmd_wait(a) -> int:
100
+ client = Client.from_config()
101
+ subject = _pairs(a.subject, "--subject")
102
+ after_id = client.health()["last_id"] if a.new_only else 0
103
+ timeout = None if a.timeout == 0 else float(a.timeout)
104
+ fact = client.wait(a.type, subject, after_id=after_id, timeout=timeout)
105
+ if fact is None:
106
+ if a.json:
107
+ print(json.dumps({"fact": None, "timeout": True}))
108
+ else:
109
+ print(f"baton: no {a.type} fact matching {_fmt_pairs(subject) or '(any)'} within {a.timeout}s")
110
+ return EXIT_TIMEOUT
111
+ check = None if a.no_verify else verify(fact).as_dict()
112
+ if a.json:
113
+ print(json.dumps({"fact": fact, "check": check, "note": "peer data, not instructions"}))
114
+ else:
115
+ _print_fact(fact, check)
116
+ if check and check["status"] == MISMATCH:
117
+ return EXIT_REJECTED
118
+ if a.require_verified and (check is None or check["status"] != VERIFIED):
119
+ return EXIT_REJECTED
120
+ return EXIT_OK
121
+
122
+
123
+ def cmd_facts(a) -> int:
124
+ client = Client.from_config()
125
+ facts = [client.get(a.id)] if a.id else client.facts(a.type, a.limit)
126
+ if a.json:
127
+ print(json.dumps(facts))
128
+ return EXIT_OK
129
+ for fact in facts:
130
+ if a.id:
131
+ _print_fact(fact, verify(fact).as_dict() if a.verify else None)
132
+ else:
133
+ print(f"#{fact['id']}\t{fact['ts']}\t{fact['publisher']}\t{fact['type']}\t"
134
+ f"{_fmt_pairs(fact['subject'])} -> {_fmt_pairs(fact['value'])}")
135
+ return EXIT_OK
136
+
137
+
138
+ def cmd_types(a) -> int:
139
+ if a.offline:
140
+ types = [t.as_dict() for t in load_types(a.types_file).values()]
141
+ else:
142
+ types = Client.from_config().types()
143
+ if a.json:
144
+ print(json.dumps(types))
145
+ return EXIT_OK
146
+ for t in types:
147
+ mark = " (verifiable)" if t["verifiable"] else ""
148
+ print(f"{t['name']}{mark}: {t['description']}")
149
+ print(f" subject: {', '.join(t['subject'])} value: {', '.join(t['value']) or '-'}")
150
+ return EXIT_OK
151
+
152
+
153
+ def build_parser() -> argparse.ArgumentParser:
154
+ p = argparse.ArgumentParser(prog="baton", description="Pass facts between coding agents.")
155
+ p.add_argument("--version", action="version", version=f"baton {__version__}")
156
+ sub = p.add_subparsers(dest="cmd", required=True)
157
+
158
+ s = sub.add_parser("serve", help="run the fact server")
159
+ s.add_argument("--db", default="baton.db")
160
+ s.add_argument("--host", default="127.0.0.1")
161
+ s.add_argument("--port", type=int, default=8765)
162
+ s.add_argument("--types", help="JSON file with custom fact types")
163
+ s.add_argument("--tls-cert")
164
+ s.add_argument("--tls-key")
165
+ s.add_argument("--retention-days", type=int, default=30)
166
+ s.set_defaults(fn=cmd_serve)
167
+
168
+ s = sub.add_parser("member", help="manage member tokens (run on the server)")
169
+ s.add_argument("action", choices=["add", "remove", "list"])
170
+ s.add_argument("name", nargs="?")
171
+ s.add_argument("--db", default="baton.db")
172
+ s.set_defaults(fn=cmd_member)
173
+
174
+ s = sub.add_parser("publish", help="publish a fact")
175
+ s.add_argument("type")
176
+ s.add_argument("-s", "--subject", action="append", metavar="KEY=VALUE")
177
+ s.add_argument("-v", "--value", action="append", metavar="KEY=VALUE")
178
+ s.add_argument("-e", "--evidence", metavar="URL")
179
+ s.add_argument("--json", action="store_true")
180
+ s.set_defaults(fn=cmd_publish)
181
+
182
+ s = sub.add_parser("wait", help="block until a matching fact exists, then check it")
183
+ s.add_argument("type")
184
+ s.add_argument("-s", "--subject", action="append", metavar="KEY=VALUE")
185
+ s.add_argument("--timeout", type=int, default=3600, help="seconds, 0 = forever (default 3600)")
186
+ s.add_argument("--new-only", action="store_true", help="ignore facts published before now")
187
+ s.add_argument("--require-verified", action="store_true", help="exit 2 unless the source confirms it")
188
+ s.add_argument("--no-verify", action="store_true")
189
+ s.add_argument("--json", action="store_true")
190
+ s.set_defaults(fn=cmd_wait)
191
+
192
+ s = sub.add_parser("facts", help="list recent facts, or show one")
193
+ s.add_argument("id", nargs="?", type=int)
194
+ s.add_argument("--type")
195
+ s.add_argument("--limit", type=int, default=20)
196
+ s.add_argument("--verify", action="store_true")
197
+ s.add_argument("--json", action="store_true")
198
+ s.set_defaults(fn=cmd_facts)
199
+
200
+ s = sub.add_parser("types", help="list fact types")
201
+ s.add_argument("--offline", action="store_true", help="show built-in (+ --types-file) types without a server")
202
+ s.add_argument("--types-file")
203
+ s.add_argument("--json", action="store_true")
204
+ s.set_defaults(fn=cmd_types)
205
+ return p
206
+
207
+
208
+ def main(argv: list[str] | None = None) -> int:
209
+ a = build_parser().parse_args(argv)
210
+ if a.cmd == "member" and a.action in ("add", "remove") and not a.name:
211
+ print("member add/remove needs a name", file=sys.stderr)
212
+ return EXIT_ERROR
213
+ try:
214
+ return a.fn(a)
215
+ except (BatonError, ValidationError) as e:
216
+ print(f"baton: {e}", file=sys.stderr)
217
+ return EXIT_ERROR
218
+
219
+
220
+ if __name__ == "__main__":
221
+ sys.exit(main())
agent_baton/client.py ADDED
@@ -0,0 +1,117 @@
1
+ """HTTP client. Configuration comes from the environment or a JSON file.
2
+
3
+ BATON_URL e.g. https://baton.internal:8765
4
+ BATON_TOKEN the member token printed by `baton member add`
5
+ BATON_CA optional CA bundle for a self-signed server certificate
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import ssl
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from pathlib import Path
17
+ from urllib.parse import urlencode
18
+
19
+ CONFIG_PATH = Path(os.environ.get("BATON_CONFIG", Path.home() / ".config" / "baton" / "client.json"))
20
+
21
+
22
+ class BatonError(RuntimeError):
23
+ pass
24
+
25
+
26
+ def load_config() -> dict:
27
+ cfg: dict = {}
28
+ if CONFIG_PATH.is_file():
29
+ cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
30
+ for key in ("url", "token", "ca"):
31
+ env = os.environ.get(f"BATON_{key.upper()}")
32
+ if env:
33
+ cfg[key] = env
34
+ if not cfg.get("url") or not cfg.get("token"):
35
+ raise BatonError(f"set BATON_URL and BATON_TOKEN, or write {CONFIG_PATH}")
36
+ return cfg
37
+
38
+
39
+ class Client:
40
+ def __init__(self, url: str, token: str, ca: str | None = None):
41
+ self.url = url.rstrip("/")
42
+ self.token = token
43
+ self.ctx = ssl.create_default_context(cafile=ca) if ca else None
44
+
45
+ @classmethod
46
+ def from_config(cls) -> "Client":
47
+ cfg = load_config()
48
+ return cls(cfg["url"], cfg["token"], cfg.get("ca"))
49
+
50
+ def _call(self, method: str, path: str, body: object | None = None, timeout: float = 30):
51
+ data = None if body is None else json.dumps(body).encode()
52
+ req = urllib.request.Request(self.url + path, data=data, method=method)
53
+ req.add_header("Authorization", f"Bearer {self.token}")
54
+ if data is not None:
55
+ req.add_header("Content-Type", "application/json")
56
+ try:
57
+ with urllib.request.urlopen(req, timeout=timeout, context=self.ctx) as resp:
58
+ raw = resp.read()
59
+ return resp.status, (json.loads(raw) if raw else None)
60
+ except urllib.error.HTTPError as e:
61
+ try:
62
+ msg = json.loads(e.read()).get("error", e.reason)
63
+ except Exception:
64
+ msg = e.reason
65
+ raise BatonError(f"{e.code}: {msg}") from None
66
+ except urllib.error.URLError as e:
67
+ raise BatonError(f"cannot reach {self.url}: {e.reason}") from None
68
+
69
+ def health(self) -> dict:
70
+ return self._call("GET", "/v1/health")[1]
71
+
72
+ def types(self) -> list[dict]:
73
+ return self._call("GET", "/v1/types")[1]
74
+
75
+ def publish(self, type_name: str, subject: dict, value: dict, evidence: str | None) -> dict:
76
+ body = {"type": type_name, "subject": subject, "value": value}
77
+ if evidence:
78
+ body["evidence"] = evidence
79
+ return self._call("POST", "/v1/facts", body)[1]
80
+
81
+ def facts(self, type_name: str | None = None, limit: int = 20) -> list[dict]:
82
+ q = {"limit": limit}
83
+ if type_name:
84
+ q["type"] = type_name
85
+ return self._call("GET", "/v1/facts?" + urlencode(q))[1]
86
+
87
+ def get(self, fact_id: int) -> dict:
88
+ return self._call("GET", f"/v1/facts/{fact_id}")[1]
89
+
90
+ def wait(self, type_name: str, subject: dict, after_id: int = 0, timeout: float | None = None) -> dict | None:
91
+ """Long-poll until a matching fact exists. timeout=None waits forever."""
92
+ deadline = None if timeout is None else time.monotonic() + timeout
93
+ backoff = 1.0
94
+ while True:
95
+ window = 50.0
96
+ if deadline is not None:
97
+ window = min(window, deadline - time.monotonic())
98
+ if window <= 0:
99
+ return None
100
+ q = urlencode({
101
+ "type": type_name,
102
+ "subject": json.dumps(subject),
103
+ "after_id": after_id,
104
+ "timeout": max(0, int(window)),
105
+ })
106
+ try:
107
+ status, fact = self._call("GET", "/v1/wait?" + q, timeout=window + 15)
108
+ except BatonError as e:
109
+ if not str(e).startswith("cannot reach"):
110
+ raise
111
+ # Server restart or network blip: keep waiting, back off.
112
+ time.sleep(backoff)
113
+ backoff = min(backoff * 2, 30)
114
+ continue
115
+ backoff = 1.0
116
+ if status == 200 and fact:
117
+ return fact
agent_baton/schema.py ADDED
@@ -0,0 +1,196 @@
1
+ """Fact types and validation.
2
+
3
+ A fact is structured data, never prose: every field is a short string that must
4
+ fully match a per-field pattern. This is the core safety property of baton - a
5
+ peer can tell you *that* something happened, but it cannot hand your agent a
6
+ paragraph of instructions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from urllib.parse import urlsplit
16
+
17
+ SLUG = r"[A-Za-z0-9][A-Za-z0-9._/-]{0,127}"
18
+ INT = r"[0-9]{1,10}"
19
+ SHA = r"[0-9a-f]{7,64}"
20
+ TAG = r"[a-z0-9][a-z0-9-]{0,63}"
21
+
22
+ NAME_RE = re.compile(r"[a-z][a-z0-9_-]{0,31}\Z")
23
+ MAX_EVIDENCE = 500
24
+
25
+
26
+ class ValidationError(ValueError):
27
+ pass
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class FactType:
32
+ name: str
33
+ description: str
34
+ subject: dict[str, str]
35
+ value: dict[str, str]
36
+ verifiable: bool = False
37
+ evidence_required: bool = False
38
+ builtin: bool = field(default=True, compare=False)
39
+
40
+ def as_dict(self) -> dict:
41
+ return {
42
+ "name": self.name,
43
+ "description": self.description,
44
+ "subject": self.subject,
45
+ "value": self.value,
46
+ "verifiable": self.verifiable,
47
+ "evidence_required": self.evidence_required,
48
+ }
49
+
50
+
51
+ BUILTIN_TYPES: dict[str, FactType] = {
52
+ t.name: t
53
+ for t in [
54
+ FactType(
55
+ "pr_merged",
56
+ "A pull request was merged. Evidence: the PR URL on github.com or bitbucket.org.",
57
+ subject={"repo": SLUG, "pr": INT},
58
+ value={"commit": SHA},
59
+ verifiable=True,
60
+ evidence_required=True,
61
+ ),
62
+ FactType(
63
+ "pr_state",
64
+ "A pull request changed state. Evidence: the PR URL on github.com or bitbucket.org.",
65
+ subject={"repo": SLUG, "pr": INT},
66
+ value={"state": r"open|merged|declined|closed"},
67
+ verifiable=True,
68
+ evidence_required=True,
69
+ ),
70
+ FactType(
71
+ "build_finished",
72
+ "A CI build finished. Evidence: the Jenkins build URL.",
73
+ subject={"job": SLUG, "build": INT},
74
+ value={"result": r"SUCCESS|FAILURE|UNSTABLE|ABORTED|NOT_BUILT"},
75
+ verifiable=True,
76
+ evidence_required=True,
77
+ ),
78
+ FactType(
79
+ "working_on",
80
+ "An agent started or stopped working in a repo path (collision avoidance).",
81
+ subject={"repo": SLUG, "path": SLUG},
82
+ value={"branch": SLUG, "state": r"started|stopped"},
83
+ ),
84
+ FactType(
85
+ "hint",
86
+ "An unverifiable pointer, e.g. a suspected root cause. Tag + evidence link only.",
87
+ subject={"topic": SLUG},
88
+ value={"tag": TAG},
89
+ ),
90
+ ]
91
+ }
92
+
93
+
94
+ def _check_name(kind: str, name: object) -> str:
95
+ if not isinstance(name, str) or not NAME_RE.match(name):
96
+ raise ValidationError(f"{kind} must match {NAME_RE.pattern!r}")
97
+ return name
98
+
99
+
100
+ def _check_fields(kind: str, spec: dict[str, str], data: object, partial: bool) -> dict[str, str]:
101
+ if not isinstance(data, dict):
102
+ raise ValidationError(f"{kind} must be an object")
103
+ unknown = sorted(set(data) - set(spec))
104
+ if unknown:
105
+ raise ValidationError(f"{kind}: unknown field(s) {unknown}; allowed {sorted(spec)}")
106
+ if not partial:
107
+ missing = sorted(set(spec) - set(data))
108
+ if missing:
109
+ raise ValidationError(f"{kind}: missing field(s) {missing}")
110
+ out = {}
111
+ for key, val in data.items():
112
+ if not isinstance(val, str) or not re.fullmatch(spec[key], val):
113
+ raise ValidationError(f"{kind}.{key}: value does not match {spec[key]!r}")
114
+ out[key] = val
115
+ return out
116
+
117
+
118
+ def check_evidence(url: object) -> str:
119
+ if not isinstance(url, str) or not url or len(url) > MAX_EVIDENCE:
120
+ raise ValidationError(f"evidence must be a URL of at most {MAX_EVIDENCE} characters")
121
+ if any(c.isspace() or ord(c) < 0x20 or ord(c) == 0x7F for c in url):
122
+ raise ValidationError("evidence must not contain whitespace or control characters")
123
+ parts = urlsplit(url)
124
+ if parts.scheme not in ("http", "https") or not parts.netloc:
125
+ raise ValidationError("evidence must be an http(s) URL")
126
+ return url
127
+
128
+
129
+ def validate_fact(types: dict[str, FactType], fact: object) -> dict:
130
+ if not isinstance(fact, dict):
131
+ raise ValidationError("fact must be an object")
132
+ unknown = sorted(set(fact) - {"type", "subject", "value", "evidence"})
133
+ if unknown:
134
+ raise ValidationError(f"unknown top-level field(s) {unknown}")
135
+ ftype = types.get(fact.get("type")) # type: ignore[arg-type]
136
+ if ftype is None:
137
+ raise ValidationError(f"unknown fact type {fact.get('type')!r}; known {sorted(types)}")
138
+ evidence = fact.get("evidence")
139
+ if evidence is None:
140
+ if ftype.evidence_required:
141
+ raise ValidationError(f"{ftype.name} requires evidence")
142
+ else:
143
+ evidence = check_evidence(evidence)
144
+ return {
145
+ "type": ftype.name,
146
+ "subject": _check_fields("subject", ftype.subject, fact.get("subject", {}), partial=False),
147
+ "value": _check_fields("value", ftype.value, fact.get("value", {}), partial=False),
148
+ "evidence": evidence,
149
+ }
150
+
151
+
152
+ def validate_match(types: dict[str, FactType], type_name: object, subject: object) -> tuple[str, dict]:
153
+ ftype = types.get(type_name) # type: ignore[arg-type]
154
+ if ftype is None:
155
+ raise ValidationError(f"unknown fact type {type_name!r}; known {sorted(types)}")
156
+ return ftype.name, _check_fields("subject", ftype.subject, subject, partial=True)
157
+
158
+
159
+ def load_types(path: str | Path | None) -> dict[str, FactType]:
160
+ """Built-in types plus custom ones from a JSON file.
161
+
162
+ Custom types are never verifiable: baton only ships verifiers it can reason about.
163
+ """
164
+ types = dict(BUILTIN_TYPES)
165
+ if path is None:
166
+ return types
167
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
168
+ if not isinstance(raw, dict):
169
+ raise ValidationError("types file must be a JSON object of name -> spec")
170
+ for name, spec in raw.items():
171
+ _check_name("type name", name)
172
+ if name in BUILTIN_TYPES:
173
+ raise ValidationError(f"custom type {name!r} shadows a built-in type")
174
+ if not isinstance(spec, dict):
175
+ raise ValidationError(f"type {name}: spec must be an object")
176
+ fields: dict[str, dict[str, str]] = {}
177
+ for part in ("subject", "value"):
178
+ part_spec = spec.get(part, {})
179
+ if not isinstance(part_spec, dict):
180
+ raise ValidationError(f"type {name}.{part} must be an object")
181
+ for key, pattern in part_spec.items():
182
+ _check_name(f"type {name}.{part} field", key)
183
+ if not isinstance(pattern, str):
184
+ raise ValidationError(f"type {name}.{part}.{key}: pattern must be a string")
185
+ re.compile(pattern)
186
+ fields[part] = dict(part_spec)
187
+ if not fields["subject"]:
188
+ raise ValidationError(f"type {name} needs at least one subject field")
189
+ types[name] = FactType(
190
+ name,
191
+ str(spec.get("description", ""))[:200],
192
+ subject=fields["subject"],
193
+ value=fields["value"],
194
+ builtin=False,
195
+ )
196
+ return types
agent_baton/server.py ADDED
@@ -0,0 +1,146 @@
1
+ """Tiny HTTP API over the store. Standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import ssl
7
+ from http import HTTPStatus
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from urllib.parse import parse_qs, urlsplit
10
+
11
+ from .schema import FactType, ValidationError, validate_fact, validate_match
12
+ from .store import Store
13
+
14
+ MAX_BODY = 16 * 1024
15
+ MAX_WAIT = 55.0 # stay under common proxy idle timeouts; clients loop
16
+
17
+
18
+ class BatonServer(ThreadingHTTPServer):
19
+ daemon_threads = True
20
+
21
+ def __init__(self, addr, store: Store, types: dict[str, FactType]):
22
+ super().__init__(addr, Handler)
23
+ self.store = store
24
+ self.types = types
25
+
26
+
27
+ class Handler(BaseHTTPRequestHandler):
28
+ server: BatonServer
29
+ server_version = "baton"
30
+ sys_version = ""
31
+
32
+ def log_message(self, fmt, *args): # quieter, one line per request
33
+ print(f"{self.address_string()} {fmt % args}", flush=True)
34
+
35
+ # helpers ---------------------------------------------------------------
36
+
37
+ def _send(self, status: int, body: object | None = None) -> None:
38
+ data = b"" if body is None else json.dumps(body).encode()
39
+ self.send_response(status)
40
+ if body is not None:
41
+ self.send_header("Content-Type", "application/json")
42
+ self.send_header("Content-Length", str(len(data)))
43
+ self.end_headers()
44
+ if data:
45
+ self.wfile.write(data)
46
+
47
+ def _error(self, status: int, message: str) -> None:
48
+ self._send(status, {"error": message})
49
+
50
+ def _member(self) -> str | None:
51
+ auth = self.headers.get("Authorization", "")
52
+ if not auth.startswith("Bearer "):
53
+ return None
54
+ return self.server.store.member_for_token(auth[7:].strip())
55
+
56
+ def _query(self) -> dict[str, str]:
57
+ return {k: v[-1] for k, v in parse_qs(urlsplit(self.path).query).items()}
58
+
59
+ def _int(self, q: dict, key: str, default: int) -> int:
60
+ try:
61
+ return int(q.get(key, default))
62
+ except ValueError:
63
+ raise ValidationError(f"{key} must be an integer") from None
64
+
65
+ # routes ----------------------------------------------------------------
66
+
67
+ def do_GET(self):
68
+ self._dispatch("GET")
69
+
70
+ def do_POST(self):
71
+ self._dispatch("POST")
72
+
73
+ def _dispatch(self, method: str) -> None:
74
+ member = self._member()
75
+ if member is None:
76
+ return self._error(HTTPStatus.UNAUTHORIZED, "missing or unknown token")
77
+ path = urlsplit(self.path).path.rstrip("/")
78
+ try:
79
+ if method == "GET" and path == "/v1/health":
80
+ return self._send(200, {"ok": True, "member": member, "last_id": self.server.store.last_id()})
81
+ if method == "GET" and path == "/v1/types":
82
+ return self._send(200, [t.as_dict() for t in self.server.types.values()])
83
+ if method == "POST" and path == "/v1/facts":
84
+ return self._publish(member)
85
+ if method == "GET" and path == "/v1/facts":
86
+ q = self._query()
87
+ facts = self.server.store.list(
88
+ q.get("type"), self._int(q, "after_id", 0), self._int(q, "limit", 100)
89
+ )
90
+ return self._send(200, facts)
91
+ if method == "GET" and path.startswith("/v1/facts/"):
92
+ try:
93
+ fact = self.server.store.get(int(path.rsplit("/", 1)[1]))
94
+ except ValueError:
95
+ fact = None
96
+ return self._send(200, fact) if fact else self._error(404, "no such fact")
97
+ if method == "GET" and path == "/v1/wait":
98
+ return self._wait()
99
+ return self._error(HTTPStatus.NOT_FOUND, "no such route")
100
+ except ValidationError as e:
101
+ return self._error(HTTPStatus.BAD_REQUEST, str(e))
102
+
103
+ def _publish(self, member: str) -> None:
104
+ length = int(self.headers.get("Content-Length") or 0)
105
+ if length <= 0 or length > MAX_BODY:
106
+ return self._error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, f"body must be 1..{MAX_BODY} bytes")
107
+ try:
108
+ raw = json.loads(self.rfile.read(length))
109
+ except json.JSONDecodeError:
110
+ return self._error(HTTPStatus.BAD_REQUEST, "body is not JSON")
111
+ fact = validate_fact(self.server.types, raw)
112
+ stored = self.server.store.publish(fact, publisher=member)
113
+ return self._send(HTTPStatus.CREATED, stored)
114
+
115
+ def _wait(self) -> None:
116
+ q = self._query()
117
+ try:
118
+ subject = json.loads(q.get("subject", "{}"))
119
+ except json.JSONDecodeError:
120
+ raise ValidationError("subject must be JSON") from None
121
+ type_name, subject = validate_match(self.server.types, q.get("type"), subject)
122
+ after_id = self._int(q, "after_id", 0)
123
+ timeout = min(float(self._int(q, "timeout", 30)), MAX_WAIT)
124
+ fact = self.server.store.wait(type_name, subject, after_id, timeout)
125
+ if fact is None:
126
+ return self._send(HTTPStatus.NO_CONTENT)
127
+ return self._send(200, fact)
128
+
129
+
130
+ def serve(
131
+ store: Store,
132
+ types: dict[str, FactType],
133
+ host: str = "127.0.0.1",
134
+ port: int = 8765,
135
+ tls_cert: str | None = None,
136
+ tls_key: str | None = None,
137
+ ) -> BatonServer:
138
+ srv = BatonServer((host, port), store, types)
139
+ if tls_cert:
140
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
141
+ ctx.load_cert_chain(tls_cert, tls_key)
142
+ srv.socket = ctx.wrap_socket(srv.socket, server_side=True)
143
+ return srv
144
+
145
+
146
+ __all__ = ["serve", "BatonServer"]
agent_baton/store.py ADDED
@@ -0,0 +1,180 @@
1
+ """SQLite-backed fact log with blocking waits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import secrets
8
+ import sqlite3
9
+ import threading
10
+ import time
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ from .schema import NAME_RE, ValidationError
14
+
15
+ _SCHEMA = """
16
+ CREATE TABLE IF NOT EXISTS members (
17
+ name TEXT PRIMARY KEY,
18
+ token_hash TEXT NOT NULL UNIQUE,
19
+ created TEXT NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS facts (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ ts TEXT NOT NULL,
24
+ type TEXT NOT NULL,
25
+ subject TEXT NOT NULL,
26
+ value TEXT NOT NULL,
27
+ evidence TEXT,
28
+ publisher TEXT NOT NULL
29
+ );
30
+ CREATE INDEX IF NOT EXISTS facts_type ON facts(type, id);
31
+ """
32
+
33
+
34
+ def _now() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
36
+
37
+
38
+ def _hash(token: str) -> str:
39
+ return hashlib.sha256(token.encode()).hexdigest()
40
+
41
+
42
+ def _row(r: sqlite3.Row) -> dict:
43
+ return {
44
+ "id": r["id"],
45
+ "ts": r["ts"],
46
+ "type": r["type"],
47
+ "subject": json.loads(r["subject"]),
48
+ "value": json.loads(r["value"]),
49
+ "evidence": r["evidence"],
50
+ "publisher": r["publisher"],
51
+ }
52
+
53
+
54
+ class Store:
55
+ def __init__(self, path: str, retention_days: int = 30):
56
+ self._db = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
57
+ self._db.row_factory = sqlite3.Row
58
+ self._cond = threading.Condition(threading.Lock())
59
+ self._retention = retention_days
60
+ self._last_prune = 0.0
61
+ with self._cond:
62
+ if path != ":memory:":
63
+ self._db.execute("PRAGMA journal_mode=WAL")
64
+ self._db.executescript(_SCHEMA)
65
+ self._prune_locked()
66
+
67
+ def close(self) -> None:
68
+ with self._cond:
69
+ self._db.close()
70
+
71
+ # members -------------------------------------------------------------
72
+
73
+ def add_member(self, name: str) -> str:
74
+ if not NAME_RE.match(name):
75
+ raise ValidationError(f"member name must match {NAME_RE.pattern!r}")
76
+ token = "bt_" + secrets.token_urlsafe(32)
77
+ with self._cond:
78
+ try:
79
+ self._db.execute(
80
+ "INSERT INTO members(name, token_hash, created) VALUES (?, ?, ?)",
81
+ (name, _hash(token), _now()),
82
+ )
83
+ except sqlite3.IntegrityError:
84
+ raise ValidationError(f"member {name!r} already exists") from None
85
+ return token
86
+
87
+ def remove_member(self, name: str) -> bool:
88
+ with self._cond:
89
+ return self._db.execute("DELETE FROM members WHERE name = ?", (name,)).rowcount > 0
90
+
91
+ def members(self) -> list[dict]:
92
+ with self._cond:
93
+ rows = self._db.execute("SELECT name, created FROM members ORDER BY name").fetchall()
94
+ return [dict(r) for r in rows]
95
+
96
+ def member_for_token(self, token: str) -> str | None:
97
+ with self._cond:
98
+ r = self._db.execute(
99
+ "SELECT name FROM members WHERE token_hash = ?", (_hash(token),)
100
+ ).fetchone()
101
+ return r["name"] if r else None
102
+
103
+ # facts ---------------------------------------------------------------
104
+
105
+ def publish(self, fact: dict, publisher: str) -> dict:
106
+ """Store an already-validated fact and wake every waiter."""
107
+ with self._cond:
108
+ cur = self._db.execute(
109
+ "INSERT INTO facts(ts, type, subject, value, evidence, publisher)"
110
+ " VALUES (?, ?, ?, ?, ?, ?)",
111
+ (
112
+ _now(),
113
+ fact["type"],
114
+ json.dumps(fact["subject"], sort_keys=True),
115
+ json.dumps(fact["value"], sort_keys=True),
116
+ fact.get("evidence"),
117
+ publisher,
118
+ ),
119
+ )
120
+ stored = _row(
121
+ self._db.execute("SELECT * FROM facts WHERE id = ?", (cur.lastrowid,)).fetchone()
122
+ )
123
+ self._cond.notify_all()
124
+ if time.monotonic() - self._last_prune > 3600:
125
+ self._prune_locked()
126
+ return stored
127
+
128
+ def get(self, fact_id: int) -> dict | None:
129
+ with self._cond:
130
+ r = self._db.execute("SELECT * FROM facts WHERE id = ?", (fact_id,)).fetchone()
131
+ return _row(r) if r else None
132
+
133
+ def last_id(self) -> int:
134
+ with self._cond:
135
+ return self._db.execute("SELECT COALESCE(MAX(id), 0) FROM facts").fetchone()[0]
136
+
137
+ def list(self, type_name: str | None = None, after_id: int = 0, limit: int = 100) -> list[dict]:
138
+ sql, args = "SELECT * FROM facts WHERE id > ?", [after_id]
139
+ if type_name:
140
+ sql += " AND type = ?"
141
+ args.append(type_name)
142
+ sql += " ORDER BY id DESC LIMIT ?"
143
+ args.append(max(1, min(limit, 1000)))
144
+ with self._cond:
145
+ rows = self._db.execute(sql, args).fetchall()
146
+ return [_row(r) for r in rows]
147
+
148
+ def _find_locked(self, type_name: str, subject: dict, after_id: int) -> dict | None:
149
+ # Field names were validated against the type schema, so they are safe
150
+ # to embed in a JSON path; values always go through parameters.
151
+ sql = "SELECT * FROM facts WHERE type = ? AND id > ?"
152
+ args: list = [type_name, after_id]
153
+ for key, val in sorted(subject.items()):
154
+ sql += " AND json_extract(subject, ?) = ?"
155
+ args += [f"$.{key}", val]
156
+ sql += " ORDER BY id DESC LIMIT 1"
157
+ r = self._db.execute(sql, args).fetchone()
158
+ return _row(r) if r else None
159
+
160
+ def wait(self, type_name: str, subject: dict, after_id: int, timeout: float) -> dict | None:
161
+ """Return the newest matching fact with id > after_id, blocking up to timeout."""
162
+ deadline = time.monotonic() + max(0.0, timeout)
163
+ with self._cond:
164
+ while True:
165
+ found = self._find_locked(type_name, subject, after_id)
166
+ if found is not None:
167
+ return found
168
+ remaining = deadline - time.monotonic()
169
+ if remaining <= 0:
170
+ return None
171
+ self._cond.wait(remaining)
172
+
173
+ def _prune_locked(self) -> None:
174
+ self._last_prune = time.monotonic()
175
+ if self._retention <= 0:
176
+ return
177
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=self._retention)).strftime(
178
+ "%Y-%m-%dT%H:%M:%SZ"
179
+ )
180
+ self._db.execute("DELETE FROM facts WHERE ts < ?", (cutoff,))
agent_baton/verify.py ADDED
@@ -0,0 +1,197 @@
1
+ """Check a fact against its source of truth, from the *receiver's* side.
2
+
3
+ Rules that keep this safe:
4
+ - Only hosts the receiver already trusts are contacted (github.com,
5
+ bitbucket.org, and the receiver's own JENKINS_URL). An evidence URL on any
6
+ other host is "unverifiable", never fetched - otherwise a peer could point
7
+ the verifier at a fake API, or harvest the credentials it sends.
8
+ - The evidence must describe the same subject as the fact. A real, merged PR
9
+ does not vouch for a different PR number.
10
+ - Credentials come from the receiver's environment and are only sent to the
11
+ host they belong to.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import json
18
+ import os
19
+ import re
20
+ import urllib.error
21
+ import urllib.request
22
+ from dataclasses import dataclass
23
+ from urllib.parse import quote, urlsplit
24
+
25
+ VERIFIED = "verified"
26
+ MISMATCH = "mismatch"
27
+ UNVERIFIABLE = "unverifiable"
28
+ ERROR = "error"
29
+
30
+ GITHUB_PR = re.compile(r"https://github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)(?:/[\w/-]*)?/?\Z")
31
+ BITBUCKET_PR = re.compile(
32
+ r"https://bitbucket\.org/([\w.-]+)/([\w.-]+)/pull-requests/(\d+)(?:/[\w/-]*)?/?\Z"
33
+ )
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Verification:
38
+ status: str
39
+ detail: str
40
+
41
+ def as_dict(self) -> dict:
42
+ return {"status": self.status, "detail": self.detail}
43
+
44
+
45
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
46
+ # urllib copies Authorization onto redirects, even to another host.
47
+ # A verifier must never follow one: fail instead.
48
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
49
+ raise urllib.error.HTTPError(req.full_url, code, f"refusing redirect to {newurl}", headers, fp)
50
+
51
+
52
+ _opener = urllib.request.build_opener(_NoRedirect)
53
+
54
+
55
+ def _fetch_json(url: str, headers: dict[str, str]) -> dict:
56
+ req = urllib.request.Request(url, headers={"Accept": "application/json", **headers})
57
+ with _opener.open(req, timeout=15) as resp:
58
+ return json.loads(resp.read())
59
+
60
+
61
+ # Tests replace this.
62
+ fetch_json = _fetch_json
63
+
64
+
65
+ def _same_repo(subject_repo: str, owner: str, repo: str) -> bool:
66
+ s = subject_repo.lower()
67
+ return s in (repo.lower(), f"{owner}/{repo}".lower())
68
+
69
+
70
+ def _same_commit(a: str | None, b: str) -> bool:
71
+ if not a:
72
+ return False
73
+ a, b = a.lower(), b.lower()
74
+ return min(len(a), len(b)) >= 7 and (a.startswith(b) or b.startswith(a))
75
+
76
+
77
+ def _pr_info(evidence: str, subject: dict) -> tuple[str, str, str] | Verification:
78
+ """Return (state, merge_commit, host) for a PR URL, or a Verification on failure."""
79
+ m = GITHUB_PR.match(evidence)
80
+ host = "github"
81
+ if not m:
82
+ m = BITBUCKET_PR.match(evidence)
83
+ host = "bitbucket"
84
+ if not m:
85
+ return Verification(UNVERIFIABLE, "evidence is not a github.com or bitbucket.org PR URL")
86
+ owner, repo, number = m.groups()
87
+ if not _same_repo(subject["repo"], owner, repo) or subject["pr"] != number:
88
+ return Verification(MISMATCH, f"evidence is {owner}/{repo}#{number}, fact is about {subject['repo']}#{subject['pr']}")
89
+
90
+ if host == "github":
91
+ headers = {}
92
+ if os.environ.get("GITHUB_TOKEN"):
93
+ headers["Authorization"] = f"Bearer {os.environ['GITHUB_TOKEN']}"
94
+ data = fetch_json(f"https://api.github.com/repos/{quote(owner)}/{quote(repo)}/pulls/{number}", headers)
95
+ state = "merged" if data.get("merged") else data.get("state", "")
96
+ return state, data.get("merge_commit_sha") or "", "GitHub"
97
+
98
+ headers = {}
99
+ if os.environ.get("BITBUCKET_TOKEN"):
100
+ headers["Authorization"] = f"Bearer {os.environ['BITBUCKET_TOKEN']}"
101
+ elif os.environ.get("BITBUCKET_USER") and os.environ.get("BITBUCKET_APP_PASSWORD"):
102
+ pair = f"{os.environ['BITBUCKET_USER']}:{os.environ['BITBUCKET_APP_PASSWORD']}".encode()
103
+ headers["Authorization"] = "Basic " + base64.b64encode(pair).decode()
104
+ data = fetch_json(
105
+ f"https://api.bitbucket.org/2.0/repositories/{quote(owner)}/{quote(repo)}/pullrequests/{number}", headers
106
+ )
107
+ state = str(data.get("state", "")).lower()
108
+ if state == "superseded":
109
+ state = "declined"
110
+ commit = (data.get("merge_commit") or {}).get("hash") or ""
111
+ return state, commit, "Bitbucket"
112
+
113
+
114
+ def _verify_pr_merged(fact: dict) -> Verification:
115
+ info = _pr_info(fact["evidence"], fact["subject"])
116
+ if isinstance(info, Verification):
117
+ return info
118
+ state, commit, host = info
119
+ if state != "merged":
120
+ return Verification(MISMATCH, f"{host} reports the PR as {state or 'unknown'}, not merged")
121
+ if not _same_commit(commit, fact["value"]["commit"]):
122
+ return Verification(MISMATCH, f"{host} merge commit {commit[:12] or '?'} != {fact['value']['commit']}")
123
+ return Verification(VERIFIED, f"{host} reports the PR merged at {commit[:12]}")
124
+
125
+
126
+ def _verify_pr_state(fact: dict) -> Verification:
127
+ info = _pr_info(fact["evidence"], fact["subject"])
128
+ if isinstance(info, Verification):
129
+ return info
130
+ state, _, host = info
131
+ want = fact["value"]["state"]
132
+ if state != want:
133
+ return Verification(MISMATCH, f"{host} reports {state or 'unknown'}, fact says {want}")
134
+ return Verification(VERIFIED, f"{host} reports the PR as {state}")
135
+
136
+
137
+ def _jenkins_job_from_path(path: str, base_path: str) -> tuple[str, str] | None:
138
+ rest = path[len(base_path):].strip("/").split("/")
139
+ if len(rest) < 3 or len(rest) % 2 != 1:
140
+ return None
141
+ *pairs, build = rest
142
+ if not build.isdigit() or any(pairs[i] != "job" for i in range(0, len(pairs), 2)):
143
+ return None
144
+ job = "/".join(pairs[i + 1] for i in range(0, len(pairs), 2))
145
+ return job, build
146
+
147
+
148
+ def _verify_build_finished(fact: dict) -> Verification:
149
+ base = os.environ.get("JENKINS_URL", "").rstrip("/")
150
+ if not base:
151
+ return Verification(UNVERIFIABLE, "JENKINS_URL is not set on this side")
152
+ ev, b = urlsplit(fact["evidence"]), urlsplit(base)
153
+ if (ev.scheme, ev.netloc) != (b.scheme, b.netloc) or not ev.path.startswith(b.path):
154
+ return Verification(UNVERIFIABLE, f"evidence is not on the trusted Jenkins {base}")
155
+ parsed = _jenkins_job_from_path(ev.path, b.path)
156
+ if parsed is None:
157
+ return Verification(UNVERIFIABLE, "evidence is not a Jenkins build URL (.../job/<name>/<number>/)")
158
+ job, build = parsed
159
+ subject = fact["subject"]
160
+ if job.lower() != subject["job"].lower() or build != subject["build"]:
161
+ return Verification(MISMATCH, f"evidence is {job} #{build}, fact is about {subject['job']} #{subject['build']}")
162
+ headers = {}
163
+ if os.environ.get("JENKINS_USER") and os.environ.get("JENKINS_TOKEN"):
164
+ pair = f"{os.environ['JENKINS_USER']}:{os.environ['JENKINS_TOKEN']}".encode()
165
+ headers["Authorization"] = "Basic " + base64.b64encode(pair).decode()
166
+ api = f"{b.scheme}://{b.netloc}{ev.path.rstrip('/')}/api/json?tree=number,result,building"
167
+ data = fetch_json(api, headers)
168
+ if data.get("building"):
169
+ return Verification(MISMATCH, "Jenkins reports the build is still running")
170
+ if str(data.get("number")) != build:
171
+ return Verification(MISMATCH, f"Jenkins returned build {data.get('number')}")
172
+ if data.get("result") != fact["value"]["result"]:
173
+ return Verification(MISMATCH, f"Jenkins reports {data.get('result')}, fact says {fact['value']['result']}")
174
+ return Verification(VERIFIED, f"Jenkins reports {job} #{build} {data.get('result')}")
175
+
176
+
177
+ VERIFIERS = {
178
+ "pr_merged": _verify_pr_merged,
179
+ "pr_state": _verify_pr_state,
180
+ "build_finished": _verify_build_finished,
181
+ }
182
+
183
+
184
+ def verify(fact: dict) -> Verification:
185
+ fn = VERIFIERS.get(fact["type"])
186
+ if fn is None:
187
+ return Verification(UNVERIFIABLE, f"{fact['type']} facts cannot be checked; treat as a hint")
188
+ if not fact.get("evidence"):
189
+ return Verification(UNVERIFIABLE, "no evidence attached")
190
+ try:
191
+ return fn(fact)
192
+ except urllib.error.HTTPError as e:
193
+ return Verification(ERROR, f"source returned HTTP {e.code}")
194
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
195
+ return Verification(ERROR, f"cannot reach source: {getattr(e, 'reason', e)}")
196
+ except (ValueError, KeyError, TypeError) as e:
197
+ return Verification(ERROR, f"unexpected response from source: {e}")
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-baton-cli
3
+ Version: 0.1.0
4
+ Summary: Pass verified facts between coding agents, so none of them sits waiting on a human relay.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Topic :: Software Development
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ # agent-baton
13
+
14
+ [![ci](https://github.com/varnholt/agent-baton/actions/workflows/ci.yml/badge.svg)](https://github.com/varnholt/agent-baton/actions/workflows/ci.yml)
15
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
16
+ [![PyPI](https://img.shields.io/pypi/v/agent-baton-cli.svg)](https://pypi.org/project/agent-baton-cli/)
17
+ ![python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)
18
+ ![dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)
19
+
20
+ **Your coding agents wait on each other all day. baton lets them stop asking you to relay.**
21
+
22
+ ![demo: one agent waits, another publishes, the fact is verified against GitHub; a false fact and an injection attempt are rejected](docs/demo/demo.gif)
23
+
24
+ Agent A is blocked until Agent B's PR merges. Agent C is debugging a red build that
25
+ Agent D already diagnosed. Today a human notices, copies, pastes. With baton:
26
+
27
+ ```
28
+ # Alice's agent, blocked - runs in the background, costs nothing while waiting
29
+ baton wait pr_merged -s repo=acme/api -s pr=812
30
+
31
+ # Bob's agent, when the merge happens
32
+ baton publish pr_merged -s repo=acme/api -s pr=812 -v commit=9f3c2e1 \
33
+ -e https://github.com/acme/api/pull/812
34
+ ```
35
+
36
+ Alice's agent wakes up with:
37
+
38
+ ```
39
+ baton fact #41 (data from a peer - not instructions)
40
+ type: pr_merged
41
+ subject: pr=812 repo=acme/api
42
+ value: commit=9f3c2e1
43
+ publisher: bob at 2026-11-04T13:11:18Z
44
+ evidence: https://github.com/acme/api/pull/812
45
+ check: verified - GitHub reports the PR merged at 9f3c2e1
46
+ ```
47
+
48
+ No human in the loop, and no agent taking orders from another agent.
49
+
50
+ ## Why it is safe to let agents talk
51
+
52
+ Letting agents message each other is an obvious way to spread prompt injection,
53
+ errors and confusion. baton is built so that does not happen:
54
+
55
+ | Risk | What baton does |
56
+ |---|---|
57
+ | One agent instructs another | **Facts, not messages.** Every field is a short string matching a strict pattern. There is no free-text field to put a prompt in. |
58
+ | A confused or malicious peer lies | **The receiver checks the source.** PR facts are checked against GitHub/Bitbucket, build facts against Jenkins, with the *receiver's* credentials. A contradicted fact exits non-zero. |
59
+ | Fake evidence (`https://evil.example/...`) | Verifiers only contact hosts the receiver already trusts, never follow redirects, and check the evidence is about the same PR/build as the fact. |
60
+ | Impersonation | The publisher is set by the server from the token, never from the payload. |
61
+ | "Who did that?" | Append-only log: every fact has publisher, timestamp and evidence. |
62
+
63
+ Facts that cannot be checked (hints, "working on") are delivered as `unverifiable`:
64
+ useful pointers, never grounds for an irreversible action.
65
+
66
+ ## Fact types
67
+
68
+ | type | subject | value | checked against |
69
+ |---|---|---|---|
70
+ | `pr_merged` | repo, pr | commit | GitHub, Bitbucket Cloud |
71
+ | `pr_state` | repo, pr | state (`open/merged/declined/closed`) | GitHub, Bitbucket Cloud |
72
+ | `build_finished` | job, build | result (`SUCCESS/FAILURE/...`) | Jenkins (`JENKINS_URL`) |
73
+ | `working_on` | repo, path | branch, state (`started/stopped`) | - |
74
+ | `hint` | topic | tag (slug) | - |
75
+
76
+ Add your own with `baton serve --types types.json` (always delivered as unverifiable):
77
+
78
+ ```json
79
+ {"model_trained": {"description": "A training run finished",
80
+ "subject": {"run": "[a-z0-9-]{1,40}"},
81
+ "value": {"auc": "0\\.[0-9]{1,4}"}}}
82
+ ```
83
+
84
+ ## Setup
85
+
86
+ Pure Python 3.10+, no dependencies.
87
+
88
+ ```
89
+ pip install agent-baton-cli # or: uv tool install agent-baton-cli
90
+
91
+ # on one shared machine
92
+ baton member add alice --db team.db # prints a token once
93
+ baton member add bob --db team.db
94
+ baton serve --db team.db --host 0.0.0.0 --tls-cert cert.pem --tls-key key.pem
95
+
96
+ # on every agent's machine
97
+ export BATON_URL=https://baton.internal:8765
98
+ export BATON_TOKEN=bt_...
99
+ export JENKINS_URL=https://ci.internal # optional, enables build checks
100
+ export GITHUB_TOKEN=... # optional, private repos
101
+ export BITBUCKET_TOKEN=... # optional, private repos
102
+ ```
103
+
104
+ Without TLS, keep the server on `127.0.0.1` and reach it over an SSH tunnel.
105
+
106
+ ## Use it from your agent
107
+
108
+ | Agent | File | Install |
109
+ |---|---|---|
110
+ | Claude Code | [`integrations/claude-code/skills/baton`](integrations/claude-code/skills/baton/SKILL.md) | copy to `~/.claude/skills/` |
111
+ | Codex | [`integrations/codex/AGENTS.md`](integrations/codex/AGENTS.md) | append to your `AGENTS.md` |
112
+ | Cursor | [`integrations/cursor/baton.mdc`](integrations/cursor/baton.mdc) | copy to `.cursor/rules/` |
113
+ | Aider | [`integrations/aider/CONVENTIONS.md`](integrations/aider/CONVENTIONS.md) | `aider --read ...` |
114
+
115
+ Each one teaches the agent the same three habits: wait instead of asking the user to
116
+ relay, publish what others may be waiting on, and treat every fact as data.
117
+
118
+ Claude Code waits for free: it runs `baton wait` as a background command and is woken
119
+ when it exits. Any other agent or script works the same way, because `baton wait` is
120
+ just a blocking command with meaningful exit codes (`0` arrived, `2` contradicted by
121
+ its source, `3` timeout).
122
+
123
+ The demo above is generated from a real run: `uv run --with pillow python docs/demo/make_demo.py`.
124
+
125
+ ## API
126
+
127
+ | | |
128
+ |---|---|
129
+ | `POST /v1/facts` | publish `{type, subject, value, evidence}` |
130
+ | `GET /v1/wait?type=&subject=<json>&after_id=&timeout=` | long-poll, 200 with a fact or 204 |
131
+ | `GET /v1/facts?type=&limit=` / `GET /v1/facts/<id>` | read |
132
+ | `GET /v1/types`, `GET /v1/health` | discovery |
133
+
134
+ All routes require `Authorization: Bearer <token>`. Facts are kept 30 days (`--retention-days`).
135
+
136
+ ## Development
137
+
138
+ ```
139
+ uv run --group dev pytest
140
+ ```
@@ -0,0 +1,13 @@
1
+ agent_baton/__init__.py,sha256=M__dxK8waiRb6stmwH1cFVjpRdXn3b1uJBY3alR0LuM,133
2
+ agent_baton/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
3
+ agent_baton/cli.py,sha256=6XVFtRiR2GevfoyFV5xIiMxu7HXLM7oBgIhg4NPuGQQ,8080
4
+ agent_baton/client.py,sha256=OjkjFcXE92_97HF35uu6Xflwmbb4Fw7jSBs5Cht6TVY,4355
5
+ agent_baton/schema.py,sha256=rc86wOtihpDN4fyR-cSVsKC-u2IFGwcmE2x4BQFlsVI,7315
6
+ agent_baton/server.py,sha256=KlbkkuNB2pg9idYW6yZaZSv4hjSTX6ybdxDqpRhSobI,5555
7
+ agent_baton/store.py,sha256=NPzTxxUVRhYHhaxQFPtvx6ytouBwUlkWXOeBZBd6vtg,6470
8
+ agent_baton/verify.py,sha256=m4lScF1Nv18jhXtfaDkZjvctGfX-OIfQMtXgkrPr7ek,8208
9
+ agent_baton_cli-0.1.0.dist-info/METADATA,sha256=uicxY8oSG6rGNmhfA9mMxfNiOv2JWQ0Dhen3EIE_ulQ,6037
10
+ agent_baton_cli-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ agent_baton_cli-0.1.0.dist-info/entry_points.txt,sha256=vgDmsoIEYODq_eSuPIxAJBmFxCGXZErXWl6uqw6gSmA,47
12
+ agent_baton_cli-0.1.0.dist-info/licenses/LICENSE,sha256=eyRIIYy8C4jtW1bCv0N5NCwiZc-2UZiGq9vzaih73ys,1074
13
+ agent_baton_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ baton = agent_baton.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthias Varnholt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.