npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@metamynd/agentsafe-signer

v0.19.1

Published

Local signer daemon for AgentSafe agent/service keys — the key never enters the calling guard's own process. See docs/design/agent-key-custody-local-signer-daemon-plan.md.

Readme

AgentSafe Signer — the local key-custody daemon

Reference implementation of docs/design/agent-key-custody-local-signer-daemon-plan.md. A separate OS process holds an agent's or a service's Ed25519 signing key — agentsafe-guard/ agentsafe-mcp-guard never hold it themselves — and exposes a small, closed set of signing operations over a local socket. The key never enters the calling guard's own process, encrypted or otherwise, under any code path.

This is a first implementation pass, not the finished design. It exists to make the protocol, key storage, and socket layer real and tested, not to claim every property the design doc describes is fully delivered yet. See "Status" below for exactly what that means.

Try it

npm test   # daemon-protocol + daemon-key-exfiltration + kek-backends + service-installer + daemon-log-integrity, offline, no network
node cli.mjs start --state-dir ./.signer --role agent --admin

Resolves the KEK from a real OS-keychain backend automatically (see "OS-keychain KEK backends" below) — no passphrase needed unless none is available on the host, in which case it says so and tells you what to set. Opens the signing socket (always on) and the admin socket (closes after one generate-key call or 60s, whichever comes first — see the design doc's "The admin interface").

0.17.1 — install output that can actually start the daemon on macOS and Windows. The launchd plist and the Windows command ran daemon.mjs (a library with no argv handling) without start, the same defect already fixed for systemd; the plist also waited on a launchd socket no client uses, and the Windows command was an sc.exe service node cannot be (no SCM protocol, error 1053). Now: the plist runs cli.mjs start at login; Windows gets a per-user Scheduled Task XML (schtasks /Create /XML), and --tier 2 on Windows is refused with a clear message. Every platform, systemd included, now passes the full DID as --identity — the systemd unit passed the %i instance hash, which the daemon would reject on every sign. See "OS service unit generation".

0.17.0 — refund is its own service-call action. sign-service-call accepts action: 'refund' with two fields, [amount, reason] (amount as String(n), or '' for a full refund; reason or ''), matching the issuer's dedicated refund signature (MAGP §8.7.6). A refund used to be signed as a void, so one signature could stand for either call; the issuer now refuses that. Needs @metamynd/agentsafe-mcp-guard 0.15.0 on the Service side.

0.16.0 — a service-role daemon signs its Service's settlement calls (sign-service-call). A Service whose key lives here can now claim, capture, release, mark unknown and reconcile as its own DID (MAGP §8.7.6) instead of anonymously — required for every mainnet hold, and on testnet once the owner registers trusted counterparties (an anonymous claim is refused COUNTERPARTY_AUTH_REQUIRED). As with every op, the daemon builds the MAGP-SERVICE-v1 | action | authorizationId | …fields | nonce | issuedAt message itself from validated fields: action must be one of claim, dispatched, unknown, capture, void, reconcile with exactly the field count the issuer signs for it, authorizationId a UUID, fields strings of at most 512 characters, issuedAt fresh, and serviceDid (when given) the daemon's own identity. Service role only — an agent-role daemon refuses it (DAEMON_OPERATION_NOT_PERMITTED), so an agent can never sign as the counterparty that executes for it. Rate limit: 120 per 10 s. Needs @metamynd/agentsafe-mcp-guard 0.13.0 or later on the Service side.

Status

Implemented and tested:

  • The full ten-operation signing-socket protocol (sign-authorize, sign-envelope, sign-payload, sign-handshake-nonce, sign-service-call, sign-key-control-challenge, sign-log-checkpoint, sign-local-decision, get-identity, ping) and the two-operation admin socket (generate-key, status), matching the design doc's closed operation set exactly — no generic "sign this string" primitive.
  • Canonical message construction via the real, backend-generated buildAuthMessage/ envelopeHashFor (npm run build:signer-core from backend/, the same generation mechanism agentsafe-guard/agentsafe-mcp-guard already use for their own copies) — not a daemon-local reimplementation that could drift.
  • Per-request validation: identity binding (DAEMON_IDENTITY_MISMATCH), amount finite/non-negative, no | in action, issuedAt freshness bound at sign time (closes the pre-signed-stockpile gap, not just the verifier's own freshness check), role-based operation allow-listing (DAEMON_OPERATION_NOT_PERMITTED), a closed DAEMON_* error-code set, fail-closed on every rejection.
  • Per-operation rate limiting (sliding window), shadow mode by default per the design doc's own "ship in shadow, promote from real data" guidance — shadowMode: false to enforce.
  • Local, append-only logging of every request (operation/timestamp/requestId/outcome), verified to never contain key material — both by fuzzing every response and by statically checking the logging call sites' own source.
  • OS-keychain-backed KEK storage (kek-backends.mjs) — see its own section below for exactly what's verified vs. implemented-but-untested per platform. The passphrase+scrypt path from the first implementation pass is now the last-resort fallback, not the only option.
  • Wired into both agentsafe-guard (0.9.0) and agentsafe-mcp-guard (0.5.0) via the keyProvider seam — keyProvider: 'daemon' talks to a running instance of this daemon over its socket on either side; the key never enters either guard's own process. Proven end to end by agentsafe-guard/daemon-keyprovider.smoke.mjs and agentsafe-mcp-guard/daemon-keyprovider.smoke.mjs (a real signer daemon signing for a real guard, both roles).
  • OS service unit generation (service-installer.mjs) — see its own section below for exactly what "generation" does and, deliberately, does not do.
  • daemon-protocol.smoke.mjs, daemon-key-exfiltration.smoke.mjs, kek-backends.smoke.mjs, service-installer.smoke.mjs, daemon-envelope-parity.smoke.mjs, daemon-admin-socket.smoke.mjs, daemon-socket-permissions.smoke.mjs, daemon-hardening.smoke.mjs and daemon-hardening-tier2.smoke.mjs from the design doc's smoke-test suite, all real and passing, not just named — all nine now exist as their own files, which was an open gap in the previous pass.
  • Socket permissions (T4) — daemon-socket-permissions.smoke.mjs asserts the signing AND admin sockets are mode exactly 0600, owned by the invoking uid, inside a 0700 directory, read back from the filesystem after a real listen(). It carries its own negative control (a deliberately 0666 file must be rejected by the same comparison), and it documents a real window it found: net.Server.listen() creates the socket node at 0777 & ~umask — observed 0755 — and daemon.mjs chmods it to 0600 a syscall later. That window is not a T4 hole, because traversing to the socket also needs +x on its parent, and the parent is created 0700 before the bind; the test asserts that directory mode is what closes it, rather than leaving the window undocumented. What it does not prove: an actual connect attempt from a second OS user — creating one needs root, so T4 rests here on the permission bits the kernel enforces, not on an observed EACCES from a foreign uid. The last two closed with zero bugs found in daemon.mjs itself — the underlying behavior (canonical-message parity, the admin socket's own-timeout/one-op-then-close lifecycle) was already correct; these two just prove it rather than leave it merely documented.
  • A real, ACL-restricted named pipe on Windows (windows-secure-pipe.mjs) — an earlier pass of this README claimed this needed a native addon (see git history); that was wrong. The same shell-to-PowerShell technique already used for DPAPI works here too: .NET's System.IO.Pipes.NamedPipeServerStream accepts a real PipeSecurity restricting the pipe to the current Windows user (verified: GetAccessRules().Count === 1), and a small relay process bridges it to Node's own net socket API over the relay's stdio. toPlatformSocketPath and daemon.mjs's startServer route through it automatically on win32; daemon-protocol.smoke.mjs and daemon-admin-socket.smoke.mjs both exercise the real transport (not a mock) on every run. Two genuine Windows-specific bugs were found and fixed getting here, verified empirically rather than assumed: additional multi-instance pipes need PipeAccessRights.FullControl, not ReadWrite, or every instance past the first fails with "Access to the path is denied"; and the pipe must be opened with PipeOptions.Asynchronous, not None — a synchronous handle can't safely run the relay's two concurrent CopyToAsync directions at once, and without it a response written back to a connected client was silently lost. A third, subtler bug surfaced only under real concurrent load (several relay processes constructing a NamedPipeServerStream with a custom PipeSecurity at nearly the same instant, even for DIFFERENT pipe names): one occasionally hangs indefinitely inside the .NET constructor call itself, never reporting readiness and never exiting on its own — with no bound on this, the hang was unrecoverable and propagated all the way up to daemon.startSigningServer/ startAdminServer's own callers. Fixed with a per-attempt construction timeout (SPAWN_TIMEOUT_MS) that kills a stuck attempt and retries (bounded, MAX_SPAWN_ATTEMPTS, for the one-shot admin socket; unbounded, matching its own "always on" design, for the signing socket's pool) — self-healing rather than assuming the first attempt always succeeds.
  • Local, tamper-evident log hash-chaining (log-checkpoint.mjs, T11 — "the covered half"): the daemon periodically (DEFAULT_CHECKPOINT_INTERVAL_MS, 15 minutes) batches whatever new lines have accumulated in signer.log since the last checkpoint, hashes each line's exact stored bytes, builds a Merkle root over the batch (merkle.mjs, the same algorithm backend/src/features/magp/merkle.ts already uses — ported rather than imported, since this package has no dependency on the backend or any sibling package, by design), and chains that root to the previous checkpoint's own hash. agentsafe-signer verify-log --state-dir <dir> recomputes the whole chain directly from the log's own bytes and reports the exact checkpoint where tampering was introduced, if any — proven by daemon-log-integrity.smoke.mjs actually flipping a byte inside an already-checkpointed line and confirming both the direct API and the real CLI subcommand detect it, not just that an untouched chain verifies. This closes the "covered half" of T11 named in the design doc's own threat model; it does not, on its own, stop an attacker who controls both the log and the checkpoints file from rewriting both consistently — that needs a checkpoint's hash anchored somewhere outside this host, which is a separate, still-outstanding piece (see below).

Also implemented and tested, since this section was last written accurately:

  • Externally anchoring a checkpoint hash (the rest of T11). log-anchor.mjs asks the daemon to sign each pending checkpoint (sign-log-checkpoint) and submits it to the backend's POST /evidence/signer-checkpoint (DID-signature-authenticated, not session-based — a headless daemon has no user session to present), which anchors it via HCS. Idempotent, capped at 96 anchors/agent/day. agentsafe-signer log-anchor CLI subcommand; log-anchor.smoke.mjs.

  • A real migration/fresh-provisioning CLI. agentsafe-signer migrate drives rotateNetworkIdentity end to end (generate-key → rotate → sign-key-control-challenge → verify-key — the proof-of-possession trio that didn't exist for BYOK network identities until this was built); create-metamynd-agent --byok --daemon-socket drives fresh provisioning the same way. Both routed through the daemon; the private key never enters either CLI's process.

  • Real mlock()/VirtualLock() protection for the decrypted key. secure-memory.mjs, backed by sodium-native (this package's only dependency, added deliberately — this specific property cannot be reached via a shell-out the way every other gap here was closed, since it must run inside the process that owns the memory). secure-memory.smoke.mjs.

  • Core-dump-disable, closed declaratively rather than in the daemon's own runtime code: the generated systemd units set LimitCORE=0, and the generated launchd plist routes through /bin/sh -c 'ulimit -c 0; exec "$@"' (launchd has no LimitCORE-equivalent key, and ulimit is a shell builtin) — a true exec() chain, never a forked supervisor process. ptrace-denial is not implemented by a wrapper: an earlier pass prefixed ExecStart= with setpriv --dumpable 0 --, but that option does not exist (see the defect table under "OS service unit generation"), so it was removed. On Linux, T3 rests on Tier 2's dedicated DynamicUser UID (verified, below) and, at Tier 1, on the host's own kernel.yama.ptrace_scope. macOS has no ptrace-deny reachable this way at all — PT_DENY_ATTACH must be called by the target process itself via a syscall, the same architectural limitation secure-memory.mjs discloses for mlock(). Windows has no core-dump or ptrace directive in the generated Scheduled Task.

  • sign-local-decision, closing the gap left when local-decision audit reporting first shipped. A daemon-custody agent can now sign its own local-first block/escalate/non-value-allow verdict for POST /policy/decisions/local the same way the static-key provider always could — agentsafe-guard's createDaemonKeyProvider gained the matching client method. Agent-role only, same as sign-authorize/sign-envelope (a service-role daemon never evaluates a mandate locally). daemon-protocol.smoke.mjs, daemon-envelope-parity.smoke.mjs.

  • sign-payload (0.15.0) — payload binding without giving up custody. An agent-role daemon signs the digest of the COMPLETE payload an agent will execute (MAGP 8.3.9), and — when given an authorizationId — the late binding of a hold that already exists (8.3.11, a reviewer's MODIFY mints one with no digest). Like every op here it builds the message itself from validated fields and never signs caller-supplied bytes: the digest must be sha256: + 64 lower-case hex, the authorization id a UUID, and the message starts with a domain prefix (MAGP-PAYLOAD-v1 / MAGP-PAYLOAD-REBIND-v1) that nothing else this daemon signs starts with, so the signature cannot be replayed as an authorize, envelope, handshake or challenge. Before this a daemon-backed guard asked to bind a payload failed closed. daemon-protocol.smoke.mjs.

Deliberately not yet implemented — real gaps, not oversights:

  • Socket activation (systemd LISTEN_FDS, launchd launch_activate_socket) — see "Still open" under "OS service unit generation". The daemon always binds its own <state-dir>/signer.sock.
  • The launchd plist and the Windows Scheduled Task have never been installed on a real host. Their content is asserted by service-installer.smoke.mjs; only the systemd unit has been run.
  • Windows Tier 2 (a separate service account) — node does not implement the Service Control Manager protocol, so cli.mjs install --platform win32 --tier 2 refuses with a clear message.
  • All the smoke tests the design doc names now exist as their own files: daemon-socket-permissions.smoke.mjs (T4), daemon-hardening.smoke.mjs (Tier 1) and daemon-hardening-tier2.smoke.mjs (Tier 2, verified against a real system unit — see above). Log integrity is its own real file (daemon-log-integrity.smoke.mjs, above) — though it covers only the local hash-chain half of T11, not the external-anchoring half, which remains open per the bullet above.

Signed jurisdiction (sign-authorize, since 0.18.0)

sign-authorize accepts an optional jurisdiction (two ASCII letters; the client normalises it to upper case and sends the same string). With it the daemon signs the v2 message (MAGP §8.3.12: the eight fields, MAGP-AUTH-v2, the jurisdiction) and echoes jurisdiction in its result; without it, the v1 message exactly as before and no echo. A malformed value is DAEMON_MALFORMED_REQUEST and nothing is signed. The echo is how a client tells this daemon from an older one, which ignores the field and would sign v1 for a request that is sent with it: @metamynd/agentsafe-guard ≥ 0.16.0 and metamynd-client ≥ 0.6.0 refuse such a daemon (JURISDICTION_SIGNING_UNSUPPORTED) rather than send a request the gate would reject. At the gate, a registered payee's country wins over the signed one (JURISDICTION_MISMATCH); the other refusals are JURISDICTION_REQUIRED and JURISDICTION_NOT_ALLOWED.

Context signature over the request as sent (sign-envelope, since 0.19.0)

sign-envelope hashes the envelope (MAGP §8.3.13) over the fields as the request sends them: an omitted currency stays omitted (0.18.0 and earlier hashed it as USD) and an omitted amount is signed (they refused it), so the signature matches the gate's envelopeHashFor for a non-financial request too. agentsafe-guard ≥ 0.17.0 signs the context by default and checks the daemon's signature against the hash it sends: with an older daemon such a request is refused CONTEXT_SIGNING_UNSUPPORTED before it is sent (upgrade the daemon, or signContext: false).

Hardening tiers — what a real host actually confirms (T3, T5)

daemon-hardening.smoke.mjs checks the tier directives from outside the process, against the kernel's own view, never by trusting the unit file's text (service-installer.smoke.mjs already covers the text; these are different questions, and the setpriv defect above is exactly what happens when only the text is checked). It reports three outcomes, not two — ok, FAIL, and INCONCLUSIVE — because on this axis a green check that would stay green with the hardening removed is worse than no check.

Confirmed on Ubuntu 24.04.4 / systemd 255:

  • LimitCORE=0 works. A live service process reported Max core file size 0 0 in /proc/<pid>/limits, soft and hard, with a negative control proving an unconstrained process on the same host reports unlimited — so the check is reading the directive, not the host default.
  • SocketMode=0600/DirectoryMode=0700 work on the real %t/agentsafe/ path (see above).

Reported INCONCLUSIVE rather than passed, at Tier 1:

  • ptrace denial (T3). This host runs kernel.yama.ptrace_scope=1, which already denies a same-UID sibling's PTRACE_ATTACH for every process, hardened or not. An EPERM here is the ambient LSM policy, not evidence the unit hardened anything, so the test says so instead of banking the pass. Distinguishing the two needs ptrace_scope=0, a root-only host change.

Tier 2 is now verified against a real system unit

daemon-hardening-tier2.smoke.mjs is the second file the design doc names, and it decides what the Tier 1 file structurally cannot: DynamicUser= is a system-manager feature, so a systemctl --user service always runs as the invoking user and can never demonstrate it. Run against a Tier 2 unit installed at /etc/systemd/system/ on Ubuntu 24.04.4 / systemd 255, audited from an ordinary unprivileged shell:

| Claim | Result | |---|---| | The daemon runs under a genuinely separate UID | Verified — uid 64750, user agentsafe-signer, against an invoking uid of 1000 | | A same-user PTRACE_ATTACH is refused (T3) | Verified — EPERM, from a real PTRACE_ATTACH probe, not from the unit's declared text | | The daemon's /proc entries are unreadable to the agent's own account | Verified — /proc/<pid>/environ returns EACCES | | RemoveIPC=true and DynamicUser=true are live on the loaded unit | Verified — systemctl show reports yes for both | | Its state directory is out of reach of the invoking user | Verified indirectly — /var/lib/private is 0700 root, so uid 1000 cannot even traverse to it |

This is also what closes the Tier 1 namespacing caveat below. The Tier 2 unit sets PrivateTmp=true and PrivateDevices=true — the two directives that, at Tier 1, flipped a same-UID PTRACE_ATTACH from EPERM to success. With both live and yama.ptrace_scope=1, the cross-UID attach was still refused, because that refusal is the ordinary permission check and owes nothing to Yama. Tier 2 does not inherit the Tier 1 hole; it closes it. The test asserts this contrast directly rather than leaving it as prose here.

Still INCONCLUSIVE even with root, and reported as such:

  • RemoveIPC's functional reap. The directive is confirmed live on the loaded unit, which is what the unit is responsible for. Observing the reap itself means creating IPC objects as the dynamic user and then stopping the unit — privileged and destructive, and systemd's behavior rather than this package's, so the read-only audit does not do it.
  • Direct stat of the state directory. /var/lib/private/<name> is root-only traversable, so the ownership check cannot read through it unprivileged. That is consistent with the isolation being real, and is reported as unproven rather than inferred.

Two things the installation surfaced that are worth repeating, because neither is obvious from the unit file:

  • Tier 2 cannot use a home-directory interpreter. nodePath defaults to process.execPath, which is correct at Tier 1 (the unit runs as the invoking user) and wrong at Tier 2: a DynamicUser account is a different UID and cannot traverse a 0750 home, which is the Ubuntu default. On the test host the dynamic user could reach neither the nvm node binary nor the package under ~. cli.mjs install now takes --node-path and warns when a Tier 2 ExecStart resolves under /home; the package itself needs a system path too (/usr/local/share/agentsafe/signer/, as the design doc already specifies).
  • The privileged step stayed a human step. The design doc requires it ("must always be a printed instruction the operator runs themselves, never something the installer executes on their behalf"), and service-installer.smoke.mjs asserts by source inspection that this package never shells out to systemctl. The verification above was done by handing the operator a script to run, not by the test installing anything.

A counterintuitive result worth re-measuring on your own target host. While establishing the Yama baseline, bisecting one directive at a time and reproducing across rounds: a plain systemd-run --user node process correctly refused PTRACE_ATTACH (EPERM), but adding either PrivateTmp=true or PrivateDevices=true — both of which the generated unit sets — flipped the identical process to ATTACH_SUCCEEDED. On such a host the "hardened" unit is more ptrace-exposed than an unhardened one. This was measured, not derived from documentation, and the underlying kernel mechanism was not chased down; it is recorded here as a caveat, and the test emits it as a standing INCONCLUSIVE whenever the unit contains those directives and Yama is active. It does not change the guidance — Tier 2's dedicated UID was already the mitigation the threat model relies on for T3, and Tier 1 was always documented as "partial" there — but it does mean Tier 1 should not be assumed to inherit host Yama protection.

One methodological note, since it produced a misleading result before it was caught: a ptrace probe that calls PTRACE_ATTACH and then PTRACE_DETACH without an intervening waitpid() races, and can leave the target in T (stopped) with TracerPid: 0 — i.e. it silently stops the service it was auditing. That is what happened here, and the daemon had to be SIGCONTed back. Any probe used for this check needs to wait for the stop before detaching.

OS service unit generation

service-installer.mjs generates the real systemd/launchd/Windows definitions from the design doc's own templates — parameterized by identity and tier — and cli.mjs install writes them to a local --out-dir you choose. It never writes to a real system service location (~/.config/systemd/user, /etc/systemd/system, ~/Library/LaunchAgents, the Windows Task Scheduler) and never runs systemctl/launchctl/schtasks itself — verified by service-installer.smoke.mjs's own static check of the module's source, not just asserted in this paragraph. It prints the exact command to actually install the result; running that command is always a separate, deliberate, human step — registering a real system service is a genuine system modification, deliberately out of scope for what this module does on its own.

node cli.mjs install --identity <did> --daemon-path /opt/agentsafe/signer/daemon.mjs \
  --platform linux --tier 2 --out-dir ./service-units

What each platform gets (every one runs cli.mjs start --state-dir <dir> --role <role> --identity <full DID>, never the daemon.mjs library module):

| Platform | Mechanism | State dir | Tiers | |---|---|---|---| | Linux | systemd .socket + .service | %S/agentsafe/<id> (StateDirectory=) | 1 (--user) and 2 (DynamicUser) | | macOS | launchd LaunchAgent, RunAtLoad, core dumps off via ulimit -c 0 | ~/Library/Application Support/agentsafe/<id> (or --state-dir); log in ~/Library/Logs/agentsafe-signer.<id>.log | 1; Tier 2's Seatbelt profile is not generated | | Windows | Task Scheduler XML: logon trigger for one user, runs as that user unelevated (InteractiveToken, so the DPAPI KEK works), no 72h time limit, not stopped on battery | %LOCALAPPDATA%\agentsafe\<id> (or --state-dir) | 1 only — see below |

Windows is a Scheduled Task, not a service. Earlier versions printed sc.exe create AgentSafeSigner-<id> binPath="node.exe daemon.mjs --identity <id>" under a Virtual Service Account. That could not work: the Service Control Manager requires the process to call StartServiceCtrlDispatcher/SetServiceStatus, node does not, and SCM kills such a process with error 1053 after about 30 seconds. It also ran daemon.mjs without start. A real Windows service needs an SCM-aware host (WinSW, NSSM) — a third-party dependency this package does not take — so --tier 2 on win32 now exits with a message saying so. The task runs a console window at logon; closing it stops the signer.

Tested by service-installer.smoke.mjs (content only — nothing is executed): the Tier 1/Tier 2 systemd differences (%h-vs-absolute ExecStart, RemoveIPC=true placement); the full launchd ProgramArguments array, RunAtLoad/KeepAlive/Umask, and XML escaping; the Windows task's <Command>/<Arguments> (including Windows command-line quoting of paths with spaces and trailing backslashes), trigger, principal and settings, the schtasks argv, the UTF-16LE+BOM file the CLI writes, and the Tier 2 refusal.

Now also tested, for the first time, by actually installing and starting it (Ubuntu 24.04.4 LTS, systemd 255, systemctl --user). The socket unit works: systemd created %t/agentsafe/signer-%i.sock at mode 0600 in a 0700 directory, owned by the invoking user — SocketMode=/DirectoryMode= take effect exactly as written, which is T4 confirmed on the real deployment path rather than only in a temp dir. LimitCORE=0 also verifiably takes effect: /proc/<pid>/limits on the live process reported Max core file size 0 0, read from outside the process.

The service unit did not start. Five defects in one ExecStart line, each found only by running it:

| # | Defect | Symptom | Status | |---|---|---|---| | 1 | setpriv --dumpable 0 -- wrapper | util-linux's setpriv has no --dumpable option (checked on util-linux 2.39.3; the string is absent from the binary). Unit died at exec: setpriv: unrecognized option '--dumpable', status=1/FAILURE | Fixed — wrapper removed | | 2 | %h/ prefixed onto an absolute --daemon-path | ExecStart resolved to /home/u/home/u/...; the CLI's own documented example passes an absolute path, so this was the common case | Fixed — %h now applied only to a relative path | | 3 | nodePath defaulting to /usr/bin/node | Absent on any nvm/asdf/volta host (this one had node only under ~/.nvm); systemd reports a bare 203/EXEC with no hint which binary was missing | Fixed — defaults to process.execPath | | 4 | MemoryDenyWriteExecute=true | V8 is a JIT; its mprotect fails with ENOMEM inside v8::base::OS::SetPermissions during Isolate init and node dies with SIGTRAP (Check failed: 12 == errno) before running any daemon code. No Node service can set this directive | Fixed — removed from both tiers | | 5 | ExecStart points at daemon.mjs | daemon.mjs is a library module with no argv handling — node daemon.mjs returns 0 immediately and serves nothing | Fixed — ExecStart now runs cli.mjs start --state-dir %S/agentsafe/%i --role <role> --identity … (see row 7 for the identity value) | | 6 | Type=notify | Nothing in the package sends sd_notify READY=1, so systemd fails the unit with Result: protocol even though node is healthy and listening | Fixed — Type=exec, which still waits for a successful execve() without requiring the notify protocol | | 7 | --identity %i | %i is the 8-hex-digit instance id, not the DID. The daemon compares every signing request's agentDid against --identity, so it answered ping (which is how the fixes above were verified) but would refuse every sign with DAEMON_IDENTITY_MISMATCH. Found by reading, not by running | Fixed (0.17.1) — --identity <full DID>, with %/$ escaped for systemd |

With all six fixed, the generated unit now starts and serves. Verified by installing the generator's own output unmodified (plus a drop-in supplying AGENTSAFE_SIGNER_PASSPHRASE, since no OS-keychain backend works on that host — see below): systemctl --user start reached active (running), the daemon unlocked, and a real ping over its socket returned {"ok":true,"result":{"status":"ok","unlocked":true}}. LimitCORE=0 was confirmed on that live process from outside it (/proc/<pid>/limits: Max core file size 0 0).

Still open: socket activation is declared but not implemented

The generated .socket unit is real and works on its own — systemd creates and listens on %t/agentsafe/signer-%i.sock at 0600 — but the daemon never accepts that socket. Nothing in daemon.mjs or cli.mjs reads LISTEN_FDS/LISTEN_PID, so cli.mjs start binds its own socket at <state-dir>/signer.sock instead. Measured on the running unit, the two paths are simply different:

socket unit listens on : /run/user/1000/agentsafe/signer-1c741375.sock   (%t/…)
daemon listens on      : ~/.local/state/agentsafe/1c741375/signer.sock   (%S/…)

So a client that connects to the systemd socket triggers the service and then talks to nobody; Requires=…​.socket currently buys the ordering relationship and nothing else. Consumers should point at the daemon's own <state-dir>/signer.sock until this is closed.

Closing it properly means implementing the sd_notify/LISTEN_FDS protocol in the daemon — a real feature, not a generator tweak, and deliberately out of scope for this verification pass in the same way the macOS ptrace-deny gap above is: disclosed as a first-implementation-pass limit rather than quietly left for someone to discover in production. The launchd plist deliberately declares no Sockets key (an earlier version did, with RunAtLoad=false, so the daemon only started when a client connected to a launchd socket no client uses — it never started at all). Still untested: launchd and Windows installation on a real host.

OS-keychain KEK backends

kek-backends.mjs tries a real platform backend first, in order, and only falls back to passphrase+scrypt if none is available — always printing which one was actually used, so an operator can see what's protecting their key rather than assume.

A backend "being available" and "actually working" are checked separately. available() only proves the tool exists and runs (e.g. systemd-creds --version exits 0) — it can't cheaply prove the backend can actually encrypt/decrypt in this environment. Caught for real, not hypothesized: wiring agentsafe-signer's tests into CI (ubuntu-latest) found that systemd-creds is present and passes its own --version check there, but systemd-creds encrypt fails with "Failed to determine local credential host secret: Permission denied" — the runner has no host/TPM secret for it to use. resolveKek() now catches a failure from the selected backend and falls through to the next one (down to passphrase as the last resort) instead of crashing the daemon — a backend that is present but unusable is not meaningfully different from one that is absent.

| Platform | Backend | Mechanism | Verified in this pass? | |---|---|---|---| | Windows | dpapi | ProtectedData.Protect/Unprotect (CurrentUser scope), via a PowerShell one-liner — no native addon | Yes — real protect/unprotect round-trip, including a simulated daemon restart (kek-backends.smoke.mjs), run on the actual development machine for this pass | | macOS | macos-keychain | security add-generic-password/find-generic-password | No — implemented per man security's documented behavior, never run against a real macOS host | | Linux | systemd-creds | systemd-creds encrypt/decrypt (TPM- or machine-ID-backed) — tried first, fits the Tier 2 systemd-service deployment model this daemon targets | Still no. Retried on a real Ubuntu 24.04 / systemd 255 host and it failed there too, with the same error as CI: Failed to determine local credential host secret: Permission denied. Root cause now pinned rather than guessed (see below) | | Linux (no systemd-creds) | secret-tool | Secret Service (GNOME Keyring/KWallet) via secret-tool store/lookup | Still no, and not testable on that host. secret-tool is not installed, no keyring daemon is running, and org.freedesktop.secrets is neither on the session bus nor D-Bus-activatable — so there was no Secret Service to exercise. Installing a keyring purely to make the test pass would prove nothing about a real desktop host, so it was not done | | Any (no backend available, or every available one failed) | passphrase | AGENTSAFE_SIGNER_PASSPHRASE + scrypt (unchanged from the first pass) | Yes (carried over), but explicitly the weakest tier — the daemon says so out loud when it's the one in use |

Why systemd-creds fails on a normal Linux host, precisely. It needs one of two secrets, and a non-root user can obtain neither:

  • Host secret — /var/lib/systemd/credential.secret, created by systemd-creds setup. The file does not exist by default and /var/lib/systemd is root:root 0755, so creating it needs root. This is the Permission denied above, on a real host and on CI alike; CI was not the anomaly.
  • TPM2 — systemd-creds encrypt --with-key=tpm2 returns Failed to create TPM2 context: Operation not supported; the host has no /dev/tpm* at all.

There is no user-scoped alternative: systemd-creds has no --user flag (systemd 255). So on any host where the operator cannot run systemd-creds setup as root, and which has no TPM, this backend can only ever be present-but-unusable — which resolveKek already handles by falling through. Verified end-to-end on that path: with AGENTSAFE_SIGNER_PASSPHRASE set, kek-backends.smoke.mjs passes with backend actually used on this host: passphrase, having logged the systemd-creds failure and moved on. With no passphrase either, resolveKek throws and the test exits non-zero — correct fail-closed behavior, but worth knowing before running the suite on a bare Linux box.

Before trusting the macOS/Linux backends in production, run kek-backends.smoke.mjs on the real target OS — it's written to exercise whichever backend resolveKek actually selects, so the same test file is the real verification step for those platforms too, not new tooling to build.

Regenerating the bundled canonical-message logic

cd ../../backend && npm run build:signer-core

Regenerates policy-core.mjs and governance-envelope.mjs from their real TypeScript sources — never hand-edit those two files.