Skip to content

feat: add versioned client and provider IPC - #362

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

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

Conversation

@domenkozar

@domenkozar domenkozar commented Aug 16, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds two versioned, private-child-process IPC protocols for SecretSpec:

Direction Protocol Purpose
Application / SDK → SecretSpec secretspec.resolver/1 Resolve, reject, release, and optionally mutate secrets through secretspec serve
SecretSpec → external provider secretspec.provider/1 Implement provider operations in an independently shipped executable

Both protocols use strict JSON-RPC over 4-byte length-prefixed stdio frames. They share initialization, version and capability negotiation, limits, deadlines, cancellation, structured errors, and orderly shutdown, while keeping their method sets separate.

This is deliberately not a daemon or socket protocol: every session launches a directly connected private child process without a shell.

What this PR adds

  • A new secretspec-ipc Rust crate with async client/server primitives, protocol types, lifecycle management, and a blocking resolver client.
  • A new secretspec-ipc-conformance crate that exercises Rust and C implementations against shared malformed-input and lifecycle cases.
  • secretspec serve and secretspec serve --read-only as the resolver endpoint used by applications and SDKs.
  • External provider discovery, validation, launch, capability negotiation, and adaptation into the existing provider trait.
  • A dependency-free C11 resolver client in libsecretspec-resolver, with CMake and Meson support and vendored yyjson.
  • Prompt callbacks for interactive resolution, including a polling API suitable for C and foreign-language runtimes.
  • Reference documentation for the architecture, shared wire format, both protocols, and implementation guidance.
  • A protocol-freeze gate and broader cross-platform CI coverage, including standalone C tests on Windows.

Resolver protocol

Applications launch secretspec serve directly and select secretspec.resolver/1 during initialization. Initialization fixes the manifest source, base directory, profile, provider selection, scope, and reason for the lifetime of the session.

Core methods:

  • resolver.get requests exactly one named secret and returns either an inline value or a leased file.
  • resolver.release releases leased material; outstanding leases are also cleaned up on disconnect.
  • resolver.reject invalidates only the derived or cached copy that the caller rejected. It remains available in read-only sessions and is required in the 0.20+ protocol surface.
  • resolver.set and resolver.delete are optional 0.20+ capabilities advertised by writable endpoints.
  • client.prompt lets the resolver request user input when the client advertises prompt support.

secretspec serve --read-only does not advertise mutation methods and rejects resolution paths that would need to persist generated or prompted values. Headless clients receive an immediate missing/error result instead of leaving an unanswered prompt pending.

The C client exposes pending prompts through take/answer/decline/wait operations rather than invoking callbacks into an unknown foreign runtime. Prompt answers are validated before they are consumed, including strict UTF-8 validation.

External provider protocol

External providers are registered as trusted executable definitions with an absolute executable path, fixed arguments, and declared credential environment names. SecretSpec launches one private child per provider URI and reason, then selects secretspec.provider/1 during initialization.

The protocol covers provider naming and addressing, get, get_many, exists, set, expiring writes, delete, clear, writability checks, descriptions, preflight checks, and reflection. Capabilities are negotiated independently, so narrow or write-only providers do not need to pretend to support unrelated operations.

Provider failures retain structured error information. Session loss and reconnect behavior distinguish replay-safe requests from mutations that must not be replayed automatically.

Library boundaries

  • secretspec-ipc is the Rust implementation of both IPC protocols.
  • libsecretspec-resolver is the pure-C11 client for secretspec.resolver/1 only.
  • libsecretspec remains the embedded, in-process resolver ABI used by existing SDK integrations. It was renamed from secretspec-ffi; the three exported C symbols remain unchanged, and loader compatibility is documented and tested.

Keeping the embedded ABI separate from the child-process protocol makes the security and lifecycle model explicit for each integration style.

Security and lifecycle hardening

  • Strict message shape, frame-size, nesting, string-size, ID, and deadline limits are enforced before dispatch.
  • Request deadlines have a bounded horizon and propagate through callbacks; cancellation frees in-flight capacity without making a healthy session unusable.
  • Expired or cancelled request and callback IDs are retained long enough to consume one terminal response safely.
  • Child exit handling drains buffered responses before declaring the transport closed, including endpoints that reply and exit immediately.
  • Startup failures kill and reap children with a fresh grace period, avoiding unreaped processes after an expired startup deadline.
  • External executables are canonicalized and validated before launch. Windows validation walks the complete canonical ancestor chain and checks relevant ACL rights, including subtree replacement through FILE_DELETE_CHILD.
  • Windows launches use constrained handle inheritance and a case-insensitively sorted Unicode environment block after inherited values and overrides are merged.
  • Unix launch validation and permission checks prevent unsafe executable and directory configurations.
  • The native clients reject malformed UTF-8, truncated frames, invalid JSON-RPC envelopes, duplicate keys, unknown IDs, oversized values, and unsupported batches.

