daemon

package module
v0.23.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 12, 2026 License: Apache-2.0 Imports: 31 Imported by: 0

README

obsigna-daemon

Single OS-user process that owns the Ed25519 signing key and the SQLite receipt store. Emitters (mcp-proxy, OpenClaw, SDK consumers) connect over a local Unix-domain socket and send fire-and-forget event frames; the daemon captures the connecting peer's OS-attested credentials, canonicalises the receipt (RFC 8785), signs it (Ed25519), and persists it.

See ADR-0010 for design rationale and issue #236 for the work breakdown.

This is Phase 1 of the daemon roll-out — the foundation slice. It ships the standalone daemon binary, peer-cred capture, chain-tail resumption, the file-backed KeySource, and the agent-receipts verify read CLI (which works whether the daemon is up or down). Emitter refactor for mcp-proxy / OpenClaw / SDK ships in later phases.

Build

go build ./cmd/obsigna-daemon
go test ./...                     # unit tests
go test -tags=integration ./...   # integration tests (real socket, real DB)

Build from a clone of the monorepo: the repo-root go.work wires the in-tree sdk/go so go build from daemon/ picks up ReceiptStore.GetChainTail.

go install github.com/agent-receipts/ar/daemon/cmd/obsigna-daemon@latest is not yet supported: the daemon depends on sdk/go.GetChainTail, which is not in the latest published sdk/go tag (v0.6.0). Standalone install becomes possible once the next sdk/go tag is released and a follow-up bumps the require in daemon/go.mod.

CI coverage

