# Configuration files reference

> Schemas and validation rules for `source.yaml`, `sink.yaml`, `allowlist.yaml`, and `blocklist.yaml` (SQLite LIKE patterns, tilde expansion, listen-address checks).

- Repository: mvanhorn/agentcookie
- GitHub: https://github.com/mvanhorn/agentcookie
- Human docs: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae
- Complete Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/llms-full.txt

## Source Files

- `internal/config/config.go`
- `internal/config/allowlist.go`
- `examples/source.yaml`
- `examples/sink.yaml`
- `examples/allowlist.yaml`
- `examples/blocklist.yaml`

---

---
title: "Configuration files reference"
description: "Schemas and validation rules for `source.yaml`, `sink.yaml`, `allowlist.yaml`, and `blocklist.yaml` (SQLite LIKE patterns, tilde expansion, listen-address checks)."
---

agentcookie reads three YAML files from `~/.config/agentcookie/` (overridable with the `--config-dir` persistent flag): `source.yaml` on the laptop, `sink.yaml` on the second Mac, and an optional `blocklist.yaml` that either side may keep. The loaders live in `internal/config/`, decode with `gopkg.in/yaml.v3` in strict mode (`KnownFields(true)`), expand a leading `~/` in path values via `ExpandTilde`, enforce a 32-byte floor on the legacy `security.shared_secret`, and reject sink `listen.addr` values that are not a Tailscale 100.64.0.0/10 address or an explicit loopback.

## Common loader behavior

All three loaders (`LoadSource`, `LoadSink`, `LoadBlocklist`) share the same plumbing:

- YAML is decoded with `dec.KnownFields(true)`, so unknown top-level keys cause `decode <path>: ...` errors. Use the field names below verbatim.
- Path-shaped fields (`chrome.db_path`, `cdp.profile_dir`) accept a leading `~/`. `ExpandTilde` only rewrites that exact prefix — a bare `~other` is left untouched, absolute paths are passed through, and an empty `db_path` falls back to `DefaultChromeCookiesPath()` (`~/Library/Application Support/Google/Chrome/Default/Cookies`).
- `source.yaml` and `sink.yaml` are required by their owning role; missing → `not found: <path> (start from examples/ in this repo)`.
- `blocklist.yaml` is optional; a missing file yields an empty `Blocklist{Version: 1}` and the sync-all default kicks in.
- `--config-dir` defaults to `~/.config/agentcookie/` and is set on the root cobra command, so every subcommand sees the same directory.

<Note>
`source.yaml`, `sink.yaml`, and `blocklist.yaml` are each independently optional from the binary's point of view — `agentcookie status` reports partial state when only one is present. A role's runtime command (`agentcookie source`, `agentcookie sink`) still requires its own file.
</Note>

## source.yaml

Lives on the machine where Chrome is logged in interactively. Tells the source watcher where to push, which Chrome SQLite to read from, and which key to seal with.

```yaml
sink:
  url: http://my-mac-mini.tailnet.ts.net:9999/sync

chrome:
  db_path: ~/Library/Application Support/Google/Chrome/Default/Cookies

peer:
  hostname: my-mac-mini.tailnet.ts.net

# security:
#   shared_secret: legacy-pre-pair-only-32-bytes-min
```

### Fields

<ParamField body="sink.url" type="string" required>
Full URL of the sink's `/sync` endpoint. Required; an empty value fails with `sink.url is required`. Usually `http://<tailnet-host>:9999/sync`.
</ParamField>

<ParamField body="chrome.db_path" type="string">
Absolute or `~/`-prefixed path to Chrome's `Cookies` SQLite file. Empty falls back to `~/Library/Application Support/Google/Chrome/Default/Cookies`. Set explicitly to read from a non-`Default` profile.
</ParamField>

<ParamField body="peer.hostname" type="string">
Filename (without extension) under `~/.config/agentcookie/keys/`. After `agentcookie pair --as source` writes `<hostname>.json`, this field tells the source watcher which 32-byte AES-GCM key to seal `/sync` payloads with.
</ParamField>

<ParamField body="security.shared_secret" type="string">
Legacy pre-pairing credential. Optional, but at load time **at least one of `peer.hostname` or `security.shared_secret` must be set** or the loader returns `either peer.hostname (paired key) or security.shared_secret (legacy) is required`. When present, the value must be **≥ 32 bytes**; a shorter secret fails with `security.shared_secret must be at least 32 bytes (got N); prefer pairing (`agentcookie pair`) over a typed secret`. Never marshalled into JSON output. Delete the field after pairing.
</ParamField>

## sink.yaml

Lives on the second Mac where agents act. Configures the HTTP listener, the cookie-delivery surfaces (Chrome SQLite, managed-CDP Chrome, sidecar, adapters), and the peer key.

