carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

AGENTS.md

πŸ€– Guidance for everyone β€” human or agent β€” changing this repository. This is the canonical instructions file; tool-specific files (CLAUDE.md) are pointers to it, so keep it current here.

What CONTRA is lives in README.md; who it is for in PRODUCT.md; the cryptographic protocol in PROTOCOL.md; the visual system in DESIGN.md; the roadmap and its reasoning in PLAN.md. This file is how we build and the decisions we have committed to.

PROTOCOL.md was called DESIGN.md until 2026-09-04. If you are reading a discussion, a commit message or a code comment older than that, the two names mean the same file.

CONTRA is a member of the CARLOS app family (carlosframework/platform); see that family's carlos:building-carlos-apps skill for the shape it follows. Where this file and the skill disagree, this file wins.

CONTRA

An end-to-end-encrypted password manager. The server is host-blind: it holds ciphertext, public keys, and opaque ids, and nothing else.

The one rule

Absolutely nothing is revealed.

CONTRA is CARLOS A1 β€” host-blind, in the strict sense the family publishes at carlosframework.com:

> Keys live on the user's own devices; the instance never holds one that > opens user content. The only residue is transport shape: who connects > when, object sizes, timing. No content-derived identifiers at rest.

Not A2. A2 permits an enumerated application-level residue published in the open, and it is where Eleven sits β€” precisely because Eleven's thread membership is visible to the server. CONTRA does not get that allowance. If a change would put a content-derived identifier in SQLite, it is wrong.

The instance's whole SQLite file is continuously replicated to object storage, so a plaintext column is a plaintext leak. Metadata β€” item names, URLs, usernames, folder structure, vault names β€” seals client-side exactly like values do.

Enforced, not promised: the end-to-end suite pushes canary strings through every write path and greps the raw SQLite bytes and the bucket objects for them. Keep that test merciless. A new storage surface lands with its canary or it does not land.

