Repository navigation
Privilege separation stays here; the gate to upstream is secretspec.provider/1 (PR #362) #2
Description
The division of labour, settled 2026-08-18
This repo keeps privilege separation only: vault files owned by a dedicated
service user, unreadable and unwritable by the calling user, mediated through
sudo. The sqlite provider — including its opt-in history feature — goes
upstream via djbclark/secretspec-sqlite, not here.
Privilege separation is not a provider concern, and this repo's own code
already says so:
Confidentiality comes entirely from filesystem permissions on the database
file — the schema and connection carry no credential of their own. That is
what lets identical code serve both an ordinary user-owned store and a
privilege-boundary vault file guarded by a0600service-user-owned path:
the privilege model stays external to the provider.
—secretspec/src/provider/sqlite.rs:7-11
The Provider trait is get/set/delete/reflect/preflight. It has no notion of
caller UID or daemon lifecycle, and adding one would force every provider to
implement a security model it does not need.
The gate between the two halves: upstream PR cachix#362
This was already worked out on 2026-08-16 and then went untracked — hence this
issue. See docs/design/upstream-ipc-v1-and-the-fork.md.
cachix/secretspec PR #362
("SecretSpec IPC v1 for 0.20+", authored by the maintainer) defines two
protocols over one framed JSON-RPC layer:
| boundary | protocol |
|---|---|
| app / SDK → secretspec broker | secretspec.client/1 |
| secretspec → external provider endpoint | secretspec.provider/1 |
secretspec.provider/1 is the gate: this fork becomes an out-of-tree
privileged provider endpoint, with no patching of upstream provider
internals.
Load-bearing distinction — upstream's "broker" is not a privilege boundary.
It is a private child process of the caller, in the caller's own trust domain.
Forwarding secret authority is a stated upstream non-goal. Upstream built the
plumbing and explicitly fenced off this fork's territory, so cachix#362 grants the
out-of-tree mechanism cachix#345 asked for.
Already engaged: a supportive comment was posted to cachix#362 on 2026-08-17
(cachix#362 (comment), text at
docs/design/pr362-comment.md), announcing intent to ship the first out-of-tree
privileged endpoint, and offering three verified gaps in upstream's
external.rs trust checks as review feedback — POSIX-mode checks blind to macOS
ACLs, std::fs::metadata following symlinks, and trust stopping at the
immediate parent. This fork's drift.rs check_ancestor_chain walks the resolved
chain to / and rejects all three.
Work items
- Prototype the privileged
secretspec.provider/1endpoint against
feat/ipc-v1in a scratch worktree. Small shim; it proves the "no
upstream patching" claim instead of asserting it. DONE 2026-08-18 —
ss-ipc-protoata204898,provider.lifecyclepasses,git diffin
the upstream checkout is empty. See the comment below for the four
findings. - Wire the endpoint's
--clientpath to the live vault end to end. It
is implemented, cancellation kills the privileged child, but it has never
been run against the live boundary. This is the next action. - Offer the macOS
sigtimedwaitportability fix upstream on feat: add versioned client and provider IPC cachix/secretspec#362.
libsecretspec-ipc/src/process_posix.c:237makes the conformance suite
unbuildable on Darwin; a portablesigpending+sigwaitfix is ready as
a patch file in the prototype crate. - Watch for a maintainer reply on feat: add versioned client and provider IPC cachix/secretspec#362.
- Plan the 0.20 rebase: adopt
secretspec-ipctypes for this fork's own
client↔broker hop, keepingsudoas the authority mechanism. - Position the README against the trust-domain distinction — it currently
does not address it at all. Risk: "SecretSpec has a broker, with audit and
reasons" will sound to the ecosystem like it already covers the local-AI-
agent threat model. - Convergence watch: if upstream later specifies the deferred socket
transport with peer credentials, it genuinely enters this fork's
space. Counter is to be the incumbent reference privileged deployment
shaping that spec — which argues for engaging now, not after 0.20 ships.
Superseded by the above
- The two-database file merge (
secrets.db|broker.sqlite3) — the
"cut by owner" rationale that broke a 2–2 reviewer tie assumed the provider
and the broker are permanently separate owners. Under the plan above the
provider goes upstream and this repo keeps only privilege separation, so that
boundary is not the one to design around. Do not build it without revisiting. - Its
transactionsFK follow-up, for the same reason. Note if it is ever
revisited: SQLite cannot add a FK to an existing table and FKs cannot span
files (both verified), so splitting the FK from the merge means rewriting the
audit trail twice.
Still live, independent of all of the above
drift/doctornever verifies the manifest chain at rest.
drift.rs:1036verifies onlyaudit::verify_read_only; there is no history
equivalent, and everyhistory::verifycaller is test-only (broker.rs:1805,
1832,1855, all after#[cfg(test)]atbroker.rs:1560). The chain is
verified on every capture (history.rs:387), so this is a detection-at-rest
gap, not silent rot. Worth closing: a deliberate integrity check should not be
asymmetric between two ledgers of equal standing.
Retracted, do not re-report
"No busy_timeout anywhere in the CLI crate" is FALSE. rusqlite 0.31.0
calls sqlite3_busy_timeout(db, 5000) unconditionally on every
Connection::open (rusqlite-0.31.0/src/inner_connection.rs:121). Two
independent reviewers reported it as a P0; a fix was written, mutation-tested,
found to be a no-op, and reverted. grep finding no explicit call is not
evidence the behavior is absent when a library sets a default. See handoff
7a1c.
Related
Companion issue in the upstreaming fork: djbclark#1 — djbclark/secretspec-sqlite (upstreaming the provider)
Activity
djbclark commentedon Aug 19, 2026
Prototype started — feasibility confirmed, and one doc correction
Worktree:
/Users/djbclark/src/ss-ipc-proto, branchproto/privileged-endpoint,from
upstream/feat/ipc-v1ata393a27. (Fetched via theupstreamremote ondjbclark/secretspec-sqlite.)docs/design/upstream-ipc-v1-and-the-fork.mdis now stale on one nameThe northbound protocol was renamed
secretspec.client/1→secretspec.resolver/1— that is literally the branch tip commit,
a393a27"feat(ipc): name thenorthbound protocol after the resolver, add a blocking client". Evidence:
secretspec-ipc/src/blocking.rs:1andlifecycle.rs:249saysecretspec.resolver/1.secretspec.provider/1is unchanged (lifecycle.rs:95) — the gate thisfork depends on is intact. Only the northbound name moved.
The endpoint-author API exists and the "small shim" claim holds up
secretspec-ipcis a real workspace member (Cargo.tomlmembers list). Theauthoring surface is two items in
secretspec-ipc/src/provider.rs:pub trait ProviderHandler: Send + Sync + 'static(line 43)pub async fn serve_provider<R, W, H>(…)(line 357)Only
resolve_addressis mandatory (pluscapabilitiesandinitialize).Every other method —
get,set,delete,exists,get_many,set_expiring,clear,check_writable,check_deletable,describe_write_target— has a default returningRpcError::new(ErrorKind::CapabilityRequired). So a first endpoint implementsthree methods and opts into more as it goes.
Values are already handled carefully upstream:
SecretValuewrapsZeroizing<String>(provider.rs:20-27).The shape of the privileged endpoint
Upstream's reference endpoint
(
conformance/ipc/runner/src/bin/ipc-provider-endpoint-rust.rs, 494 lines)serves over stdio:
That is exactly the shape privilege separation needs: the endpoint is a
stdio-speaking child process, so
sudolaunching it as the service user isthe whole privilege mechanism. The calling user never opens the vault files;
secretspec talks JSON-RPC to a process that can.
Upstream also ships a conformance suite to test against —
conformance/ipc/cases/provider-{lifecycle,operations,errors,reconnect,session-isolation}.json.Passing those is a much stronger correctness bar than anything hand-rolled.
Next steps
ProviderHandler(capabilities / initialize /resolve_address / get) and get it compiling against
secretspec-ipc.conformance/ipc/cases/provider-lifecycle.json.sudo.docs/design/upstream-ipc-v1-and-the-fork.mdfor thesecretspec.resolver/1rename.docs: the privileged endpoint prototype exists, passes, and found fou…
djbclark commentedon Aug 19, 2026
Prototype done — the claim is demonstrated, not asserted (2026-08-18)
First work item is complete. Worktree
/Users/djbclark/src/ss-ipc-proto, branchproto/privileged-endpoint, commita204898, crateprivileged-endpoint-proto/.Built against
feat/ipc-v1ata393a27.No upstream patching, shown mechanically. The crate declares its own
[workspace]table, so it is not a member of the upstream workspace and dependson
secretspec-ipcby path through public API only. After a full build, test,and conformance run,
git diffin the upstream checkout is empty andgit statusshows exactly one untracked directory.provider.lifecyclepasses, driven by upstream's own checked-in case throughipc-provider-conformance-driver:{"case":"provider.lifecycle","events":[{"kind":"initialized"},{"kind":"cancelled"},{"kind":"deadline_exceeded"},{"kind":"terminal"},{"kind":"closed"}]}Verified by mutation rather than trusted: replacing the
__BLOCK__branch ingetwithif falsefails the case with "provider cancellation did not produceone cancelled terminal". The pass depends on the behaviour it claims to test.
"A first endpoint is three methods" holds —
capabilities,initialize,resolve_address, plusget. Framing, version negotiation, capability gating,deadlines, cancellation and shutdown are all
serve_provider's.Four findings
libsecretspec-ipc/src/process_posix.c:237callssigtimedwaitunder a plain#ifndef _WIN32. Darwin has no such function — absent from every SDK headerand from
libSystem, so it is a link failure as well as a compile error,not a strict-mode warning. A portable
sigpending+sigwaitfix is kept asa patch file rather than applied, so the no-patching claim stays literally
true. Worth offering on feat: add versioned client and provider IPC cachix/secretspec#362 — the same lane as fix(cli): restore the default SIGPIPE disposition cachix/secretspec#377.
The broker exits 1 for has no value, is not resolved, and a resolve error
alike (
broker.rs:960-978), and asudo -ndenial also surfaces as 1. Theendpoint collapses every non-zero exit to one opaque
OperationFailed:mapping 1 to
Missingwould tell a caller a secret does not exist when it wasactually denied. Accepted consequence: the endpoint currently can never
report a genuinely absent secret.
println!(broker.rs:962), so exactly one\nis framing.(
Address::validate,protocol.rs:737), so a caller may send--helpor-nas a key. Nothing reaches a shell, but our ownclapwould parse it. Anendpoint author bridging to a CLI needs their own name guard; ours is
is_safe_name.Findings 2 and 3 are the sharpest argument yet for the 0.20 rebase item: on
secretspec.provider/1the failure is a structuredErrorKindand the value isa byte-exact JSON string, so both defects disappear rather than being decoded.
One mistake worth recording
The first version used the installed client when given no arguments. Upstream's
driver spawns an endpoint with no arguments, so the first conformance run
crossed the live
sudoboundary twice, and the case passed for the wrong reason— the subprocess was merely slow enough to look like a blocked request. No
arguments now means no privileged access, and the privileged path is opt-in.
Still open
--clientto the live vault end to end. Implemented, including killingthe privileged child when a request is cancelled (
kill_on_drop), but not yetrun against the live boundary.
sigtimedwaitfix upstream.djbclark commentedon Aug 19, 2026
Blocked on #3, and the fork is now on upstream HEAD (2026-08-18)
Two updates.
The fork moved to bleeding edge.
sudo-mainis at9138c96, a merge ofupstream/main(cf48a7a), and is now 0 commits behind upstream. The realmerge base turned out to be
dfa4b10— 87 commits pastv0.19.1, notv0.19.1itself — so this was 5 commits with exactly one conflict (
CHANGELOG.md,purely additive, both sides kept).
cargo test -p sudo-secretspec-cliis229 passed / 0 failed, unchanged from baseline, and
cargo check -p secretspec --all-targetsis clean. The stalemainmirror was fast-forwardedto
cf48a7a, andupstream's push URL is now DISABLED in this checkout (it wasnot).
Merging
upstream/feat/ipc-v1on top is the next integration step and is notdone. It has 6 conflicts —
.github/workflows/test.yml,CHANGELOG.md,Cargo.lock,Cargo.toml,secretspec/src/config.rs,secretspec/src/lib.rs— so it wants a session with room to do it properly, on a topic branch rather
than on
sudo-main(cachix#362 is still open and will be rebased).The
--clientwork item is blocked on #3. Wiring the endpoint to a livevault is the remaining piece, and it should not be wired to the operational
boundary — this session already crossed it twice by accident. #3 records the
decision to build an isolated test boundary instead (compile-time test prefix
plus ephemeral macOS CI; a VM was considered and rejected on licence and disk
grounds).