```yaml
listen:
  addr: 100.80.229.80:9999

cdp:
  enabled: true
  managed: true
  # profile_dir: ~/.agentcookie/chrome-profile

# chrome:
#   db_path: ~/Library/Application Support/Google/Chrome/Default/Cookies

peer:
  hostname: my-laptop.tailnet.ts.net

# skip_chrome_sqlite: true   # v0.12.0-beta.3 headless mode
# delivery: universal         # v0.13 wizard intent marker
```

### Fields

<ParamField body="listen.addr" type="string" required>
`host:port` the sink binds its HTTP server to. **Required** since v0.12 — empty fails with `listen.addr is required (run `agentcookie wizard install --as sink` ...)`. The value is enforced twice: by `validateListenAddr` at sink startup and by the same function inside the wizard when `--listen` is passed.
</ParamField>

<ParamField body="chrome.db_path" type="string">
Only set when running in legacy SQLite-write mode (`cdp.enabled: false` and `skip_chrome_sqlite: false`). Tilde-expanded; defaults to the Chrome `Default/Cookies` path when omitted.
</ParamField>

<ParamField body="peer.hostname" type="string">
Source machine's hostname. Used to look up the per-peer key under `~/.config/agentcookie/keys/<hostname>.json`. As with `source.yaml`, either this or `security.shared_secret` must be present.
</ParamField>

<ParamField body="security.shared_secret" type="string">
Legacy fallback; same ≥ 32-byte rule as `source.yaml`.
</ParamField>

<ParamField body="skip_chrome_sqlite" type="bool" default="false">
v0.12.0-beta.3 headless flag. When true, the sink never reads Chrome Safe Storage and never writes Chrome's SQLite/leveldb/indexeddb files; only the sidecar at `~/.agentcookie/cookies-plain.db` and per-CLI adapter session files remain. Designed for SSH-only Mac mini installs where no GUI session can answer the Keychain prompt. Omitted/false keeps the legacy behavior — installed users see no flip on binary upgrade.
</ParamField>

<ParamField body="cdp.enabled" type="bool" default="false">
When true, the sink launches its own Chrome via chromedp after each `/sync` and pushes cookies through `Storage.setCookies`. Chrome owns its own Safe Storage key on this path; agentcookie never reads the macOS Keychain item.
</ParamField>

<ParamField body="cdp.profile_dir" type="string">
Tilde-aware profile directory passed as `--user-data-dir`. Defaults to `~/.agentcookie/chrome-profile` when omitted.
</ParamField>

<ParamField body="delivery" type="string">
v0.13 wizard-intent marker; accepts `universal` (real Default Chrome profile + any-app Keychain open) or `degraded` (the `-T`/`skip_chrome_sqlite` opt-out). `omitempty` — a `sink.yaml` written before this field loads with `Delivery == ""` and unchanged behavior. The flag exists so `agentcookie doctor` can report intent without re-inferring from `skip_chrome_sqlite` plus a Keychain probe.
</ParamField>

### Listen-address policy

`internal/cli/wizard.go:validateListenAddr` is the single source of truth, called by the sink, the pair listener, and the wizard's `--listen` flag.

| Input | Result | Reason |
|---|---|---|
| `0.0.0.0:9999`, `[::]:9999`, `:9999` | Rejected | `refuses to bind on "..." (every interface)` |
| Non-`host:port` strings (e.g. `no-colon-here`) | Rejected | `parse host:port: ...` |
| `192.168.1.5:9999`, `100.63.0.5:9999` | Rejected | `not a Tailscale 100.x address` |
| `127.0.0.1:9999`, `[::1]:9999`, `localhost:9999` | Accepted | Explicit loopback for local dev |
| Any IPv4 in `100.64.0.0/10` (e.g. `100.64.0.1`, `100.80.229.80`, `100.127.255.254`) | Accepted | Tailscale CGNAT range |

`agentcookie wizard install --as sink` calls `tsclient.RequireTailnetIP` to write a concrete 100.x address into `sink.yaml`; `ErrAmbiguousTailnetIP` is returned when multiple 100.x interfaces are present, prompting the operator to pin one manually.

<Warning>
A pre-v0.12 `sink.yaml` that omits `listen.addr` used to silently fall through to `127.0.0.1:9999`, which masked the wizard's auto-detection failures. v0.12 makes empty a hard error and re-running `agentcookie wizard install --as sink` is the supported repair.
</Warning>

## blocklist.yaml

Optional opt-out file read by both source and sink. Patterns are matched against Chrome's `host_key`; a match means **drop**. Missing file or empty `domains:` means sync everything.

```yaml
version: 1

domains:
  - pattern: "%chase.com"
    description: Chase bank
  - pattern: "%1password.com"
  - pattern: "%irs.gov"
```

### Schema

<ParamField body="version" type="int" required>
Must equal `1`. Any other value fails with `unsupported blocklist version N (this binary speaks version 1)`.
</ParamField>

<ParamField body="domains" type="[]BlocklistEntry">
List of opt-out entries. Empty or omitted = sync-all.
</ParamField>

