feat: add versioned client and provider IPC - #362
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
secretspec | a393a27 | Commit Preview URL Branch Preview URL |
Aug 18 2026, 02:03 AM |
2b080cc to
337950c
Compare
Filed the check-stdout report as cachix#372 and opened cachix#373 against it. The PR is built on upstream/main rather than cherry-picked from sudo-main, so it carries only the secrets.rs and check_report_stream.rs hunks plus a hand-written Changed entry under upstream's own Unreleased -- the fork's CHANGELOG diff is 458 fork-local insertions and could not be lifted. All three regression tests were verified against a pure dfa4b10 base, not just against our merged tree. Posting also turned up a hole in a draft marked READY TO POST: the ELI5 section said "if you try to do the obvious thing:" and then jumped straight to "...you get nothing", with the example command block missing entirely. Restored before sending. The larger find is upstream PR cachix#362, which the ledger did not track at all -- it was visible only as a pointer in a comment on cachix#64. It introduces SecretSpec IPC v1 for 0.20+, including `secretspec broker --stdio`, and is close enough in vocabulary to this fork that the distinction has to be written down: upstream's broker is an IPC endpoint inside the caller's own trust domain, not a privilege boundary. Its initialize accepts a caller-supplied manifest, provider and profile, which is exactly what this fork's control plane exists to remove, and its audit is fail-open where ours is fail-closed and hash-chained. The practical consequence is favourable: `secretspec.provider/1` is the exec:// mechanism cachix#345 asked for, and a privileged endpoint can be registered as data without patching upstream internals. Recorded in docs/design/upstream-ipc-v1-and-the-fork.md, along with the finding that cachix#362 does NOT retire the codegen-schema shape debt -- no manifest-shape reflection anywhere in the client protocol, so cachix#371 remains the only route. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tier 2 handoff e439, parent 7c73 (deterministic). Covers releasing 0.19.1-sudo.15, posting upstream issue cachix#372 and PR cachix#373, tracking upstream's IPC v1 PR cachix#362 -- and the vault truncation incident this session caused. The incident is the reason this document leads with it rather than the release: plain `install` without --adopt-existing truncated /var/db/sudo-secretspec/.env to 0 bytes, destroying every stored value. Both shipped docs specify the flag (SKILL.md:197, AI-GUIDANCE.md:87) and I handed over the command without it. The audit ledger brackets the loss to 26 seconds after the install, and Arq's Aug 17 02:10 SYSTEM record predates it, so recovery is available. Three failed approaches are recorded in full because each was expensive: the merge hypothesis presented to the operator before it was cheaply falsifiable, reading a green template-check as reassurance when it was evidence of the overwrite, and concluding the loss predated the session because fs::copy on macOS preserves source mtime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator request 2026-08-17, sequenced after the .16 release, cachix#370 manifest-edit, and the cachix#362 comment. Motivated by the vault truncation incident: the boundary keeps no history of its own, so logical loss currently depends on external backup tooling to recover. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…view draft The design doc asserted /Library/Application Support is admin-group writable on macOS. Verified false against this machine (26.6.1): root:admin 0755, no ACL. Replaced with what actually is verifiable in external.rs as of PR head 337950c -- the unix trust check is blind to macOS ACLs (only the Windows path validates ACLs), both checks follow symlinks via fs::metadata, and trust genuinely does stop at the immediate parent with no verification above it. Landed the corrected review comment as a tracked file (docs/design/pr362-comment.md) rather than leaving it in scratch, since it was drafted but not yet posted when work paused. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Following up from #345 (closed into #64) — One piece of review feedback from operating that kind of boundary, aimed at the unix registration trust checks in
Happy to turn this into (a) a small hardening PR against |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
337950c to
cce973e
Compare
Adds SecretSpec 0.20 local IPC: a private stdio resolution broker, trusted out-of-tree provider endpoints, independent Rust and pure-C clients, exact-name resolution with broker-owned file leases, and shared schema, OpenRPC, and conformance contracts with executable drivers for both clients, the Rust provider endpoint, and the real broker process. Provider IPC preserves structured error kinds, never uses protocol streams for prompts, and isolates endpoint state by URI and reason. Deadlines live once on the request envelope and are clamped to a 300 second horizon by both clients so a peer cannot hold an in-flight slot indefinitely. Windows ACL isolation covers provider discovery and broker lease files. The embedded C ABI is renamed to `libsecretspec`, with `libsecretspec.so`, `.dylib`, `secretspec.dll`, `libsecretspec.a`, and `libsecretspec.pc` as its public artifacts. Runtime SDK loaders still recognize the pre-0.20 `secretspec-ffi` filenames. Also adds `extract` support for INI documents, selecting an unsectioned key with `/key` or a named-section key with `/section/key`, using RFC 6901 escaping for pointer segments. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The checked-in yyjson copy is about half of every diff that touches the C client, which buries the reviewable changes. Marking it keeps GitHub from counting it toward language statistics and collapses it in review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Captures the upstream pkg-config bug that currently blocks consuming yyjson as a system package, the submitted fix, and the concrete steps to take once it is released. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cce973e to
0b65662
Compare
a2109d8 to
37cc1ad
Compare
…cking client Version 1 named its two protocols from opposite ends of their own connections. `secretspec.provider/1` was named after the side that answers, while `secretspec.client/1` was named after the side that asks, so the method namespace claimed the wrong actor: `client.resolve` says the client resolves, when the client requests and the server resolves. `provider.get` already established the convention, which is the role being asked followed by the verb. The northbound protocol is now `secretspec.resolver/1` with methods `resolver.get` and `resolver.release`. That collapses three words into one: the wire said `client`, the server API said `serve_resolution`, and the types said `Resolution*`, because nobody could write `serve_client` and mean it. The schema, OpenRPC document, fixtures, C client, conformance cases, and docs all follow, and `client` now has exactly one meaning in the system, which is the party that initiated a connection. The CLI is now `secretspec serve` rather than `secretspec broker --stdio`. The role is implicit because provider endpoints are out-of-tree executables, so this binary never serves the provider protocol; if that changes, an optional positional adds it back compatibly. The transport is implicit because stdio is the safe mode: it is a private child that exits with its parent, so only a future daemon mode, which would expose a socket other local processes can reach, has to be asked for. Nothing had shipped under the old names. The protocol is documented as 0.20+ and the latest tag is v0.19.1, so this is the last point at which the rename is free rather than a version 2 negotiation. Also adds a `blocking` feature to secretspec-ipc: a synchronous `secretspec.resolver/1` session over `std::process`, for consumers with no async runtime that should not acquire one. It reuses the same framing, envelopes, and validation as the async client and passes the same fake peer conformance cases. Since the server only ever writes responses and such a caller issues one request at a time, it needs no multiplexing, pending map, or cancellation arbitration. Deadlines are enforced by terminating the child, because a blocking pipe read cannot be interrupted. It adds no dependency beyond the crate's existing serde, serde_json, thiserror, and zeroize; zeroize is now declared without the unused `derive` feature so the proc macro stays out of a consumer's tree. `LaunchOptions` and `Environment` move to a runtime independent `launch` module so both transports share them, and `lifecycle` re-exports them unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
This introduces SecretSpec IPC v1 for 0.20+: two application protocols over one canonical framed JSON-RPC wire/session layer.
secretspec.client/1secretspec.provider/1The two method sets cannot be mixed in one session. They share framing, initialization, capability negotiation, request IDs, deadlines, cancellation, structured errors, limits, and shutdown.
The PR includes:
secretspec broker --stdio, exact-name resolution, and broker-owned file leases;Provideradapter;get_manywithoutget,set_expiringwithoutset, and write-only providers withexistsbut no plaintext read;secretspec-ffitolibsecretspec, retaining legacy runtime loader compatibility in every existing SDK.Which library is which?
libsecretspecis the embedded in-process resolver ABI used by the current language SDKs.libsecretspec-ipcis the pure-C client for launching and talking to either IPC endpoint. New non-Rust broker-mode integrations should bind this library instead of reimplementing framing and lifecycle.secretspec-ipcis the independent Rust wire/client/server/handler implementation.One IPC client implementation is sufficient because initialization selects the application protocol. The client and provider protocols remain separate contracts with disjoint methods and trust boundaries.
Client IPC usage
The client library directly launches the broker as a private child—there is no global socket or daemon:
Initialization fixes the manifest and resolver context for the session:
{ "manifest": { "kind": "path", "path": "/absolute/project/secretspec.toml" }, "provider": null, "profile": "production", "scope": "deploy", "reason": "build api container" }The consumer then resolves exactly the declaration it needs:
{ "jsonrpc": "2.0", "id": 2, "method": "client.resolve", "params": { "deadline_unix_ms": 1786766405000, "name": "FORGE_TOKEN", "representation": "value", "purpose": { "consumer": "nix", "operation": "fetch", "host": "github.com", "path": "/acme/project" } } }representation: "value"prevents a path from being mistaken for a token.representation: "file"returns an opaque lease that must be released withclient.release; disconnect also cleans it up. The broker never resolves unrelated required secrets.Rust consumers use
secretspec-ipc's lifecycle and typedResolutionClient. C and non-Rust consumers uselibsecretspec-ipcand free every returned buffer withsecretspec_ipc_buffer_free.Provider IPC usage
An endpoint author implements
ProviderHandler, advertises only the operations it actually supports, wraps it inProviderApplication, and serves it on stdin/stdout:Install a trusted registration for the provider scheme:
{ "schema_version": 1, "scheme": "factorseal", "executable": "/absolute/path/to/secretspec-provider-factorseal", "arguments": [], "credential_names": [] }Then use the scheme like an in-tree provider:
The provider receives its configured URI, base directory, resolved semantic credentials, and immutable access reason during initialization. It does not receive
config_file, reread the manifest, receive a caller-supplied identity, or choose SecretSpec routing.Write-only and narrow providers
A write-only endpoint can advertise:
That supports
set,delete, value-freecheck --json/check --explain, SDK no-values resolution, and ordinary imports into missing destinations. Existing destination entries are preserved without reading them. Plaintext resolution returnscapability_required; a write-only provider cannot be an import source, andimport --delete-sourcerequires a readable destination for exact-value verification.Capabilities are otherwise independent except for real dependencies:
provider.resolve_address;setorset_expiring;delete.The adapter can serve one
getthrough a one-itemget_many, but it never weakensset_expiringinto an ordinary write when the endpoint did not advertiseset.Why a child endpoint, not a proxy configuration?
The endpoint is the unavoidable language/process adapter between SecretSpec's in-process Rust
Providertrait and an out-of-tree provider implementation. It is not a network proxy and not a shared daemon: SecretSpec launches one private child for one provider URI and one reason, owns its lifecycle, and communicates over inherited pipes.This keeps routing and policy in SecretSpec while allowing Factorseal (and future providers) to keep their database, encryption, agents, grants, and remote API logic in their own implementation. Provider-specific settings stay in the provider URI or endpoint-owned configuration; arbitrary proxy-shaped fields and manifest forwarding are deliberately excluded.
PR #98 feedback incorporated
This replaces the unmerged direction in #98 with explicit boundaries and a canonical wire contract:
config_file;Documentation
Rendered branch docs, in contract order:
Canonical source contracts are under
docs/src/content/docs, with machine-readable schemas and fixtures underschema/ipc/v1.Testing
Executed locally and passing:
Focused executable gates:
The provider matrix runs the same checked-in cases directly against the Rust endpoint and through SecretSpec's external-provider adapter, including write-only and narrow-capability modes.