Settled decisions (don't relitigate casually)

Each is dated and attributed. Rejected alternatives are recorded beside them. These change with new facts, dated and attributed β€” not in a drive-by.

1. Trust class: A1 strict, on the Slopbox shape (Paul, 2026-09-01)

Instance-per-account. A shared vault lives on its owner's instance; other members are identities on that instance holding the vault's group key wrapped to their own public key.

Rejected: Eleven's sharing model as-is. It was the starting proposal and it is the thing that makes Eleven an A2 app. Membership of a thread on a shared multi-tenant instance is a content-derived identifier. Membership of a vault on your own instance is not β€” it is the access-control list the instance must consult to decide who it serves sealed bytes to, which is transport shape. That distinction is load-bearing; if a change erodes it, the class claim goes with it.

The consequence we accept: revocation is Slopbox's honest three-part answer β€” credentials die immediately, the group key rotates forward for new and changed items, and nothing already downloaded is un-downloaded. No copy pretends otherwise.

2. Recovery: a passphrase and print-once codes (Paul, 2026-09-01)

A passphrase signs in to the web console. Recovery codes are shown exactly once, at signup. Lose both and the data is gone β€” no restore, no operator escape hatch, ever.

This reverses two dated family decisions, deliberately:

  • Eleven, Paul 2026-07-25: "recovery is a passkey, never a passphrase or a printed code." The paper kit was killed and the passphrase-wrap primitive deleted the same day.
  • Slopbox, 2026-07-26: "the mandatory paper recovery kit is gone; passkeys hold the keys."

Why it is right here and nowhere else in the family: a password manager cannot store its own recovery credential in a password manager. The family's recovery passkey lives in iCloud Keychain or 1Password β€” which is the exact product CONTRA replaces. The circularity is worst for the person migrating off 1Password, who is our first user. Paper is back because paper is the only medium that is not the thing we are replacing.

Secondary reason, not the deciding one: the 1Password Emergency Kit means this pattern is already understood by the audience.

Settled the same day (Paul, 2026-09-01): the kit prints an Account Key too. A 128-bit Account Key is mixed into the passphrase's KEK branch and printed on the same client-generated PDF as the recovery code, because passphrase-only unlock would have made the security of the replicated bucket equal to the strength of whatever the user typed. Rejected: Option B, passphrase-only plus compensating controls β€” a control that depends on user behaviour is a hope with a progress bar.

A fresh device needs the kit, or a handoff from an enrolled one, so "passphrase known, kit lost, every device gone" is unrecoverable. That is not a cost we are paying β€” it is the one rule applied without exception (Paul, 2026-09-01), and the copy states it without apologising. The everyday case is unchanged: a device holding a cached Account Key unlocks with the passphrase alone.

What it retires is a promise that was never quite true β€” "your passphrase or your codes get you back in". The honest model is the kit is the root; the passphrase is the everyday convenience.

The obligation is therefore not to soften the rule but to ensure nobody meets it by accident: a three-rung ladder that never dead-ends, a prominent reprint flow, and a nag to enrol a second device.

The kit is two separate PDFs, not one sheet β€” because the two values have different blast radii. The Account Key alone is nearly harmless (one input to the passphrase branch); the recovery code alone is full takeover. One is a setup aid to keep handy, the other a bearer credential to archive, and the risk we are managing is storage β€” people save PDFs, and one file puts both in the same folder forever. PROTOCOL.md carries both documents, the ladder, the reprint semantics and the loss table.

3. Email exists only as a blind index (Paul, 2026-09-01)

Kass's shape: an HMAC under a server-held pepper. It proves identity for sign-in and can never decrypt anything. There is no stored address column and no provider user-id. Email-gated key escrow is the forbidden shortcut and stays forbidden β€” a compromised server plus a passed email gate must still yield nothing.

Direction of travel (Paul, 2026-09-01): email + magic link for signup, then passkey for quick sign-in thereafter, is intended to become the CARLOS default. CONTRA is where it gets built first. Build it so it can be lifted.

4. Passkey + PRF is a fast unlock, never the only door (Paul, 2026-09-01)

A third wrap of the same key material, not a shortcut around it. The failure that forced Eleven's rethink β€” the macOS ceremony sheet reaching only iCloud Keychain, and 1Password's macOS provider returning a PRF that does not match its own extension's for the same credential β€” means a present passkey can still fail. Every screen that offers it must offer the passphrase beside it, always, with no dead ends.

5. Agents may use a secret; they may not see one (Paul, 2026-09-01)

An agent gets a scoped, per-agent credential that can list metadata, use a value without receiving it, and create new values (free β€” sealing to a public key needs no unlock). Reading a value into a model's context is not a path that exists in v1.

Sharpened by a design review, 2026-09-01. The review's findings are binding, not advisory:

Say which threat each control actually stops. Two adversaries, and they must never be conflated in our docs or our UI:

  • T1 β€” the honest but leaky agent. Doing its job, about to spill a value into a transcript and, through the prompt ledger, into public.
  • T2 β€” the adversarial agent. Prompt-injected through a README, a web page, an issue comment; actively trying to exfiltrate.

Use-without-seeing defeats T1. It does not defeat T2 β€” an agent that chooses the command can make that command print its own environment. We ship the honest claim: values never enter transcripts by default; a hostile agent is contained by scoping, staging and audit, not by the injection trick. Marketing this as a T2 control would be a lie.

Same-UID is not a boundary. The agent's shell runs as the same user as the daemon. Minimum posture: macOS hardened runtime, non-dumpable and no core dumps on Linux, mlocked key pages, and a written plan to move the daemon to its own UID with peer-credential checks on the socket. The threat model says out loud that a determined same-UID process wins; our job is to make winning noisy.

The three v1-mandatory controls, built before anything else in the agent surface:

  1. The daemon-side redaction choke point. One serialization boundary through which every byte returning from daemon to agent passes β€” stdout, stderr, error strings β€” scanned for each injected value and its cheap encodings (base64, hex, URL- and JSON-escaped) and replaced.
  2. Redaction-hit alerting. A spike in redactor firings is the best intrusion signal we will ever own. Alert on it; do not merely log it.
  3. The ledger publish-time screen. Every prompt is checked against local vault-value hashes before it is published. Values are local, so the check is local and exact. Given that this repo publishes a prompt ledger automatically, this is the last gate between "leaked into a transcript" and "published to the internet". It is not optional.

Agents create; humans promote; nothing mutates. The attack that forced this: an agent rewrites the URL of an existing item to a lookalike host, the human later autofills, and the client pours the real password β€” which the agent could never read β€” into the attacker's page. Write access to metadata plus autofill is credential theft through the human's own hands. So agent writes are create-only into a quarantined staging state: never autofilled, never usable by use, visibly badged, kept out of the main list, forbidden from colliding with an existing item's name or host. A human promotes after a diff-style review. No agent overwrite of an existing item in v1, period. Rotation is stage-a-new-version plus promote. This costs one human click per provisioned credential; that is a good trade.

Two use verbs that fail in opposite directions. use-env leaks the value easily but wields only the command's authority; use-http protects the value nearly perfectly but hands over the credential's entire authority at that host β€” and for most API tokens the authority is worth more than the string. Therefore:

  • use-env grants name a human-registered command binding, not a free-form command. Prefer stdin/fd injection over the environment where the tool supports it.
  • use-http is default-deny with per-grant method and path allowlists, on a human-confirmed origin pin (scheme+host+port, HTTPS only): no redirects followed, proxy env ignored, TLS validated against the pinned name, response bodies redacted, size caps and timeouts. The daemon owns credential placement β€” the agent supplies method, path and body, and never composes the authenticated part of a request. An agent may never set an origin pin; pins are trusted only once promoted.

Grant scope is (agent identity, item, verb, verb-specific narrowing, TTL). Per-item for anything value-touching β€” vault-wide grants are how you lose everything to one injection; vault-level is fine for list. Use counts are theatre except n=1 on read. Revocation is a local delete, checked atomically per use: nothing new starts, anything already launched completes β€” so the console must list processes currently holding secrets, with a kill button.

Approval attaches to an (agent, item, verb, binding) tuple, not to each use, with a session TTL as the default and the concrete action shown. Long unattended runs get their grants at kickoff, while the human is present. A grant not anticipated mid-run blocks the run β€” that is correct behaviour, not a bug. Per-use prompts are worse than useless: they train reflexive clicking.

Audit is local, append-only, hash-chained and signed, with a sealed copy synced to the server for durability (the operator cannot read it, which is fine β€” the server was never going to enforce anything). Audit-before-result: no operation's result is released to the agent until its log entry is durably queued. Review is a screen β€” "what did agents do last night", anomalies first β€” not a log file. Audit without review is a diary, not a control.

The failure mode we expect to actually bite, recorded so nobody is surprised by it: an honest agent debugging a failing job at 3am under a standing use-env grant runs env, and the secret rides the transcript into the public ledger. Boring, likely, and uniquely bad here because publication is automated. The three mandatory controls above exist for exactly this.

6. Build order (Paul, 2026-09-01)

  1. rastrillo instance + client-shape web console β€” sealing happens where the keys are, so this surface is client-shape by force.
  2. Native Mac app β€” the second implementation of a settled contract.
  3. AutoFill Credential Provider extension (macOS).
  4. iOS.

The protocol is proven once, in one place, before a second client exists.

7. Import is 1PUX, and it is client-side (Paul, 2026-09-01)

1Password's 1PUX export for v1: it carries item types, TOTP seeds, attachments and vault structure, where CSV loses nearly all of it. The file is parsed and sealed entirely in the browser. An unsealed export touching the server would be an A1 violation on day one.

Stated honestly in the copy: passkeys stored in 1Password cannot be exported by anyone, us included. That is 1Password's limit, not ours, and it needs a manual migration path rather than a pretence.

Bitwarden and Apple Passwords importers are wanted, later β€” tracked as discussions on this repo, not built now.

8. Ledgers on, at prompts level (Paul, 2026-09-01)

Prompt ledger and carbon ledger both enabled (amadan ledger init -level prompts). Not dialogue or full, and the reason is this product: a fuller level publishes more of what passes through a session working on a password manager.

The law that goes with it: no real secret ever enters a prompt, a test fixture, a commit, or a canary. Fixtures are generated. If a real value is ever typed, amadan ledger redact / amadan ledger withhold are routine tools here, not emergency ones β€” use them the same day.

9. Licence: MPL 2.0 (Paul, 2026-09-01)

Mozilla Public License 2.0. File-level copyleft: a modified CONTRA must ship its modified files, so what a user runs stays checkable against what we published β€” the same argument the whole product makes. Factor VI, not deferred.

The mechanical part, for when code lands: MPL expects each source file to carry the Exhibit A notice. Add it as files are created rather than in a sweep afterwards, and if it is worth enforcing, enforce it as a test β€” this house does not rely on remembering.

10. Money: free rung, then €5/year (Paul, 2026-09-01)

Slopbox-inspired: a genuine free rung, paid starting at €5/year. Cheap on purpose β€” a password manager people cannot afford to keep is a password manager that loses their data. Integer cents; a float never touches an amount.

Build and test

  • Go, one static binary, CGO_ENABLED=0, thin main.go dispatch.
  • rastrillo is the framework (github.com/carlosframework/rastrillo) β€” its packages exist to be used, not hand-rolled. Read the repo's own SKILL.md first; it postdates most models' training data. Honest note carried from the family: rastrillo does not yet support the E2EE app story at framework level, so the sealed flow is app-side, built on the Keymail/Kass/Slopbox patterns. Don't claim the framework does it.
  • Stdlib first. Adding a dependency is a decision, not a default.
  • modernc.org/sqlite, WAL (busy_timeout set before journal_mode), SetMaxOpenConns(1), additive-only migrations. New code over an old DB must always be safe. Never delete data to update.
  • go test ./..., go vet ./..., gofmt -l . β€” clean, always. Nothing lands untested; a bug fix carries its regression test. Never pipe a test run you are gating on: a pipe eats the exit code.
  • Crypto is byte-identical in Go and JS, pinned by shared golden vectors. Change one side and you must change the other, plus the context strings.
  • JS: small self-contained ES modules, no build step, go:embed, 300 lines per module enforced by test, caps ratchet down only. Never innerHTML untrusted content β€” build DOM nodes.
  • The merciless greps (above) run in the end-to-end suite.

Picking up work

The backlog lives in this repo's discussions, not in this file. Discussion 18, "START HERE", is the map β€” the two tracks (web/* for remote agents, native/* for local), the dependency order, and the claiming convention.

Read it before claiming anything. In particular: amadan has no assign or claim verb yet (filed as amadan/amadan 36), so claiming is a reply on the discussion, and skipping that step is how two agents end up building the same thing overnight.

Workflow

  • Worktree per session, always. Never build in the main checkout; reading it is fine, writing is not.
  • On amadan the branch is the pull request. Take a worktree, take a branch, push it, and describe it before the first edit: amadan branch describe <branch> -body -. The branch page is the only view of your work anyone gets β€” keep the description true, put the plan in Tasks, and put questions and decisions in Discussions.
  • Agents post as their agent account (claude~paul@keymail.dev, via AMADAN_HOME), never as the human. The πŸ€– badge is not optional.
  • Squash-merge to main only when all three hold: the work is done, a human has approved it, and CI is green. Never commit to main.
  • The bootstrap exception, once. "CI green" binds where CI exists. This repo has no CI script until web/1 lands, so there is no green to wait for and the condition cannot be met by anyone. amadan agrees: its merge gate defaults to off β€” "a gate nobody chose is not a gate" β€” and reports such a repo's CI as skipped, "this repo has no CI script, so nothing can be green". So the first merges land on done + approved, and adding CI is itself the first work item, not a prerequisite that cannot be satisfied. The moment .amadan/ci exists the three-condition rule binds absolutely, and this paragraph is spent.
  • Commit subjects are area-prefixed prose that says why. Every commit carries a Co-Authored-By: trailer; the human is the author of record.
  • Review happens on a deployed canary, never on localhost. Report the clickable canary URL in every reply while iterating.
  • Never echo a secret, a claim link, or a recovery code into a session.

AI authorship

Machine-authored text is marked πŸ€– where it surfaces. A person marker (πŸ‘¨/πŸ‘€/πŸ§‘) certifies human text an LLM must not rewrite, rephrase, condense or delete. AI features in the product are opt-in, always disclosed, and every path works with the AI switched off.

Deliberately not built yet

Each with the trigger that would start it.

| Not built | Trigger | |---|---| | Bitwarden and Apple Passwords importers | 1PUX import is shipped and a real user asks | | Passkey migration out of 1Password | A documented manual path exists first; automation only if the export limit ever lifts | | iOS app and its AutoFill extension | The Mac app and its extension are shipped and settled | | Cross-instance vault sharing (federation) | Never in v1 β€” the biggest scope trap; revisit only after single-instance sharing is proven | | Agent read of a secret value into context | Not planned. Would need a written argument that use-without-seeing genuinely cannot serve a real case | | Breach / reused-password monitoring | Requires sending something derived from a value off-device; needs an A1-compatible design before it is even sketched | | Secure sharing with non-members (one-off links) | After vault sharing ships | | Agent mutation of an existing item | A user-demanded rotation workflow β€” and then as staged-version-plus-promote, never an in-place write | | Vault-wide grants for value-touching verbs | Sustained user reports that per-item granting is the top friction. We are host-blind, so this can only come from users, never telemetry | | Remote or mobile approval of an agent grant | The audit pipeline proven in the field, plus a dedicated design review of the control channel | | Free-form use-http (no path allowlist) | No trigger. This stays denied. | | Agent-settable origin pins; autofill of agent-created items | Never β€” both are the lookalike-URL attack |