.github/workflows/daemon.yml runs go vet, builds ./cmd/..., and runs the unit + integration test suite with -race and -tags=integration on pushes to main and on pull requests targeting main whose diff touches daemon/** or sdk/go/**. The sdk/go/** trigger mirrors mcp-proxy.yml so that sdk/go changes which break the daemon are caught in the same PR that introduces them.

Run

The daemon takes config from a TOML config file, environment variables, or flags, in precedence order file < env < flags (lowest to highest): a key omitted from the file leaves the env/flag/default value untouched, and any matching env var or explicit flag overrides a file value. All fields have sensible per-OS defaults. The config file defaults to $XDG_DATA_HOME/agent-receipts/daemon.toml (falling back to ~/.local/share/agent-receipts/daemon.toml) — a missing default-path file is fine; override the path with --config or AGENTRECEIPTS_CONFIG, where a missing file, malformed TOML, or an unknown key is an error. --print-config prints the fully resolved config (paths only — never key material) in the same shape, so it doubles as a starting daemon.toml.

obsigna-daemon \
  --socket /run/agentreceipts/events.sock \
  --db    /var/lib/agentreceipts/receipts.db \
  --key   /etc/agentreceipts/signing.key \
  --chain-id default \
  --issuer-id "did:agent-receipts-daemon:$(hostname)" \
  --verification-method "did:agent-receipts-daemon:$(hostname)#k1"
Flag Env Default
--socket AGENTRECEIPTS_SOCKET Linux: $XDG_RUNTIME_DIR/agentreceipts/events.sock (falls back to /run/agentreceipts/events.sock). macOS: $XDG_DATA_HOME/agent-receipts/events.sock (defaults to ~/.local/share/agent-receipts/events.sock).
--unsafe-socket-path false — permit a --socket outside the per-platform safe set (see Socket-path safety).
--db AGENTRECEIPTS_DB $XDG_DATA_HOME/agent-receipts/receipts.db (defaults to ~/.local/share/agent-receipts/receipts.db)
--key AGENTRECEIPTS_KEY $XDG_DATA_HOME/agent-receipts/signing.key (defaults to ~/.local/share/agent-receipts/signing.key)
--chain-id AGENTRECEIPTS_CHAIN_ID default
--issuer-id AGENTRECEIPTS_ISSUER_ID did:agent-receipts-daemon:local
--public-key AGENTRECEIPTS_PUBLIC_KEY <--key>.pub
--verification-method AGENTRECEIPTS_VERIFICATION_METHOD did:agent-receipts-daemon:local#k1
--shutdown-deadline 200ms — time budget for emitting interrupted-chain terminators on SIGTERM/SIGINT (see Graceful shutdown).
--config AGENTRECEIPTS_CONFIG $XDG_DATA_HOME/agent-receipts/daemon.toml (falls back to ~/.local/share/agent-receipts/daemon.toml) — TOML config file; a missing default-path file is fine, but a missing --config/AGENTRECEIPTS_CONFIG path, malformed TOML, or an unknown key is an error.
--print-config false — print the fully resolved config (paths only — never key material) and exit; the output doubles as a starting daemon.toml.

The signing key file must be a PKCS#8-encoded Ed25519 private key (the format receipt.GenerateKeyPair() in sdk/go produces) with permissions no looser than owner-only — the daemon rejects any group or world bit (read, write, or execute), so 0600, 0400, etc. are accepted; 0640 and 0644 are not. The daemon also refuses to start on a non-Ed25519 key, a symlink, or a non-regular file at this path.

The socket directory is created with mode 0750 if missing; the socket itself is 0660. Phase 1 unprivileged installs use the per-user defaults ($XDG_DATA_HOME on macOS, $XDG_RUNTIME_DIR on Linux when set).

Socket-path safety

The per-user runtime/data directory is what makes peer-credential capture and the trust boundary meaningful (ADR-0010 § IPC transport). A socket in a shared, world-traversable, periodically-swept directory (e.g. /tmp) keeps peer creds working but loses location privacy, and the socket file may disappear under load. To stop a safe default being silently abandoned by an override, the daemon refuses to start when --socket / AGENTRECEIPTS_SOCKET resolves outside the per-platform safe set:

  • Linux: under $XDG_RUNTIME_DIR (when set), /run, or /var/run.
  • macOS: under $TMPDIR (when set), /var/run, or $XDG_DATA_HOME/agent-receipts (where the per-user default lives, alongside receipts.db and the signing key).

The path is canonicalized with filepath.EvalSymlinks before the check, so a symlink pointing out of the safe set is judged by its real target. The default socket path always resolves inside the safe set, so defaults are never rejected. TCP addresses (e.g. 127.0.0.1:9000) are rejected unconditionally — the daemon speaks Unix-domain sockets only.

To deliberately run on a path outside the safe set — containers with unusual mounts, dev experiments, the short /tmp path the integration tests need to stay within the macOS sun_path limit — pass --unsafe-socket-path. The daemon then starts, logs a level=warn line naming the path, and re-emits the warning every 60 seconds. The flag unblocks legitimate edge cases; it does not suppress the warning, and it does not override the TCP rejection.

On every startup the daemon publishes the matching SPKI public key to --public-key (default <KeyPath>.pub, tracking any --key override) with mode 0644, so independent verifiers — agent-receipts verify, audit scripts, CI checks — can load it without access to the private key path. If the file already exists with the same contents the publish is a no-op; if the contents differ the daemon refuses to start (a mismatch means either the signing key was rotated / restored from backup, or the published file was tampered with — operator must remove the stale file deliberately). The daemon also refuses if the path is a symlink, FIFO, device, etc.

The published key file is 0644, but its parent directory is created at 0750 to match the receipt-store directory's access policy — non-owners must be in the daemon user's group to traverse it and reach the public key. Per-user installs (the MVP path: $XDG_DATA_HOME/agent-receipts/, defaulting to ~/.local/share/agent-receipts/) are unaffected since the operator who runs the verify CLI owns the directory. System installs (/etc/agentreceipts/, /var/lib/agentreceipts/) are expected to give the daemon a dedicated agentreceipts user and the read-side an agentreceipts-read group whose members traverse the directory; that ownership/grouping is a packaging concern (Homebrew / launchd / systemd) and not something the daemon assigns at runtime. If the directory already exists the daemon does not modify its mode, so operator-managed permissions are preserved.

Graceful shutdown

On SIGTERM or SIGINT the daemon performs a three-phase shutdown:

  1. Stop accepting — the IPC socket listener closes immediately. New emitter connections are refused from this point.
  2. Drain — Active handler goroutines that have already started reading their frame run to completion; connections not yet fully accepted or partially read are closed. No new receipts can enter the chain after this step.
  3. Terminate — if the configured chain (--chain-id) has at least one receipt and no terminal receipt yet, the daemon emits a terminal receipt with chain.terminal: true and chain.status: "interrupted". This receipt is signed with the daemon's key and persisted to the store before the process exits, so verifiers can later classify the chain as interrupted rather than unknown. (The daemon owns one chain per process; multi-chain support is future work.)

The total deadline for step 3 is --shutdown-deadline (default 200ms). The deadline is best-effort: it gates entry into the signing and store operations, but cannot preempt an already-in-progress SQLite call (the store does not yet use context-aware QueryRowContext/ExecContext). Under normal conditions (single-writer process, local disk) this is not a practical concern. If the deadline expires before the terminator is written, the daemon logs a level=warn line (terminator: deadline expired, chain … will be classified as 'unknown' by verifier) and exits cleanly — the verifier's unknown classification (spec §7.3.3) is the documented fallback for chains whose terminator could not be written in time. Store I/O or signing failures during terminator emission are surfaced as a non-zero exit code.

Once a terminal receipt has been written, the daemon will refuse to start again against the same --chain-id and --db; use a new --chain-id or a fresh --db for subsequent runs.

Crash case (SIGKILL / OOM kill): the daemon cannot write anything. Chains left without a terminal receipt are classified as unknown by the verifier. This is by design — spec §7.3.3 documents unknown as the recourse for chains the daemon never sees again.

TTL semantics (v1): there is no idle-chain TTL in v1. The daemon emits interrupted for every open chain at shutdown time, regardless of how long ago the last receipt was written. Over-emitting interrupted for a long-idle chain is the lesser failure mode compared to designing TTL semantics under shutdown pressure. Follow-up issue tracked separately.

Read interface: agent-receipts verify

agent-receipts verify --chain-id default
# or, with explicit paths:
agent-receipts verify \
  --db /var/lib/agentreceipts/receipts.db \
  --public-key /etc/agentreceipts/signing.key.pub \
  --chain-id default

Defaults match the daemon's: a verify run without flags works after obsigna-daemon has run at least once with the same per-user paths.

verify opens the SQLite store read-only via sdk/go/store.OpenReadOnly so it is safe to run while the daemon is the active writer, and it does not require the daemon socket to be reachable. Independent verifiability is not gated on daemon availability (issue #236, Section 4).

Rotated chains verify with no extra flags. After an offline obsigna-daemon --rotate, the published --public-key holds the new key, but a chain is anchored to the key that signed its first receipt. verify resolves that genesis key automatically from the superseded keys --rotate archives beside the live one (<public-key>.rotated-<fingerprint>), then traverses each key_rotated receipt forward (spec §7.3.7) — so a rotated chain reports VALID against the published key path. If those archives are missing, the chain reports BROKEN at the first receipt rather than silently passing.

Resolution stays pinned to the operator's key: a chain reached through an archive must end its rotation lineage at the --public-key it was verified against. A cryptographically self-consistent chain that rotates to some other key — e.g. an attacker who planted a <public-key>.rotated-* archive and a chain signed under their own key — reports BROKEN (the published key is not the chain's current key), so a forged archive cannot turn into a VALID result.

Exit codes are stable for scripting:

Code Meaning
0 Chain verified
1 Chain failed verification (output lists per-receipt status)
2 Usage error (bad flags, missing key file, unreadable DB)

Read interface: agent-receipts show <seq>

agent-receipts show 42
# or, against a multi-chain store / explicit DB:
agent-receipts show 42 --chain-id default --db /var/lib/agentreceipts/receipts.db
# pretty-printed JSON:
agent-receipts show 42 --json

Prints the full fields of the receipt at chain sequence <seq> (1-indexed): issuer, chain id, action type / tool, parameters hash, outcome, signature, and any action-specific payload (e.g. the drop count on an events_dropped receipt). Like verify, it opens the store read-only so it is safe to run while the daemon writes.

--chain-id is required only when the store holds more than one chain; with a single chain it is auto-detected. Without it on a multi-chain store, the command lists the available chain ids and exits with a usage error.

Exit codes are stable for scripting:

Code Meaning
0 Receipt found and printed
1 No receipt at the requested sequence (or empty store)
2 Usage error (bad flags, ambiguous chain, unreadable DB)

Read interface: agent-receipts verify-event

verify answers "is this chain internally consistent?". verify-event answers the narrower question that turns out to matter most for trust: was this specific receipt produced by the documented emitter → daemon → chain pipeline, or written to the store by some other path? It composes the chain checks (signature, hash linkage, sequence contiguity) with the daemon-captured peer_credential (ADR-0010 § Permissions and trust) that makes the audit meaningful — the agent's self-asserted identity is untrusted; peer attestation is what is load-bearing.

# A single receipt by id:
agent-receipts verify-event --id urn:receipt:...

# The most recent receipt in the chain:
agent-receipts verify-event --chain-head

# Every receipt issued in a trailing window (e.g. post-incident triage):
agent-receipts verify-event --since 10m --json

# Pin the expected emitter(s) — mismatches warn, they do not fail:
agent-receipts verify-event --chain-head \
  --emitter-allowlist /usr/bin/mcp-proxy,/usr/bin/openclaw

Exactly one selector (--id, --chain-head, --since) is required. Like the other read commands it opens the store read-only, so it is safe to run against a live daemon's database or a forensic snapshot, and it never emits — unlike doctor's synthetic round-trip, this is a cheap historical read.

It runs six checks per receipt, each reported with a structured pass / fail / warn / n/a status:

  1. Signature — the Ed25519 signature verifies under the supplied public key.
  2. Hash linkage — the receipt chains back to the daemon's startup baseline and is reachable from the chain head (any break anywhere taints it).
  3. Peer credential present — the daemon-captured peer_credential is present and well-formed. Receipts predating peer-credential capture are flagged n/a ("predates peer-credential evidence"), not failed.
  4. Emitter identity — the captured exe_path matches the operator --emitter-allowlist. This is operator policy, not protocol: a mismatch warns, it never fails. With no allowlist configured the observed path is surfaced informationally.
  5. Schema version — the receipt's schema version is one this verifier understands (compatible by major version).
  6. Chain context — the receipt's sequence position is contiguous with its neighbours (no gap immediately before or after).

The verdict distinguishes the two cases operators currently cannot tell apart:

  • VERIFIED — pipeline-provenance confirmed: crypto holds and the peer-credential evidence shows the documented pipeline produced it.
  • VERIFIED (cryptographically) — no pipeline-provenance evidence: crypto holds but there is no peer credential. This is the state a receipt written directly to SQLite (or emitted before peer-credential capture) produces.

What it deliberately does not check: whether the audited action actually happened in the world (no protocol can attest to that), and whether the emitter binary is trustworthy beyond its exe_path matching the allowlist (binary integrity attestation is a separate, ADR-grade decision).

Use cases: forensic snapshot review, post-incident triage (--since), and a CI gate on a known-good receipt — gate on exit 0 to require provenance, or accept 0 and 3 alike to require only cryptographic validity.

Exit codes are stable for scripting:

Code Meaning
0 Verified and pipeline-provenance confirmed
1 A check failed — the receipt is suspect, investigate
2 Usage error (bad flags, no/ambiguous selector, unreadable DB or key)
3 Verifies cryptographically but lacks peer-credential evidence

When a selector resolves to multiple receipts (--since), the process exit code is the worst case across them (1 outranks 3, which outranks 0).

Health check: agent-receipts doctor

agent-receipts doctor
# structured output for CI / healthchecks:
agent-receipts doctor --json
# treat warnings as failures (stricter CI gate):
agent-receipts doctor --json --warn-as-error
# skip the synthetic round-trip (writes no event to the chain):
agent-receipts doctor --no-roundtrip

doctor diagnoses the whole pipeline ADR-0010 describes — emitter → socket → daemon → SQLite → verify — and reports an actionable per-step result. It exists because the pipeline's failure modes are subtle: tool calls succeed at the application layer and individual signatures verify, yet the documented path can be silently broken (wrong socket path, world-readable DB, version skew, missing peer-credential capture). doctor makes "agent-receipts is working on this host" mean doctor exits 0, not "a row exists in SQLite" (issue #539).

It resolves paths and the chain id exactly like verify/list (--socket, --db, --public-key, --chain-id, or the matching AGENTRECEIPTS_* env vars), so a no-flag run works after the daemon has run once with the same per-user paths.

Checks, in pipeline order:

Check What it asserts fail means
daemon process A daemon is reachable on the resolved socket. No daemon is listening — start it with obsigna daemon run.
socket The socket file exists, is a socket, and is not world-accessible (daemon binds 0660). Missing/usurped path, or a non-socket file at the path.
emitter dial path The path an emitter on this host would dial matches the daemon's. (warns) Emitter and daemon disagree — events would never arrive.
db permissions The receipt DB is no looser than 0640 (ADR-0010 § Read interface). World-readable receipts leak peer attestation / disclosures.
schema/version The store is readable and the published public key parses; reports the key fingerprint and receipt count. Unreadable DB or malformed/absent public key.
peer credentials The OS peer-credential primitive (SO_PEERCRED / LOCAL_PEERCRED) is available. Unsupported platform — peer-cred capture, and thus the trust model, is unavailable.
chain head The stored chain verifies via the verify code path. An unknown head (never cleanly terminated) is surfaced as a warn (issue #475). The chain fails verification (BROKEN).
round-trip Load-bearing. A synthetic event fired through the real socket lands in the DB with a fresh peer credential matching doctor's PID/UID. The event never landed, or its peer credential was not freshly attested for this process.

The round-trip is the check the Max-incident postmortem motivated: an INSERT-then-SELECT on the DB file "works" while bypassing the socket, the daemon's peer-cred capture, and the chain head. The synthetic event is deliberately visible in the chain — channel doctor, tool agent-receipts-doctor.roundtrip, which the daemon records as action.type doctor.agent-receipts-doctor.roundtrip (a low-risk diagnostic self-check). That is the value to filter on when querying the chain. A "test mode" that bypassed the chain would defeat the property being tested: that real events make the full traversal. Use --no-roundtrip to skip it (e.g. a forensic-mode daemon that must not receive synthetic events); the round-trip check then reports warn.

On macOS the daemon's accept-time LOCAL_PEEREPID lookup can race a fast peer detach and record pid=0 (see peercred_darwin.go); when the synthetic event lands with a matching UID but pid=0, the round-trip reports warn (pipeline intact, fresh PID unconfirmed) rather than a misleading credential-mismatch fail.

chain head verifies the full stored chain rather than only a tail window: hash-link verification is meaningless without the prefix, so a partial-tail check could not establish integrity.

Exit codes are stable for CI:

Code Meaning
0 All checks ok (or only warn without --warn-as-error)
1 At least one check failed (or warned under --warn-as-error)
2 Usage error (bad flags)

Wire protocol

SOCK_STREAM Unix-domain socket (uniform across Linux and macOS — see Transport choice below). Each emitter message is a 4-byte big-endian length prefix followed by a JSON payload of that many bytes. Maximum payload is 1 MiB; larger frames are dropped with the connection.

The JSON payload is the ADR-0010 emitter schema:

{
  "v": "1",
  "ts_emit": "2026-05-03T00:00:00.000Z",
  "session_id": "uuid-v4",
  "channel": "mcp_proxy",
  "tool": { "server": "github", "name": "list_repos" },
  "input": { "owner": "agent-receipts" },
  "output": [ { "name": "ar" } ],
  "error": "",
  "decision": "allowed"
}

decision is one of allowed / denied / pending. input and output accept any JSON value (object, array, primitive) or null / omitted for events with no payload; the daemon canonicalises (RFC 8785) and stores only the SHA-256 digest in action.parameters_hash and outcome.response_hash, never the raw bytes. The daemon adds seq, prev_hash, ts_recv, peer attestation, and the receipt id before signing, so emitters never see those fields and cannot forge them.

Phase 1 scope and deviations

The following are deliberate Phase 1 choices, all callable out for follow-up:

  • Transport choice. ADR-0010 specifies SOCK_SEQPACKET. macOS does not support SEQPACKET on AF_UNIX, so the daemon uses SOCK_STREAM uniformly on both Linux and macOS with explicit length-prefix framing. Peer-credential retrieval works identically on stream sockets, so the trust model is unchanged. A follow-up issue should amend ADR-0010 to record the per-OS socket type.
  • Peer attestation placement. ADR-0010 called for a top-level peer field; spec v0.3.0 (PR #496) added a dedicated action.peer_credential object on the receipt. The daemon writes that typed field directly (platform, pid, uid, gid, exe_path) and the synthetic events_dropped receipt's drop counter rides on the sibling action.emitter_metadata.drop_count field. The values are still signature-protected. The Phase-1 flat-map shape (peer.platform, peer.pid, etc. inside parameters_disclosure) has been retired.
  • macOS peer_credential.exe_path. Linux populates this from /proc/<pid>/exe; macOS uses the SYS_PROC_INFO(PROC_PIDPATHINFO) syscall directly (the call libproc's proc_pidpath() wraps), so the daemon stays CGO-free. Failure is non-fatal — exe_path is left empty and pid / uid / gid are still recorded.
  • Single chain id. The daemon owns one chain id per process. Multi-chain support can grow chain.State into a chainID-keyed map without breaking callers.
  • No emitter refactor. mcp-proxy, OpenClaw, and the three SDKs continue to sign in-process. They will be migrated to thin emitters in Phase 2+.
  • No drop-counter / events_dropped synthetic receipts. That mechanism belongs with the emitter side (EAGAIN handling) and ships with the emitter refactor.
  • No Homebrew / launchd / systemd packaging. Operators run the binary directly in Phase 1.
  • No Windows port. Tracked as a separate issue per #236.

Layout

daemon.go                                  # Run() entrypoint and Config; publishes the public key on startup
cmd/obsigna-daemon/main.go                 # daemon CLI: flag/env parsing, signal handling
cmd/agent-receipts/main.go                 # read CLI: thin shim over internal/{listcli,showcli,verifycli,doctorcli}
internal/
  chain/state.go                           # in-memory (seq, prev_hash) owner; sole writer
  keysource/keysource.go                   # KeySource interface (ADR-0015 shape)
  keysource/file.go                        # PEM-on-disk adapter
  socket/listener.go                       # Unix-domain socket + length-prefix framing
  socket/peercred_{linux,darwin,other}.go  # OS-specific peer-credential capture
  pipeline/build.go                        # frame + peer -> AgentReceipt -> sign -> store
  listcli/list.go                          # `agent-receipts list` subcommand
  showcli/show.go                          # `agent-receipts show <seq>` subcommand
  verifycli/verify.go                      # `agent-receipts verify` subcommand
  doctorcli/doctor.go                      # `agent-receipts doctor` pipeline health check
integration_test.go                        # tags: integration. End-to-end concurrency, peer-cred, and verify-CLI fixtures.
tests_doctor_test.go                       # tags: integration. `agent-receipts doctor` round-trip against a live daemon.

Documentation

Overview

Package daemon assembles the obsigna-daemon's components — chain state, key source, receipt store, frame socket — into a single Run entrypoint. cmd/obsigna-daemon/main.go wraps Run with flag/env parsing and signal handling.

Index

Constants

View Source
const (
	DefaultIssuerID             = "did:agent-receipts-daemon:local"
	DefaultVerificationMethodID = DefaultIssuerID + "#k1"
)

DefaultIssuerID and DefaultVerificationMethodID are the identity defaults a local daemon signs under when no issuer/verification-method is configured. They are exported so any tool that builds a Config for the same local install — the daemon's own config resolution and `obsigna keys rotate` — shares one source of truth: the issuer DID and verification method are embedded in every signed receipt, so two callers defaulting them differently would fork chain identity for the same operator.

View Source
const RotatedPublicKeySuffix = ".rotated-"

RotatedPublicKeySuffix is the filename suffix archiveOldPublicKey appends to a superseded public key (followed by a short key fingerprint), e.g. signing.key.pub.rotated-<fp>. The verify CLI scans for it to rediscover a rotated chain's genesis key, so the writer here and that reader share this one definition rather than hardcoding the literal on each side.

Variables

This section is empty.

Functions

func DefaultConfigPath added in v0.14.0

func DefaultConfigPath() string

DefaultConfigPath returns the per-user TOML config path used when --config is not given: $XDG_DATA_HOME/agent-receipts/daemon.toml, co-located with receipts.db and the signing key (DefaultDBPath/DefaultKeyPath). Returns "" when the XDG data home cannot be resolved (no XDG_DATA_HOME and no home directory), matching the other Default*Path helpers.

func DefaultDBPath

func DefaultDBPath() string

DefaultDBPath returns the per-user SQLite path used when AGENTRECEIPTS_DB is not set. Uses XDG_DATA_HOME (defaults to ~/.local/share on Linux/macOS).

func DefaultForensicKeyPath added in v0.15.0

func DefaultForensicKeyPath() string

DefaultForensicKeyPath returns the default forensic private-key path, co-located with the signing key and receipt store. Empty when the XDG data home cannot be resolved, matching the other Default*Path helpers.

func DefaultForensicPublicKeyPath added in v0.15.0

func DefaultForensicPublicKeyPath(keyPath string) string

DefaultForensicPublicKeyPath returns the default forensic public-key path: keyPath with the ".pub" suffix. Empty when keyPath is empty.

func DefaultKeyPath

func DefaultKeyPath() string

DefaultKeyPath returns the per-user signing-key path used when AGENTRECEIPTS_KEY is not set. Uses XDG_DATA_HOME (defaults to ~/.local/share on Linux/macOS).

func DefaultPublicKeyPath

func DefaultPublicKeyPath(keyPath string) string

DefaultPublicKeyPath returns the default published public-key path: the same directory as keyPath with the suffix ".pub". Empty when keyPath is empty so cmd/main.go can surface a clearer "Config.KeyPath is required" error from validateConfig instead of a less-helpful PublicKeyPath one.

func DefaultSocketPath

func DefaultSocketPath() string

DefaultSocketPath returns the per-OS default socket path. Phase 1 resolves Q1 of issue #236; the macOS default was reworked again for issue #545:

  • macOS: $XDG_DATA_HOME/agent-receipts/events.sock — per-user, unprivileged. Defaults to $HOME/.local/share/agent-receipts/events.sock when XDG_DATA_HOME is unset, co-located with receipts.db and the signing key.
  • Linux with $XDG_RUNTIME_DIR set: $XDG_RUNTIME_DIR/agentreceipts/ events.sock — per-user, unprivileged.
  • Linux fallback (no $XDG_RUNTIME_DIR): /run/agentreceipts/events.sock — this is the system-install path and requires privileged directory creation/write. Unprivileged users on systems without $XDG_RUNTIME_DIR should set AGENTRECEIPTS_SOCKET explicitly.
  • Other platforms: empty string (the daemon refuses to start outside Linux/macOS, see Run).

The OS resolution is delegated to emitter.DefaultSocketPath so the daemon and the SDK's emitter cannot drift on what counts as the per-user default socket path. emitter.DefaultSocketPath additionally honours AGENTRECEIPTS_SOCKET when set — that branch is a no-op for the daemon binary, which independently consults AGENTRECEIPTS_SOCKET in main before calling this function, but it keeps any library consumer aligned with the emitter without needing to re-implement the env-var fallback.

func GenerateForensicKey added in v0.15.0

func GenerateForensicKey(keyPath, publicKeyPath string) (string, error)

GenerateForensicKey creates a new X25519 forensic key pair (ADR-0012) and writes the raw 32-byte private key to keyPath (mode 0600) and the raw 32-byte public key to publicKeyPath (mode 0644). It returns the public key's canonical fingerprint (ADR-0015, sha256:<hex>) for display so an operator can confirm the key the daemon will encrypt to matches the private key they keep for recovery.

Like GenerateKey, it refuses to overwrite or follow a symlink at either path, and rolls back the private key if the public-key write fails. The two keys have deliberately separate lifecycles: the public key goes in daemon config; the private key is kept offline by the forensic responder and never given to the daemon.

func GenerateKey

func GenerateKey(keyPath, publicKeyPath string) error

GenerateKey creates a new Ed25519 key pair and saves the private key to keyPath (mode 0600) and public key to publicKeyPath (mode 0644). Refuses to overwrite an existing file at either path, and refuses to follow a symlink at either path. Use this explicitly via --init; never call it as a side-effect of starting the daemon — silently regenerating a missing key would invalidate every receipt previously signed by the operator's real key.

Atomicity: both files are created with O_CREATE|O_EXCL|O_NOFOLLOW so an attacker who plants a symlink (or any other dirent) at either path between the directory creation and the file open trips O_EXCL — we never write through the symlink target. If the public-key write fails after the private-key write succeeded, the private-key file is removed so the caller doesn't end up with a half-initialised on-disk state.

The mode passed to OpenFile may be narrowed by the process umask; an explicit fchmod after open ensures the on-disk mode matches what the caller asked for.

func Run

func Run(ctx context.Context, cfg Config) error

Run starts the daemon and blocks until ctx is cancelled. It returns the first fatal error or nil on graceful shutdown.

Types

type Config

type Config struct {
	// SocketPath is the Unix-domain socket the daemon listens on.
	SocketPath string

	// UnsafeSocketPath permits a SocketPath outside the per-platform safe set
	// (see checkSocketPath). When false (the default), Run refuses to start on
	// an unsafe explicit override; when true, Run starts and warns periodically.
	// Set from --unsafe-socket-path. TCP addresses are rejected regardless.
	UnsafeSocketPath bool

	// DBPath is the SQLite receipt-store path.
	DBPath string

	// KeyPath is the PEM-encoded Ed25519 private key path. Mode must be 0600.
	KeyPath string

	// PublicKeyPath is where the daemon publishes the matching SPKI public
	// key in PEM form, mode 0644, on every startup. Read-side tools
	// (`agent-receipts verify`) load it without needing access to KeyPath or
	// the daemon's signing surface. Defaults to KeyPath + ".pub" when empty.
	PublicKeyPath string

	// ForensicPublicKeyPath is the path to the X25519 forensic public key
	// (32 raw bytes) used to encrypt action parameters (ADR-0012, HPKE envelope).
	// When set, incoming tool parameters are encrypted before signing and
	// attached as action.parameters_disclosure. When empty, parameters are
	// hashed only (the default, privacy-preserving). The private key is held
	// offline by the forensic responder. Set from --forensic-public-key.
	ForensicPublicKeyPath string

	// ChainID is the chain id all incoming frames are written under. Phase 1
	// supports one chain per daemon process.
	ChainID string

	// IssuerID is embedded in receipts as issuer.id, e.g.
	// "did:agent-receipts-daemon:<host>".
	IssuerID string

	// VerificationMethodID goes into proof.verificationMethod.
	VerificationMethodID string

	// AnchorLogPath, when set, is an append-only external-witness log that
	// rotation events are written to before the local chain commits (ADR-0015
	// anchor-first ordering). Empty disables anchoring — the operator keeps the
	// chain-integrity guarantees but forgoes the post-compromise integrity
	// guarantee. Set from --anchor-log (env: AGENTRECEIPTS_ANCHOR_LOG).
	AnchorLogPath string

	// Logger receives daemon log lines. Defaults to log.Default().
	Logger *log.Logger

	// TraceLog optionally receives daemon trace lines for test debugging.
	// When nil, tracing is silent. Tests can pass a buffer to inspect
	// what frames were received, receipts signed, etc.
	TraceLog io.Writer

	// ParameterDisclosure selects which actions have their parameters encrypted
	// into the parameters_disclosure envelope (ADR-0012). Value space mirrors the
	// OpenClaw plugin: "false"/"off"/"" (default, hash only), "true"/"all",
	// "high" (high- and critical-risk actions), or a comma-separated allowlist of
	// action types. Disclosure also requires ForensicPublicKeyPath — without a
	// key there is nothing to encrypt to. Parsed via
	// pipeline.ParseDisclosurePolicy at startup.
	ParameterDisclosure string

	// RedactPatternsPath is an optional path to a YAML file of additional
	// redaction patterns applied to receipt body fields after hashing. When
	// empty, only the built-in patterns are used. File format:
	//
	//   patterns:
	//     - name: my-secret
	//       pattern: 'MY_SECRET_[A-Z0-9]+'
	RedactPatternsPath string

	// ShutdownDeadline is the total time budget for emitting interrupted-chain
	// terminators on SIGTERM/SIGINT. Defaults to 200ms when zero.
	ShutdownDeadline time.Duration
}

Config is the daemon's startup configuration. Resolve from flags/env in cmd/obsigna-daemon/main.go and pass to Run.

type DisclosureConfig added in v0.15.0

type DisclosureConfig struct {
	Value string
}

DisclosureConfig is the parsed `parameter_disclosure` config-file value. It normalises three accepted TOML shapes into the policy string consumed by pipeline.ParseDisclosurePolicy:

  • boolean: true → "true", false → "false". Preserves backwards compatibility with configs (and the documented default) written when parameter_disclosure was a bool, instead of failing to decode.
  • string: a policy keyword ("false"/"true"/"high") or a comma-separated action-type allowlist, used verbatim.
  • array of strings: an action-type allowlist, joined with commas — the natural TOML/JSON spelling of the list form.

The flag and environment-variable layers only accept the string spelling (they have no array type); the array form is a TOML convenience.

func (*DisclosureConfig) UnmarshalTOML added in v0.15.0

func (d *DisclosureConfig) UnmarshalTOML(v any) error

UnmarshalTOML implements toml.Unmarshaler so the parameter_disclosure key can be a boolean, a string, or an array of strings.

type Duration added in v0.14.0

type Duration struct {
	time.Duration
}

Duration wraps time.Duration so it decodes from a TOML string such as "200ms" or "1s" via Go's time.ParseDuration. BurntSushi/toml has no native duration type; without this an operator would have to write nanoseconds.

func (*Duration) UnmarshalText added in v0.14.0

func (d *Duration) UnmarshalText(text []byte) error

UnmarshalText implements encoding.TextUnmarshaler so toml.DecodeFile parses a quoted duration string into a time.Duration. An empty string is rejected — a key present in the file but blank is a misconfiguration, not a default.

type FileConfig added in v0.14.0

type FileConfig struct {
	Socket              *string           `toml:"socket"`
	DB                  *string           `toml:"db"`
	Key                 *string           `toml:"key"`
	PublicKey           *string           `toml:"public_key"`
	ForensicPublicKey   *string           `toml:"forensic_public_key"`
	ChainID             *string           `toml:"chain_id"`
	IssuerID            *string           `toml:"issuer_id"`
	VerificationMethod  *string           `toml:"verification_method"`
	ParameterDisclosure *DisclosureConfig `toml:"parameter_disclosure"`
	RedactPatterns      *string           `toml:"redact_patterns"`
	UnsafeSocketPath    *bool             `toml:"unsafe_socket_path"`
	// ShutdownDeadline accepts a Go duration string, e.g. "200ms" or "1s".
	ShutdownDeadline *Duration `toml:"shutdown_deadline"`
}

FileConfig is the subset of Config that can be set from the TOML config file. Every field mirrors an existing flag/env var so operators have one mental model: the TOML key is the flag name with dashes turned into underscores. Pointer-typed fields distinguish "absent in the file" (nil, so a lower-precedence default/env/flag wins) from "explicitly set to the zero value" (e.g. unsafe_socket_path = false) — the config file is the lowest-priority layer, so an absent key must never clobber env or flags.

func LoadConfigFile added in v0.14.0

func LoadConfigFile(path string, required bool) (*FileConfig, error)

LoadConfigFile reads and strictly decodes the TOML config at path.

  • required=false (default-path load): a missing file is not an error — it returns (nil, nil) so the daemon runs on flags/env alone. Any other read or parse error is returned, because a present-but-broken config is a misconfiguration we refuse to silently ignore.
  • required=true (explicit --config): a missing file IS an error — the operator named a path that does not exist, which is almost certainly a typo rather than an intentional "no config".

Unknown keys are rejected: a typo'd key (e.g. "sockett") would otherwise be silently ignored, leaving the daemon running with a different config than the operator believes they set. This mirrors the redact-pattern loader's "reject malformed config rather than silently degrade" stance.

type ProtocolVersion added in v0.14.0

type ProtocolVersion struct {
	// FrameVersion is the range of emitter-frame schema versions (the `v`
	// field on the wire) the daemon can interpret.
	FrameVersion VersionRange `json:"frame_version"`
}

ProtocolVersion describes the wire protocol this daemon speaks. It is the machine-readable surface ADR-0024 Gate #8 reads (via the `obsigna-daemon --protocol-version` flag) to assert the released daemon's spoken range intersects the range each released SDK declares it can emit.

func SpokenProtocolVersion added in v0.14.0

func SpokenProtocolVersion() ProtocolVersion

SpokenProtocolVersion returns the daemon's spoken protocol range, sourced from the pipeline that actually accepts frames so the declaration cannot drift from the bytes the daemon honours.

type RotateSummary added in v0.22.0

type RotateSummary struct {
	ChainID           string
	Sequence          int
	ReceiptID         string
	OldFingerprint    string
	NewFingerprint    string
	ArchivedPublicKey string
	AnchoredTo        string
}

RotateSummary reports the outcome of a key rotation for the CLI to print.

func RotateKey added in v0.22.0

func RotateKey(cfg Config) (RotateSummary, error)

RotateKey rotates the daemon's signing key (ADR-0015 Phase A, offline).

It loads the current ("outgoing") key, generates a new ("incoming") key, appends a key_rotated receipt — signed with the outgoing key — to the head of cfg.ChainID, archives the outgoing public key so historical receipts stay verifiable, and swaps the incoming key into place. After this returns the daemon must be (re)started to pick up the new key; the rotated chain verifies end-to-end only when the verifier starts from the *genesis* public key and traverses the rotation (spec §7.3.7), not from the freshly published key.

The daemon MUST be stopped first: a running daemon holds the outgoing key in memory and would keep signing with it while the chain records a handover to the incoming key. RotateKey refuses to run when its socket is reachable.

Ordering is chosen so a committed rotation receipt always matches the on-disk key: the rotation receipt is signed in memory with the outgoing key, the key files are swapped (with rollback on failure), and only then is the receipt inserted (rolling the key files back if the insert fails). The residual window — a crash between the key swap and the insert — is the gap the external anchor (--anchor-log, written before any local change) closes.

type VersionRange added in v0.14.0

type VersionRange struct {
	Min int `json:"min"`
	Max int `json:"max"`
}

VersionRange is an inclusive range of integer protocol versions. An empty intersection between two ranges means the two peers cannot talk.

Directories

Path Synopsis
cmd
agent-receipts command
Command agent-receipts is the deprecation shim for the renamed `obsigna` CLI (ADR-0030).
Command agent-receipts is the deprecation shim for the renamed `obsigna` CLI (ADR-0030).
obsigna command
Command obsigna is the Agent Receipts read-side CLI.
Command obsigna is the Agent Receipts read-side CLI.
obsigna-daemon command
Command obsigna-daemon runs the receipts daemon: a single OS-user process that owns the Ed25519 signing key and the SQLite receipt store, and receives fire-and-forget event frames from emitters over a Unix-domain socket.
Command obsigna-daemon runs the receipts daemon: a single OS-user process that owns the Ed25519 signing key and the SQLite receipt store, and receives fire-and-forget event frames from emitters over a Unix-domain socket.
internal
anchor
Package anchor defines the external-witness sink the daemon writes rotation (and, in a later phase, checkpoint) events to (ADR-0015).
Package anchor defines the external-witness sink the daemon writes rotation (and, in a later phase, checkpoint) events to (ADR-0015).
binresolve
Package binresolve locates sibling binaries that ship together.
Package binresolve locates sibling binaries that ship together.
chain
Package chain owns the daemon's in-memory chain state — the next sequence number and the previous-receipt-hash for each chain id.
Package chain owns the daemon's in-memory chain state — the next sequence number and the previous-receipt-hash for each chain id.
doctorcli
Package doctorcli implements the `agent-receipts doctor` subcommand: an end-to-end health check of the receipts pipeline (emitter → socket → daemon → SQLite → verify) described by ADR-0010.
Package doctorcli implements the `agent-receipts doctor` subcommand: an end-to-end health check of the receipts pipeline (emitter → socket → daemon → SQLite → verify) described by ADR-0010.
keyscli
Package keyscli implements the `obsigna keys` subcommands — generate, pubkey, and rotate — that manage the daemon's Ed25519 signing key from the read-side CLI.
Package keyscli implements the `obsigna keys` subcommands — generate, pubkey, and rotate — that manage the daemon's Ed25519 signing key from the read-side CLI.
keysource
Package keysource defines the interface the daemon uses to sign receipts.
Package keysource defines the interface the daemon uses to sign receipts.
listcli
Package listcli implements the `agent-receipts list` subcommand: query recent receipts from a daemon-written SQLite store and print them in tabular or JSON form.
Package listcli implements the `agent-receipts list` subcommand: query recent receipts from a daemon-written SQLite store and print them in tabular or JSON form.
pipeline
Package pipeline maps an emitter frame plus an OS-attested peer credential into a signed AgentReceipt and persists it to the store.
Package pipeline maps an emitter frame plus an OS-attested peer credential into a signed AgentReceipt and persists it to the store.
showcli
Package showcli implements the `agent-receipts show <seq>` subcommand: inspect a single receipt by its chain sequence number, read from a daemon-written SQLite store.
Package showcli implements the `agent-receipts show <seq>` subcommand: inspect a single receipt by its chain sequence number, read from a daemon-written SQLite store.
socket
Package socket owns the daemon's Unix-domain-socket listener, the per-OS peer-credential capture, and the length-prefix message framing.
Package socket owns the daemon's Unix-domain-socket listener, the per-OS peer-credential capture, and the length-prefix message framing.
sockettest
Package sockettest provides shared helpers for AF_UNIX socket tests.
Package sockettest provides shared helpers for AF_UNIX socket tests.
verifycli
Package verifycli implements the `agent-receipts verify` subcommand: validate a stored chain's signatures and hash links using a daemon-written SQLite store and the daemon-published public key.
Package verifycli implements the `agent-receipts verify` subcommand: validate a stored chain's signatures and hash links using a daemon-written SQLite store and the daemon-published public key.
verifyeventcli
Package verifyeventcli implements the `agent-receipts verify-event` subcommand: end-to-end pipeline-provenance evidence for a single historical receipt.
Package verifyeventcli implements the `agent-receipts verify-event` subcommand: end-to-end pipeline-provenance evidence for a single historical receipt.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL