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.
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
- What makes it different
- Requirements
- Tech stack
- Architecture
- Modules
- Installation
- Commands
- The safety model
- Agents
- Configuration
- Operations
- Development
- Testing
- Publishing
- Known limitations
- Roadmap
- FAQ
- Security
- Contributing
- License
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.
- @BotFather →
/newbot. Keep the token on the machine running the bridge. /setprivacy→ Disable. Otherwise the bot sees only commands, never the prose a session consists of.- Create a group → Edit → enable Topics.
createForumTopicfails silently without it. - 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 yes → scratch → 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 pushisn't matched. - Absolute paths bypass the Codex shim entirely.
- Effects achieved without a command (
shutil.rmtreein 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.pyfails ifmedium/imports the core, oragent/imports a medium, orshared/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.
Packaging — pip 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. |
| 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.jsonis mode 600 andshowmasks tokens.- SSH keys are not mounted by default, so
git pushfails at the container boundary — a stricter form of the always-probe rule. .gitignorecovers 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 checkgreen — 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9d302cf57117ac3bc6ad44ed55e4b6afa60b2e0cfcc7cbd6a05a5099acdc83aa
|
|
| MD5 |
61890105d3718f7ad36b0553addfd89e
|
|
| BLAKE2b-256 |
71e9504feced5d55e4d5e0a2f58956b96b9ad5e94154d1a58a7cbe9192abda3b
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
31a566805b2471401f61fccd6a3e4b294205ecf619942fa83c64df3b6c8eb377
|
|
| MD5 |
4ee8a9730f8eb6122f016cbb419f0eb5
|
|
| BLAKE2b-256 |
d1940e595bdcdc9d92cb234e1facd40e78a39ac23132e67cd2d995b531b586d4
|