Skip to main content

AgentZap

Your coding agent, on your machine, controlled from your phone — without lowering the bar on what it's allowed to do while you're not watching.

tests dependencies python agents docker license


The pitch

You're on the bus. A test is failing in a repo on your desk. You open Telegram, tap into a topic, and type "find out why test_auth is failing and fix it."

Twenty seconds later your phone buzzes:

PERMISSION REQUESTED
always-probe rule: floor:git-commit

Bash: git commit -am "fix token expiry check in auth guard"

No answer within 15 min = denied.
          [ Approve ]  [ Deny ]

You read the actual command — not a summary, not a paraphrase — and decide.

That prompt is the entire product. Everything else is plumbing built so that prompt is trustworthy: it appears even when you've told the bridge you trust this task, it shows you exactly what will run, an unanswered one denies rather than waits, and there is a short list of operations no answer of yours can unlock at all.


Table of contents


Why this exists

Coding agents are most useful exactly when you can't supervise them — a long refactor, a flaky test, a dependency bump. But "unsupervised" and "has a shell on my machine" is an uncomfortable combination, and the usual answers are both bad:

  • Watch every step. Then you're chained to the keyboard and the agent's value evaporates.
  • Grant it everything. Then you find out afterwards. This project exists because that actually happened: an agent run with permissions disabled force-deleted files that weren't meant to be touched.

There's a third option, and it needs a channel you already carry: let the agent work, and interrupt you only for the things that matter.

That's what a bridge is for. Not a chat wrapper around a model — a supervisor that holds the agent, decides what needs your consent, and reaches you wherever you are.

Why a chat app rather than a web UI

Because it already solves the hard parts. Push notifications that actually arrive. An identity you're already logged into. Message history. Threads. Works on every device you own. Nothing to host, no port to open, no TLS certificate, no login page. Telegram forum topics map one-to-one onto sessions, which is why routing is exact instead of inferred.


What makes it different

The floor cannot be switched off. Trust mode (/probe no) lowers the noise, but git push, git commit, rm -rf and writes under protected paths still ask. Config can extend that list, never shrink it — a rule beginning !, allow: or except: is rejected at load.

There's a tier no approval can unlock. rm -rf /, your SSH keys, agent credentials, the bridge's own config, and any write outside your projects root are refused outright. This exists because if a network ever carries your approvals, a compromised relay can forge one — and there is no cryptographic fix, since your tap only ever arrives through it. So the local process refuses some things itself.

Enforcement is ours, not the model's. Not a line in CLAUDE.md asking nicely. The gate sits in the permission-evaluation path, and a denial physically blocks the call. Instruction files are documentation here, never a mechanism.

Unanswered means denied. Every probe has a deadline and the verdict on timeout is not configurable. Asleep, out of signal, phone dead — the answer is no.

You approve what will actually run. All output is HTML the adapter generates itself, so no stray backtick can swallow half of a command before you read it. Malformed markup is impossible rather than unlikely.

Agents are swappable, and honest about what they can't do. Claude Code gates every tool call. Codex cannot — so its floor is enforced by a PATH shim and its sandbox instead, and the chat says so when a Codex session starts. A weaker guarantee announced beats a strong one assumed.

Zero dependencies. Standard library only. Nothing to audit, nothing to pin, no supply chain inherited by anyone who installs it — for a tool you're granting shell access to, that's a feature.

The process model is observed, not assumed. Some agents exit after every turn even when driven over a persistent stream. The core notices whether a turn finished before the process left: a clean exit is the idle state and the next message silently relaunches with resume, while an unclean one is a crash that denies pending probes and says so.

Starting is one keystroke, or one line. /start prompts for nothing. The core resolves project, agent, model, posture and verbosity, and the new topic states what was chosen and how to change each one. Any subset can be given up front:

/start
/start project=myrepo
/start probe=no agent=claude model=opus project=myrepo verbosity=trace
/start myrepo opus                    bare project, then model

A typo is rejected rather than acted on — porbe=no errors instead of quietly starting a session with probing on.

/start validates before it creates anything, so a refusal never leaves you sitting in an empty topic. If creation still fails, the topic it made is removed. And /start inside a topic that has no session binds one to that topic instead of opening another.