Conformance and testing

The conformance suite now runs a shared malformed-input corpus across the Rust resolver client, C resolver client, provider endpoint, and external-provider adapter. It covers empty and batch messages, duplicate keys, invalid UTF-8, truncated headers and payloads, oversized frames, and unknown response IDs.

Lifecycle coverage includes:

  • repeated immediate-exit-after-shutdown-response sessions;
  • a real Linux /proc assertion that startup-timeout children are reaped;
  • end-to-end expired callback responses without session loss;
  • in-flight slot reuse after cancellation, deadline expiry, and dropped requests;
  • inherited and replaced Windows environment-variable ordering.

Focused gates run during the hardening pass:

cargo test -p secretspec-ipc
cargo test -p secretspec-ipc-conformance
cargo test -p secretspec --test ipc_resolver
cargo check -p secretspec-ipc --tests --target x86_64-pc-windows-gnu
cmake -S libsecretspec-resolver -B <build-dir>
cmake --build <build-dir>
ctest --test-dir <build-dir> --output-on-failure
npm --prefix docs run build

The full Linux, macOS, Windows, SDK, packaging, feature, and documentation matrix also runs in CI.

Documentation

PR #98 feedback incorporated

This design preserves the useful direction from #98—version negotiation, capability discovery, explicit limits, structured errors, preflight checks, and external-provider extensibility—while tightening process ownership, least-access resolution, cancellation/deadline behavior, non-replayable mutations, lease cleanup, prompt handling, executable trust validation, and cross-language conformance.

@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 Updated (UTC)
🔵 In progress
View logs
secretspec be91afe Aug 20 2026, 05:55 PM

