WOUF guide
Overview
A sandbox is a separate workspace that limits what a bot can access. Host mode has no sandbox. Headless means the bot runs the assistant without an interactive chat window.
What WOUF is
WOUF is an agent team in a folder, set up with the AI tool you already use. You talk to one lead. The lead hands tasks to a worker, has a reviewer check the result, asks a decider for the hard calls and tells you what happened.
It is not a product or a platform, and it does not replace your agent CLI. It is a folder of plain instructions plus small helper scripts written in standard-library Python. You copy that folder into a project and keep using your own agent CLI, such as Claude Code, Codex CLI or another CLI that reads AGENTS.md.
WOUF is only the example name. You can call your team whatever you like.
A plain folder
Everything lives in one folder, .agents/, next to your work:
the rules every member follows, in
.agents/rules.mdthe roles: lead, worker, review, decide and specialist, in
.agents/roles/the skills the lead loads for a step, such as writing a brief or reviewing and merging, in
.agents/skills/your own team: settings, bots, board, decisions, schedule, knowledge and logs, in
.agents/team/small helper scripts in
.agents/bin/, mainlyteamfor bots, runs and checks,logfor receipts and search, andsecretfor keys
The board, the decisions, the schedule and the knowledge are files you can open and read. An update replaces the shared pack and leaves .agents/team/ as it is.
A team that works by a method
What makes the folder a team is the method, not a new program:
you talk to one lead, and the lead does not do the work itself
every piece of work starts from a written brief
a second bot reviews the result in a fresh context
hard calls go to a decide bot or to you
anything irreversible, outward, paid or needing new access waits for your yes
The method part of this guide explains each step.
Where the safety comes from
The rules are instructions to the models. A model can still get them wrong. Docker, Podman and bubblewrap runs stage inputs and a repository copy into a separate workspace; host mode runs without a sandbox and can access the host under the user's permissions. Results come back only as plain files.
The safety part of this guide explains the sandbox modes and their trade-offs, and the limits part says plainly what WOUF does not do.
Try it without a model
.agents/bin/team demo runs an offline demonstration with a fake agent. It shows the gate that checks a change without any model, and an approval that only counts for the exact commit it names. It needs Python 3.9 or newer and git.
Status
This guide describes the repository as it is on main. Version 0.1.0 is dated 2026-11-06. See the changelog for what it contains.
Method
WOUF works the way a small company works with a team. This part explains who does what, and which steps are checked by code and which are rules the models are asked to follow.
One lead
You talk to one member only: the lead, called WOUF unless you rename the team. The lead is the agent CLI session you already use, started in your project folder.
The lead talks to you, writes briefs, hands them out, relays questions and reports back. It does not do the work, review it or decide alone. Bots never talk to you directly; they ask the lead, for example "WOUF, I need the logo file from the user", and the lead collects such questions and asks you once.
This is a rule the models are asked to follow, not something a program can force on a model.
Roles
Each member has one role, written down in .agents/roles/:
Lead: talks to you, briefs, dispatches and reports.
Worker: does the briefed work, and only that, in its own sandbox.
Review: checks one result against its brief and approves or asks for changes. It never fixes what it reviews.
Decide: makes a hard call once, with reasons, when the brief and the rules do not settle it.
Specialist: a bot for one domain, such as the example research, writer, tester and ops bots.
Nobody grades their own work. Flows also use helper bots: a scout that reads the code first, a bugfixer, and a planner that turns a large goal into a plan and draft tasks for the lead to read.
The brief
Every piece of work starts from a brief. A brief states the objective, the acceptance criteria, the paths the work may touch, what is in and out of scope, the inputs and the expected output. team brief new prints an empty brief with the next free task id.
The brief's Class: line picks the bot, the tier and the number of reviews: for example mechanical runs on the light tier with one review, and security runs on the strong tier with two reviews.
The brief's Paths: line is checked by code. Before any review, a pre-gate without any model checks the branch: a changed file outside those paths turns the gate red, and so do failing tests when you configured a gate command. The gate command gets no keys. In container and bwrap mode it runs in a sandbox; in host mode it runs in a throwaway clone on your computer, with no sandbox. A red gate sends the work back to the worker once; a second red gate blocks the task.
Fresh, independent review
A second bot checks every result, in a fresh context and its own sandbox, so it does not inherit the worker's reasoning. It checks the result against the brief, not against the worker's summary.
The review role asks an approval to name the exact commit it checked. team flow accepts an approval only when the commit it names is the head of the task branch. It accepts the full commit id or a shortened one of at least 7 characters that the head's id starts with, as git shows short ids. An approval of another commit, a commit id shorter than 7 characters or a missing commit counts as no approval. Any new commit needs a new review.
Decide
Some questions do not follow from the brief and the rules: two reasonable options, rules in conflict, or high risk. The lead then asks the decide bot, which picks one option, names the remaining risk and hands the decision back.
How far the team may decide without you is a setting, autonomy in .agents/team/config.ini:
ask, the default: decisions go to you.auto: the lead decides reversible things inside the rules with the decide bot, records them inteam/decisions.mdand tells you in its next report.
In both settings some things always wait for your yes: sending, publishing, deploying, purchasing, deleting or overwriting outside a sandbox, merging to main, new access such as a new key or host, and anything that costs money. These limits are rules for the models. Merging and publishing are your own steps.
Speak up
Every member says so, unasked, when something is wrong, missing, risky or could be better: bots under Flags in their return block, the lead to you.
A whole task on its own
team flow <task> takes a brief through worker, pre-gate, review and up to two rounds of rework. It ends ready with an approval on the exact head, blocked, limited or with one question for you. If a run hits a usage limit, the flow waits, or switches once to another CLI when you configured a fallback.
A goal too big for one brief goes through four phases: INIT, CONCEPT, REALIZE and VERIFY. Leaving a phase needs an explicit yes, and with autonomy = ask your yes first.
Safety
The rules in .agents/ are instructions to the models. A model can misread or ignore them. What actually limits a bot is the place it runs in, the network it gets and what comes back from it. This part explains each, with its trade-offs.
How .agents/ is built
.agents/rules.md,.agents/roles/and.agents/skills/are the shared pack: plain text every member reads..agents/team/is your team:config.iniwith the settings,bots/with your bots, and the board, decisions, schedule, knowledge and logs..agents/bin/holds the helpers. They are standard-library Python, so the pack needs no package installs of its own..agents/bots/and.agents/connectors/hold examples. They are off until you say yes, and a connector reaches only the bot that lists it.
Setup copies .agents/ into your folder. Outside it, setup only touches your own secret store and installs software you agree to; it changes no global settings.
Sandbox modes
mode in the [environment] section of .agents/team/config.ini says where bots run. A new team starts as undeclared, and the lead runs setup first.
Docker, Podman and bubblewrap runs stage inputs and a repository copy into a separate workspace; host mode runs without a sandbox and can access the host under the user's permissions.
Container (recommended)
One image for the whole team holds the agent CLIs and tools. Each bot has its own storage, and its container exists only while it works on a task. The container gets the bot's storage as its only volume; the original project and home folders are not mounted as such. The lead copies the brief and inputs in and the results out.
Every container the helper creates drops all capabilities, cannot gain new privileges and has a limit on processes; a bot's container also has CPU and memory limits. A bot that works longer than the time limit is stopped.
This needs Docker or Podman, on your computer or on a VM or server reached through an SSH alias.
Bubblewrap (bwrap)
For Linux without Docker or Podman. Each run gets a bubblewrap sandbox with its own namespaces, no capabilities and no host environment variables. It sees its own folders (its storage, inputs and outbox, and a small team prefix that stands in for the image) and, read-only, parts of your computer: the program folders such as /usr, /bin and /lib, and a fixed list of entries in /etc, such as certificates, time zone and name resolution settings. Nothing of your home folder or project is mounted. Anything kept in those program folders or /etc entries is readable by the bot, so keep no secrets there.
The message at every start says "only its own folders"; the read-only program folders and /etc entries above come on top.
It is weaker than a container. There is no system call filter, and the run says so at every start. CPU, memory and process limits depend on your computer: where a systemd user scope works, each run and the gate get the memory and cpus settings and a process limit; where it does not, the run warns that there are no such limits and goes ahead without them. team doctor tells you which case you have. Software that needs administrator rights is not installed for the bots; you install it.
Host (no sandbox)
host runs the bots directly on your computer, in .agents/team/env/<id>/, with the agent CLI's own safe defaults. There is no sandbox. Use it only to try things out.
Network and egress
Technical details
network in the same section decides what a bot can reach in container and bwrap mode. In host mode it fences nothing: there is no sandbox, so a bot uses your computer's network whatever network says, none included.
bridge, the default: open internet access, which cloud models need. It is not filtered: a bot can also reach your local network and, on a cloud VM, the provider's metadata service. Block those on the engine's host if that matters.none: no network in container andbwrapmode. This only fits local models inside the container. It does not take the network away inhostmode.egress: the bot reaches outside hosts only through a proxy for each run, which lets through only an allowlist; the bot's own loopback is the one exception, see below. In container mode the bot sits on an internal network with no way out, and the proxy sits on both networks. Inbwrapmode the bot has no network beyond its own loopback and reaches the proxy through a socket.
With egress, each run gets the base allow list plus the model and login hosts of its own CLI. A brief can add hosts for one task with an Egress: line; a host outside the base list needs the lead's recorded yes for that task first.
There is one exception to "only through the proxy": the bot reaches its own loopback (localhost, 127.0.0.1, ::1) directly, without the proxy. This exception is fixed in code; neither the config nor a brief can widen it. It is the loopback inside the bot's own container or sandbox, not your computer's: in bwrap mode a test checks that the bot cannot reach a service on the host's loopback, though that test runs only where bubblewrap can start a sandbox. Separately, the proxy refuses names that resolve to private, loopback or link-local addresses, so the bot cannot reach those through the proxy either. Older Docker and Podman versions with a known name-resolution leak are refused.
bwrap mode refuses bridge, and host mode refuses egress, because there is no sandbox to fence in.
Keys
.agents/bin/secret keeps keys out of the repository, the chat and the bots' storage. You type a key yourself, in your own terminal, with secret set NAME; it has no command that prints a value back. It uses your system's keychain or Secret Service when available, else a file store with owner-only permissions that it refuses to keep inside a git repository.
A bot gets a key for one run, as a line on its standard input, never in an argument or environment variable of the container command. Keys found in what a bot hands back are masked as ***.
Masking prevents accidents. It is not a barrier against a bot that wants to leak a key it was given. The limits part lists the known gaps.
Bot output is data
What a bot prints and hands back reaches the lead framed as data from the bot, not as instructions, and the lead's role tells it so. Results come back as plain files and folders only: the helper skips and lists links instead of following them.
The lead is still a model reading text a bot wrote. The framing helps; it does not make the lead immune to what it reads.
Privacy and where your data goes
You choose where the team and model run. WOUF does not require its own cloud or server.
- Cloud model: the agent CLI sends the content it uses to its model provider. An allowed network destination can receive data.
- Compatible local model: the CLI sends content to the model server you configure. Verify model support, authentication, tool use and network reachability in your chosen environment. Other configured services, including Git hosting and external engines, may still receive data.
Docker, Podman and bubblewrap stage inputs and a repository copy into a separate workspace. Host mode runs under your user permissions without a sandbox. Choose what inputs, keys and connectors to provide.
Read the network settings and their limits; read the limits before using real data.
Getting started
What you need
An AI assistant for the terminal, an agent CLI, signed in once.
Python 3.9 or newer and git.
For the sandboxes: Docker or Podman, on your computer or on a VM or server. On Linux without either, bubblewrap can stand in, with the trade-offs explained in the safety part.
If something is missing, the setup tells you what and why, and installs it with you after you say yes.
Set it up
Never opened a terminal? Here is how.
On a Mac: make the folder in Finder. Open the Terminal app, type cd and a space, drag the folder into the window and press Return.
On Windows 11: open the folder in File Explorer, right-click an empty spot in it and choose “Open in Terminal”.
Now type the name of your assistant, for example claude, and press Enter.
Make a folder for your work, open it in the terminal, start your agent CLI there and paste this sentence:
Set up the agent team from https://github.com/vaiev/wouf/blob/main/INSTALL.md in this folder. I am not technical: explain in plain words and do the steps for me.
The assistant sets up the team in this folder, asks for your choices, then runs its checks.
The agent follows INSTALL.md. It copies .agents/ into the folder without overwriting anything, adds the adapter lines your CLI needs, asks you one round of questions with a recommendation each, creates the bots and runs .agents/bin/team doctor and a smoke test. One step is yours: typing your key into your own terminal, so that no key ends up in a chat.
AGENTS.md and adapters
Technical details
Most agent CLIs read a file called AGENTS.md in the folder they start in. Setup adds one line at its end, or creates the file with it:
This folder runs an agent team. Every agent session reads .agents/START.md first.
Some CLIs read their own file instead, so setup adds a small adapter:
Claude Code reads
CLAUDE.md: setup creates it with the single line@AGENTS.md, or adds that line, and lets the helpers run without prompts in.claude/settings.json. With your yes it also adds session hooks.Gemini CLI reads
GEMINI.md: the same, with the line@AGENTS.md.Codex CLI, Antigravity, Grok Build, Cursor and GitHub Copilot read
AGENTS.mddirectly; nothing more to do.
So setup is a few added lines, not a single line for every CLI.
Runners: which CLI runs the bots
Technical details
The lead is your own session. The bots run a CLI too, headless, and each CLI is described once as a runner: a [runner.<name>] section in .agents/team/config.ini. The shipped runners are claude, codex and agy, plus an empty local section to fill in for a CLI that talks to a local model server.
A runner says how to install the CLI in the team image, how to start it in a container and on your computer, how to pass the model and the reasoning effort, and which environment variables carry its key or that it signs in with a subscription instead.
You can choose the CLI for the whole team, for one bot or for one run: "WOUF, use Codex for the worker." The commands in the shipped runners follow each CLI's documentation; check them against your versions.
Tiers: which model
Technical details
Bots ask for a tier, not a model name:
strong: the provider's top model, the default for real work, review and decisions.mid: the provider's middle model, for bounded reading work such as the scout and the bugfixer. A runner without a mid model runs it on strong.light: the provider's small model, for mechanical, frequent work.
Each runner maps the tiers to its own models. An empty entry means the CLI's own default model. A brief can ask for another tier, and team run --model NAME picks an exact model for one run. Review, decisions, plans and complex or security work always stay on strong.
First steps
Try
.agents/bin/team demofor an offline demonstration with a fake agent.Talk to the lead by name: "WOUF, what can you do?"
Start a session with "without WOUF" when you want your CLI without the team.
Settings live in
.agents/team/config.ini, bots in.agents/team/bots/. You can change both by asking the lead.
Limits
What WOUF does not do, said plainly. Read this before you give the team real data or real keys.
Rules are instructions, not enforcement
The rules, roles and skills are text the models are asked to follow. A model can misread or ignore them. Only the sandbox, the network setting in container and bwrap mode, what comes back from a bot and the checks in code such as the gate and the exact-head approval are enforced by programs. Merging, publishing and branch protection stay with you and your repository host.
Host mode has no sandbox
mode = host runs the bots directly on your computer, as your user. They can reach whatever your user can reach, including the network: network = none does not take it away in this mode. The gate command also runs on your computer, in a throwaway clone, not in a sandbox. It is meant only for trying things out.
Bubblewrap is weaker than a container
mode = bwrap has no system call filter; the run says so at every start. CPU, memory and process limits apply only where a systemd user scope works on your computer. Without one, a bot can use all of the computer's power, and the run only warns about it. The tests check the command that sets these limits, not that systemd enforces them. Tools that need administrator rights are not installed for the bots.
The default network is open
The default is network = bridge. It is not filtered: a bot can reach the internet, your local network and, on a cloud VM, the provider's metadata service. Block those on the engine's host if that matters, or use network = egress.
With egress:
An allowed host can still carry data out, for example a package registry that takes uploads. For encrypted connections the proxy sees only the host name, not what is sent.
Keys still reach the bot as with
bridge; there is no key proxy yet. The CLI's model API and login hosts must be open.The default host lists for each CLI are unverified until you try them: run the CLI once, read the refused hosts in
egress.log, and set the lists to what it really needs.The proxy refuses private and loopback addresses, so it cannot reach a model server on your own computer or in a private network.
In container mode it needs Podman with aardvark-dns 1.11.0 or newer, or a Docker version with the fix for a known name-resolution leak; older ones are refused.
Secret masking has gaps
Masking keys in a bot's output prevents accidents. It is not a barrier against a bot that wants to leak a key it was given. Known gaps:
binary files are not scanned for key patterns; only registered values are replaced in their bytes
a registered value split across lines, or spanning more lines than the stream buffer holds, may pass in streamed output
a value containing
/is not masked in a pathyour own files are checked only where they are text
The key store keeps keys out of the repository, the chat and the bots' storage, but a process running as your own user could still read it.
The lead reads what bots write
Bot output reaches the lead framed as data, not instructions. The lead is still a model reading text a bot wrote.
What the tests cover
The container flow is tested with Docker and a stand-in agent CLI. The tests that need a real container engine or a working bubblewrap are skipped on a computer without them, so a green run there proves less. The commands for the real agent CLIs and the example connectors follow their documentation; check them against your versions.
Version 0.1.0
Version 0.1.0, dated 2026-11-06, is the first release. This guide describes main; the changelog says what 0.1.0 contains.
Sources
Every factual claim in this guide and in the changelog, grouped by document and heading. Each entry names the repository source and the test that asserts it, as a file and a stable symbol in it, file::symbol, not a line number. The symbol is a function, class, method or constant in Python (Class.method for a test), the heading in Markdown (README.md::Limits), the section and key in config.ini ([environment] network; for a key documented only in a comment, the section and key the comment names, such as [egress] allow), a function in a shell script and a job in a workflow file. A key of [runner.claude] stands for the key every [runner.<name>] section has, as the comment above [runner.claude] describes; [class.standard] is the example in the task-class comment of [flow]. Every cited symbol was checked to exist, and to back its claim, in the tree of the commit that adds this file. "Test not found" means the claim is a rule or an instruction to the models: it is described, not enforced by code. "Skipped without an engine" or "skipped without bubblewrap" means the test runs only where Docker, Podman or a working bubblewrap is available, and proves nothing on a computer without them.
How to read the evidence
Implemented behaviour: source and test.
Rules and roles: the rule or role file, and "test not found" unless a test pins its text; a pinned text is still an instruction, not a guarantee.
Plans: labelled as plans, with the source that says so.
Overview
What WOUF is
An agent team in a folder, set up with the CLI you use; lead, worker, reviewer, decider:
README.md::WOUF. Test not found (description).Not a product or a platform; plain instructions plus helper scripts; used with the agent CLI you already have:
README.md::WOUF,README.md::Set it up (technical); helpers are standard-library Python:INSTALL.md::3. Check the requirements,AGENTS.md::Layout. Test:tests/test_pack.py::OpenSourceFilesTest.test_helpers_are_executable(helpers are executable Python scripts).WOUF is only the example name:
README.md::WOUF,.agents/team/config.ini::[team] name. Test:tests/test_team_cli.py::RunPlanTest.test_renamed_team(a renamed team, "Atlas Worker").
A plain folder
Rules, roles, skills, team layer and helpers and their places:
README.md::Make it yours,AGENTS.md::Layout. Tests:tests/test_pack.py::PackStructureTest.test_every_skill_has_the_required_sections,tests/test_pack.py::PackStructureTest.test_the_lead_role_names_every_skill,tests/test_pack.py::PackStructureTest.test_files_named_in_the_pack_exist.Board, decisions, schedule and knowledge are files:
README.md::How it works,.agents/rules.md::7. State lives in the event log; the store is its cache, board and decisions are its readable export. Test:tests/test_pack.py::LeanPackTest.test_rules_section_7_is_the_owners_sentence(rule text only).An update replaces the pack and keeps
.agents/team/:README.md::Make it yours,INSTALL.md::Update. Test:tests/test_install_smoke.py::InstallSmokeHostTest.test_d10_update_removes_retired_files_and_preserves_nested_content(the Update block ofINSTALL.mdrun on a folder: retired pack files removed and the team'sconfig.iniunchanged); needs git and bash,tests/test_install_smoke.py::InstallSmokeHostTest.setUpClass.
A team that works by a method
One lead that does not do the work; brief; fresh review; decide; human yes for irreversible steps:
.agents/rules.md::1. Roles stay separate,.agents/rules.md::3. Reversible is free, irreversible asks,.agents/roles/lead.md::Role: Lead (WOUF). Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchangedpins the text of rules 3 and 8; enforcement test not found (instructions).
Where the safety comes from
Rules are instructions; Docker, Podman and bubblewrap stage inputs and a repository copy into a separate workspace; host mode has no sandbox and can access the host with the user's permissions:
README.md::Limits,.agents/bin/teamlib/engine.py::inbound,.agents/bin/teamlib/bwrap.py::stage,.agents/bin/teamlib/run.py::stage_run,.agents/bin/teamlib/run.py::attach. Tests:tests/test_engine_calls.py::EngineCallsTest.test_one_tar_brings_the_inputs_and_the_pack_and_the_bundle_travels_once,tests/test_bwrap_isolation.py::BoxArgvTest.test_exactly_the_mount_table_is_bound_and_nothing_else,tests/test_team_cli.py::RunPlanTest.test_host_mode_uses_the_safe_command. Plain files back:.agents/bin/teamlib/core.py::copy_regular; tests: see Safety, Bot output is data.
Try it without a model
team demois offline with a fake agent and shows the gate and the exact-head approval:README.md::WOUF,.agents/bin/teamlib/demo.py::exercise. Test:tests/test_demo.py::DemoTest.test_cli_json_repeat_cleanup_and_text(zero model calls, gate refuses outside Paths, stale approval refused, folder unchanged, under ten seconds). The "no network calls" wording is asserted as output text, not measured.
Status
Version 0.1.0, dated 2026-11-06, guide describes
main:CHANGELOG.md::[0.1.0] - 2026-11-06,.agents/VERSION. Publishing a release needs owner approval:AGENTS.md::Guardrails. Test not found.
Method
One lead
The lead talks to the human, briefs, dispatches, relays and reports, never does the work:
.agents/roles/lead.md::Role: Lead (WOUF),.agents/rules.md::1. Roles stay separate,README.md::How it works. Test not found (role).Bots talk to the lead only; questions collected and asked once:
.agents/rules.md::1. Roles stay separate,README.md::How it works. Test:tests/test_live_state.py::RunEventsTest.test_a_bot_question_is_asked_by_the_bot_and_reaches_the_board_and_context(a worker's question reaches the lead's context framed as data from the bot); that bots never address the human is test not found (rule).
Roles
Five roles in
.agents/roles/:README.md::Make it yours;.agents/roles/worker.md::Role: Worker,.agents/roles/review.md::Role: Review,.agents/roles/decide.md::Role: Decide. Test:tests/test_pack.py::PackStructureTest.test_files_named_in_the_pack_exist(files named in the pack exist).Nobody grades their own work:
.agents/rules.md::1. Roles stay separate,.agents/roles/review.md::You ask WOUF (and stop). Test not found (rule).Scout, bugfixer and planner serve flows; planner drafts a plan for the lead:
.agents/skills/dispatch.md::Sequence of work,README.md::Use it. Test:tests/test_flow_parts.py::FlowNextTest.test_start_with_or_without_scout,tests/test_flow_parts.py::FlowNextTest.test_changes_go_to_the_worker_and_bugs_to_the_bugfixer.Example bots research, writer, tester, ops:
README.md::Use it,.agents/bots/writer.md::id: writer. Test:tests/test_pack.py::PackStructureTest.test_files_named_in_the_pack_exist.
The brief
Brief fields and
team brief newwith the next free id:.agents/skills/brief.md::Sequence of work. Test:tests/test_help.py::BriefNewTest.test_the_skeleton_takes_the_next_free_id_from_the_store.Class:picks bot, tier and reviews;mechanicallight with one review,securitystrong with two:.agents/bin/teamlib/flow.py::CLASS_DEFAULTS,.agents/team/config.ini::[class.standard]. Test:tests/test_flow_parts.py::ClassTest.test_class_defaults_keep_their_tiers.Pre-gate without a model: a file outside Paths turns it red; a failing gate command turns it red; a red gate goes back once, a second blocks:
.agents/bin/teamlib/gate.py::cmd_gate,README.md::How it works. Tests:tests/test_flow_parts.py::ScopeTest.test_globs,tests/test_team_cli_gate.py::GateTest.test_red_when_the_command_fails,tests/test_flow_parts.py::FlowNextTest.test_a_red_gate_goes_back_once_on_strong.The gate command gets no keys in every mode:
.agents/bin/teamlib/gate.py::gate_host(host: a scratch clone with an environment of its own, no sandbox). It runs in a sandbox only in container mode (.agents/bin/teamlib/gate.py::gate_execute,--networkfromgate_network, defaultnone) andbwrapmode (.agents/bin/teamlib/gate.py::gate_execute,.agents/bin/teamlib/bwrap.py::gate_run); host mode takes thegate_hostbranch in.agents/bin/teamlib/gate.py::gate_execute, as the docstring of.agents/bin/teamlib/gate.py::cmd_gatesays. The comment on.agents/team/config.ini::[flow] gatesays "in a sandbox" without naming the mode; the guide follows the code. Tests:tests/test_team_cli_gate.py::GateTest.test_green_on_the_branch_head_without_keys(host mode, no key reaches the gate),tests/test_gate_cache.py::GateCacheTest.test_engine_budget_and_mount_policy(container argv, stand-in engine),tests/test_bwrap_gate.py::BwrapGateTest.test_no_key_host_environment_or_network(bwrap: no key, no host environment, only loopback; skipped without bubblewrap, theskipUnlessontests/test_bwrap_gate.py::BwrapGateTest),tests/test_bwrap_gate.py::GateSetup.test_bridge_is_refused(bwrap refusesgate_network = bridge). No test asserts that the host-mode gate is unfenced; that follows from the code.
Fresh, independent review
Review in a fresh context and own sandbox, against the brief not the summary:
.agents/roles/review.md::Role: Review,.agents/roles/review.md::You check,.agents/rules.md::1. Roles stay separate. Test not found for the fresh context (role instruction); thatteam flowruns a separate review step:tests/test_flow_parts.py::FlowNextTest.test_the_happy_path.The intended rule: an approval names the exact commit it checked, and a new commit needs a new review:
.agents/roles/review.md::Return block,.agents/rules.md::8. Quality(instructions; test not found for the role text).What
team flowimplements: the named commit must be 7 to 40 hexadecimal characters and the head's id must start with it, so a matching prefix of at least 7 characters counts as an approval of the head; another commit, a prefix of fewer than 7 characters or no commit isstale, no approval:.agents/bin/teamlib/flow.py::review_verdict. Tests:tests/test_flow_parts.py::ReviewVerdictTest.test_approve_on_the_head(a 12-character prefix of the head is accepted),tests/test_flow_parts.py::ReviewVerdictTest.test_approve_on_another_commit_is_no_approve(another commit is stale),tests/test_flow_parts.py::ReviewVerdictTest.test_a_short_or_missing_commit_is_no_approve(5 characters and a missing commit are stale),tests/test_flow_parts.py::FlowParallelNextTest.test_both_must_approve_the_same_head. The guide describes the accepted prefix rather than saying a shortened commit is refused.
Decide
Decide bot picks one option with risk and next step:
.agents/roles/decide.md::Role: Decide,.agents/roles/decide.md::How you decide. Test not found (role).autonomyask by default, auto decides reversible things and reports:.agents/team/config.ini::[team] autonomy,.agents/rules.md::3. Reversible is free, irreversible asks. Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchanged(text pinned); behaviour test not found (instruction). A decide bot's decision is not the lead's yes for an egress host:tests/test_egress_run.py::AllowlistTest.test_a_decide_bots_decision_or_a_pending_one_is_not_the_leads_yes.Always the human's yes: send, publish, deploy, purchase, delete or overwrite outside a sandbox, merge, new access, money:
.agents/rules.md::3. Reversible is free, irreversible asks. Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchanged(text pinned); enforcement test not found (rule).
Speak up
Bots under
Flags, the lead to the human:.agents/rules.md::4. Speak up. Test not found (rule).
A whole task on its own
team flowstages and endings, up to two rework rounds:README.md::How it works,.agents/skills/dispatch.md::Sequence of work. Tests:tests/test_flow_parts.py::FlowNextTest.test_the_happy_path,tests/test_flow_parts.py::FlowNextTest.test_two_rounds_without_approve_block,tests/test_team_cli_gate.py::FlowTest.test_ready_after_one_approve.Usage limit: wait, or one switch to a configured fallback:
.agents/team/config.ini::[flow] fallback.<runner>. Tests:tests/test_flow_parts.py::FlowNextTest.test_a_limit_switches_to_the_fallback_runner_once,tests/test_flow_parts.py::FlowNextTest.test_a_limit_without_fallback_waits_and_then_runs_again.Four phases INIT, CONCEPT, REALIZE, VERIFY; leaving a phase needs a yes:
.agents/skills/brief.md::Sequence of work. Tests:tests/test_pack.py::LeanPackTest.test_brief_and_dispatch_describe_the_four_phases(text),tests/test_goal.py::ClassifierTest.test_twenty_fixture_goals_at_least_eighteen_match(classifier),tests/test_project_phases.py::ProjectTest.test_brief_from_a_plain_goal.
Safety
How .agents/ is built
Pack, team layer, helpers, examples off by default:
README.md::Make it yours,AGENTS.md::Layout. Test:tests/test_pack.py::PackStructureTest.test_files_named_in_the_pack_exist.A connector reaches only the bot that lists it:
README.md::Use it. Test:tests/test_team_cli.py::RunPlanTest.test_connectors_are_wired_only_for_bots_that_list_them; real container checktests/test_team_cli_export.py::ContainerRunTest.test_4_connectors_reach_only_the_bot_that_lists_them, skipped without an engine.Setup changes only your folder, apart from your own secret store and software you agree to install:
INSTALL.md::Install: set up the agent team in this folder,README.md::Set it up (technical). Tests:tests/test_install_smoke.py::InstallSmokeHostTest.test_d06_secret_store_and_approved_install_exceptions_disclosed(the two exceptions are named inINSTALL.mdand the setup skill; text only),tests/test_install_smoke.py::InstallSmokeContainerTest.test_install_in_fresh_containers(install in fresh containers, skipped without an engine). No test checks the human's home folder after setup; test not found for that.
Sandbox modes
modevalues andundeclaredstart:.agents/team/config.ini::[environment] mode. Test:tests/test_bwrap_run.py::BwrapPlanTest.test_the_plan_is_a_boxed_one(bwrap plan),tests/test_team_cli.py::RunPlanTest.test_host_mode_uses_the_safe_command(host plan).Docker, Podman and bubblewrap stage inputs and a repository copy into a separate workspace; host mode runs without a sandbox under the user's permissions:
.agents/bin/teamlib/engine.py::inbound,.agents/bin/teamlib/bwrap.py::stage,.agents/bin/teamlib/run.py::stage_run,.agents/bin/teamlib/run.py::attach. Tests:tests/test_engine_calls.py::EngineCallsTest.test_one_tar_brings_the_inputs_and_the_pack_and_the_bundle_travels_once,tests/test_bwrap_isolation.py::BoxArgvTest.test_exactly_the_mount_table_is_bound_and_nothing_else,tests/test_team_cli.py::RunPlanTest.test_host_mode_uses_the_safe_command.Container: own storage as the only volume, original project and home folders not mounted as such, CPU and memory limits:
.agents/bin/teamlib/engine.py::box_command,.agents/team/config.ini::[environment] mode. Tests:tests/test_engine_calls.py::EngineCallsTest.test_one_tar_brings_the_inputs_and_the_pack_and_the_bundle_travels_once(one tar brings the inputs in),tests/test_team_cli_export.py::ContainerRunTest.test_1_run_in_the_sandbox,tests/test_team_cli_export.py::ContainerRunTest.test_2_nothing_runs_while_idle_and_storage_persists(no container while idle, storage persists), both skipped without an engine.Container hardening:
--cap-drop ALL,no-new-privileges,--pids-limit 1024:.agents/bin/teamlib/core.py::HARDEN. Test:tests/test_team_cli_export.py::ContainerRunTest.test_8_the_container_has_no_capabilities_and_no_way_to_gain_them, skipped without an engine.Time limit stops a bot:
.agents/team/config.ini::[environment] timeout_minutes. Test:tests/test_team_cli_export.py::ContainerRunTest.test_6_the_time_limit_keeps_what_was_done, skipped without an engine.Docker or Podman, locally or through an SSH alias:
.agents/team/config.ini::[environment] engine,.agents/team/config.ini::[environment] docker_host,README.md::Set it up (technical). Test:tests/test_engine_calls.py::EngineCallsTest.test_a_warm_container_run_takes_at_most_three_round_trips.Bubblewrap: own namespaces, no capabilities, cleared environment:
.agents/bin/teamlib/bwrap.py::FLAGS. Test:tests/test_bwrap_isolation.py::BoxArgvTest.test_the_sandbox_always_unshares_everything_and_never_shares_the_network(the unshare, cleared-environment and session flags in the argv, no--share-net), runs everywhere; no capabilities in a real sandbox:tests/test_bwrap_isolation.py::BwrapIsolationTest.test_the_bot_has_no_capabilities_and_cannot_gain_any, skipped without bubblewrap.Bubblewrap mounts: the run's own paths (storage, inputs, outbox, team prefix, its own
passwdandgroup), plus, read-only, the host program folders/usr,/bin,/sbin,/lib,/lib32,/lib64,/libx32and the/etcentriesssl,ca-certificates,pki,alternatives,ld.so.*,localtime,nsswitch.conf,hosts, where they exist; private/tmp,/devand/proc:.agents/bin/teamlib/bwrap.py::HOST_DIRS,.agents/bin/teamlib/bwrap.py::HOST_ETC,.agents/bin/teamlib/bwrap.py::BINDS,.agents/bin/teamlib/bwrap.py::PRIVATE,.agents/bin/teamlib/bwrap.py::host_mounts,.agents/bin/teamlib/bwrap.py::box_argv. Test:tests/test_bwrap_isolation.py::BoxArgvTest.test_exactly_the_mount_table_is_bound_and_nothing_else(the argv's mount table equals the host mounts oftests/test_bwrap_isolation.py::HOST_DIRSandtests/test_bwrap_isolation.py::HOST_ETCplus the run's own and private mounts), runs everywhere.No home, project or runtime folder is a mount source: only the fixed host list and the run's own
BINDSare mounted, and an unknown bind is refused (.agents/bin/teamlib/bwrap.py::BINDS,.agents/bin/teamlib/bwrap.py::run_mounts). Tests that run everywhere:tests/test_bwrap_isolation.py::BoxArgvTest.test_no_home_project_or_runtime_folder_is_ever_a_source(no source under/home,/root,/run/user,/var, the home folder or the working folder),tests/test_bwrap_isolation.py::BoxArgvTest.test_bridge_and_unknown_networks_and_binds_are_refused(ahomebind is refused).The start message says "only its own folders" (
.agents/bin/teamlib/bwrap.py::WEAKER, said once per run in.agents/bin/teamlib/bwrap.py::stage), which leaves out the read-only host mounts above; the guide follows the mount table. Test:tests/test_bwrap_run.py::BwrapRunTest.test_a_run_returns_its_result_and_commits(the message appears once in a real run), skipped without bubblewrap (tests/test_bwrap_run.py::BwrapRunTest). Real sandbox tests of the mounts, also skipped without bubblewrap:tests/test_bwrap_isolation.py::BwrapIsolationTest.test_a_project_folder_and_home_with_ssh_key_cannot_be_found_or_read,tests/test_bwrap_isolation.py::BwrapIsolationTest.test_a_host_environment_variable_does_not_reach_the_sandbox,tests/test_bwrap_isolation.py::BwrapIsolationTest.test_the_bot_has_no_capabilities_and_cannot_gain_any,tests/test_bwrap_isolation.py::BwrapIsolationTest.test_no_network_but_own_loopback_and_no_host_socket_is_reachable,tests/test_bwrap_isolation.py::BwrapIsolationTest.test_only_bot_storage_is_writable_and_nothing_else_of_the_host_changes.Team prefix stands in for the image:
.agents/bin/teamlib/bwrap.py::module docstring,.agents/team/config.ini::[environment] mode. Test:tests/test_bwrap_prefix.py::PrefixRecipeTest.test_a_bot_sandbox_gets_the_prefix_read_only_and_first_on_path.Bubblewrap is weaker: no system call filter, said at every start:
.agents/bin/teamlib/bwrap.py::WEAKER,.agents/bin/teamlib/bwrap.py::stage. Test:tests/test_bwrap_run.py::BwrapRunTest.test_a_run_returns_its_result_and_commits, skipped without bubblewrap. Root installs skipped:INSTALL.md::3. Check the requirements,.agents/team/config.ini::[environment] mode. Test:tests/test_bwrap_prefix.py::PrefixRecipeTest.test_setup_is_skipped_and_the_lines_come_from_runner_and_provision(setup that needs root is skipped).CPU, memory and process limits in
bwrapmode only where a systemd user scope works:scope_usablestarts a trial scope (.agents/bin/teamlib/bwrap.py::scope_usable);limit_argvthen wraps the sandbox insystemd-run --user --scopewithMemoryMaxfrom[environment] memory,CPUQuotafromcpusandTasksMax=1024, and otherwise says "no CPU, memory or process limit on this computer" and runs without them (.agents/bin/teamlib/bwrap.py::limit_argv); a bot run applies it in.agents/bin/teamlib/bwrap.py::launch, the gate in.agents/bin/teamlib/bwrap.py::gate_check(the limits) and.agents/bin/teamlib/bwrap.py::gate_run;team doctorchecks the scope in.agents/bin/teamlib/status.py::bwrap_doctor; described in.agents/team/config.ini::[environment] mode,INSTALL.md::3. Check the requirements,docs/DESIGN.md::2. Two places, one boundary. Tests, all with a stand-insystemd-runand running everywhere:tests/test_bwrap_doctor.py::BwrapDoctorTest.test_scope_limits_and_fallback(the scope with the limits; with no usable scope, the command unchanged and the warning),tests/test_bwrap_doctor.py::BwrapDoctorTest.test_bot_and_gate_use_scopes_with_configured_limits(a bot run and the gate use the configured limits),tests/test_bwrap_doctor.py::BwrapDoctorTest.test_doctor_reports_bwrap_requirements(doctor names systemd). They check the command line; that systemd enforces the limits: test not found.Host mode: no sandbox, runs in
.agents/team/env/<id>/with the CLI's safe defaults:.agents/team/config.ini::[environment] mode,.agents/team/config.ini::[runner.claude] host_command,.agents/bin/teamlib/engine.py::bot_command,README.md::Where the bots run. Tests:tests/test_team_cli.py::RunPlanTest.test_host_mode_uses_the_safe_command,tests/test_team_cli.py::RunPlanTest.test_the_shipped_claude_runner_keeps_its_permission_checks.
Network and egress
bridgedefault, open, reaches local network and metadata service:.agents/team/config.ini::[environment] network,README.md::Limits. Test not found for the shipped default value (test fixtures write their own config).nonefor local models in the container:.agents/team/config.ini::[environment] network. Container mode passes it to the engine as--network:.agents/bin/teamlib/engine.py::box_command. Test:tests/test_egress_run.py::BridgeTest.test_none(the spec's network isnone, stand-in engine). bwrap: no network withnone:--unshare-allwithout--share-net,.agents/bin/teamlib/bwrap.py::FLAGS,.agents/bin/teamlib/bwrap.py::box_argv. Test:tests/test_bwrap_isolation.py::BwrapIsolationTest.test_no_network_but_own_loopback_and_no_host_socket_is_reachable, skipped without bubblewrap (tests/test_bwrap_isolation.py::BwrapIsolationTest).hostmode does not applynetwork,noneincluded: the host run starts the CLI as a plain process with this computer's environment and network (.agents/bin/teamlib/run.py::attach, its branch for a mode that is neithercontainernorbwrap:sh -cwithos.environ, no sandbox and no network option); the only host-mode network check refusesegress(.agents/bin/teamlib/flow.py::prepare_egress). Test foregress:tests/test_egress_run.py::RefusalTest.test_host_mode_refuses_egress. Test not found fornonein host mode; the claim follows from the code, which has no network fence on the host path.egressin container mode: internal network for the bot, proxy on both networks:.agents/team/config.ini::[egress]. Test:tests/test_egress_run.py::TopologyTest.test_two_networks_the_proxy_on_both_and_the_bot_on_the_internal_one_only, checked against the commands sent to a stand-in engine, not a live network.egressin bwrap mode: the bot's sandbox never gets--share-net(.agents/bin/teamlib/bwrap.py::FLAGS,.agents/bin/teamlib/bwrap.py::box_argv); the proxy runs in a sandbox of its own with--share-netand listens on a Unix socket in the control folder (.agents/bin/teamlib/bwrap.py::proxy_argv); a relay inside the bot takes127.0.0.1:3128to that socket (.agents/bin/teamlib/bwrap.py::RELAY, started by.agents/bin/teamlib/bwrap.py::egress_launch). Tests:tests/test_bwrap_egress.py::HandOffTest.test_the_bot_gets_the_relay_on_its_loopback_and_loopback_only_around_it(the bot's proxy is the relay on its loopback; runs everywhere),tests/test_bwrap_egress.py::BwrapEgressTest.test_the_bot_reaches_its_list_through_the_proxy_and_nothing_else(a real run: the listed host through the proxy, unlisted hosts refused, direct addresses, the host's loopback and DNS fail), skipped without bubblewrap (tests/test_bwrap_egress.py::BwrapEgressTest, astests/test_bwrap_egress.py::module docstringsays).Base list plus the run's own CLI hosts; brief hosts need the lead's recorded yes:
.agents/team/config.ini::[egress] allow. Tests:tests/test_egress_run.py::RunnerNeedsTest.test_a_claude_run_and_a_codex_run_on_one_config_each_get_their_own_hosts,tests/test_egress_run.py::AllowlistTest.test_a_host_outside_the_base_list_without_a_lead_decision_refuses_the_run,tests/test_egress_run.py::AllowlistTest.test_a_recorded_lead_decision_lets_the_host_through_for_that_task_only.Loopback goes around the proxy, directly, and only loopback; fixed in code, not widened by the config or a brief:
.agents/bin/teamlib/engine.py::EGRESS_ENV(NO_PROXYislocalhost,127.0.0.1,::1),.agents/bin/teamlib/bwrap.py::BOT_PROXY(bwrap mode takes the same variables, with the relay as the proxy). Tests:tests/test_egress_run.py::LoopbackTest.test_the_bot_gets_no_proxy_for_its_loopback_only_next_to_the_four_proxy_variables,tests/test_egress_run.py::LoopbackTest.test_the_loopback_exception_is_fixed_in_code_and_does_not_widen(config and brief cannot widen it),tests/test_egress_run.py::LoopbackTest.test_urllib_reaches_loopback_directly_and_an_outside_host_through_the_proxy(loopback reached directly, outside hosts through the proxy; a local stand-in proxy, not a live container),tests/test_bwrap_egress.py::HandOffTest.test_the_bot_gets_the_relay_on_its_loopback_and_loopback_only_around_it(bwrap uses the same list; runs everywhere). It is the bot's own loopback, not the host's:tests/test_bwrap_egress.py::BwrapEgressTest.test_the_bot_reaches_its_list_through_the_proxy_and_nothing_else(a service on the host's loopback is not reached, loopback inside the sandbox is), skipped without bubblewrap (tests/test_bwrap_egress.py::BwrapEgressTest). For container mode, test not found against a live container; it follows from the bot's own network namespace on the internal network.The proxy refuses private, loopback and link-local addresses:
catalog/tools/egress-proxy/egress_proxy.py::_refusal. Test:catalog/tools/egress-proxy/test_egress_proxy.py::ConnectTest.test_names_that_resolve_to_refused_addresses, run bytests/test_catalog.py::CatalogToolTests.test_tool_tests_pass.Old Docker and Podman refused:
.agents/bin/teamlib/engine.py::AARDVARK_MIN,.agents/bin/teamlib/engine.py::egress_engine_check,.agents/team/config.ini::[egress]. Tests:tests/test_egress_run.py::RefusalTest.test_an_old_aardvark_dns_or_cni_is_refused,tests/test_egress_run.py::DockerTest.test_a_docker_without_the_cve_2024_29018_fix_is_refused.bwrap refuses
bridge:.agents/bin/teamlib/bwrap.py::box_argv(the sandbox's argv),.agents/bin/teamlib/bwrap.py::check(before a run is staged), with the reason in.agents/bin/teamlib/bwrap.py::BRIDGE. Tests:tests/test_bwrap_isolation.py::BoxArgvTest.test_bridge_and_unknown_networks_and_binds_are_refused,tests/test_bwrap_run.py::BwrapPlanTest.test_bridge_is_refused_before_anything_is_staged,tests/test_bwrap_egress.py::HandOffTest.test_bridge_stays_refused_and_egress_is_not, all running everywhere. Host refusesegress:.agents/bin/teamlib/flow.py::prepare_egress. Test:tests/test_egress_run.py::RefusalTest.test_host_mode_refuses_egress.
Keys
Keys out of repository, chat and bot storage; typed in your own terminal; no command prints a value:
.agents/bin/secret::module docstring,.agents/rules.md::5. Secrets,README.md::Limits. Tests:tests/test_secret.py::SecretTest.test_set_refuses_without_a_terminal,tests/test_secret.py::SecretTest.test_there_is_no_get.Keychain or Secret Service, else an owner-only file store refused inside a git repository:
.agents/bin/secret::module docstring. Tests:tests/test_secret.py::SecretTest.test_set_then_run_injects_and_masks(modes 0600 and 0700),tests/test_secret.py::SecretTest.test_refuses_a_store_inside_a_git_repository.A key for one run on standard input, never in an argument or environment of the container command:
.agents/team/config.ini::[runner.claude] auth,docs/DESIGN.md::6. Keys are typed once and never stored with the bots. Test:tests/test_engine_calls.py::EngineCallsTest.test_keys_reach_the_bot_only_as_lines_on_its_stdin; bwrap:tests/test_bwrap_isolation.py::BwrapIsolationTest.test_a_key_on_stdin_reaches_the_script_and_no_command_line, skipped without bubblewrap.Keys in what a bot hands back are masked:
.agents/bin/teamlib/core.py::mask_secrets. Tests:tests/test_engine_calls.py::EngineCallsTest.test_results_are_masked_straight_into_out_and_receipt_values_never_reach_the_host_index,tests/test_team_cli.py::WayBackTest.test_a_key_split_over_output_lines_is_masked_whole,tests/test_secret_sinks.py::HandlerAndBinaryTest.test_binary_files_are_listed_and_only_their_secrets_are_masked.
Bot output is data
Output framed as data from the bot, not instructions; the lead's role says so:
.agents/bin/teamlib/run.py::report(a run's output),.agents/bin/teamlib/status.py::FROM_BOT(receipts and questions in the lead's context),.agents/roles/lead.md::Never,README.md::Limits. Tests:tests/test_team.py::FirstRunDocsTest.test_bot_output_is_data_for_the_lead(role text),tests/test_live_state.py::RunEventsTest.test_a_bot_question_is_asked_by_the_bot_and_reaches_the_board_and_context. That the lead obeys the framing: test not found (it is a model).Plain files only; links skipped and listed:
.agents/bin/teamlib/core.py::copy_regular,README.md::Limits. Tests:tests/test_team_cli.py::WayBackTest.test_a_linked_folder_is_not_followed,tests/test_engine_calls.py::EngineCallsTest.test_results_are_masked_straight_into_out_and_receipt_values_never_reach_the_host_index; real containertests/test_team_cli_export.py::ContainerRunTest.test_9_links_and_forged_receipts_do_not_come_back, skipped without an engine.
Getting started
What you need
Agent CLI, Python 3.9+, git, Docker or Podman, bubblewrap on Linux:
README.md::Set it up (technical),INSTALL.md::3. Check the requirements. Test not found (requirements text).The setup names what is missing and installs it after a yes:
README.md::Quickstart (no technical background needed),INSTALL.md::Install: set up the agent team in this folder. Test not found (setup instruction).
Set it up
The sentence to paste:
README.md::Quickstart (no technical background needed). Steps ofINSTALL.md, no overwriting, one round of questions, doctor and smoke test, the key typed by the human:README.md::Set it up (technical),INSTALL.md::Install: set up the agent team in this folder,INSTALL.md::4. Run the setup,.agents/skills/setup.md::Sequence of work. Tests:tests/test_install_smoke.py::InstallSmokeContainerTest.test_install_in_fresh_containers(skipped without an engine),tests/test_install_smoke.py::InstallSmokeHostTest.test_unchanged_guide_passes(the install smoke script on this folder: setup, bot create, doctor and a signed stand-in run; needs git and bash).
AGENTS.md and adapters
The
AGENTS.mdline;CLAUDE.mdandGEMINI.mdwith@AGENTS.md; permissions and optional hooks for Claude Code; other CLIs readAGENTS.mddirectly:INSTALL.md::2. Adapters for the CLI. Script:scripts/install-smoke.sh::adapters. Tests:tests/test_install_smoke.py::InstallSmokeContainerTest.test_install_in_fresh_containers(skipped without an engine),tests/test_install_smoke.py::InstallSmokeHostTest.test_wrong_session_hook_fails_the_script(the script fails on a wrong session hook; needs git and bash,tests/test_install_smoke.py::InstallSmokeHostTest.setUpClass).
Runners: which CLI runs the bots
[runner.<name>]sections, keys and the shippedclaude,codex,agyand emptylocal:.agents/team/config.ini::[runner.claude],.agents/team/config.ini::[runner.codex],.agents/team/config.ini::[runner.agy],.agents/team/config.ini::[runner.local]. Tests:tests/test_team_cli.py::RunPlanTest.test_strong_tier_and_first_installed_runner_by_default,tests/test_team_cli.py::RunPlanTest.test_the_shipped_claude_runner_keeps_its_permission_checks.Choose the CLI for the team, a bot or a run:
.agents/skills/dispatch.md::Sequence of work,README.md::Use it. Tests:tests/test_team_cli.py::RunPlanTest.test_light_tier_and_cli_default_model(a run),tests/test_team_cli.py::RunPlanTest.test_the_human_or_the_session_can_choose_the_runner(the session or human); per bot: test not found.Commands follow each CLI's documentation; check them:
README.md::Limits. Test not found.
Tiers: which model
strong,mid,lightand their use; empty means the CLI default; mid falls back to strong:.agents/team/config.ini::[tiers],.agents/team/config.ini::[runner.claude] model_flag. Tests:tests/test_team_cli.py::RunPlanTest.test_light_tier_and_cli_default_model,tests/test_flow_parts.py::ClassTest.test_a_class_may_run_on_mid,tests/test_flow_parts.py::FlowNextTest.test_a_missed_mid_run_goes_again_on_strong.--modelfor one run:.agents/team/config.ini::[runner.claude] model_flag. Test:tests/test_team_cli.py::RunPlanTest.test_effort_and_model_per_run.Review, decision, plan, complex and security stay strong:
.agents/skills/dispatch.md::Sequence of work. Tests:tests/test_flow_parts.py::ClassTest.test_complex_security_decision_and_plan_never_run_below_strong,tests/test_flow_parts.py::FlowNextTest.test_the_tier_of_each_step.
First steps
team demo, talk by name, "without WOUF", settings and bots:README.md::WOUF,README.md::Use it,INSTALL.md::5. Hand over. Test:tests/test_demo.py::DemoTest.test_cli_json_repeat_cleanup_and_text; the rest test not found (instructions).
Limits
Rules are instructions, not enforcement
README.md::Limits,SECURITY.md::Scope. Enforced checks: gatetests/test_flow_parts.py::ScopeTest.test_globs, head approvaltests/test_flow_parts.py::ReviewVerdictTest.test_approve_on_another_commit_is_no_approve; the network setting is enforced only in container andbwrapmode (see Safety, Network and egress).
Host mode has no sandbox
.agents/team/config.ini::[environment] mode,README.md::Where the bots run,README.md::Limits. Test:tests/test_team_cli.py::RunPlanTest.test_host_mode_uses_the_safe_command.Network not taken away by
nonein host mode: see Safety, Network and egress (test not found; from the code).Host-mode gate in a throwaway clone, not a sandbox: see Method, The brief.
Bubblewrap is weaker than a container
No system call filter:
.agents/bin/teamlib/bwrap.py::WEAKER,INSTALL.md::3. Check the requirements; root installs skipped:tests/test_bwrap_prefix.py::PrefixRecipeTest.test_setup_is_skipped_and_the_lines_come_from_runner_and_provision. CPU, memory and process limits only with a working systemd user scope, else a warning: see Safety, Sandbox modes (tests with a stand-insystemd-run; enforcement by systemd: test not found).
The default network is open
README.md::Limits,.agents/team/config.ini::[environment] network. Test not found for the default value.An allowed host can still carry data; over
CONNECTthe proxy sees only the host:docs/DESIGN.md::2. Two places, one boundary. Test not found (design note).Keys still reach the bot; no key proxy yet:
.agents/team/config.ini::[egress],docs/DESIGN.md::2. Two places, one boundary. Test:tests/test_egress_run.py::TopologyTest.test_keys_reach_the_bot_as_today_and_never_the_proxy.Host lists unverified until a trial:
.agents/team/config.ini::[egress]. Test not found (instruction).Private and loopback addresses refused:
catalog/tools/local-runner/README.md::Ollama setup,catalog/tools/egress-proxy/egress_proxy.py::_refusal. Test:catalog/tools/egress-proxy/test_egress_proxy.py::ConnectTest.test_names_that_resolve_to_refused_addresses.Version requirements:
.agents/team/config.ini::[egress]. Test:tests/test_egress_run.py::RefusalTest.test_an_old_aardvark_dns_or_cni_is_refused,tests/test_egress_run.py::VersionTest.test_moby_versions.
Secret masking has gaps
Masking is not a barrier; the store is readable by your own user:
README.md::Limits. Test not found (limitation).Known gaps:
README.md::Limits,docs/DESIGN.md::6. Keys are typed once and never stored with the bots. Tests:tests/test_secret_sinks.py::HandlerAndBinaryTest.test_binary_files_are_listed_and_only_their_secrets_are_masked(binary files: only registered values are masked),tests/test_team_cli.py::HostRunTest.test_a_key_block_longer_than_the_hold_limit_is_masked_in_the_bot_outputandtests/test_stream_masking.py::StreamMaskingTest.test_long_raw_private_keys_hide_every_body_line(a private-key block longer than the stream buffer still stays masked: these test what is closed, not the gaps). The gaps for registered values split across lines or longer than the buffer, for/in a path and for the human's own files are documented, not tested.
The lead reads what bots write
README.md::Limits. Test not found (limitation).
What the tests cover
Tested with Docker and a stand-in CLI; container tests run when an engine is reachable:
README.md::Limits,README.md::Develop,CONTRIBUTING.md::Checks. Skip conditions:tests/test_team_cli_export.py::ContainerRunTest,tests/test_bwrap_isolation.py::BwrapIsolationTest,tests/test_bwrap_egress.py::BwrapEgressTest.
Version 0.1.0
Dated 2026-11-06, the first release, guide describes
main: see Overview, Status;CHANGELOG.md::[0.1.0] - 2026-11-06. Test not found.
Examples
Project management for a non-technical project
Default
autonomy = ask:.agents/team/config.ini::[team] autonomy. Test not found for the shipped default.Under
ask, routine reversible work goes ahead and other decisions go to the human; send, publish, delete outside a sandbox, new access and paid steps go to the human in every setting:.agents/rules.md::3. Reversible is free, irreversible asks. Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchanged(text pinned); behaviour test not found (instruction).Brief to worker, review, board as a file:
README.md::How it works. Tests:tests/test_flow_parts.py::FlowNextTest.test_the_happy_path.Sending is left to the human by rule; nothing in the pack stops a bot that has a way to send:
.agents/rules.md::3. Reversible is free, irreversible asks,.agents/rules.md::2. Each bot stays in its sandbox(sandboxes hold no accounts). Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchangedpins the rule text only; enforcement test not found (instruction).Reminders, and that they come up at the next session without a scheduler:
README.md::Use it,.agents/skills/clock.md::Required inputs and access. Test not found (skill).Example writer bot:
.agents/bots/writer.md::id: writer,README.md::Use it. Test:tests/test_pack.py::PackStructureTest.test_files_named_in_the_pack_exist.
A coding co-worker
gatein[flow]runs the tests without keys, in a sandbox in container mode:.agents/team/config.ini::[flow] gate,.agents/bin/teamlib/gate.py::gate_execute. Tests:tests/test_team_cli_gate.py::GateTest.test_green_on_the_branch_head_without_keys,tests/test_gate_cache.py::GateCacheTest.test_engine_budget_and_mount_policy; see Method, The brief.Flow, Paths, rework, exact-head approval, branch back:
README.md::How it works. Tests:tests/test_flow_parts.py::ScopeTest.test_globs(path globs),tests/test_flow_parts.py::ReviewVerdictTest.test_approve_on_the_head(approval on the head),tests/test_flow_parts.py::FlowNextTest.test_a_red_gate_goes_back_once_on_strong(a red gate goes back once).team merge proposerecords the proposal and never merges:.agents/bin/teamlib/state.py::USAGE. Test:tests/test_state_cmd.py::MergeTest.test_propose_records_head_branch_and_verdict_and_never_merges(branches unchanged, "nothing merged").Main only with review, green CI and the human's yes is a rule, not branch protection; the repository host must enforce it:
.agents/rules.md::8. Quality. Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchangedpins the rule text only; enforcement test not found (instruction).Two reviews for
security, reviewer on another runner:.agents/team/config.ini::[class.standard]. Tests:tests/test_flow_parts.py::FlowNextTest.test_two_reviews_with_their_own_runners,tests/test_flow_parallel.py::RunFlowListTest.test_two_reviews_run_at_once_and_both_approve.Tester bot and browser connector after a yes:
README.md::Use it. Test:tests/test_team_cli.py::RunPlanTest.test_connectors_are_wired_only_for_bots_that_list_them.
In a company
Bots on a VM through an SSH alias:
.agents/team/config.ini::[environment] docker_host. Test:tests/test_engine_calls.py::EngineCallsTest.test_a_warm_container_run_takes_at_most_three_round_trips.Under
askandauto, irreversible, outward, paid and new-access steps wait for a person by rule; the company enforces it through the access it grants:.agents/rules.md::3. Reversible is free, irreversible asks,.agents/rules.md::2. Each bot stays in its sandbox. Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchangedpins the rule text only; enforcement test not found (instruction).Egress after a trial, own CLI hosts: see Safety, Network and egress.
Keys typed by the owner; connectors per bot:
.agents/rules.md::5. Secrets,README.md::Use it. Tests:tests/test_secret.py::SecretTest.test_set_refuses_without_a_terminal,tests/test_team_cli.py::RunPlanTest.test_connectors_are_wired_only_for_bots_that_list_them.Export without keys, without data unless asked:
README.md::Use it,.agents/skills/team.md::Sequence of work. Tests:tests/test_team_cli_export.py::ExportImportTest.test_export_has_declarations_and_no_data_by_default,tests/test_team_cli_export.py::ExportImportTest.test_export_with_data_and_refusal_of_secrets.Token caps per run, per day and per bot:
.agents/team/config.ini::[budget](per_run,per_day,per_day.<bot>). Tests:tests/test_budget.py::BudgetRunTest.test_at_per_day_the_run_is_skipped,tests/test_budget.py::BudgetRunTest.test_a_bots_own_cap,tests/test_budget.py::BudgetRunTest.test_per_run_marks_the_receipt_and_the_result.
An authority, such as the Swiss federal administration
This is a setup description. Data protection under the Swiss nDSG and sovereignty are the organisation's goals and assessment; no repository source claims WOUF ensures them, and this guide makes no such claim.
No classification feature: none found in
.agents/; what bots see is what the lead stages:.agents/rules.md::2. Each bot stays in its sandbox,README.md::How it works. Test not found.Where bots run,
docker_host, cloud CLI needs network to its model API, emptylocalrunner:.agents/team/config.ini::[environment] docker_host,.agents/team/config.ini::[runner.local],README.md::Limits,.agents/team/config.ini::[egress]. Test:tests/test_engine_calls.py::EngineCallsTest.test_a_warm_container_run_takes_at_most_three_round_trips; local runner: test not found in the pack.Container mode,
noneoregress,bridgeopen, private addresses refused: see Safety and Limits.The egress allowlist is not the whole list: each run adds its own CLI's model API and login hosts, built in (
.agents/bin/teamlib/engine.py::EGRESS_NEEDS) or fromneeds.<runner>, which replaces the built-in list (.agents/team/config.ini::[egress] allow,.agents/bin/teamlib/flow.py::prepare_egress); the run's list is shown at start (.agents/bin/teamlib/flow.py::prepare_egress). Tests:tests/test_egress_run.py::RunnerNeedsTest.test_a_claude_run_and_a_codex_run_on_one_config_each_get_their_own_hosts(claude and codex runs each get their own hosts on top ofallow),tests/test_egress_run.py::RunnerNeedsTest.test_needs_of_a_runner_replace_its_built_in_list(needs.<runner>replaces the built-in list),tests/test_egress_run.py::RunnerNeedsTest.allow_of(the run's list at start).Under
ask, routine reversible work goes ahead; other decisions and the always-human steps go to a person, by rule; these are instructions, enforced only by the accounts, keys, destinations and permissions the organisation grants:.agents/rules.md::3. Reversible is free, irreversible asks. Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchangedpins the rule text only; enforcement test not found (instruction).A bot given a key can leak it:
README.md::Limits. Test not found (limitation).The review bot checks against the brief only:
.agents/roles/review.md::Role: Review,.agents/roles/review.md::You check. Test not found (role).
Contribute
Contribute (introduction)
Pack small on purpose; every line costs context; a good change is clearer, shorter or safer:
CONTRIBUTING.md::Contributing. Test not found (statement of intent).Public repository at github.com/vaiev/wouf:
SECURITY.md::Reporting a vulnerability. Test not found.
Ways to help
Real failures, example bots or connectors, fixes for contradictions and outdated runner commands:
CONTRIBUTING.md::What helps most. Test not found (invitation).Security issues privately, not in a public issue:
SECURITY.md::Reporting a vulnerability. Test:tests/test_pack.py::OpenSourceFilesTest.test_project_files_exist(SECURITY.md names private vulnerability reporting).
Rules for changes
Test first:
CONTRIBUTING.md::Rules for changes,AGENTS.md::Workflow. Test not found (procedural rule, not enforced).A new skill copies the template, keeps its six sections in order and is named in the lead role:
CONTRIBUTING.md::Rules for changes. Tests:tests/test_pack.py::PackStructureTest.test_every_skill_has_the_required_sections(every skill has the sections in order),tests/test_pack.py::PackStructureTest.test_the_lead_role_names_every_skill(the lead role names every skill). Preferring to extend a skill: test not found (guidance).Stay generic:
CONTRIBUTING.md::Rules for changes. Test:tests/test_pack.py::LeakScanTest.test_no_secrets_or_private_addressescovers secret markers and private IP addresses only; company, host and account names: test not found.Route by tier; provider and model names in
config.ini:CONTRIBUTING.md::Rules for changes. Test:tests/test_pack.py::PackStructureTest.test_model_routing_uses_tiers_not_versions(no pinned model versions in Markdown outside the history records).Write plainly:
CONTRIBUTING.md::Rules for changes. Test not found (style rule).
Checks
The test command:
CONTRIBUTING.md::Checks. The container tests run only where an engine is reachable:CONTRIBUTING.md::Checks; skip conditiontests/test_team_cli_export.py::ContainerRunTest.CI runs markdownlint, an offline link check and actionlint:
.github/workflows/docs-ci.yml::jobs.docs. Test not found (CI configuration).
Review
Branch, pull request against
main, merge after review, describe the change and the test:CONTRIBUTING.md::Pull requests,AGENTS.md::Workflow. Test not found (procedural rule, not enforced by the pack).The project's changes follow the method: approval of the exact commit, green CI and the owner's yes:
.agents/rules.md::8. Quality,README.md::Built with WOUF. Test:tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchangedpins the rule text only; enforcement test not found (instruction).Helping by reviewing pull requests: the guide's invitation, no repository claim; the approach mirrors
.agents/roles/review.md::Role: Reviewand.agents/rules.md::4. Speak up. Test not found (role and rule text).
Security issues
Private vulnerability reporting, the organisation owners until it is enabled, no secrets in reports, revoke a pasted secret first:
SECURITY.md::Reporting a vulnerability. Test:tests/test_pack.py::OpenSourceFilesTest.test_project_files_exist(private vulnerability reporting named); the rest: test not found (policy text).
License
Contributions under Apache License 2.0:
CONTRIBUTING.md::License,LICENSE. Test:tests/test_pack.py::OpenSourceFilesTest.test_license_is_apache_2.
Changelog
Introduction: commands, sandboxing and checks are implemented and tested, with skips by environment (see the entries below and Limits, What the tests cover); the pack's rules, roles and skills are instructions, their text and structure pinned by
tests/test_pack.py::PackStructureTest.test_every_skill_has_the_required_sections,tests/test_pack.py::PackStructureTest.test_the_lead_role_names_every_skill,tests/test_pack.py::LeanPackTest.test_the_hard_limits_are_unchanged, with no test that a model follows them:.agents/rules.md::Rules(sections 1 to 8). Enforcement test not found (instructions).
Added
Pack and setup: see Overview and Getting started.
team runmodes and container hardening: see Safety, Sandbox modes.mode = bwrap, prefix, lifecycle and gate in the sandbox:.agents/bin/teamlib/bwrap.py::module docstring,.agents/bin/teamlib/bwrap.py::gate_run; limits through a systemd user scope: see Safety, Sandbox modes. Tests:tests/test_bwrap_run.py::BwrapLifecycleTest.test_create_makes_a_private_box,tests/test_bwrap_prefix.py::PrefixRecipeTest.test_a_bot_sandbox_gets_the_prefix_read_only_and_first_on_path,tests/test_bwrap_gate.py::GateSetup.test_bridge_is_refused; the real sandbox runs are skipped without bubblewrap.network = egress: see Safety, Network and egress.team flow, classes, tiers, runners, fallback: see Method and Getting started; the gate by mode and the accepted commit prefix: see Method, The brief and Fresh, independent review.Parallel steps:
.agents/team/config.ini::[flow]. Tests:tests/test_flow_parallel.py::RunFlowListTest.test_two_reviews_run_at_once_and_both_approve,tests/test_flow_parallel.py::RunFlowListTest.test_scout_and_research_run_at_once_then_the_worker.team batch: the packages of one plan wave at the same time, each on its own worker, when their paths are pairwise disjoint (.agents/bin/teamlib/batch.py::module docstring); an overlap starts nothing (.agents/bin/teamlib/batch.py::run_batch). Tests:tests/test_batch.py::OverlapTest.test_disjoint(disjoint paths),tests/test_batch.py::BatchCliTest.test_an_overlap_refuses_the_batch_and_names_both_packages(an overlap refuses the batch, no flow starts),tests/test_batch.py::BotOverrideTest.test_worker2_and_scout2_run_the_flow(worker2andscout2run the second flow).team goaland the classifier:.agents/skills/brief.md::Sequence of work,.agents/team/config.ini::[classify]. Tests:tests/test_goal.py::ClassifierTest.test_twenty_fixture_goals_at_least_eighteen_match,tests/test_goal.py::ClassifierTest.test_a_security_path_brings_a_threat_model_skeleton.Secrets and masking: see Safety, Keys.
Event log and SQLite projection:
.agents/rules.md::7. State lives in the event log; the store is its cache, board and decisions are its readable export. Tests:tests/test_events.py::Events.test_origin_creation_validation_and_sequence,tests/test_projection.py::Projection.test_rebuild_equals_incremental_500_events.Budget and a bot's end date:
.agents/team/config.ini::[budget]. Tests:tests/test_budget.py::BudgetRunTest.test_at_per_day_the_run_is_skipped,tests/test_bot_until.py::BotUntilTest.test_past_refused_before_staging.Context graph:
.agents/team/config.ini::[flow]. Tests:tests/test_graph.py::GraphTest.test_fixture_and_determinism,tests/test_login.py::Login.test_graph_only_with_repo_and_paths.Federation, signed envelopes:
docs/FEDERATION.md::Federation between two WOUF leads,.agents/bin/federate::sign,.agents/bin/federate::signed_by,.agents/bin/federate::cmd_pack. Tests:tests/test_federate.py::FederateTest.test_pack_prints_one_signed_envelope_line(one signed envelope line),tests/test_federate.py::FederateTest.test_verify_accepts_good_lines_and_names_each_refusal(verify accepts good lines and names each refusal),tests/test_federate_transport.py::TransportTest.test_a_forged_signature_is_refused_and_nothing_is_held(a forged signature is refused and nothing is held).Federation, git mailbox:
docs/FEDERATION.md::Federation between two WOUF leads,.agents/bin/federate::cmd_send,.agents/bin/federate::cmd_pull. Tests:tests/test_federate_transport.py::TransportTest.test_send_puts_one_envelope_per_commit_and_only_moves_forward(send puts one envelope per commit on the mailbox),tests/test_federate_transport.py::TransportTest.test_round_trip_holds_and_applies_only_by_hand(a pull carries it back).Federation, incoming data kept in quarantine until applied by hand:
docs/FEDERATION.md::Federation between two WOUF leads,.agents/bin/federate::hold(holdstores a checked envelope underimport/federation/<peer>/),.agents/bin/federate::cmd_receive(receivewithout--applywrites nothing into the team). Tests:tests/test_federate.py::FederateTest.test_receive_without_apply_only_holds(receive without--applyonly holds: the envelope is in quarantine, nothing in the team's knowledge),tests/test_federate_transport.py::TransportTest.test_round_trip_holds_and_applies_only_by_hand(a pull holds the envelope, writes nothing into the team, and onlyreceive --applydoes). Both test files skip without ssh-keygen (tests/test_federate.py::FederateTest,tests/test_federate_transport.py::TransportTest; the second also needs git).team demo:README.md::WOUF. Test:tests/test_demo.py::DemoTest.test_cli_json_repeat_cleanup_and_text.
Changed
Dropped SSH connection keeps the result, a host that stays away ends the run naming the lost connection:
.agents/bin/teamlib/engine.py::SSH_ALIVE(keepalive options),.agents/bin/teamlib/engine.py::rejoin, called in.agents/bin/teamlib/run.py::attach. Tests:tests/test_engine_ssh_loss.py::SshLossTest.test_a_stream_that_drops_once_still_brings_the_return_block_and_the_results,tests/test_engine_ssh_loss.py::SshLossTest.test_a_host_that_stays_away_ends_the_run_naming_the_lost_connection.At most three round trips:
.agents/bin/teamlib/engine.py::START(one shell starts the bot, inputs and keys in one call),.agents/bin/teamlib/engine.py::box_command,.agents/bin/teamlib/engine.py::fetch_results(collect and remove in one call). Test:tests/test_engine_calls.py::EngineCallsTest.test_a_warm_container_run_takes_at_most_three_round_trips.Loopback around the proxy, fixed in code:
.agents/bin/teamlib/engine.py::EGRESS_ENV. Tests:tests/test_egress_run.py::LoopbackTest.test_the_bot_gets_no_proxy_for_its_loopback_only_next_to_the_four_proxy_variables,tests/test_egress_run.py::LoopbackTest.test_the_loopback_exception_is_fixed_in_code_and_does_not_widen.Own runner's model hosts only:
.agents/bin/teamlib/flow.py::prepare_egress. Tests:tests/test_egress_run.py::RunnerNeedsTest.test_a_claude_run_and_a_codex_run_on_one_config_each_get_their_own_hosts,tests/test_egress_run.py::RunnerNeedsTest.test_needs_of_a_runner_replace_its_built_in_list.Locked secret store ends fast with one line:
.agents/bin/secret::PASS_TIMEOUT(pass gets five seconds),.agents/bin/teamlib/core.py::read_once(records the fault),.agents/bin/teamlib/core.py::register_stored(says the one line). Tests:tests/test_secret_locked.py::LockedPassTest.test_a_command_that_prints_team_data_fails_closed,tests/test_secret_locked.py::LockedPassTest.test_secret_run_on_a_locked_store_is_one_line,tests/test_secret_locked.py::LockedPassTest.test_log_on_a_locked_store_writes_nothing_and_fails_closed.Demo brings its own config:
.agents/bin/teamlib/demo.py::TEAM_CONFIG,.agents/bin/teamlib/demo.py::exercise. Tests:tests/test_demo.py::DemoTest.test_disposable_team_gets_only_its_own_minimal_config.
Not in this version
Key proxy for egress, so that keys no longer reach the bot:
.agents/team/config.ini::[egress],docs/DESIGN.md::2. Two places, one boundary. Plan.
Claims left out
Claims from the owner's status note that this audit could not back with code and tests in this tree were left out: speed figures for queries, comparisons with other products, cost or rate-limit outcomes, and statements about other projects.