Added
-
Stored placeholders — the note pins what the consumer emits (ADR-0029). A binding note/annotation may now carry a
placeholder:key; when present and valid it wins over salt derivation for that secret.avp binding newmints one by default (CSPRNG, 130-bit lowercase-base32 tail) and prints a consumer-wiring hint on stderr (export NAME='<placeholder>');--no-placeholderkeeps the legacy salt-derived flow. Three guardrails: generated-not-hand-chosen, a strict format gate at parse (avp-PLACEHOLDER-+ ≥21-char lowercase-base32 tail — a weak string liketokencannot parse, so a note can never aim injection at innocent traffic), and global uniqueness at resolve — fail-closed with thief-loses semantics, covering equal AND substring-overlapping claims (a stored placeholder colliding with another secret's derived or file placeholder drops only the thief, so a note-writer can't un-broker someone else's secret nor brick the reload via the merge validator's overlap raise; contests with no single legitimate claimant drop all claimants, every drop audited with both names and kept attributable at request time).avp envnow reads notes and projects the stored placeholder where pinned, derived otherwise, so the projection always matches what the daemon enforces. Closes the onboarding gap where a freshly bound secret couldn't inject until someone with daemon-host access ranavp envand hand-wired the derived placeholder; stored placeholders also survive salt rotation and secret renames. Notes without the key are unchanged. 21 tests including an addon end-to-end (stored placeholder in, real secret on the wire, no value in the audit log). -
avp binding new— deterministic binding-note generator (code before prompts). A new operator verb that builds the per-secret binding note/annotation from flags and validates it through the daemon's ownparse_notes_bindingbefore printing it — so it can only ever emit a binding the daemon will accept: the# avp-bindingmarker present, the generic{secret}token present, a real host (a URL or malformed host is refused with the parser's own diagnostic and a non-zero exit).--host(repeatable for multi-host),--header/--format(non-Bearer auth),--methods/--paths(scope),--name,--backend bws|gsm(prints the notes block or thegcloud secrets update … --update-annotationscommand). This is the security-critical artifact that two outages came from hand-authoring (a missing marker; a host-shaped description) — now structurally impossible to emit wrong. The bundledavpskill is rewritten to run this tool and hand the user its output rather than author YAML free-hand.avp setupnow prints a next-steps block pointing at the tool and the skill install. 10 tests, including a round-trip smoke that re-parses the tool's output back into a validParsedBinding. -
avp setupbackend selection + graceful token handling. Setup no longer hard-defaults to Bitwarden and dead-ends on an unskippable token prompt. It now supports all three backends via mutually-exclusive flags —--bws,--gsm,--static— and, when none is given on a TTY, presents an interactive picker (1/2/3) that loops until a valid choice; off a TTY it defaults to BWS so Ansible/container runs stay non-interactive.--gsmwrites a keyless, secure-by-default GSM config (self_check: deny,reject_ambient_key: true, no key-file field) and, following DRY, hands off to the separate privileged IAM helperavp gcp-setupfor per-secret grants rather than duplicating it. The BWS token prompt is now graceful: empty input re-prompts once, then skips cleanly — it never writes a 0-byte token file (which previously produced confusing downstream daemon failures), and the message points at--static/--gsm; the entered token is whitespace-stripped. 11 tests (picker mapping + re-prompt, GSM keyless render + no token/static step, empty-token writes-nothing, mutual exclusion). -
sigv4injector — AWS Signature Version 4 request signing (ADR-0027, Slice 1). The first signing injector: AWS APIs don't take a static bearer credential, they require each request to be signed (AWS4-HMAC-SHA256over method + canonical URI/query + signed headers + SHA-256 of the body).inject.type: sigv4takesregion,service, and vault-secret referencesaccess_key_id_secret/secret_access_key_secret/ optionalsession_token_secret; the secret'splaceholder(planted inAuthorization) is the detection trigger, not a substituted value. The pure, dependency-free signer (injectors/sigv4.py) is verified byte-for-byte against the AWS SigV4 test-suiteget-vanillavector. Because SigV4 hashes the full request body (unavailable atrequestheaders), the addon detects + stashes the verdict there and signs in a newrequesthook once the body has buffered, over the raw wire bytes; a sigv4 + body-injector combination on one host is rejected at config-load. End-to-end tested through the addon (valid signature applied, placeholder gone, session tokens signed, missing-credential fail-closed 503, credential values never in the audit log). Unblocks brokering AWS credentials (S3, execute-api, STS) so they never enter the agent's process. The sibling signing injectors (hmac,jwt_bearer) can now land on the same seam. -
hmac+jwt_bearersigning injectors (ADR-0028). Two more auth mechanisms on the SigV4 request-hook seam.hmac(RFC 2104): signs an operator-declaredsigning_string— a template over{method}{path}{query}{host}{body_sha256}{timestamp}— withHMAC-<sha256/…>and writes thehex/base64digest to a header (optionaltimestamp_header); body-hashing, so it signs in the request hook, and hmac+body-injector on one host is rejected at config-load.jwt_bearer(RFC 7519/7515): mints a signed JWT from a vault key + operator claims (iss/sub/aud, iat/exp fromttl_seconds,extra_claims) and injects it asBearer {jwt}; supportsHS256(shared secret),RS256andES256(PEM key via the existingcryptographydep; ES256 emits raw R||S per RFC 7518). Signers pinned to public vectors (HMAC-SHA256; jwt.io HS256 byte-for-byte; RS256/ES256 round-trips); end-to-end tested (signature applied, placeholder gone, fail-closed 503 on missing key, key never in the audit log). With SigV4 + OAuth2-refresh + static header/body, AVP now covers the major authentication mechanisms. -
oauth2_client_credentials+github_appinjectors (ADR-0030) — the injector taxonomy is complete. The last two reserved types, both network-exchange (resolved at requestheaders likeoauth2_refresh) sharing a new SSRF-guarded token transport (injectors/_token_transport.py).oauth2_client_credentials(RFC 6749 §4.4): exchanges a vault client id + secret for an access token (body_post/basicauth, optional scope), caches it per-binding, injectsBearer {access_token}.github_app: mints an App JWT (RS256, reusing the jwt_bearer signer; iat backdated for skew, exp ≤10 min) and exchanges it at/app/installations/{id}/access_tokensfor a short-lived installation token, injectingtoken {token}. Both fold the rotating credential into the token-cache key (rotation invalidates), reuse thetoken_exchangeaudit (no new event type), and fail closed with 503; the client secret / App private key and the minted token never reach the audit log. End-to-end tested against mocked endpoints (exchange, cache hit, 4xx, missing/malformed key). All eight injector types now ship — static header/body,oauth2_refresh,oauth2_client_credentials,github_app,sigv4,hmac,jwt_bearer,multi. -
Zero-restart notes-refresh (ADR-0032). Notes-mode bindings re-resolve on a background timer (
notes_refresh_seconds, default 60), so an added/removed/edited vault-note binding goes live without restarting the daemon. Value + derived-token caches stay warm across the atomic swap — the token-cache key folds in the secret value, so a changed binding can't reuse a stale token — and a transient vault-listing failure keeps the previous bindings rather than dropping them. Emits anotes_refreshedaudit event on change;file-mode is untouched.
Changed
- Note parser tolerates uniformly-indented bodies. A hand-pasted note whose body is indented (copied out of a rendered/quoted block, or tab-indented — which raw YAML rejects) now parses: the common leading whitespace is stripped before YAML load. Safe because the note grammar is flat with flow-style lists, so dedent never alters key nesting; genuinely inconsistent indentation still fails loud.
- Skill renamed
avp-bindings→avp(skills/avp/): its scope now covers the full onboarding ceremony — authoring the binding, minting the stored placeholder, and wiring the consumer — not just bindings. Plugin name (/plugin install avp@agent-vault-proxy) is unchanged; the in-plugin invoke becomes/avp:avp. Update any~/.claude/skills/symlink to point atskills/avp.
Security
- TLS termination scoped to bound hosts (ADR-0026). New
tls_termination: bound | all(defaultbound). Inboundmode AVP MITM-terminates and injects ONLY for hosts the config binds; every other CONNECT is an opaque TCP passthrough — no leaf cert minted, no decryption, the client validates the upstream's real certificate end-to-end. So AVP never holds plaintext for traffic it does not broker, and a stolen AVP CA cannot decrypt unbound flows. Decided per-connection attls_clienthello(SNI, CONNECT-host fallback) against the live config snapshot, so hot-reload applies to new connections. Each tunneled connection emits atls_passthroughaudit event (destination host + reason only — no secret material) to preserve exfil visibility without interception. Deny-the-unbound (egress allow-list) was considered and rejected — it would force a per-deploymentallow_unboundlist maintained alongside the vault, breaking the "drop a secret, done" model; blocking stays with the host egress layer (nftables/OpenSnitch). BEHAVIOUR CHANGE: unbound HTTPS is no longer decrypted by default — settls_termination: allto keep full termination. Orthogonal tounmatched_destination_policy: deny, which still 403s an unbound CONNECT before TLS. - Notes-binding marker required (ADR-0025) — a note is only a binding when it says so. A note/annotation is now parsed as a binding ONLY when its first non-blank line is exactly
# avp-binding; anything unmarked is a human description and yieldsNoBinding— it cannot bind, cannot be judged malformed, and can never fail-closed-exclude the same secret's file bindings (the structural close of the v0.8.0 incident: ambient vault prose could previously become a "malformed binding" that silently un-brokered the secret underbinding_source: both). Unmarked notes that look host-shaped log a load-time warning naming the secret, so a forgotten marker is self-diagnosing. Marked notes fail LOUD: marker with no body, or a marked scalar that is not hostname-shaped, isInvalidBinding(audited). Uniform across BWS notes and the GSMavp-bindingannotation. BREAKING (note bindings only): prepend a# avp-bindingline to any existing note/annotation binding, or it becomes inert (with the warning) on first reload; filebindings.yamlis unaffected.