Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@ All notable changes to vouch are documented here. Format follows
## [Unreleased]

### Added
- **dedicated agent kbs — `vouch kb create <name> --for-agent <agent>`** (#609): a
scheduled agent (a pr triager in ci, an incident summariser) produces a lot of
narrow, machine-shaped memory, and mixing it into the project kb buries what a
human curated. `vouch kb create` provisions an isolated kb outside any project
tree, registers it with an **owner and the agent it serves**, and issues a
credential **bound to that kb and only that kb** — printed once, never
persisted; what lands on disk is the 16-hex subject `trust.py` already derives.
The binding is what makes this worth more than `vouch init` in a subdirectory:
upward discovery makes which kb an agent hits a function of its working
directory, so a leaked ci secret otherwise reaches the project kb from the
wrong cwd. It is enforced at the existing transport chokepoint
(`agents.subject_is_active`) and lives machine-local in
`~/.config/vouch/credentials.yaml` (0600) — a binding stored *inside* the kb it
names is circular, since the project kb's copy has nothing to say about a token
issued for another kb and would still fall open. **Unbound tokens are
unaffected**, so this is opt-in rather than a migration; a bound one fails
closed everywhere but its own kb, including against a kb whose `kb.id` was
stripped. Ambient capture refuses to write into an agent kb reached by upward
discovery, the same guard the personal catch-all already gets. `vouch kb issue`
is the rotation path, `vouch kb list` shows what exists, `vouch agents revoke`
still retires a credential, and `vouch adopt` still promotes anything worth
keeping into the project kb.
- **bench: composite guards** (#616): `efficiency`, `consistency` and `canary`
as bounded multipliers over the composite, plus a `bench_version` stamp on
every report. Reported **beside** the composite, never folded into it —
Expand Down
56 changes: 56 additions & 0 deletions docs/multi-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,62 @@ A common convention: `<host>-<human>` so you can tell apart "Alice
running Claude Code" from "Bob running Claude Code" from "Alice
running Cursor".

## Dedicated KBs for scheduled agents

`VOUCH_AGENT` separates *attribution* inside one KB. An agent that runs
on a schedule — a PR triager in CI, an incident summariser, a docs bot —
usually wants separation of *storage* too: it produces a lot of narrow,
machine-shaped memory, and a thousand run logs bury the knowledge a human
curated.

```bash
vouch kb create ci-triage --for-agent pr-triager
```

That provisions an isolated KB under `$XDG_DATA_HOME/vouch/kbs/` (never
inside a project tree), registers it in the machine registry as owned by
you and provisioned for `pr-triager`, and prints a credential **once**:

```
VOUCH_TOKEN_PR_TRIAGER=…
```

vouch never stores that secret. What it keeps is the token's 16-hex
subject — in the KB's own committed `agents.yaml`, and in a machine-local
binding file at `~/.config/vouch/credentials.yaml` (mode 0600). The
binding is the part that matters: **that credential authenticates against
that KB and no other.**

Why the binding rather than just running `vouch init` somewhere else:
which KB an agent hits is normally a function of its working directory,
because discovery walks upward. A CI job that starts in the wrong
directory — or a leaked token used from one — would otherwise reach the
project KB. A bound credential fails closed everywhere but its own KB,
regardless of cwd.

Point the agent at it with both halves:

```bash
export VOUCH_TOKEN_PR_TRIAGER=… # the credential
export VOUCH_KB_PATH=~/.local/share/vouch/kbs/ci-triage/.vouch
```

Housekeeping:

```bash
vouch kb list # what exists, and how many credentials each has
vouch kb issue ci-triage --for-agent triager-2 # rotate / add a credential
vouch agents revoke pr-triager # retire one (terminal, by design)
vouch adopt … # promote what's worth keeping into the project KB
```

Two properties worth knowing. **Tokens with no binding are unaffected** —
an existing deployment keeps working exactly as before, so this is opt-in
rather than a migration. And ambient capture *refuses* to write into an
agent KB it reached by walking up from a project directory, the same
guard the personal catch-all KB gets; set `VOUCH_KB_PATH` when you mean
it deliberately.

## Concurrency

vouch is single-writer per file. Two agents proposing simultaneously
Expand Down
12 changes: 11 additions & 1 deletion src/vouch/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,18 +275,28 @@ def is_active(store: KBStore, subject: str) -> bool:
def subject_is_active(subject: str) -> bool:
"""Store-resolving gate for the transport chokepoint.

Two questions, one gate: is this subject's row active *here*, and may this
subject reach this KB at all (`kb_binding`)? The second is what isolates a
credential issued for an agent's own KB — without it the token would still
authenticate against the project KB, because the answer below for a
subject this KB has never heard of is deliberately "yes".

Best-effort by design: a request that cannot resolve a KB has no registry
to be denied by, and an unreadable registry must not lock every agent out
of a running server.
"""
# Imported here rather than at module scope for the same reason `trust.py`
# takes this gate as an injected callable: the auth path must not drag the
# storage layer into every importer of this module.
from . import kb_binding
from .storage import KBStore, discover_root

try:
store = KBStore(discover_root())
except Exception:
return True
try:
return is_active(store, subject)
return is_active(store, subject) and kb_binding.store_allows(store, subject)
except Exception: # pragma: no cover - defensive
logger.debug("agents: registry unreadable, allowing subject")
return True
Expand Down
223 changes: 223 additions & 0 deletions src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from . import hub as hub_mod
from . import inbox as inbox_mod
from . import install_adapter as install_mod
from . import kb_binding as kb_binding_mod
from . import lifecycle as life
from . import media as media_mod
from . import metrics as metrics_mod
Expand Down Expand Up @@ -124,6 +125,7 @@ def _cli_errors() -> Iterator[None]:
codex_rollout_mod.CodexRolloutError,
agents_mod.AgentError,
pins_mod.PinError,
kb_binding_mod.BindingError,
) as e:
raise click.ClickException(str(e)) from e

Expand Down Expand Up @@ -395,6 +397,227 @@ def hub_unregister(token: str) -> None:
click.echo(f"unregistered {removed.name} ({removed.kb_id})")


# --- dedicated agent KBs (#609) -------------------------------------------


@cli.group(name="kb")
def kb_group() -> None:
"""Provision KBs, including dedicated ones for scheduled agents.

An agent that runs on a schedule produces a lot of narrow, machine-shaped
memory; mixing it into the project KB buries what a human curated. A
dedicated KB keeps it separate, and the credential issued alongside it
reaches that KB and no other — so a leaked CI secret cannot write to the
project KB no matter which directory the agent runs from.
"""


def _issue_agent_credential(
store: KBStore, *, agent: str, actor: str, note: str | None
) -> tuple[str, str]:
"""Mint a credential for `agent`, bind it to `store`'s KB, register it.

Returns (token, env_var). The token is returned for a single echo and is
never written anywhere by vouch: what lands on disk is its 16-hex subject,
in the KB's own committed agent registry and in the machine-local binding
file. Recovering it later is impossible by construction — issue a new one.
"""
identity = store.identity()
if identity is None: # pragma: no cover - init always mints
raise click.ClickException(f"KB at {store.root} has no identity to bind to")
kb_id, kb_name = identity
token = kb_binding_mod.issue_token()
subject = trust_mod.auth_subject_for_token(token)
agents_mod.register(
store, subject=subject, name=agent, actor=actor,
note=note or f"dedicated KB {kb_name}",
)
kb_binding_mod.bind(
subject=subject, kb_id=kb_id, kb_name=kb_name, agent=agent,
)
audit_mod.log_event(
store.kb_dir, event="agent.bind", actor=actor,
data={"subject": subject, "kb_id": kb_id, "agent": agent},
)
return token, kb_binding_mod.token_env_var(agent)


def _echo_credential(token: str, env_var: str, kb_dir: Path) -> None:
"""Print a freshly issued credential once, with what to do with it."""
click.echo("")
click.echo(f" {env_var}={token}")
click.echo("")
click.echo(
"This is the only time the token is shown — vouch stores its "
"fingerprint, never the secret."
)
click.echo(
f"Export it where the agent runs, then point the agent at this KB:\n"
f" export {env_var}=...\n"
f" export VOUCH_KB_PATH={kb_dir}"
)


@kb_group.command("create")
@click.argument("name")
@click.option(
"--for-agent",
"for_agent",
default=None,
help="Provision this KB for an agent and issue it a credential bound to "
"this KB alone.",
)
@click.option(
"--path",
default=None,
type=click.Path(file_okay=False),
help="Where to create it (default: $XDG_DATA_HOME/vouch/kbs/<name>).",
)
@click.option("--note", default=None, help="What this agent is for.")
def kb_create(
name: str, for_agent: str | None, path: str | None, note: str | None
) -> None:
"""Create a KB called NAME and register it on this machine.

With --for-agent, the KB is provisioned for that agent: the registry row
records who owns it, ambient capture from a project directory refuses to
write into it, and a fresh credential is printed once, bound to this KB.
Promote anything worth keeping into the project KB with `vouch adopt`.
"""
if path is not None:
root = Path(path).expanduser().resolve()
else:
with _cli_errors():
derived = hub_mod.agent_kb_root(name)
if derived is None:
raise click.ClickException(
"cannot determine a home for the KB — pass --path or set "
f"{hub_mod.AGENT_KBS_ENV} to a writable folder"
)
root = derived
if (root / ".vouch").is_dir():
raise click.ClickException(
f"a KB already exists at {root} — choose another name, or run "
f"`vouch kb issue {name}` to issue it another credential"
)
with _cli_errors():
try:
store, _seed, _tmpl = _bootstrap_kb(root)
except Exception as e:
# Same rollback as the personal KB: an unwritable path must not
# leave half a KB behind for a rerun to mistake for a finished one.
shutil.rmtree(root / ".vouch", ignore_errors=True)
raise click.ClickException(f"could not initialise the KB at {root}: {e}") from e
entry = hub_mod.register_kb(
root, name=name, actor=_whoami(),
owner=_whoami(), agent=for_agent or "",
)
click.echo(f"Initialised KB at {store.kb_dir}")
click.echo(f"Registered in the machine registry: {entry.name} ({entry.kb_id})")
if for_agent is None:
return
with _cli_errors():
token, env_var = _issue_agent_credential(
store, agent=for_agent, actor=_whoami(), note=note
)
_write_serve_token_ref(store, env_var)
click.echo(f"Issued a credential for {for_agent}, bound to this KB only.")
_echo_credential(token, env_var, store.kb_dir)


@kb_group.command("issue")
@click.argument("name")
@click.option("--for-agent", "for_agent", required=True, help="The agent to issue for.")
@click.option("--note", default=None, help="What this agent is for.")
def kb_issue(name: str, for_agent: str, note: str | None) -> None:
"""Issue another credential for the existing KB called NAME.

The rotation path: a leaked credential is retired with `vouch agents
revoke`, which is terminal, and re-admitting an agent means giving it a
new token — which is a new subject, bound afresh to this KB.
"""
entry = _agent_kb_entry(name)
store = KBStore(Path(entry.path))
with _cli_errors():
token, env_var = _issue_agent_credential(
store, agent=for_agent, actor=_whoami(), note=note
)
click.echo(f"Issued a credential for {for_agent}, bound to {entry.name} only.")
_echo_credential(token, env_var, store.kb_dir)
if env_var not in store.config_path.read_text(encoding="utf-8"):
click.echo(
f"note: add `env:{env_var}` to serve.bearer_tokens in "
f"{store.config_path} for this KB to accept it.",
err=True,
)


@kb_group.command("list")
@click.option("--json", "as_json", is_flag=True, help="Emit JSON instead of a table.")
def kb_list(as_json: bool) -> None:
"""List the KBs provisioned for agents on this machine."""
entries = hub_mod.agent_entries()
if as_json:
_emit_json(
{
"registry": str(hub_mod.registry_path()),
"kbs": [
{
"kb_id": e.kb_id,
"name": e.name,
"path": e.path,
"owner": e.owner,
"agent": e.agent,
"added_at": e.added_at,
"credentials": len(kb_binding_mod.bindings_for_kb(e.kb_id)),
}
for e in entries
],
}
)
return
if not entries:
click.echo("no agent KBs on this machine (vouch kb create <name> --for-agent <agent>)")
return
for e in entries:
creds = len(kb_binding_mod.bindings_for_kb(e.kb_id))
click.echo(
f"{e.name} agent={e.agent} owner={e.owner or '-'} "
f"credentials={creds} {e.path}"
)


def _agent_kb_entry(name: str) -> hub_mod.RegistryEntry:
"""The registry row for an agent KB called `name`, or a clean error."""
entry = next((e for e in hub_mod.agent_entries() if e.name == name), None)
if entry is None:
raise click.ClickException(
f"no agent KB called {name!r} — `vouch kb list` shows what exists"
)
if not (Path(entry.path) / ".vouch").is_dir():
raise click.ClickException(
f"agent KB {name!r} is registered at {entry.path} but there is no "
".vouch/ there — it moved or was deleted"
)
return entry


def _write_serve_token_ref(store: KBStore, env_var: str) -> None:
"""Point the fresh KB's accept-list at the credential's env var.

Only ever called on a KB `_bootstrap_kb` just wrote, so a structural
rewrite is safe here — there are no hand-written comments to lose, which
is the reason `hub.set_personal_fallback` goes to textual lengths.
The `env:` indirection is what keeps the secret out of a committed file.
"""
loaded = yaml.safe_load(store.config_path.read_text(encoding="utf-8")) or {}
serve = loaded.setdefault("serve", {})
serve["bearer_tokens"] = [f"env:{env_var}"]
store.config_path.write_text(
yaml.safe_dump(loaded, sort_keys=False, allow_unicode=True), encoding="utf-8"
)


def _init_personal_kb(fallback: bool | None) -> Path:
"""Create + register the personal catch-all KB; shared by the two entry
points (`vouch hub init-personal` and `install-mcp --global`'s opt-in)
Expand Down
Loading
Loading