Secrets
Secrets are a first-class concern in Graphorin. The framework gives you:
- A
SecretValuewrapper that cannot be accidentally logged, serialised, or displayed. - A
SecretRefURI scheme that decouples your application code from the underlying vault. - OS keychain integration via
@napi-rs/keyring(MIT). - An optional encrypted-file store via
@node-rs/argon2+ AES-256-GCM. - Per-tool secrets ACL scoping that flows through the agent runtime.
- An optional reference adapter for the 1Password CLI in
@graphorin/secret-1password.
SecretValue
import { SecretValue } from '@graphorin/security';
const apiKey = SecretValue.fromString('sk-...redacted...');
// Logging or serialising a SecretValue prints a fixed redaction marker:
console.log(apiKey); // SecretValue([REDACTED])
console.log(JSON.stringify({ apiKey })); // {"apiKey":"[SECRET]"}
// `length` is safe to log; the raw bytes are kept on a private buffer.
console.log(apiKey.length);
// Reading the raw value is explicit and audited. Prefer the scoped
// form, which never leaves a raw string lingering in your code:
const receipt = await apiKey.use(async (raw) => {
// `raw` is the UTF-8 string, live only inside this callback.
return raw.length;
});
// One-shot reads also exist:
const raw = apiKey.reveal(); // returns the UTF-8 string
const raw2 = apiKey.unwrap(); // deprecated alias for reveal()
// When you no longer need the secret, dispose() zero-fills the buffer.
apiKey.dispose();Two static helpers cover the common construction paths:
SecretValue.fromString(raw, opts?)- wrap a UTF-8 string at the I/O boundary.SecretValue.fromBuffer(buf, opts?)- wrap aBuffer(defensively copied).
SecretValue is also exposed in @graphorin/core as an interface (the contract), so any package that types a parameter as SecretValue depends only on @graphorin/core - the concrete class lives in @graphorin/security.
SecretRef URI scheme
A SecretRef is a URI of the form <scheme>:<scheme-specific-part> that names where a secret lives without binding the application to a specific vault. The default registry ships seven schemes; custom resolvers can register additional ones.
| URI | Resolves to |
|---|---|
env:OPENAI_API_KEY | process.env.OPENAI_API_KEY. Optional ?default=... fallback. |
keyring:openai_api_key?service=graphorin | OS keychain entry (account, optional service prefix). Requires the @napi-rs/keyring peer. |
file:///abs/path/to/secret | File on disk. Optional ?encoding=, ?warnOnPermissions=0. |
encrypted-file:/abs/path#key-name | Entry inside an Argon2id + AES-256-GCM file. |
vault:foo/bar | Lookup through an operator-registered VaultAdapter (setVaultAdapter(...) at bootstrap; a packaged @graphorin/secret-vault adapter is planned post-MVP, not shipped yet). |
ref:foo | Indirection through the active SecretsStore. |
literal:value | Inline literal (off by default; opt in explicitly per environment). |
op://<vault>/<item>/<field> | 1Password CLI reference, via @graphorin/secret-1password. |
import { parseSecretRef, resolveSecret } from '@graphorin/security';
const parsed = parseSecretRef('keyring:openai_api_key?service=graphorin');
const value = await resolveSecret(parsed);
console.log(value.length); // safe; never reveals
const raw = value.reveal();parseSecretRef(uri) strict-parses the URI and throws a typed SecretRefParseError on malformed input. resolveSecret(refOrUri) walks the resolver registry and returns a SecretValue. Resolvers register a single scheme and own the parsing of their scheme-specific part - see registerResolver(...) for plugging in your own.
Per-tool secrets ACL
Tools never see the application's full secret scope. A tool declares its allowlist in the secretsAllowed field, the executor wraps every execution in a scope built with withChildToolSecretsContext(...) from @graphorin/security/secrets, and the tool reads secrets through the enforced accessor ctx.secrets.require(...):
import type { SecretValue } from '@graphorin/core';
import { tool } from '@graphorin/tools';
import { z } from 'zod';
const refundSchema = z.object({ orderId: z.string(), amountUsd: z.number() });
const receiptSchema = z.object({ receiptId: z.string() });
// your integration
declare function callPaymentApi(
input: z.infer<typeof refundSchema>,
apiKey: SecretValue,
): Promise<z.infer<typeof receiptSchema>>;
const refundTool = tool({
name: 'refund.create',
description: 'Issue a refund for a previously placed order.',
inputSchema: refundSchema,
outputSchema: receiptSchema,
// Per-tool ACL: the only keys ctx.secrets.require() will resolve.
// The agent's default backend treats each key as a SecretRef.
secretsAllowed: ['keyring:payments_api_key'],
async execute(input, ctx) {
const apiKey = await ctx.secrets.require('keyring:payments_api_key');
return callPaymentApi(input, apiKey);
},
});A tool that asks for a secret outside its declared ACL fails closed with SecretAccessDeniedError and writes one row to the audit log. An empty / omitted secretsAllowed means the tool may not request any secret. Store-backed reads inside the scope are gated the same way: SecretsStore.require(...) throws, and the non-throwing SecretsStore.get(...) reads a denied key as absent (SPL-14). Note that the gate lives in the accessor and the stores - a direct resolveSecret(...) call resolves the ref without consulting the ACL, so route tool code through ctx.secrets.
Sub-agent inheritance
Sub-agents do not inherit the parent's secret scope, and agent.toTool(...) deliberately exposes no secret-forwarding option: a child agent resolves secrets only through its own tools' per-execution ACLs (withChildToolSecretsContext), so least authority holds across multi-agent boundaries by construction.
Each handoff record stamps the resolved posture in its inheritedSecrets field. The HandoffSecretsInheritance type reserves 'inherit-allowlist' / 'forward-explicit' / 'isolated' for a future configurable mechanism; today the recorded posture is always 'inherit-allowlist' with an empty ref list.
OS keychain
KeyringSecretsStore is backed by the OS keychain - Keychain on macOS, Credential Manager on Windows, libsecret-compatible services on Linux - through the optional @napi-rs/keyring (MIT) peer dependency.
graphorin secrets list
graphorin secrets get openai_api_key
graphorin secrets set openai_api_key --from-stdin
graphorin secrets rotate openai_api_key --new-value …
graphorin secrets delete openai_api_key
graphorin secrets ref keyring:openai_api_key?service=graphoringraphorin secrets get prints a redaction marker by default; pass --reveal to print the raw value (audited). Use --secrets-source <auto|keyring|encrypted-file|env> and --strict-secrets to control which SecretsStore the CLI activates.
Encrypted-file store
When the OS keychain is not available (servers, containers, headless CI), EncryptedFileSecretsStore provides the same API on top of:
- Argon2id (
@node-rs/argon2, MIT) for key derivation; - AES-256-GCM for ciphertext;
- A versioned on-disk format with integrity tags.
The store is selected through the --secrets-source encrypted-file flag, the matching secrets.source config field, or the createSecretsStore({ kind: 'encrypted-file', ... }) factory. The master passphrase resolves through a SecretRef (typically env:GRAPHORIN_MASTER_PASSPHRASE or file:///path/to/passphrase) so it is never embedded in plain config.
Durability and recovery. The store treats the bundle as precious data:
- Fail-loud on a wrong passphrase or corruption. A read that fails because the passphrase is wrong (or rotated), or because the bundle is tampered, truncated, or malformed, throws - it never silently re-initialises an empty bundle. A fresh empty bundle is created only when the file genuinely does not exist yet (
ENOENT). This means a mistyped/rotatedGRAPHORIN_MASTER_PASSPHRASEsurfaces as an error on the nextget/set/deleterather than wiping every stored secret. Recovery: restore the correct passphrase - the on-disk bundle is left untouched by a failed write. - Atomic writes. Every write goes to a temp sibling (
<path>.tmp, mode0o600) and is thenrenamed onto the target, so a crash mid-write can never truncate or corrupt the existing bundle; a reader only ever sees the old or the new file in full. - In-process single-writer guard. Concurrent
set/deletecalls on one store instance are serialised so their read-modify-write cycles cannot interleave and clobber each other. Cross-process concurrent writers are out of scope (the atomic rename still rules out corruption - worst case is last-write-wins).
Optional 1Password adapter
The @graphorin/secret-1password package is an optional reference adapter that delegates to the system 1Password CLI (op). It does not bundle the CLI - install the binary yourself. The adapter exposes a SecretResolver for the canonical op:// URI scheme defined by 1Password:
op://<vault>/<item>/[<section>/]<field>import { registerResolver, resolveSecret } from '@graphorin/security';
import {
createOnePasswordResolver,
onePasswordResolver,
} from '@graphorin/secret-1password';
// Register the default resolver (uses the `op` binary on $PATH):
registerResolver(onePasswordResolver);
// Or build a customised one with a specific binary + timeout + token:
registerResolver(
createOnePasswordResolver({
binary: '/usr/local/bin/op',
timeoutMs: 15_000,
// OP_SERVICE_ACCOUNT_TOKEN is forwarded for headless use.
serviceAccountToken: process.env.OP_SERVICE_ACCOUNT_TOKEN,
}),
);
const apiKey = await resolveSecret('op://Production/Stripe API/credential');Beyond serviceAccountToken, the resolver options also support 1Password Connect mode (connect: { host, token }, wired through OP_CONNECT_HOST / OP_CONNECT_TOKEN) and an account override for machines signed in to multiple 1Password accounts. The op invocation has a hard wall-clock timeout that escalates SIGTERM → SIGKILL, so a wedged CLI can never hang the resolver.
Errors from the CLI surface as typed OpCliError codes ('binary-missing', 'signed-out', 'reference-not-found', 'timeout', 'unknown') so your code can react cleanly.
Where OAuth tokens live (SPL-1)
graphorin auth login persists the OAuth access / refresh / id tokens into the active SecretsStore (the same keyring → encrypted-file → env chain as every other secret) under oauth:<serverId>:<kind> keys; the OAuthServerStore record carries only the refs, never token material. A fresh process resolves the refresh token back from the store, so graphorin auth refresh / auth revoke and the MCP bridge's Authorization header all work across restarts. auth status reports hasRefreshToken only when the ref actually resolves. Without a usable secrets store the tokens live in process memory only and do not survive a restart (the pre-SPL-1 behaviour, kept as the documented fallback).
Telemetry redaction for SecretValues
Every exporter is auto-wrapped with withValidation(...) by the tracer factory. The validator substitutes a redacted placeholder for any attribute whose serialised form matches a known SecretValue shape. Operators that pass validation: 'off' must wrap exporters explicitly - the tracer refuses to register a raw exporter in that mode and throws UnvalidatedExporterError at startup.
Capability matrix
| Capability | OS keychain | Encrypted-file | 1Password CLI |
|---|---|---|---|
| Read | yes | yes | yes |
| Write | yes | yes | no (read-only) |
| List | yes | yes | no (resolver-only, no store surface) |
Per-tool ACL (ctx.secrets) | yes | yes | yes |
| Audit log | yes | yes | reveal/use audit only (no store rows) |
| Headless / CI | no | yes | yes |