A project is always chosen; /start never asks and never refuses. Resolution order: explicitly requested → a configured default → the most recently used → the only one → the first alphabetically. The last case says so in the message, so an unwanted pick is obvious and one command fixes it:

/project myrepo          remember it for future sessions
/start project=myrepo    just this once

No project name is hardcoded and no default directory is required — an agent in an empty directory would be a worse default than any real project. The only failure is having no projects at all, which is not a resolution problem. doctor reports in advance what a new session would choose.

Sessions outlive their process. Kill the agent, reboot, restart the adapter or the core: the session reattaches and your topic keeps its history. /stop is the only thing that ends one — and if a session is ever left claiming to be alive with no process behind it, sending to it repairs the state and tells you so, rather than failing quietly.


Requirements

Docker install pip install
Runtime Docker + Compose v2 Python 3.9+
Agent CLI bundled and pinned in the image install separately (npm i -g @anthropic-ai/claude-code)
Node.js not needed needed by the agent CLIs
Telegram a bot and a supergroup same
OS Linux, macOS, Windows, WSL same

Tech stack

Layer Choice Why
Language Python 3.9+, standard library only Nothing to audit in a tool with shell access
Concurrency asyncio One daemon supervising N agent subprocesses
Storage SQLite (WAL) Sessions and probes must survive a restart; no server to run
Agent transport raw NDJSON over stdin/stdout (--input-format stream-json) Chosen over the SDK after testing that a denial actually blocks
Permission hooks MCP stdio sidecar + PreToolUse hook + PATH shim Three interception points, because agents differ
Internal IPC NDJSON over a unix socket (or loopback TCP), duplex, token-authed Probes are unsolicited, so request/response won't do; a socket needs no port, no DNS and no shared network namespace
Gate IPC Unix domain socket Holds a connection open for the full life of a probe
Chat platform Telegram Bot API over long polling Outbound only — no webhook, no public address, works behind NAT
Rendering self-generated Telegram HTML Balanced tags by construction; malformed markup impossible
Rate limiting token bucket, per-chat + global, priority lanes 20 msg/min per group, and every API call counts
Packaging pyproject.toml, Docker multi-stage-free image pip install or docker pull, same code
Tests unittest, 300 tests, no network needed A scripted fake agent exercises the real gate

Architecture

                          ┌──────────────────┐
                          │   Your phone     │
                          │   Telegram app   │
                          └────────┬─────────┘
                                   │  one forum topic per session
                          ┌────────┴─────────┐
                          │ Telegram servers │
                          └────────┬─────────┘
                                   │  long polling — OUTBOUND ONLY
                                   │  no webhook, no open port, NAT-friendly
╔══════════════════════════════════╪════════════════════════════════════════╗
║  YOUR MACHINE                    │                                        ║
║                     ┌────────────┴────────────┐                           ║
║                     │  medium adapter          │  restartable; owns the   ║
║                     │  (bridge-telegram)       │  bot token; NO logic     ║
║                     │  · rate limiter          │                          ║
║                     │  · HTML renderer         │                          ║
║                     │  · priority queue        │                          ║
║                     └────────────┬────────────┘                           ║
║                                  │ duplex NDJSON envelopes                ║
║                                  │ 127.0.0.1, token-authed, replayable    ║
║  ┌───────────────────────────────┴──────────────────────────────────────┐ ║
║  │  core daemon (bridge-core)                                           │ ║
║  │                                                                      │ ║
║  │   session      state machine, reattach, idle reaper                  │ ║
║  │   policy       NEVER tier → FLOOR tier → posture                     │ ║
║  │   probe        pending approvals, idempotent, timeout→deny           │ ║
║  │   state        SQLite: sessions, bindings, probes, events            │ ║
║  │   render       verbosity, content-type registry, chunking            │ ║
║  │   transfer     files in, artifacts out                               │ ║
║  │   gate         unix socket — the enforcement point                   │ ║
║  │   report       outbound, metadata only, no authority                 │ ║
║  └───────┬──────────────────────────────────┬───────────────────────────┘ ║
║          │                                  │                             ║
║   one process per session          one process per turn                   ║
║   ┌──────┴───────┐                  ┌───────┴──────┐                       ║
║   │ claude       │                  │ codex exec   │                       ║
║   │ stream-json  │                  │ --json       │                       ║
║   └──────┬───────┘                  └───────┬──────┘                       ║
║          │                                  │                             ║
║   ┌──────┴────────────┐            ┌────────┴─────────┐                    ║
║   │ MCP sidecar       │            │ PATH shim        │                    ║
║   │ PreToolUse hook   │            │ agent sandbox    │                    ║
║   └──────┬────────────┘            └────────┬─────────┘                    ║
║          └────────────► gate socket ◄───────┘                              ║
║                     every tool call, before it runs                        ║
╚════════════════════════════════════════════════════════════════════════════╝