<ParamField body="domains[].pattern" type="string" required>
SQLite-LIKE pattern. `%` matches any sequence (including empty); all other characters match literally. Empty pattern fails with `domains[i].pattern is empty`. Matching is **case-insensitive** and runs against Chrome's `host_key` column, which includes a leading dot for subdomain cookies (so `%chase.com` covers both `chase.com` and `.www.chase.com`).
</ParamField>

<ParamField body="domains[].description" type="string">
Free-form human note. Not used by the matcher.
</ParamField>

### Matching semantics

`internal/protocol/allowlist.go:matchLike` implements the subset of SQLite LIKE that agentcookie uses:

```text
'%'            wildcard (matches zero or more characters)
any other char literal match
case-insensitive  (input and pattern are both lowercased before compare)
```

There is **no `_` single-character wildcard, no LIKE-ESCAPE clause, and no glob metacharacters** (`*`, `?`, `[...]` are literal characters). Both source and sink build a `BlocklistMatcher` and run it independently — the sink reapplies the filter as defense in depth so the receiver always has the final say on what lands on disk.

```text
"%chase.com"      matches ".chase.com", "www.chase.com", ".secure.chase.com"
"%.example.com"   matches ".example.com", "x.example.com"; does NOT match "example.com"
"chase.com"       matches only the literal string "chase.com" (rare — host_key usually has a leading dot)
```

### allowlist.yaml (legacy)

v0.3 inverted the v0.2 allowlist semantic to opt-out blocking. The package keeps a thin shim:

- `LoadAllowlist(dir)` is an alias for `LoadBlocklist(dir)` and returns the new `Blocklist` shape.
- `AllowlistEntry = BlocklistEntry` and `Allowlist = Blocklist` Go type aliases keep older call sites compiling.
- If `blocklist.yaml` is missing **and** `allowlist.yaml` exists, the loader renames the legacy file to `allowlist.yaml.v2.bak` (only when no `.v2.bak` already exists), prints a one-line stderr warning, and returns an empty blocklist. The legacy patterns are **not** carried over — sync-all becomes the new default and the old file is preserved for manual review.

<Info>
`agentcookie wizard install` writes a starter `blocklist.yaml` with all patterns commented out, so a fresh install runs in explicit sync-all mode. Add entries on either side; the sink owner's blocklist wins because the sink filters after decrypt.
</Info>

## Error reference

| Loader | Trigger | Error substring |
|---|---|---|
| `LoadSource` | file missing | `not found: <path> (start from examples/ in this repo)` |
| `LoadSource` | `sink.url` empty | `sink.url is required` |
| `LoadSource` / `LoadSink` | no peer & no shared secret | `either peer.hostname (paired key) or security.shared_secret (legacy) is required` |
| `LoadSource` / `LoadSink` | `shared_secret` < 32 bytes | `security.shared_secret must be at least 32 bytes (got N)` |
| `LoadSink` | `listen.addr` empty | `listen.addr is required (run \`agentcookie wizard install --as sink\` ...)` |
| Sink/wizard | `listen.addr` is `0.0.0.0`/`::`/empty host | `refuses to bind on "..." (every interface)` |
| Sink/wizard | non-tailnet IP | `refuses to bind on "...": not a Tailscale 100.x address` |
| Any loader | unknown YAML field | `decode <path>: ... field ... not found in type ...` |
| `LoadBlocklist` | `version` ≠ 1 | `unsupported blocklist version N (this binary speaks version 1)` |
| `LoadBlocklist` | empty pattern | `domains[i].pattern is empty` |

## File layout

```text
~/.config/agentcookie/
├── source.yaml        # role: source
├── sink.yaml          # role: sink
├── blocklist.yaml     # optional, both roles
├── allowlist.yaml.v2.bak  # legacy v0.2 file, renamed on first v0.3 load
└── keys/
    └── <peer-hostname>.json   # per-peer 32-byte key, mode 0600
```

Both source and sink load `blocklist.yaml` from this same directory; the file is not pushed over the wire — each side maintains its own.

## Related pages

<CardGroup cols={2}>
  <Card title="Configure source and sink" href="/configure-source-sink">
    Editing the same YAML files in a guided walk-through — peer hostname, Chrome DB path, CDP managed-Chrome toggle, and `skip_chrome_sqlite` for headless boxes.
  </Card>
  <Card title="Pairing and per-peer keys" href="/pairing-and-keys">
    What populates `~/.config/agentcookie/keys/<peer>.json` and how `peer.hostname` is resolved at sync time.
  </Card>
  <Card title="CLI reference" href="/cli-reference">
    Flags for `agentcookie source`, `agentcookie sink`, `agentcookie wizard install`, and how `--config-dir` overrides the default directory.
  </Card>
  <Card title="Wire protocol v1" href="/wire-protocol">
    How the AES-256-GCM seal uses the key indexed by `peer.hostname` and the sink validation order applied after decrypt.
  </Card>
</CardGroup>
