Metadata-Version: 2.1
Name: agent-zap
Version: 1.3.0
Summary: Control coding agents from chat, with a permission floor the trust toggle cannot reach
Project-URL: Homepage, https://github.com/rakshify/agent-zap
Project-URL: Repository, https://github.com/rakshify/agent-zap
Project-URL: Documentation, https://github.com/rakshify/agent-zap/blob/master/docs/INSTALL.md
Keywords: claude-code,codex,telegram,coding-agent,remote-control,permissions
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Build Tools
Classifier: Topic :: Communications :: Chat
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE

<div align="center">

# 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](https://img.shields.io/badge/tests-300%20passing-brightgreen)](#testing)
[![dependencies](https://img.shields.io/badge/dependencies-zero-blue)](#tech-stack)
[![python](https://img.shields.io/badge/python-3.9%2B-blue)](#requirements)
[![agents](https://img.shields.io/badge/agents-Claude%20Code%20%7C%20Codex-8A2BE2)](#agents)
[![docker](https://img.shields.io/badge/docker-rakshified%2Fagent--zap-2496ED)](https://hub.docker.com/r/rakshified/agent-zap)
[![license](https://img.shields.io/badge/license-Apache%202.0-blue)](LICENSE)

</div>

---

## 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](#why-this-exists)
- [What makes it different](#what-makes-it-different)
- [Requirements](#requirements)
- [Tech stack](#tech-stack)
- [Architecture](#architecture)
- [Modules](#modules)
- [Installation](#installation)
  - [Path A — Docker](#path-a--docker)
  - [Path B — pip](#path-b--pip)
  - [Telegram setup](#telegram-setup)
  - [Verify it holds](#verify-it-holds)
- [Commands](#commands)
- [The safety model](#the-safety-model)
- [Agents](#agents)
- [Configuration](#configuration)
- [Operations](#operations)
- [Development](#development)
- [Testing](#testing)
- [Publishing](#publishing)
- [Known limitations](#known-limitations)
- [Roadmap](#roadmap)
- [FAQ](#faq)
- [Security](#security)
- [Contributing](#contributing)
- [License](#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`](https://hub.docker.com/r/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.

```bash
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.

```bash
./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

```bash
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):

```ini
# ~/.config/systemd/user/bridge-core.service
[Unit]
Description=AgentZap core
[Service]
ExecStart=%h/.local/bin/bridge-core
Restart=always
[Install]
WantedBy=default.target
```

```bash
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

```bash
./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.

```bash
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.

```bash
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:

```bash
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

```bash
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.

```bash
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

```bash
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

```bash
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:

```bash
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.

```bash
./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

```bash
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:

```bash
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. |
| **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

```bash
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](LICENSE).** 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`](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.

---

<div align="center">

**Documents:** [`docs/INSTALL.md`](docs/INSTALL.md) ·
[`docs/v2-requirements.md`](docs/v2-requirements.md) ·
[`docs/v2-design.md`](docs/v2-design.md) ·
[`docs/acceptance-checklist.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.*

</div>