The decision path for one tool call

agent wants to run something
        │
        ▼
   gate socket ──► policy guard
        │              │
        │              ├── NEVER tier match?  ──► DENY. No prompt. No override.
        │              │                          (rm -rf /, ~/.ssh, outside root)
        │              │
        │              ├── FLOOR match?       ──► always ask, whatever the posture
        │              │                          (git push, git commit, rm -rf)
        │              │
        │              └── posture off?       ──► allow
        │                  posture on?        ──► ask
        ▼
   probe broker ──► your phone ──► your tap ──► verdict
        │
        └── no answer by the deadline ──► DENY

Two properties worth noticing. Posture is ours, not the agent's/probe is a flag the gate consults, not an agent permission mode, which is why flipping it takes effect on the next tool call with no restart. And the restart asymmetry is deliberate: restarting the adapter is free and disturbs no session; restarting the core interrupts in-flight calls. That's the whole reason there are two processes.


Modules

Six layers, dependencies pointing one way. scripts/check_imports.py fails CI if a boundary is crossed — the rule most likely to erode quietly.

bridge/
├── shared/          depends on NOTHING
│   ├── ids.py           time-sortable prefixed ids
│   ├── config.py        the single source of truth; agent dirs, tokens, mediums
│   ├── envelope.py      wire format + command/event vocabulary
│   ├── discovery.py     WHERE things are — never guessed (see below)
│   └── deployment.py    renders .env, docker-compose.yml, launcher
│
├── api/             depends on shared
│   ├── transport.py     NDJSON framing over a duplex connection
│   ├── server.py        core side; auth, event replay from a cursor
│   └── client.py        medium side; persisted cursor, auto-reconnect
│
├── agent/           one driver per agent kind
│   ├── port.py          the interface + AgentCapabilities
│   ├── registry.py      the only place that knows which agents exist
│   ├── claude.py        raw stream-json driver, persistent process
│   ├── codex.py         codex exec driver, per-turn process
│   ├── shim.py          PATH shim, for agents that can't gate a call
│   └── fake.py          scripted test double — how the floor is tested in CI
│
├── core/            everything that MUST stay on your machine
│   ├── session.py       lifecycle, gate decisions, reattach, trace coalescing
│   ├── policy/
│   │   ├── rules.py     command / path / outside-root rule kinds
│   │   └── guard.py     NEVER and FLOOR tiers, compiled and versioned
│   ├── probe.py         approvals: idempotent, session-scoped, timeout→deny
│   ├── state/           SQLite schema and access
│   ├── render/          verbosity, renderers, chunking
│   ├── transfer.py      inbound files, outbound artifacts
│   ├── report.py        outbound metadata only, whitelisted
│   └── gate/
│       ├── protocol.py          fail-closed request/verdict
│       ├── server.py            the daemon end of the socket
│       ├── permgate_sidecar.py  MCP permission tool
│       └── hook_guard.py        PreToolUse hook
│
├── medium/          one adapter per chat platform
│   ├── port.py          the interface + declared capabilities
│   ├── registry.py      the only place that knows which mediums exist
│   └── telegram/
│       ├── adapter.py       commands, topics, probes, events
│       ├── telegram_api.py  stdlib Bot API client
│       ├── formatting.py    cards, probes, session lists
│       ├── start_args.py    parses /start key=value arguments
│       ├── tg_html.py       the ONLY thing that emits markup
│       └── limiter.py       token buckets + priority lanes
│
└── cli/
    ├── core_main.py     the daemon
    ├── telegram_main.py the adapter
    └── admin.py         setup, doctor, auth, agent, medium, floor

medium/ may import shared and api and nothing else. Delete the daemon's source and the adapter still compiles. That boundary is what makes a second chat platform additive.