@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
domenkozar force-pushed the feat/ipc-v1 branch 3 times, most recently from a2109d8 to 37cc1ad Compare August 17, 2026 19:53
djbclark added a commit to frdminc/sudo-secretspec that referenced this pull request Aug 19, 2026
Parent 7a1c. The two-database merge is superseded: the gate between the
upstreamable provider and this privileged fork already exists upstream as
secretspec.provider/1 (PR cachix#362), verified against feat/ipc-v1 at a393a27.

Records three retracted findings and the method that caught them, the new
djbclark/secretspec-sqlite fork, and the endpoint prototype worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@djbclark

Copy link
Copy Markdown
Contributor

macOS: libsecretspec-ipc does not build — sigtimedwait is not available on Darwin

Following up on my earlier comment: I built the first out-of-tree privileged secretspec.provider/1 endpoint against feat/ipc-v1 at a393a27 this week. The endpoint side went cleanly — three methods, no patching of this PR, and it passes the checked-in conformance/ipc/cases/provider-lifecycle.json. Full write-up in a moment; one portability bug is worth reporting on its own first, because it blocks the conformance suite entirely on macOS.

libsecretspec-ipc/src/process_posix.c:237 calls sigtimedwait under a plain #ifndef _WIN32:

if (count <= 0) {
    if (mask_changed && !sigismember(&previous, SIGPIPE)) {
        struct timespec zero = {0, 0};
        (void)sigtimedwait(&blocked, NULL, &zero);
    }

macOS has no sigtimedwait at all. It is not a header-guard or feature-test-macro problem, and not a strict-mode warning:

$ grep -rn sigtimedwait "$(xcrun --show-sdk-path)/usr/include/"   # no matches
$ nm -g /usr/lib/libSystem.B.dylib | grep -i sigtimedwait          # no matches

So it is a link failure as well as a compile error. With -std=c11 -Werror it surfaces at compile time as:

process_posix.c:237:23: error: call to undeclared function 'sigtimedwait';
ISO C99 and later do not support implicit function declarations

Practical effect: cargo build -p secretspec-ipc-conformance fails on macOS, because that crate's build.rs compiles the pure-C client. That takes out ipc-provider-conformance-driver, ipc-client-conformance-driver, and the provider_cases / client_cases tests — i.e. a third-party endpoint author on macOS cannot run the conformance suite this PR ships for them. (macOS 26.6, Apple clang, aarch64.)

Suggested fix

sigpending + sigwait is the portable equivalent, and it cannot block here: SIGPIPE generated by this thread's own write is thread-directed, so if it is pending it stays pending until this thread accepts it.

         if (count <= 0) {
             if (mask_changed && !sigismember(&previous, SIGPIPE)) {
-                struct timespec zero = {0, 0};
-                (void)sigtimedwait(&blocked, NULL, &zero);
+                sigset_t pending;
+                int signal_number;
+                if (sigpending(&pending) == 0 && sigismember(&pending, SIGPIPE)) {
+                    (void)sigwait(&blocked, &signal_number);
+                }
             }
             if (mask_changed) (void)pthread_sigmask(SIG_SETMASK, &previous, NULL);
             return false;

With that applied the conformance driver builds and runs on macOS unchanged. Two notes on the shape:

  • sigpending reports the union of thread-pending and process-pending signals, so in principle this could consume a SIGPIPE generated elsewhere in the process. That is strictly better than the status quo of not building, and if you would rather keep sigtimedwait where it exists, an #if defined(__APPLE__) split around just these lines is equally fine by me.
  • I deliberately did not apply this in my prototype tree — I kept it as a patch file — so that "an out-of-tree privileged endpoint needs no patching of upstream" stayed literally true and mechanically checkable (git diff empty). Happy to open it as a PR against feat/ipc-v1 if that is easier than a review comment.

Related, since it is the same lane: #377 (SIGPIPE default disposition) is still open from my side.

@domenkozar

Copy link
Copy Markdown
Member Author

The INI extract support has been split out into #386 and removed from this branch.

It was unrelated to the IPC work and only shared a commit with it, so the two can now be reviewed and merged independently. This branch is unchanged otherwise: the first commit was rewritten to drop the INI hunks and the remaining 18 were replayed on top, so the IPC diff is byte-identical to what it was.


Created by Claude Code (see cli/cli#13904 for why this note is here rather than in the GitHub API).

@domenkozar
domenkozar force-pushed the feat/ipc-v1 branch 7 times, most recently from bb96dbe to 7118678 Compare August 20, 2026 00:09
@domenkozar
domenkozar force-pushed the feat/ipc-v1 branch 6 times, most recently from 40356cb to bfcfbfb Compare August 20, 2026 16:48
domenkozar and others added 2 commits August 20, 2026 12:54
Adds SecretSpec 0.20 local IPC across two boundaries with different authority.
The Secret Resolution Protocol resolves an exact declared name through a
complete configuration for an application or SDK. The Secret Provider Protocol
implements one provider behind SecretSpec's resolver, receiving provider
addresses and values but not SecretSpec's storage or resolver internals. Both
ride one wire protocol: JSON-RPC 2.0 in bounded length-prefixed frames over a
child process's stdin and stdout.

`secretspec serve` runs one resolver session as a private child of whoever
launched it. It never prompts on the protocol streams and exits with its
parent. `--read-only` refuses any resolution that would write to a provider,
not just the store and remove methods: resolving is not always a read, since a
`generate = true` declaration with no stored value is minted and written back,
and a `prompt = true` one is written back after a person answers.

The protocol ships two first-class implementations tested against one
language-neutral conformance suite plus differential state-machine tests, so a
bug shared between them is less likely to redefine the contract.
`libsecretspec-resolver` is a portable C11 resolution client, C rather than a C
ABI over Rust, with opaque handles, explicit-length buffers, and no callbacks
into foreign runtimes. `secretspec-ipc` is the Rust client/server crate, with
typed sessions, endpoint helpers, and a `blocking` feature for consumers that
must not acquire an async runtime.

A declaration with `prompt = true` resolves over IPC by asking the process that
launched the resolver, which has the terminal. A client declares during
initialization whether it can answer, so a headless consumer is never asked and
never waits out a deadline. The C client answers prompts without handing a
function pointer across the ABI: a waiting call reports a pending prompt, and
the caller takes it, answers or declines, and waits again.

`resolver.reject` discards SecretSpec's cached copy of a value the consumer was
refused, since expiry only retires what the clock invalidated and a token
revoked at its issuer stays fresh by the clock. Optional `resolver.set` and
`resolver.delete` let a consumer store or remove one declared secret on the
route the session already reads from. Unknown error kinds and value sources
decode rather than failing the session, so a later revision can name a new
failure or origin without breaking a deployed peer.

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 resolver lease files. An
endpoint that writes a banner or stack trace to the frame stream is reported as
having written non-protocol text, without echoing the bytes.

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.

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