中文版 SAFA iconSAFA

The SAFA Access Pattern: Every Resource, Topology, and No Plaintext Password

The previous article made the case: agents should never hold reusable credentials. This one walks the actual machinery — how a database, an object store, a cache, or a host is registered without its password becoming plaintext, how protected values are entered through a system-authenticated process, how jump routes and pinned host identity work, and how topology answers are computed from observed evidence rather than assumptions.

SAFA — Secure Access for Agents2026-08-19github.com/juju-w/safaPart one: whyLegacy article

One resource directory for every kind of thing

SAFA's inventory is a single encrypted, transport-independent directory. "Resource" is a generic aggregate — host, database, object-storage, cache, messaging, search, graph, and service all reuse the same record. Each record binds an immutable template that names the adapter/configuration schema, such as ssh@1, mysql@1, redis@1, s3@1, or http@1.

Registering a type is deliberately not granting access, and it does not claim an adapter exists. The directory answers "what did the user register and where does its protected data live?" — an adapter later decides which operations are implemented for that type and access method.

FieldVisibilityNotes
canonical aliaspublicLowercase logical name; the only selector an agent may use
resource kind / typepublice.g. database.mysql, object-storage.s3, host.linux
templatepublicImmutable adapter/schema binding, e.g. mysql@1
endpoint / username / routeauthorizedEncrypted; disclosed only by authorized detailed show
typed metadataallowlist / authorizedUnknown keys always default private
credential bindingsbroker onlyOpaque IDs only; never in Agent DTOs
credential values / locatorsKeychain / broker onlyNever resource metadata
The rule that makes the rest work: a credential value is never a field in the domain model. The record stores an opaque reference; the secret lives in Keychain or the Secure Enclave, inside the Broker boundary.

How passwords get in without becoming plaintext

Entering a password for the first time is the most sensitive moment in the whole system, so SAFA separates it from anything the agent can influence. A separately signed safa-trusted-setup process runs system-authenticated on the Mac: protected fields are typed with terminal echo disabled, and the values travel to the Broker as a typed ProtectedResourceSetupPayload over authenticated local XPC — never through the agent CLI's argv, environment, stdin, stdout, or stderr. If no trusted controlling terminal is available, SAFA returns a safe_for_agent: false command for the user to run themselves.

The result is a CredentialReference: an opaque UUID that also serves as the Keychain account identifier, plus a kind (SSH today; database passwords, object-store access keys, and API tokens extend it without a vault-shape change), a health state, and — critically — an access class:

Device-bound private keys have no export transition. Removing a reference deletes or invalidates its Keychain material after dependent resources are disabled. Secrets are not fields in the domain model — there is nothing to leak from the vault schema itself.

A password that is typed into a system-authenticated local process, stored as an opaque Keychain reference, and injected only inside the Broker boundary is a password that never becomes model input, tool output, or chat history.

Getting there: direct routes, jump routes, pinned identity

Connection metadata (endpoint, username, route) is encrypted and never part of the agent-visible surface. When a route is needed, the resource model carries a jumpRoute: an ordered, acyclic list of SSH resources the Broker must traverse. Direct routes are supported too — including an already-running local Core Tunnel listener, so a tunnel can be fronted without exposing anything.

Every execution is a fresh, isolated SSH invocation built from an opaque per-request alias and a temporary ssh_config and known_hosts in a 0700 directory:

A host-identity record guards the destination: status trusted, changed, or revoked, with a verification method. Changed or missing identity fails closed — no raw SSH fallback, ever.

Topology: answers from evidence, not from diagrams

Relationships are stored as a directed, typed, attributed multigraph — a tree cannot express "this host uses a route in another security domain and depends on three stores". Edges carry a layer and verification:

A successful bounded SSH setup or execution refreshes a single observed, verified runtime.local can-reach <resource> edge for five minutes; an OpenSSH exit 255 never creates one. Reachability returns confirmed only when the Broker finds a directed path made entirely of fresh, verified evidence.

Here is what the real commands return — recorded against a real registered environment (aliases sanitized, numbers verbatim):

$ safa topology show data.home --limit 64   # where is the database host?
schema: dev.safa.cli/v2
command: topology.show
status: completed
task: placement
answer:
  outcome: found
  source: data.home
count:
  nodes: 6
  edges: 5
nodes[6]{alias,kind,resource_kind}:
  data.home,resource,host
  site.home,site,null
  desk.win,resource,host
  router.home,resource,host
  virt.host,resource,host
  nas.primary,resource,host
edges[5]{id,from,relation,to}:
  ...,data.home,located-in,site.home
  ...
$ safa topology path data.home nas.primary --limit 64   # can the DB host reach the NAS?
schema: dev.safa.cli/v2
command: topology.path
status: completed
task: reachability
answer:
  outcome: not-found
  source: data.home
  target: nas.primary
count:
  nodes: 2
  edges: 0

Read the contrast: the database host and the NAS are co-located in the same site (placement found, six nodes, five located-in edges) yet the Broker reports no verified path between them. That is the whole point — co-location is not connectivity. An agent that asked "can this node reach the database?" would get not-found and would be forbidden from inventing a route or attempting a direct connection. The same graph revision produces topology impact answers (reverse dependency search) and topology link/unlink mutations, which require macOS user authorization.

What is visible, and what costs user presence

The default, non-interactive summary exposes only source-reviewed allow-listed keys: host.os.family, host.docker.available, database.engine, object-storage.provider, cache.engine, service.protocol. IP addresses, kernel releases, CPU/memory/disk details, Docker versions, routes, and every unknown key require resource show --details — and that command relies on the macOS-owned user-presence prompt. Protected details and privileged actions are gated by the same rule: the user is present on the Mac, or the value stays hidden.

Approval is not a vague concept in the codebase: a pending request carries a TrustedApprovalPresentation (resource alias, privilege, exact command, intent, expected effect, risk level, findings), and the user decides through safa-trusted-setup request approve or a denial — with optional scoped grants for sudo (spec 002). The agent's own risk assessment is advisory and can never substitute for this decision.

Current scope and the road ahead

Today the executable profile is SSH hosts with allow-listed read-only diagnostics. Databases, object stores, caches, and services are registered as typed records whose operations are gated pending review. The generic directory, the opaque credential-reference model, the jump-route field, the access classes, and the user-presence approval path are already in the design and contracts — the adapters are what ship next, each with conformance fixtures rather than promises.

See the real diagnosis View on GitHub