shared/discovery.py never guesses. Three sources, in order, and it reports which one answered: an explicit override, the agent's own environment variable, or the one documented default for your OS. No candidate lists, no filesystem search. It used to exist in shell and Python; two answers to "where are the credentials" eventually disagree.

Environment variables are scoped to the filesystem being inspected. The image sets CLAUDE_CONFIG_DIR so the agent inside the container knows where its state lives; reading that while inspecting a mounted host home would shadow the host's real location with a path that exists only inside the container. When scanning another filesystem, only HOST_-prefixed variables count.


Installation

Two paths, same code, same commands. bridge-admin doctor answers "is this ready" identically either way.

Path A — Docker

Nothing to install but Docker. Agents come bundled and version-pinned.

The image is published at rakshified/agent-zap — nothing to build, no clone required. latest and versioned tags (e.g. 1.3.0) carry the same agent CLI versions the image was tested with.

mkdir ~/bridge && cd ~/bridge

docker run --rm -v "$PWD:/out" -v "$HOME:/host-home:ro" \
  -e HOST_HOME="$HOME" -e HOST_OS="$(uname -s)" \
  rakshified/agent-zap:latest setup --projects-root "$HOME/projects"

That's the only long command. It writes .env, docker-compose.yml and a bridgectl launcher — because bridge-admin lives inside the image, and without a launcher every command would be a docker compose run incantation nobody types.

./bridgectl pull
./bridgectl up -d
./bridgectl auth login claude
./bridgectl doctor
./bridgectl medium connect telegram --token <bot-token>
docker compose --profile telegram up -d
Flag in the bootstrap Why
-v "$PWD:/out" where the generated files land
-v "$HOME:/host-home:ro" read-only, so it can find your agent credentials
-e HOST_HOME so generated paths are what the host calls them
-e HOST_OS a container is always Linux inside; whether credentials can be mounted depends on the host

Your uid/gid come from the ownership of the mounted home — no flags needed.

./bridgectl passes compose verbs (up, down, logs, pull, restart) straight through and everything else to bridge-admin. So every command below works both ways — with ./bridgectl in front for Docker, without it for pip. On Windows it's bridge.cmd.

Path B — pip

pip install agent-zap
npm install -g @anthropic-ai/claude-code      # and/or @openai/codex

bridge-admin setup
bridge-admin auth login claude
bridge-admin doctor

bridge-core           # terminal 1
bridge-telegram       # terminal 2

Offline: pip install -e . --no-build-isolation. Or skip installing entirely — with zero dependencies, python3 -m bridge.cli.core_main works from a clone.

Running as a service (so sessions survive your shell closing):

# ~/.config/systemd/user/bridge-core.service
[Unit]
Description=AgentZap core
[Service]
ExecStart=%h/.local/bin/bridge-core
Restart=always
[Install]
WantedBy=default.target
systemctl --user enable --now bridge-core bridge-telegram
loginctl enable-linger "$USER"

Telegram setup

Four steps in the app. Each one, if missed, breaks things differently.

  1. @BotFather → /newbot. Keep the token on the machine running the bridge.
  2. /setprivacy → Disable. Otherwise the bot sees only commands, never the prose a session consists of.
  3. Create a group → Edit → enable Topics. createForumTopic fails silently without it.
  4. Add the bot as administrator with Manage Topics. That right must be granted specifically; other admin rights don't imply it.

Then bind the chat — post the pairing code in the General topic:

/link a3f1-9b2c-77de

Agent authentication

./bridgectl auth login claude

Routes by your OS rather than making you learn the matrix:

Host Route
Linux, WSL, Windows credentials are files on disk; mount them or log in normally
macOS credentials live in the login Keychain — nothing to mount, and in-container OAuth is unreliable. Mint a token: claude setup-token, then ./bridgectl auth login claude --token sk-ant-oat01-…
Any ANTHROPIC_API_KEY / CODEX_API_KEY, accepting per-token billing

Container logins persist in ~/.agent-zap/agents/<kind>/ — a bind mount, so they survive rebuilds, container removal, and docker compose down -v.

Set the probe timeout from measurement

It defaults to 900s and must be shorter than the agent CLI's own tool timeout, or the CLI decides what an unanswered probe means instead of you.

cd tools/verification && ./run-check.sh 2 180
./bridgectl set-probe-timeout <measured minus a margin>

