Skip to content

feat: add versioned client and provider IPC - #362

Open
domenkozar wants to merge 6 commits into
mainfrom
feat/ipc-v1
Open

feat: add versioned client and provider IPC#362
domenkozar wants to merge 6 commits into
mainfrom
feat/ipc-v1

Conversation

@domenkozar

@domenkozar domenkozar commented Aug 16, 2026

Copy link
Copy Markdown
Member

Summary

This introduces SecretSpec IPC v1 for 0.20+: two application protocols over one canonical framed JSON-RPC wire/session layer.

Boundary Protocol Purpose
Application or SDK → SecretSpec broker secretspec.client/1 Resolve one exact declared name as a value or leased file
SecretSpec → external provider endpoint secretspec.provider/1 Provider naming, reads, presence, writes, expiry, deletion, clearing, preflight, and reflection

The 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:

  • canonical JSON Schema, OpenRPC, fixtures, and protocol documentation;
  • an independent Rust client/server crate and a pure-C11 client;
  • secretspec broker --stdio, exact-name resolution, and broker-owned file leases;
  • trusted external-provider discovery, a Rust endpoint-author API, and the core Provider adapter;
  • C/Rust client differential tests plus executable broker/provider conformance cases;
  • independently negotiable provider capabilities, including get_many without get, set_expiring without set, and write-only providers with exists but no plaintext read;
  • the embedded ABI rename from secretspec-ffi to libsecretspec, retaining legacy runtime loader compatibility in every existing SDK.

Which library is which?

  • libsecretspec is the embedded in-process resolver ABI used by the current language SDKs.
  • libsecretspec-ipc is 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-ipc is 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:

secretspec broker --stdio

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 with client.release; disconnect also cleans it up. The broker never resolves unrelated required secrets.

Rust consumers use secretspec-ipc's lifecycle and typed ResolutionClient. C and non-Rust consumers use libsecretspec-ipc and free every returned buffer with secretspec_ipc_buffer_free.

Provider IPC usage

An endpoint author implements ProviderHandler, advertises only the operations it actually supports, wraps it in ProviderApplication, and serves it on stdin/stdout:

let application = Arc::new(ProviderApplication::new(Arc::new(MyProvider::new())));
serve(
    tokio::io::stdin(),
    tokio::io::stdout(),
    application,
    ServerConfig::default(),
).await?;

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:

[providers]
deploy = "factorseal://default"

[profiles.production]
DEPLOY_TOKEN = { providers = ["deploy"] }

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:

[
  "provider.resolve_address",
  "provider.exists",
  "provider.set",
  "provider.delete",
  "provider.reflect"
]

That supports set, delete, value-free check --json / check --explain, SDK no-values resolution, and ordinary imports into missing destinations. Existing destination entries are preserved without reading them. Plaintext resolution returns capability_required; a write-only provider cannot be an import source, and import --delete-source requires a readable destination for exact-value verification.

Capabilities are otherwise independent except for real dependencies:

  • addressed operations require provider.resolve_address;
  • writable preflight/description requires set or set_expiring;
  • deletion preflight requires delete.

The adapter can serve one get through a one-item get_many, but it never weakens set_expiring into an ordinary write when the endpoint did not advertise set.

Why a child endpoint, not a proxy configuration?

The endpoint is the unavoidable language/process adapter between SecretSpec's in-process Rust Provider trait 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:

  • client-to-broker and broker-to-provider are separate application protocols;
  • the provider does not parse SecretSpec configuration or receive config_file;
  • no protocol field pretends caller metadata is authenticated identity;
  • capability negotiation and structured errors replace guessed optional behavior;
  • credentials and secret values stay off argv and are bounded/redacted on the private transport;
  • subprocess ownership, cancellation, non-replay, crash recovery, and shutdown are specified and executable in conformance tests.

Documentation

Rendered branch docs, in contract order:

  1. IPC architecture
  2. Shared IPC wire protocol
  3. Secret Resolution / client protocol
  4. Secret Provider protocol
  5. Repository implementation and conformance guide

Canonical source contracts are under docs/src/content/docs, with machine-readable schemas and fixtures under schema/ipc/v1.

Testing

Executed locally and passing:

cargo test --all
cargo check -p secretspec --no-default-features
cargo check -p secretspec --all-features
cargo clippy -p secretspec-ipc -p secretspec-ipc-conformance --all-targets --no-deps -- -D warnings
cargo fmt --all -- --check
git diff --check
npm --prefix docs run build

Focused executable gates:

cargo test -p secretspec-ipc-conformance --test client_cases
cargo test -p secretspec-ipc-conformance --test client_differential
cargo test -p secretspec-ipc-conformance --test provider_cases
cargo test -p secretspec --test ipc_broker

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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@domenkozar
domenkozar force-pushed the feat/ipc-v1 branch 2 times, most recently from 2b080cc to 337950c Compare August 16, 2026 23:59
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 17, 2026
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>
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 17, 2026
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>
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 17, 2026
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>
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 17, 2026
…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>
@djbclark

Copy link
Copy Markdown
Contributor

Following up from #345 (closed into #64) — secretspec.provider/1 is the out-of-tree provider mechanism that proposal asked for, so: thank you, and we intend to be an early adopter. sudo-secretspec plans to ship a System-scope registration whose endpoint is a thin unprivileged shim crossing a sudo-mediated privilege boundary into a root-owned broker — the "privileged integrations construct an allowlisted environment" case the architecture doc already nods at. As far as we can tell from the PR, that needs no upstream patching: ProviderHandler/ProviderApplication are public API and registration is data.

One piece of review feedback from operating that kind of boundary, aimed at the unix registration trust checks in external.rs (as of 337950c). Offered as hardening plus conformance cases, not an objection to the design:

  1. macOS ACLs are invisible to the mode-bit check. check_file_security/check_parent_security gate on metadata.mode() & 0o022, but on macOS an extended ACL can grant add_file/write to a group while the POSIX bits read 0755 — third-party installers add such ACLs routinely. The Windows path already validates ACLs (path_acl_is_trusted); the unix path currently has no ACL story. sudo-secretspec's drift checker refuses any extended ACL on a protected path or its ancestors for this reason.

  2. Both checks follow symlinks. std::fs::metadata resolves, so what gets validated is the resolved target, not the literal registered path. A symlinked component anywhere in the chain silently redirects discovery to wherever a root-owned, mode-clean target happens to be. symlink_metadata plus an explicit no-symlinked-components rule is cheap at discovery time.

  3. Trust stops at the immediate parent. Everything above the registration directory's parent is implicitly trusted. On stock macOS 26 the default System chain (/Library/Application Support, root:admin 0755, no ACL — verified) is sound as shipped, but that soundness is assumed rather than checked, and one loosened ancestor — by mode or by ACL, see (1) — defeats both checks below it. Walking the resolved chain to / once per discovery is what sudo-secretspec does (check_ancestor_chain in its drift checker): refuse non-root-owned, group/world-writable, ACL-bearing, or symlinked ancestors.

Happy to turn this into (a) a small hardening PR against feat/ipc-v1 and (b) conformance-suite cases — ancestor-writable-by-mode, ancestor-writable-by-ACL, symlinked-component, symlinked-registration-file — if that would be useful. And once the privileged endpoint ships against this we'll write the deployment up so the reference case is documented.

djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 17, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
domenkozar and others added 3 commits August 17, 2026 14:22
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>
domenkozar and others added 2 commits August 17, 2026 15:34
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants