diff --git a/CHANGELOG.md b/CHANGELOG.md index e23aa55..2f9b7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to postbag are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added +- Recipient-owned, send-only service grants with explicit expiry and revocation. +- `service-grant`, `service-check` and structured `service-send --json` receipts. +- Private grant sidecars preserve compatibility with existing peer ledgers. + +### Fixed +- Discover Codex in common installation directories when service managers omit + package-manager directories from PATH. + ## [2.3.0] - 2026-10-03 Clarifies peer authorization and send outcomes. Each letter reminds its diff --git a/README.md b/README.md index a74df96..22bad95 100644 --- a/README.md +++ b/README.md @@ -100,3 +100,37 @@ See [SECURITY.md](https://github.com/parasxos/postbag/blob/v2.3.0/.github/SECURI [Changelog](https://github.com/parasxos/postbag/blob/v2.3.0/CHANGELOG.md) · [Contributing](https://github.com/parasxos/postbag/blob/v2.3.0/.github/CONTRIBUTING.md) · [MIT](https://github.com/parasxos/postbag/blob/v2.3.0/LICENSE) +## Service notifications + +A receiving peer can grant a local service permission to send it notifications. +The service is send-only: it does not register a Claude/Codex door, borrow a peer +identity, or receive replies. + +From the receiving session, after joining a bag: + +```sh +postbag --bag work service-grant whatsapp --ttl 1800 +``` + +Keep the returned token private. It is scoped to the service, bag, and recipient's +current registration. A rejoin invalidates the grant. A new grant rotates its +token; `--ttl 0` revokes it. The default lifetime is one hour, maximum seven days. +Grants are hashed in an owner-private sidecar next to the ledger. The peer ledger +format is unchanged, so existing readers can continue to read it. + +The service supplies the token through its environment, not a command argument: + +```sh +postbag --bag work service-check whatsapp my-agent +postbag --bag work service-send whatsapp my-agent - --json +``` + +`POSTBAG_SERVICE_TOKEN` supplies the credential. `service-send` reads the body from +stdin when it is `-`; `--json` returns structured submission metadata even on a +refusal, with a nonzero exit status on failure. Services must distinguish +`not_submitted`, `unknown`, and `submitted`; none proves recipient processing. +Service letters are visibly marked and final, with no reply command. + +Notifications use the same user-message entry point as peer letters. Prefer fixed +pointer notices to external content: the agent should read actual data through +the source's tool. A service notice does not grant permission for actions. diff --git a/postbag.py b/postbag.py index 52699df..715ada8 100644 --- a/postbag.py +++ b/postbag.py @@ -9,23 +9,28 @@ postbag mcp # start the optional MCP server on stdio, the same as postbag-mcp """ import argparse +import hashlib +import hmac +import io import errno import fcntl import json import os import re +import secrets import shutil import socket import stat import subprocess import sys +import tempfile import unicodedata -from contextlib import contextmanager +from contextlib import contextmanager, redirect_stdout from contextvars import ContextVar -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path -__version__ = "2.3.0" +__version__ = "2.4.0.dev0" PEERS = {"claude", "codex"} # supported vendors; registered peer names come from the ledger NAME = re.compile(r"[a-z][a-z0-9-]{0,15}") @@ -99,7 +104,14 @@ def codex_path(): return os.environ["POSTBAG_CODEX"] if os.access(MACOS_CODEX, os.X_OK): return MACOS_CODEX - return shutil.which("codex") or "codex" + found = shutil.which("codex") + if found: + return found + # launchd and other service managers commonly omit package-manager bin dirs. + for candidate in ("/opt/homebrew/bin/codex", "/usr/local/bin/codex", str(Path.home() / ".local/bin/codex")): + if os.access(candidate, os.X_OK): + return candidate + return "codex" class Refusal(SystemExit): @@ -545,7 +557,8 @@ def envelope(rec, state): """The submitted text, as CONCEPT.md shows it: heading, roster when the bag holds more than two names, the guidance line, body, then how to answer. A final letter ends with FINAL and no command. The guidance is text a model reads, not a native role or boundary.""" - text = f"Letter {state.letters + 1} from @{rec['from']} to @{rec['to']} via postbag (bag {bag().label})." + origin = "Service notice" if rec.get("service") else "Letter" + text = f"{origin} {state.letters + 1} from @{rec['from']} to @{rec['to']} via postbag (bag {bag().label})." if len(state.peers) > 2: text += "\nRegistered names in this bag: " + ", ".join(f"@{p}" for p in sorted(state.peers)) + "." text += f"\n{GUIDANCE}\n\n{rec['body']}\n\n" @@ -585,7 +598,93 @@ def join(source, peer=None, *, wait=True): "took": {"vendor": vendor(taken), "at": display_stamp(taken["at"])} if taken else None} -def send(to, body, *, final=False, wait=True): +def service_grants(): + """Grants live outside the peer ledger so older clients can keep reading letters.""" + path = ledger_path().with_suffix(".services.json") + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except FileNotFoundError: + return {} + with os.fdopen(fd, encoding="utf-8") as f: + info = os.fstat(f.fileno()) + if not stat.S_ISREG(info.st_mode) or exposed(stat.S_IMODE(info.st_mode)) or info.st_uid != os.getuid(): + fail("service registry must be an owner-private regular file") + try: + value = json.load(f) + except ValueError: + fail("service registry is invalid") + if not isinstance(value, dict): + fail("service registry is invalid") + return value + + +def write_service_grants(value): + path = ledger_path().with_suffix(".services.json") + fd, temporary = tempfile.mkstemp(prefix=".postbag-services-", dir=path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(value, f) + f.flush() + os.fsync(f.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def grant_service(service, *, ttl=3600): + """The receiving peer grants one service permission to notify its current door.""" + service = name(service) + if type(ttl) is not int or not 0 <= ttl <= 604800: + fail("service lifetime must be 0–604800 seconds", error_code="invalid_input") + token = secrets.token_urlsafe(32) + with ledger() as _: + state = Snapshot(records()) + recipient = state.sender() + if service in PEERS or service == recipient or service in state.peers: + fail("service name must not name a registered peer", error_code="invalid_input") + target = state.target(recipient) + rec = dict(service=service, to=recipient, + target_join=target["n"], target_hash=hashlib.sha256(json.dumps(identity(target)).encode()).hexdigest(), + expires_at=(datetime.now().astimezone() + timedelta(seconds=ttl)).isoformat(), + token_hash=hashlib.sha256(token.encode()).hexdigest()) + grants = service_grants() + now = datetime.now().astimezone() + grants = {key: value for key, value in grants.items() + if isinstance(value, dict) and isinstance(value.get("expires_at"), str) + and datetime.fromisoformat(value["expires_at"]) > now} + grants[f"{service}:{recipient}"] = rec + write_service_grants(grants) + receipt = {"bag": bag().label, "peer": recipient, "service": service, + "token": token, "expires_at": rec["expires_at"]} + print(json.dumps(receipt)) + return receipt + + +def service_sender(state, service, recipient, token): + service = name(service) + target = state.target(recipient) + grant = service_grants().get(f"{service}:{recipient}") + if (service in state.peers or not grant or not isinstance(token, str) or not token + or not hmac.compare_digest(grant["token_hash"], hashlib.sha256(token.encode()).hexdigest()) + or grant["target_join"] != target["n"] + or grant["target_hash"] != hashlib.sha256(json.dumps(identity(target)).encode()).hexdigest() + or datetime.fromisoformat(grant["expires_at"]) <= datetime.now().astimezone()): + fail("service grant missing, expired, revoked or bound to an earlier recipient registration", + error_code="service_not_permitted") + return service + + +def check_service(service, recipient, token): + recipient = name(recipient, mention=True) + with ledger() as _: + state = Snapshot(records()) + service_sender(state, service, recipient, token) + grant = service_grants()[f"{service}:{recipient}"] + print(json.dumps({"ok": True, "bag": bag().label, "peer": recipient, "expires_at": grant["expires_at"]})) + + +def send(to, body, *, final=False, wait=True, service=None, service_token=None): """Submit once; return receipt metadata without asserting recipient acceptance.""" to = name(to, mention=True) if type(final) is not bool: @@ -600,11 +699,14 @@ def send(to, body, *, final=False, wait=True): try: with ledger(wait=wait) as write: state = Snapshot(records()) - sender = state.sender() + sender = (service_sender(state, service, to, service_token) + if service is not None else state.sender()) if sender == to: fail(f"@{to} is your own name") target = state.target(to) fields = {"from": sender, "to": to, "body": body} + if service is not None: + fields["service"] = True if final: fields["final"] = True # the record carries final only when it is true rec = record("letter", **fields) @@ -1009,6 +1111,17 @@ def cli(argv): b.add_argument("--resume", action="store_true", help="show Claude resume commands for conversations recorded at join") sub.add_parser("leave", help="withdraw this session's door from the bag until it joins again", description="Inside a session: withdraw its registered name from the bag. The history stays.") + g = sub.add_parser("service-grant", help="grant a service send-only access to your registered session") + g.add_argument("name") + g.add_argument("--ttl", type=int, default=3600) + for verb in ("service-send", "service-check"): + service = sub.add_parser(verb) + service.add_argument("name") + service.add_argument("to") + service.add_argument("--token-env", default="POSTBAG_SERVICE_TOKEN") + if verb == "service-send": + service.add_argument("body", help='notification text, or "-" for stdin') + service.add_argument("--json", action="store_true", help="return a structured submission receipt") sub.add_parser("mcp", help="start the optional MCP server on stdio, the same as postbag-mcp", description="Start the MCP server that postbag-mcp starts. It takes no bag and no arguments. " "Hosts that run a package by its own name, such as uvx, use this entry.") @@ -1041,7 +1154,24 @@ def serve_mcp(a): def run(a): - if a.verb == "join": + if a.verb == "service-grant": + grant_service(a.name, ttl=a.ttl) + elif a.verb == "service-check": + check_service(a.name, a.to, os.environ.get(a.token_env)) + elif a.verb == "service-send": + body = sys.stdin.read().rstrip("\n") if a.body == "-" else a.body + if not a.json: + send(a.to, body, final=True, service=a.name, service_token=os.environ.get(a.token_env)) + else: + try: + with redirect_stdout(io.StringIO()): + receipt = send(a.to, body, final=True, service=a.name, service_token=os.environ.get(a.token_env)) + print(json.dumps({"ok": True, **receipt})) + except Refusal as error: + print(json.dumps({"ok": False, "error_code": error.error_code, + "submission_state": error.submission_state})) + raise SystemExit(2) + elif a.verb == "join": join(a.vendor, a.name) elif a.verb == "send": send(a.to, sys.stdin.read().rstrip("\n") if a.body == "-" else a.body, final=a.final) diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..e696c96 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,91 @@ +import pytest +import postbag_mcp +from test_postbag import bag, be, joined + + +def test_service_sends_from_outside_a_session_only_to_its_grant(joined, be): + original = joined.ledger_path().read_bytes() + grant = joined.grant_service("whatsapp") + assert joined.ledger_path().read_bytes() == original + assert grant["token"] not in joined.ledger_path().read_text() + assert "service-grant" not in joined.ledger_path().read_text() + assert grant["token"] not in joined.ledger_path().with_suffix(".services.json").read_text() + be(None) + with pytest.raises(joined.Refusal): + joined.send("claude", "notice") + receipt = joined.send("claude", "Watch W has new replies", final=True, + service="whatsapp", service_token=grant["token"]) + assert receipt["from"] == "whatsapp" + assert "Watch W has new replies" in joined.KNOCKED[-1][2] + assert "Service notice" in joined.KNOCKED[-1][2] + assert joined.Snapshot(joined.records()).peers.keys() == {"claude", "codex"} + with pytest.raises(joined.Refusal, match="service grant"): + joined.send("codex", "notice", service="whatsapp", service_token=grant["token"]) + + +def test_service_rotation_revocation_and_rejoin_invalidate_capability(joined, be): + first = joined.grant_service("whatsapp") + second = joined.grant_service("whatsapp") + be(None) + with pytest.raises(joined.Refusal, match="service grant"): + joined.check_service("whatsapp", "claude", first["token"]) + joined.check_service("whatsapp", "claude", second["token"]) + be("claude") + joined.grant_service("whatsapp", ttl=0) + with pytest.raises(joined.Refusal, match="service grant"): + joined.check_service("whatsapp", "claude", second["token"]) + third = joined.grant_service("whatsapp") + joined.join("claude") + with pytest.raises(joined.Refusal, match="service grant"): + joined.check_service("whatsapp", "claude", third["token"]) + + +def test_grant_requires_receiving_session_and_read_hides_hash(joined, be, capsys): + grant = joined.grant_service("whatsapp") + joined.read(None) + assert "whatsapp" in capsys.readouterr().out + data = postbag_mcp.read_data(100, None) + assert "token_hash" not in str(data) + assert grant["token"] not in str(data) + be(None) + with pytest.raises(joined.Refusal): + joined.grant_service("whatsapp") + + +def test_structured_service_receipt_distinguishes_refusal_and_submission(joined, be, monkeypatch, capsys): + import json + grant = joined.grant_service("whatsapp") + capsys.readouterr() + be(None) + monkeypatch.setenv("POSTBAG_SERVICE_TOKEN", grant["token"]) + joined.main(["service-send", "whatsapp", "claude", "Watch W updated", "--json"]) + receipt = json.loads(capsys.readouterr().out) + assert receipt["ok"] and receipt["submission_state"] == "submitted" + monkeypatch.setenv("POSTBAG_SERVICE_TOKEN", "invalid") + with pytest.raises(SystemExit): + joined.main(["service-send", "whatsapp", "claude", "notice", "--json"]) + receipt = json.loads(capsys.readouterr().out) + assert not receipt["ok"] and receipt["submission_state"] == "not_submitted" + + +def test_registry_refuses_symlinks_and_other_users_access(joined, tmp_path): + import os + target = tmp_path / "elsewhere" + target.write_text("{}") + registry = joined.ledger_path().with_suffix(".services.json") + registry.symlink_to(target) + with pytest.raises(OSError): + joined.grant_service("whatsapp") + registry.unlink() + registry.write_text("{}") + registry.chmod(0o644) + with pytest.raises(joined.Refusal, match="owner-private"): + joined.grant_service("whatsapp") + + +def test_codex_is_discovered_with_service_manager_path(monkeypatch): + import postbag + monkeypatch.delenv("POSTBAG_CODEX", raising=False) + monkeypatch.setattr(postbag.shutil, "which", lambda _: None) + monkeypatch.setattr(postbag.os, "access", lambda path, _: path == "/opt/homebrew/bin/codex") + assert postbag.codex_path() == "/opt/homebrew/bin/codex"