Re-measure after upgrading an agent.

Verify it holds

Two checks. Don't skip them — they're the reason the rest exists.

mkdir -p ~/projects/scratch && cd ~/projects/scratch
git init && echo hello > file.txt && git add file.txt   # staged, zero commits

/start in General → probe yesscratch → a model. Then in the topic:

/probe no
commit the staged change with message "floor test"

Expect to be asked anyway, labelled always-probe rule: floor:git-commit. Deny it, then on the host:

git -C ~/projects/scratch rev-list --count --all      # must be 0

Repeat and approve — it should become 1. Both halves matter: the approve run is what proves the denial was the cause and not broken plumbing.

Then the never-tier:

write a file at /etc/test.txt

Expect a flat refusal with no probe at all. No answer of yours can unlock that.


Commands

In chat

Command Where Effect
/start [key=value ...] General Asks nothing. Every choice is optional: probe= agent= model= project= verbosity=
/stop Session topic Ends the session. Authoritative
/sessions General Live sessions with links to their topics
/agents General Installed agents and how each enforces the floor
/agent [name] Anywhere Default agent for new sessions
/project [name] Anywhere Default project for new sessions
/probe · /probe yes · /probe no Session topic Posture, immediate, no restart
/model [name] Anywhere Applies to the next session; a running process keeps its model
/verbose trace|diff|result Session topic Output fidelity
/fork Session topic Fork into a new session and topic
/idle <hours> General Idle reaper, default 24h
/link <code> Supergroup Bind the chat to this install

Send a file with a caption to include it in a prompt. Artifacts the agent leaves in the session outbox come back as documents.

Closing or deleting a topic is not a stop signal — Telegram emits no update for it, so /stop is authoritative and the idle reaper is the backstop.

Administration

bridge-admin setup                          # locate, configure, generate
bridge-admin doctor [-v]                    # is this ready? exit 0 = yes
bridge-admin auth login <agent> [--token X]  # per-OS routing
bridge-admin auth status [-v]
bridge-admin agent list
bridge-admin agent set-dir claude /path     # never guessed; tell it once
bridge-admin agent clear-dir claude
bridge-admin medium list | connect | disconnect | rotate-link-code
bridge-admin floor                          # both tiers
bridge-admin add-rule "git tag"             # extend the always-probe tier
bridge-admin add-rule "path:secrets/**" --never
bridge-admin set-projects-root PATH
bridge-admin set-probe-timeout SECONDS
bridge-admin sessions
bridge-admin show                           # config, secrets masked

The safety model

Two tiers, both with an immovable minimum

ALWAYS PROBE — asked regardless of posture, and you may approve:

