# RFC: Repository-scoped reversible secret placeholders Status: Implemented. The supported boundaries and failure behavior are specified below. ## 1. Goal The global low-entropy secret filter answers "does this session contain a value the user registered explicitly", but refusing the push outright forces the user to choose between "keep the whole session" and "publish the session to the remote". This RFC adds a repository-scoped reversible projection: the local runtime keeps seeing the real value, Git and the hub see only an opaque placeholder. ```text runtime plaintext │ agit commit (matches on JSON semantic strings) ▼ repository-scoped placeholder ── agit push ──► hub / other devices │ │ agit resume / run (only on an explicit local materialization) ▼ runtime plaintext ``` Session settlement protects native event content and generated commit messages, together with user-controlled observations in `session/meta.json`: working directory, code origin, observed branch and milestone. Metadata schema, session identity, object hashes and enum values remain structural. Heuristic discovery completes before event objects are formed, so values learned from observations also protect matching transcript content. File commits protect selected UTF-8 shared files before publishing their tree, including files captured by a first session commit. Memory collection and promotion use the same dictionary, and runtime memory mirrors hydrate locally. A partially staged file keeps its unstaged working copy; protection applies to the selected bytes. Outward export and share protect semantic content before rendering can truncate it. Claimed live/history sessions resolve the dictionary from the Agent repository that owns the native session, not from the source-code working directory. Arbitrary Git headers, tag messages, direct Git writes and history that predates protection still pass through the publication gate. Protecting a new snapshot does not remove an old plaintext object. Push validates the entire outgoing history and never rewrites it. Local resume and same-repository discovery read observation placeholders through the local dictionary without rewriting stored metadata. A missing mapping cannot establish repository identity or equal worktree state. Shared/backend metadata reads retain placeholders; bounded status inspection reports unavailable evidence when its local dictionary cannot be read. Two kinds of candidate enter the dictionary automatically: literals the user registered explicitly in the global vault, and heuristic hits in semantic content. Independent entropy discovery supplements provider and contextual rules; it does not require their regexes to match first. JSON property keys and values are both inspected. A candidate is protected by default, and the user can allow its exact value by opaque id. Allowances suppress future local projection and client findings without deleting the reverse mapping, so old placeholders still hydrate. A property named `sha`, `signature`, `session_id` or `provenance` does not exempt its value or subtree. Verified envelope identities, typed Git headers resolved in the selected repository, and canonical existing secret tokens have scoped exemptions; adjacent content is inspected normally. One settlement adds at most 1024 distinct candidates; a candidate already present in this repository's dictionary (an allowed one included) does not spend that budget again, so a long append-only session is never blocked forever at its 1025th historical candidate. Going over the limit on additions fails before any Git object is written. The independent tokenizer accepts ASCII letters, digits and `_-+/=.!@#$%^&*?~`. It measures Shannon entropy in bits per character with these minimum length / entropy pairs: hex (including hyphenated hex or `agit-`, `sha1-` and `sha256-` prefixes), 32 / 3.2; mixed-case alphabetic, 24 / 3.8; alphanumeric, 20 / 4.0; tokens containing symbols, 20 / 4.2. Tokens must contain mixed-case letters, a digit, or use the hex alphabet. Credential-related JSON properties add evidence and lower the pair to 10 / 3.5. These are best-effort discovery policies, not a guarantee that every credential is recognized. A path-like spelling alone is not an exemption. An automatic repository candidate uses a 64 KiB plaintext cap and a padded ciphertext bucket of at most 128 KiB, enough to hold a common 4096-bit PEM private key reversibly; a manually registered global rule keeps its 512 UTF-8 byte cap. The dictionary reports an oversized finding without partially replacing its header. Settlement, file/memory projection and outward rendering must reject that incomplete result before publishing a version or emitting content. The original native input remains available; a resource failure never means the input was clean. PEM discovery covers the region from a private-key header through its matching footer. If a semantic string ends before that footer, its remaining bytes belong to the sensitive region. A header-only substitution must never leave captured key material in a supposedly protected field. Overlapping redaction findings cover their union, including credentials nested inside a PEM region. RC delta protection buffers each item until completion before inspecting and emitting its text. This also covers an empty global registry: provider rules and multiline findings cannot rely on a registered literal's length to choose a safe stream boundary. The buffer limit is 1 MiB per item and 8 MiB across streams sharing a redactor. Overflow withholds the whole item and emits an explicit protection-limit notice on completion; later chunks cannot release an unchecked suffix. The native transcript remains available for settlement. This boundary delays delta display until item completion. ## 2. Why the substitution cannot wait for `git push` Git blobs, trees, commits and tags are all content-addressed objects. Swapping one secret for a placeholder just before the push changes every OID along blob → tree → commit → tag; local and remote stop being the same fast-forwardable history, and the version identity `session/meta.json` defines no longer holds. The substitution point therefore sits before AgentGit forms its first canonical Git object — the boundary where `agit commit` wraps the runtime transcript into an envelope. `agit push` keeps its full, fail-closed repo-wide scan as the last backstop for: - plaintext history that predates the feature; - content the user writes or commits directly, bypassing `agit commit`; - unsupported shared-file carriers and commit/tag headers outside automatic projection; - a corrupt dictionary, a missing keystore entry, or a conversion that did not finish. This delivers what the user observes — what goes up is the key, what comes back hydrates on this device — without faking Git's identity model. ## 3. Module boundary Both chains live in the `domain::secret_filter` domain module and share these primitives: - AES-256-GCM envelope encryption that fails closed on an authentication failure; - linear Aho–Corasick matching over arbitrary UTF-8 literals; - semantic traversal over JSON string values: serialized bytes carrying `\"`, `\\` or `\n` must not impersonate the original value. Inside the module the two storage responsibilities stay apart; they do not go into one vault: | Component | Scope | Contents | Lifetime | | --- | --- | --- | --- | | global filter vault | the whole device | the user's detection rules | user add / remove | | repository dictionary | one Git checkout | placeholder key → secret | the local checkout | A repository must still hydrate an already published placeholder after the global rule is deleted, so the repository dictionary keeps its own encrypted copy instead of only a foreign key pointing at a global rule id. ## 4. Storage and placeholders Each repository's dictionary lives at: ```text /.git/agit/secret-dictionary/vault.json ``` It sits inside Git metadata, so `git add`, push, an ordinary workspace scan and shared-file export never carry it away. The file retains envelope encryption, but its KEK is created automatically at `/.git/agit/secret-dictionary/keys/.key`. Repository storage does not consult `secrets.keystore` or create Keychain entries. On Unix the key file is owner-only (`0600`) in a private directory (`0700`). On Windows the key directory and temporary key file receive an explicit current-user private ACL before key bytes are written; existing key files are checked for private ownership and permissions through the handle used to read them. A local backup containing this directory includes both the encrypted mappings and their key. Treat it as sensitive data. Neither file is part of Git history or uploaded by push. The global registration vault remains separate and uses the user's configured keystore. Its configuration does not change when a repository dictionary is created or migrated. Dictionaries without the `key_storage` marker use their existing configured keystore until a locked operation successfully authenticates every record. That operation durably installs the repository key and atomically marks the dictionary `repository-local-v1`. The vault id, record ids, ciphertext and placeholders remain unchanged; the previous key is retained. A retry accepts an identical local key but rejects a conflicting one. Strict read-only inspection can read the existing storage without migrating it. Once marked local, missing or corrupt local keys fail explicitly and never fall back to the global keystore. On macOS, reading the previous key may require authorization once during migration. After migration, repository protection and hydration no longer access Keychain. For manually registered global secrets, select "Always Allow" to retain authorization for the same signed executable. "Allow" grants a single access. Rebuilding an ad-hoc-signed executable or changing its signing identity may require authorization again; the CLI does not weaken Keychain ACLs or suppress an authorization decision. A random record id is generated the first time a secret is met in that repository; the same secret in the same repository reuses one record, and another repository generates a different id. The placeholder format is: ```text {{AGIT_SECRET_V1::}} ``` The key is never `SHA-256(secret)`, a truncated hash or deterministic encryption. A deterministic digest of a low-entropy secret hands whoever holds the remote content an offline dictionary oracle, and it leaks that two repositories use the same value. A placeholder is a versioned, repository-scoped opaque capability. Only a token matching the local dictionary in full is hydrated; an unknown or malformed token, or one belonging to another repository, is kept verbatim and quietly — never guessed at, never fetched over the network, never replaced with an empty string. Malformed lookalikes remain ordinary input for discovery; they do not acquire the opaque-token exemption. ## 5. Write path `agit commit` parses every parsable JSONL line into a `serde_json::Value` and walks string values and arrays recursively; matching and replacement happen on the decoded UTF-8 string, which is then serialized canonically. Effective repository rules protect each of their occurrences verbatim. A secret carrying quotes, backslashes, newlines or Unicode therefore matches under exactly the same semantics as ordinary characters. The dictionary payload records `schema_version=2` and `projection_version=1` separately. One conversion compiles the currently effective repository records, the registered global rules and the new heuristic candidates into a single leftmost-longest matcher: 1. an existing dictionary entry keeps mapping to its original key even once the global rule is deleted; 2. a newly hit global rule appends an encrypted record to the repository dictionary and gets a new key from it; 3. every dictionary change persists atomically before any Git object is written; 4. an existing placeholder span is opaque; matching never runs again inside a token; 5. preset allowances and user allowances are subtracted after entropy, built-in rules and explicit registrations contribute their candidates; user allowances win over blocks; 6. output is built hit by hit as a stream, never materializing "all hit ranges", so auxiliary memory stays bounded on highly repetitive input. The management commands offer no show / decrypt / export: ```text agit secrets review [--repo ] [--json] agit secrets allow [--repo ] agit secrets unallow [--repo ] agit secrets block add