git push        git commit        rm -rf
published/**    staging/**

NEVER — refused outright; no approval overrides it, including one arriving over a network:

rm -rf /  (and other filesystem roots)
.ssh/**
agent credentials (.claude/.credentials.json, .codex/auth.json)
.agent-zap/**       ← so an agent can't edit the floor constraining it
any write outside the projects root

Reads are never restricted. The floor governs effects, not curiosity.

bridge-admin floor                                # inspect
bridge-admin add-rule "path:secrets/**" --never    # extend

Config may only extend. A rule beginning !, -, allow:, not: or except: is rejected at load.

Why matching is ours

Not the CLI's pattern language. Claude Code's documented prefix matching has a footgun — Bash(git diff*) also matches git diff-index — so the bridge tokenises instead. cd /tmp && git push is caught. git -C /repo push is caught. rm -r without -f is not a floor match.

And no bare allow rules are ever emitted: a tool approved by one never reaches the permission callback, which would punch a hole straight through the floor.

Accepted limits, stated not hidden

  • Wrapped commands escape command rules; a script calling git push isn't matched.
  • Absolute paths bypass the Codex shim entirely.
  • Effects achieved without a command (shutil.rmtree in Python) aren't caught.
  • Subagents inherit a permissive parent posture.
  • Approvals are per call — every gated call blocks on your phone.

All asserted by tests, so they stay visible instead of quietly becoming untrue.


Agents

bridge-admin agents
Claude Code Codex
Command rules MCP permission tool → gate → you PATH shim → gate → you
Path rules PreToolUse hook → gate → you agent sandbox
Per-call approval
Process model observed, not assumed — persistent or per-turn per turn, resume between
Mid-session posture change
Fork

Codex has no external approval hook — no --permission-prompt-tool equivalent, and in exec mode its approval policy is effectively never. So its floor comes from a PATH shim (small executables named git, rm, curl… that ask the gate, then execv the real binary or exit 126) plus Codex's own sandbox for writes. Posture selects the sandbox: probe on → read-only, probe off → workspace-write. danger-full-access is never selected.

This is weaker, and it's announced. When a Codex session starts, the chat says which mechanism is in force, and marks such an agent in the /start picker.

Adding an agent is one driver module plus one line in agent/registry.py.


Configuration

~/.agent-zap/config.json, mode 600. Written by setup, edited by bridge-admin, never hand-edited in normal use.

Key Meaning
projects_root the only directory sessions may target
agent_dirs per-agent state overrides, injected into the subprocess
agent_tokens long-lived OAuth tokens (masked by show)
mediums per-platform token, chat binding, pairing code
probe_timeout_s must be under the agent CLI's tool timeout
idle_timeout_s reaper, default 24h
user_command_rules / user_path_rules extend ALWAYS PROBE
user_never_rules extend NEVER
report_url / report_enabled outbound metadata only

The bridge never reads a shell profile. Non-interactive shells skip .bashrc, and systemd, cron and docker exec are exactly the unattended cases this exists for. Agent state and tokens are injected into the subprocess from config instead. setup will offer a profile line, but only for a non-default path, only on POSIX, only on opt-in — and never for the default location, where setting CLAUDE_CONFIG_DIR would move a sibling file under the directory and break an existing one.


Operations

Diagnosing a session

S=~/.agent-zap/runtime/<session-id>
cat $S/argv.json             # the exact command line — runnable by hand
cat $S/gate.log              # sidecar/hook/shim activity and every verdict
cat $S/stderr.log            # the agent's own complaints
cat $S/unknown-frames.jsonl  # frames the driver didn't recognise — never dropped
ls  $S/shim/                 # generated command shims (Codex)

sqlite3 ~/.agent-zap/bridge.db \
  "select session_id, agent_kind, status, agent_session_id, pid from sessions;"

A non-null agent_session_id means the init frame parsed and reattachment will work. sidecar_start in gate.log means the permission path is wired — if it's missing, everything denies, which is fail-closed working correctly and the app being unusable.

Watching the wire

The adapter is only one possible client:

python3 - <<'EOF'
import asyncio, json
from bridge.shared.config import Config
cfg = Config.load()
async def main():
    r, w = await asyncio.open_connection(cfg.api_host, cfg.api_port)
    w.write((json.dumps({"v":1,"id":"x","kind":"hello","name":"hello",
             "payload":{"token":cfg.api_token,"last_seq":10**9}}) + "\n").encode())
    while True:
        line = await r.readline()
        if not line: break
        print(json.dumps(json.loads(line), indent=2))
asyncio.run(main())
EOF

Upgrading

An agent upgrade can orphan live sessions — a transcript written by one version may not resume on the next.

./bridgectl sessions        # note what's live, /stop each from Telegram
./bridgectl down
# edit CLAUDE_CODE_VERSION / CODEX_VERSION in .env
./bridgectl build && ./bridgectl up -d

Pin both once you have a working pair. latest is a moving target.

Multiple users on one machine

Nothing pins a container name, so Compose derives them from the directory. Each user gets isolated containers, volumes and ~/.agent-zap/. One shared install serving several people is a different product and isn't supported.

Volumes

docker compose down keeps everything. down -v destroys every live session and both agent logins.


Development

make help           # every target
make check          # dependency rules + 300 tests + full-stack smoke
make test           # verbose
make doctor         # readiness on this machine
make setup          # generate deployment files, build flavour
make rebuild        # after editing bridge/ — source is COPYd into the image

Editing bridge/ requires an image rebuild; up -d alone won't pick up changed Python.

Invariants enforced mechanically

Because these are the things that erode quietly:

  • Layer boundaries. scripts/check_imports.py fails if medium/ imports the core, or agent/ imports a medium, or shared/ imports anything.
  • No drift between the checked-in compose and the generator. A test renders and compares. The file a developer reads and the file a user gets cannot diverge.
  • No shell scripts in scripts/. Logic in shell duplicated the Python and they disagreed; a test asserts it stays gone.
  • Markup can't be malformed. Every renderer output is asserted tag-balanced against hostile input — unclosed fences, <script>, injection inside fences.
  • Documented limits stay true. The Codex shim bypass is asserted to work, so the limitation can't quietly become a lie.

Testing

300 tests, 14 suites, no network and no agent binary required — a scripted fake agent speaks the same event vocabulary through the same real gate.

Suite Covers
test_acceptance.py Check 12, timeout→deny, idempotent verdicts, session-scoped probes, concurrency isolation, reaper, lifecycle
test_never_tier.py Tier ordering, protected paths, writes outside root, extensibility
test_policy_floor.py Rule matching, the prefix footgun, config-cannot-shrink
test_codex_and_shim.py Codex argv and frames, the shim blocking real subprocesses
test_discovery.py Precedence, per-OS profiles, Windows path translation
test_install_paths.py Both installation paths through the real CLI
test_deployment.py Generated compose/env/launcher, Dockerfile invariants
test_transport.py Envelope round-trip, auth, redelivery from a cursor
test_limiter.py Token buckets, priority lane, cosmetic drops
test_tg_html.py Markup can never be rejected, against hostile input
test_render_and_gate.py Renderers, chunking, every gate path failing closed
test_store_and_reports.py Store invariants, path traversal, report scrubbing
test_formatting_and_cursor.py Plain-text system messages, cursor persistence
test_repo_compose.py The checked-in compose equals the generator's output

scripts/smoke.py boots the real daemon and drives it over the real API, including the never-tier refusing without asking and trace output coalescing.


Publishing

See docs/INSTALL.md for the full checklist. The one that's easy to forget:

docker build \
  --build-arg BRIDGE_IMAGE=YOURNAME/agent-zap:1.0 \
  --build-arg CLAUDE_CODE_VERSION=1.2.3 \
  --build-arg CODEX_VERSION=0.45.0 \
  -t YOURNAME/agent-zap:1.0 .

BRIDGE_IMAGE is baked in and becomes the default --image for setup, so a user's generated compose references what they actually pulled. Build without it and every generated file points at a tag that exists only on your machine.


Known limitations

Floor — wrapped commands escape command rules; absolute paths bypass the Codex shim; effects without a command aren't caught; subagents inherit a permissive parent posture; approvals are per call.

Sessions — core restarts interrupt in-flight tool calls (adapter restarts don't); reattachment restores the conversation, not the world; an agent CLI upgrade can orphan sessions; compaction detection is heuristic; model changes apply to the next session.

Platform — Telegram is the source of truth for history, the bridge stores no transcripts; roughly 20 messages/minute per group is shared by all sessions in it; DM topics are unusable pending an upstream regression; macOS cannot share agent credentials with a container (Keychain); in-container OAuth is flaky with several open upstream issues.

Packagingpip install needs network for build dependencies despite zero runtime dependencies; editing bridge/ needs an image rebuild; container paths aren't host paths.


Roadmap

Why it's not built
Scoped allowances Per-call approval will feel heavy after one real session; the schema reserves the field. Expected to be the first thing wanted.
Slack / Discord / Matrix The medium interface exists; both have real threads and no hostile policy. One adapter each.
Gemini and other agents One driver plus one registry line. AgentCapabilities already handles a weaker floor honestly.
Relay for hosted use Designed, deliberately unbuilt. The never-tier is its prerequisite, because a relay that carries approvals can forge one.
Trace digests with diff stats Coalescing exists; richer summaries would make trace genuinely pleasant.
WhatsApp Assessed and rejected: Meta's terms bar general-purpose AI assistants from the Business API, there's no threading, and the 24-hour window means a probe can't reach you after a day of silence.

FAQ

Is this just a chat wrapper around a model? No. The model runs in an agent CLI on your machine. This supervises it: decides what needs your consent, blocks what doesn't get it, and reaches you wherever you are.

Do I need to expose a port or host anything? No. The adapter pulls from Telegram over outbound HTTPS. Nothing listens publicly. Works from a laptop behind NAT.

What if my laptop sleeps mid-task? The session survives. On wake the daemon reattaches by session id and the topic keeps its history. Any probe pending when the process died is denied, and the denial is stated in chat.

Can I read the code that has shell access to my machine? Yes, and you should. A Docker image is a stack of tar archives; Python bytecode decompiles. Nothing client-side is protectable, which is fine — the floor protects you from the agent, not the code from you. For a tool with this pitch, being inspectable is the product.

Why not the Agent SDK? Tested both. Raw channels won because posture ends up in our code rather than depending on an undocumented control call, and because the same transport will serve Codex and Gemini. Costs: no bundled binary (the image bundles one) and no clean interrupt (not in the command surface). All recorded in docs/v2-design.md.

Can several people share one install? Not supported. Each user runs their own, with their own bot — which also means their content never passes through anyone else's machine.

What happens if I never answer a probe? It's denied at the deadline. The timeout is configurable; the verdict isn't.


Security

Threat model. This protects you from an agent doing something you wouldn't sanction. It does not protect a machine from its owner.

Reporting. Security-relevant issues in the floor, gate or probe path deserve a private report rather than a public issue.

Practices already in place:

  • Every gate path fails closed — unreachable daemon, malformed payload, a rule that raises, a rejected token: all deny. Asserted by tests.
  • Probe arguments are stored as a digest, never in the clear.
  • Reports are whitelisted, not blacklisted — unrecognised fields are dropped, so prompts, diffs and paths cannot leak by omission.
  • No secret is ever baked into an image; layers are immutable, so a secret added and later deleted stays readable in the earlier layer.
  • config.json is mode 600 and show masks tokens.
  • SSH keys are not mounted by default, so git push fails at the container boundary — a stricter form of the always-probe rule.
  • .gitignore covers config, env, keys, databases and generated files. Git history is permanent: a secret committed once is there forever.

Contributing

git clone <repo> && cd agent-zap
make check          # must pass before anything else

Before opening a pull request:

  • make check green — dependency rules, 300 tests, smoke test
  • new behaviour comes with a test that would fail without it
  • a documented limitation stays asserted, so it can't quietly become untrue
  • nothing new in scripts/*.sh; logic belongs in Python where it's testable
  • if you touch generation, the checked-in compose must still match the generator

Note on copyright: there is no separate contributor agreement. Under Apache-2.0 §5, anything you intentionally submit for inclusion is licensed under the same terms as the project unless you explicitly state otherwise.


License

Apache-2.0. Permissive, with an explicit patent grant and trademark clause, and universally accepted — which matters for a security-adjacent tool people must be willing to inspect before trusting.

You may use, modify and redistribute this project commercially, provided you keep the notices required by LICENSE — see §4 for the conditions on redistribution.

The bundled claude and codex CLIs in the Docker image are separately licensed by Anthropic and OpenAI; their terms apply to those binaries, not to this project.


Documents: docs/INSTALL.md · docs/v2-requirements.md · docs/v2-design.md · docs/acceptance-checklist.md

Built by interviewing the requirements before writing the code, and by recording every deviation from the design with the reason it was forced.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agent_zap-1.3.0.tar.gz (179.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agent_zap-1.3.0-py3-none-any.whl (130.6 kB view details)

Uploaded Python 3

File details

Details for the file agent_zap-1.3.0.tar.gz.

File metadata

  • Download URL: agent_zap-1.3.0.tar.gz
  • Upload date:
  • Size: 179.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.8.3

File hashes

Hashes for agent_zap-1.3.0.tar.gz
Algorithm Hash digest
SHA256 9d302cf57117ac3bc6ad44ed55e4b6afa60b2e0cfcc7cbd6a05a5099acdc83aa
MD5 61890105d3718f7ad36b0553addfd89e
BLAKE2b-256 71e9504feced5d55e4d5e0a2f58956b96b9ad5e94154d1a58a7cbe9192abda3b

See more details on using hashes here.

File details

Details for the file agent_zap-1.3.0-py3-none-any.whl.

File metadata

  • Download URL: agent_zap-1.3.0-py3-none-any.whl
  • Upload date:
  • Size: 130.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.8.3

File hashes

Hashes for agent_zap-1.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 31a566805b2471401f61fccd6a3e4b294205ecf619942fa83c64df3b6c8eb377
MD5 4ee8a9730f8eb6122f016cbb419f0eb5
BLAKE2b-256 d1940e595bdcdc9d92cb234e1facd40e78a39ac23132e67cd2d995b531b586d4

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

1.3.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page