@hasna/secrets
v0.4.2
Published
Hosted-first secrets vault client for AI agents — CLI, MCP server and SDK against the Hasna secrets API (credentials via @hasna/contracts), with an explicit opt-in on-box encrypted vault
Maintainers
Readme
@hasna/secrets
A local encrypted secrets vault for AI agents, CLIs, and developer machines. Store API keys, passwords, tokens, and other credentials without committing them to source control.
Install
bun install -g @hasna/secretsCLI Usage
secrets --help
secrets docsCommon Commands
Store a secret:
secrets set example/anthropic/test/api_key "$ANTHROPIC_API_KEY" \
--type api_key \
--label "Anthropic API Key (test)"Consume a secret without ever printing it (the value only exists in the child process's environment):
secrets exec example/anthropic/test/api_key --as ANTHROPIC_API_KEY -- my-tool syncConsume an AWS Secrets Manager value from an account configured in the standard
shared AWS profile files. --env names the child variable and its account-scoped
secret selector; the value is never printed or written into the local vault:
secrets exec --provider your-source-profile --account <account-id> \
--env EXAMPLE_NPM_TOKEN -- my-tool syncThe account route must resolve to exactly one configured profile whose standard
role, SSO, or account fields match the requested provider and account. A literal
remote secret name remains supported. Otherwise canonical names shaped as
<provider>/oss/<workload>/<key> map to the normalized environment name
<PROVIDER>_<WORKLOAD>_<KEY>. All metadata pages are considered, and missing,
ambiguous, non-current, or non-string selections fail before the child runs.
Prove a secret exists or compare values without revealing them:
secrets get example/anthropic/test/api_key --check # prints length + sha256 onlyCopy a secret to a new path without the value ever rendering anywhere (the
value-safe migration primitive; works on non-TTY without get --show):
# carries type/label/expiry from the source and auto-records "migrated from <old>"
secrets copy example/anthropic/test/api_key anthropic/test/api_key
# override metadata and demand an internal verify (length + sha256) in the same call
secrets copy example/anthropic/test/api_key anthropic/live/api_key \
--type api_key --label "Anthropic (live)" --reason "taxonomy 2026-08-20" --verifyThe copy reads the source value in-process and writes the destination in the
same call; the value never touches stdout, stderr, a transcript, a log, or a
child environment. The source key is left intact (deletion is a separate
explicit operation), and --verify exits 0 only when the source and
destination values are internally equal.
Store a value without putting it in argv (ps/shell-history safe):
secrets set example/anthropic/test/api_key --stdin < value-fileExplicitly print a plaintext value (escape hatch — get is redacted by default
and refuses captured, non-TTY output entirely):
secrets get example/anthropic/test/api_key --showList and search without printing secret values:
secrets list
secrets list example/anthropic
secrets search anthropicInspect audit history:
secrets audit example/anthropic/test/api_keyWhat the agent column attributes
agent is the issued-to subject of the credential that made the call, not
the process, session, host, or person behind it.
In cloud mode the server derives it once per request from the verified API-key claims, and never from request input:
const actor = decision.principal.agent ?? decision.principal.kid;That subject is fixed at issuance — issue-key --agent <name>, see
Cloud service — and is covered by the token
signature, so a caller can neither assert nor override it. No endpoint accepts an
agent identity parameter: /v1/secrets/get takes key and nothing else, and
an unknown agent query parameter or header is ignored rather than rejected.
(POST /v1/users does take a type of human or agent; that is a user record
kind, not the identity of the caller.) In local mode the same column is instead
filled from AGENT_ID ?? USER ?? hostname(), which is self-asserted. The two
modes populate one column from two sources with different trust properties; read
the mode before reading the value.
The consequence that matters when interpreting a row: every caller sharing one
key collapses to one agent value. A deployment in which many callers share a
single API key records a single constant, and each row then attributes the access
to that key rather than to whoever used it. Such a row is key-attributed — and
where the key is shared, key-attributed means unattributed. It does not
narrow the access to a machine either, so it must not be described as
machine-attributed; that claims a narrowing the record does not contain.
Distinct per-caller attribution therefore comes from distinct credentials, each
issued with its own --agent. In cloud mode it is not reachable by any
client-side change, because the client does not supply this value at all; in
local mode the value is whatever the calling process asserts, which is a
different property and not a substitute for it.
Inspect metadata-only secret reference health:
secrets status --jsonThe status contract reports package version, redacted local data paths, env
override names, and aggregate counts only. It does not include secret values,
secret key names, raw env values, provider inventory, or private key material.
For a hosted run it also names WHERE the transport was resolved from, under
transport — api_url_source, api_key_source and api_key_tier — as names
only (an env key name, a Keychain item reference such as
keychain:hasna.credentials.secrets.api-key@station03, a credentials-file path
with the home folded to ~, or default for the fleet gateway); a local-vault
run reports transport: null. Without a credential from any tier status
fails closed exactly like every other verb: one actionable line on stderr and
exit 1.
import { getSecretReferenceStatus } from "@hasna/secrets/status";
const status = await getSecretReferenceStatus();
console.log(status.counts.byType.api_key);Secret References For Automations
Deterministic automations and action manifests must reference secrets by key,
not embed raw values. In @hasna/actions manifests this appears as
secrets[].ref; the scoped resolver request below names it secretRef. A
reference is the exact, case-sensitive, slash-delimited vault key. It has no URI
scheme, query, fragment, embedded value, or authority of its own. Access comes
from a separate resolver-side grant.
The persistable request contract is:
{
"contractVersion": 1,
"secretRef": "example/connectors/prod/github",
"grantId": "github-issue-writer-prod",
"automationId": "issue-triage",
"action": "issue.create",
"connector": "github",
"scopes": ["repo:issues:write"],
"reason": "Create a GitHub issue from an approved automation action"
}reason is audit context, not authorization. grantId is a non-secret policy
selector, not a bearer token. The grant itself is configured in the trusted
resolver and must not be accepted from an automation payload:
{
"contractVersion": 1,
"grantId": "github-issue-writer-prod",
"policyRevision": 3,
"secretRef": "example/connectors/prod/github",
"automationId": "issue-triage",
"allowedBindings": [
{
"action": "issue.create",
"connector": "github",
"allowedScopes": ["repo:issues:write"]
}
],
"allowedRuntimes": [
{"machine": "runner-prod-01", "profile": "production"}
],
"notBefore": "2026-07-01T00:00:00Z",
"expiresAt": "2026-08-01T00:00:00Z"
}The resolver obtains machine from an authenticated workload or host identity
and profile from trusted runner configuration. Those values are execution
context and are never trusted when supplied by a queued action. Profile here
means the named automation execution profile, not an AWS credential profile.
Before every invocation, the resolver must deny unless all of these checks pass:
- The contract version, grant ID, secret reference, and automation ID match.
- The action/connector pair exactly matches one
allowedBindingsentry, and the authenticated machine/profile pair exactly matches oneallowedRuntimesentry. Keeping pairs intact prevents unintended cross-product permissions. - Every requested scope is present in the matched binding's
allowedScopes; grants cannot be widened by a request. - The current time is in the half-open grant interval
notBefore <= now < expiresAt.expiresAtis required;notBeforemay be omitted to make the grant valid immediately. - The referenced secret exists and its own
expires_at, if set, has not passed.
Timestamps are RFC 3339 UTC instants. Invalid timestamps and unavailable runtime
identity are authorization failures. Comparisons are exact and case-sensitive.
All request fields except reason are required; scopes and all grant allowlists
must be non-empty. Omitted bindings and wildcard values deny. policyRevision is
a monotonically increasing, non-secret audit value and is not client-selected.
The resolver should audit the decision, grant ID, secret key, requested bindings,
scopes, machine, profile, and policy revision, but never the decrypted value.
Persistence and rotation
- automation specs, action manifests, queued payloads, retries, todo comments, and run evidence may persist the reference request, but never a decrypted value or a raw value encoded into another field
- grants persist bindings and metadata only; they never contain the raw secret
- resolve immediately before connector invocation, inject the value directly into that invocation, discard it afterwards, and never place it in a durable cache, event, log, error, or audit record
- re-resolve on every retry and replay; do not copy the historical value from an earlier attempt
- rotate a credential by atomically replacing the value under the same stable
secretRef; the next invocation receives the new value without changing the automation or grant - secret expiry and grant expiry are independent and the earlier one wins; renew a grant by issuing a new policy revision, and revoke a grant or secret to deny all subsequent resolutions immediately
- a resolver may return an opaque invocation handle instead of plaintext; if a connector requires plaintext, it may exist only in invocation-scoped memory
Minimum integration boundary for OpenAutomations and @hasna/actions consumers:
- redaction must treat keys named
secretRef,secret,token,apiKey,authorization, and provider credential fields as sensitive - scoped consumers must use the resolver boundary rather than the generic
get/get_secretplaintext APIs - authorization failure must not reveal whether the reference exists or include a value in its error
Secret access audit event contract
The existing get / set / delete audit actions describe direct vault CRUD.
They do not prove that a scoped grant was checked or that a resolved value was
actually handed to a consumer. A grant-aware resolver must append the following
versioned events. Event names are past tense because audit records describe facts
that have already occurred.
| action | Emit when | Required event metadata |
| --- | --- | --- |
| secret.grant.created | A grant is durably issued. | grant_id, scopes, grant_expires_at, decision=allowed, reason_code=grant_created |
| secret.grant.resolved | The referenced secret exists and the grant, scope, and expiry checks pass. No value has crossed the vault boundary yet. | grant_id, scopes, resolver_package, decision=allowed, reason_code=scope_allowed, secret_version |
| secret.used | The vault hands the value to the named resolver or connector. Emit once per hand-off, not once per downstream API call. | grant_id, scopes, resolver_package, decision=allowed, reason_code=value_handed_off, secret_version |
| secret.access.denied | Resolution is rejected. Never emit secret.used for the same attempt. | Available identifiers, decision=denied, and a bounded reason_code |
| secret.grant.expired | A sweeper or access attempt first transitions an issued grant to expired. | grant_id, grant_expires_at, decision=denied, reason_code=grant_expired |
| secret.rotated | A new secret version is durably committed. | decision=allowed, reason_code=secret_rotated, previous_secret_version, secret_version |
All six actions use the following output contract. It extends the current flat
AuditEntry shape so old readers can continue to display id, action, key,
agent, and timestamp. Optional fields are omitted rather than filled with
values copied from request context.
The agent field below describes the intended contract for these grant-aware
events. On the existing get / set / delete rows it is not a per-caller
identity but the identity resolved for the caller by the active mode — the
credential's issued-to subject in cloud mode, the process environment in local
mode — and it carries exactly the attribution that source carries; see
What the agent column attributes.
A resolver implementing these events inherits that limit: it cannot narrow an
access below the granularity of the identity available to it.
interface SecretAccessAuditEventV1 {
id: number;
schema_version: 1;
action:
| "secret.grant.created"
| "secret.grant.resolved"
| "secret.used"
| "secret.access.denied"
| "secret.grant.expired"
| "secret.rotated";
key: string; // secretRef; metadata, never the secret value
agent: string; // authenticated human or workload identifier
timestamp: string; // UTC ISO 8601
correlation_id: string; // joins events from one access attempt
grant_id?: string; // opaque identifier, never a bearer grant/token
scopes?: string[]; // normalized, sorted requested scopes
resolver_package?: string; // package/service name, not command arguments
resolver_version?: string;
decision: "allowed" | "denied";
reason_code: string; // bounded enum; never free-form error text
grant_expires_at?: string;
secret_version?: string; // opaque version id, not a value-derived hash
previous_secret_version?: string;
}Denials use a bounded reason code such as grant_missing, grant_expired,
scope_mismatch, secret_missing, or policy_denied. Operator-facing clients
may collapse these to access_denied when revealing whether a key or grant
exists would create an oracle. Stack traces and provider responses belong in a
separately protected diagnostic channel, not in the audit record.
Example metadata-only output:
{
"id": 184,
"schema_version": 1,
"action": "secret.access.denied",
"key": "example/connectors/prod/github",
"agent": "automation:issue-sync",
"timestamp": "2026-01-15T12:00:00.000Z",
"correlation_id": "request-example-184",
"grant_id": "grant-example-12",
"scopes": ["repo:issues:write"],
"resolver_package": "@example/issue-connector",
"decision": "denied",
"reason_code": "scope_mismatch"
}Audit serialization is an allowlist. It must never contain decrypted values,
ciphertext, value hashes or fingerprints, bearer grants, API keys, session
tokens, authorization headers, environment values, command arguments, connector
request/response bodies, raw authentication claims, or free-form reasons and
exceptions. Implementations must not accept an arbitrary metadata object and
must not serialize request context with object spread. Identifiers and scope
strings are length-bounded and control characters are rejected before storage.
Emission and storage rules:
- grant creation and rotation commit their event in the same transaction as the
state change;
secret.rotatedlinks opaque version ids only - allowed resolution is followed by use under the same
correlation_id; a resolution may have no use, but a use may not exist without a resolution - an allowed path fails closed if its audit event cannot be persisted before the value leaves the vault; a denial stays denied even if denial logging fails
- expiry uses the grant state transition as its idempotency boundary, so a sweeper and concurrent access attempt cannot create duplicate expiry events
- audit rows are append-only, use server-side UTC timestamps, and are subject to the same tenant authorization as the referenced secret
Implementation plan:
- Extend
AuditEntryand both audit tables with the explicit nullable columns above; do not add an unbounded JSON metadata column. Keep legacy CRUD rows and actions readable. - Add one typed store operation that accepts only the six event variants. Make local and cloud implementations validate fields and write parameterized SQL.
- Emit from the future grant/resolver boundaries described in the table. Do not
reinterpret the current raw
getaction as proof ofsecret.used. - Make
/v1/audit, the CLI, MCP, SDK, and OpenAPI return an explicitly selected audit DTO rather thanSELECT *, preserving the allowlist end to end. - Test all six actions in local and cloud stores, lifecycle ordering, denied access producing no use event, expiry idempotency, rotation version linkage, migration of existing rows, and the absence of sentinel secret material from every JSON and text output.
Redaction Before Persistence
OpenLoops, OpenEvents, and OpenAutomations should create one redactor with all secret values resolved for a run, then invoke the matching hook immediately before every durable write or event publish. The hooks copy their input and deep-redact registered values, sensitive fields, credential-shaped text, error messages/stacks, stdout, and stderr.
import { createPersistenceRedactor } from "@hasna/secrets/redaction";
const redactor = createPersistenceRedactor({
secretValues: resolvedSecrets.map((secret) => secret.value),
});
await runs.save(redactor.hooks.run({ stdout, stderr, error }));
await runOutput.append(redactor.hooks.stdout(stdoutChunk));
await audits.append(redactor.hooks.audit(auditEntry));
await events.publish(redactor.hooks.event(event));Pass registered raw values whenever possible: field-name and token-shape matching are defense in depth, not substitutes for registering arbitrary secret values. Apply the hook at the final persistence boundary so later formatting or error wrapping cannot reintroduce a raw value.
Export redacted compact JSON for review:
secrets exportExport plaintext only when a local restorable artifact is explicitly needed:
secrets export --show --pretty > secrets-backup.jsonScan the current working tree or full git history for exposed credentials. Each
JSON response is bounded; when nextCursor is present, pass it back with
--cursor to continue:
secrets scan workspace --limit 50
secrets scan history --max-commits 200 --limit 50
secrets scan history --cursor "$NEXT_CURSOR" --limit 50Findings contain deterministic secret-exposure: IDs, remediation metadata, and
evidencePath locations suitable for task bodies. detector, line and
column locate the finding; preview is a constant ***REDACTED*** marker and
carries no bytes from the scanned line — not the matched value, and not the
surrounding context. Read the coordinates rather than the content: a scan result
is safe to paste into a transcript, a task, or a channel.
Create secure loop evidence and deduped Todos tasks for unsafe sensitive-file permissions:
secrets security permissions \
--report-dir ~/.hasna/loops/evidence/secret-file-permissions \
--upsert-tasks \
--todos-project ~/.hasna/loops \
--task-list secret-file-permissions \
--max-task-actions 20 \
--jsonThe permissions report is written with private file permissions and contains paths, modes, fingerprints, and task routing metadata only. It does not include secret values.
Delete a secret:
secrets delete example/anthropic/test/api_keyKey Format
Use slash-delimited keys:
<division>/<service>/<env>/<name>Examples:
example/anthropic/test/api_key
example/local/dev-workstation/tool/exa-api-key
example-app/oauth/youtube_client_secretHasna XYZ Canonical Keys
Generic keys still work as before. Keys under hasna/xyz/ are validated so
new Hasna XYZ app resources follow the canonical migration shape:
hasna/{division}/{app_type}/{app}/{env}/{component}
hasna/{division}/infra/{resource_group}/{env}/{component}[/role]Allowed app types are:
opensource, internalapp, companywebsite, projectinfra is reserved for shared infrastructure ownership. Deprecated migration
taxonomies such as connector, website, and platform are rejected under
hasna/xyz/. App names should omit repo prefixes such as open-, iapp-,
cweb-, and project-.
Examples (illustrative placeholders only — substitute your own resource names; no real production resource names are shipped in this package):
hasna/xyz/opensource/example-app/dev/api_key
hasna/xyz/infra/example-group/dev/postgres/masterAWS Secrets Manager Sync
secrets aws can use the legacy static-key config written by
secrets aws configure, or AWS profile/default-chain credentials. Supply your
own AWS profile and secret paths — none are hardcoded:
AWS_PROFILE=your-aws-profile secrets aws sync --dry-run
secrets aws push example/app/prod/s3 --profile your-aws-profile --dry-run
secrets aws sync --credential-mode role --role-arn <iam-role-arn> --source-profile your-aws-profile --dry-runCredential source precedence is command flags, HASNA_SECRETS_AWS_*
environment variables, ~/.hasna/secrets/aws.json, then the standard AWS
provider chain. If aws.json contains static keys and no explicit override is
provided, static-key behavior is preserved for compatibility.
Use --dry-run or --plan before live sync. Plan output is metadata-only JSON:
it reports names, regions, prefixes, credential source descriptors, and
intended actions without printing secret values or writing AWS/local vault
state.
Secret Types
Supported types:
api_key, password, token, credential, otherStructured Vault Items
The generic key/value store remains supported. For browser autofill and LastPass-like records, use structured vault items. Item payloads are encrypted; titles, domains, tags, and item kind are stored as searchable metadata.
Create a login item:
secrets items add-login \
--title "GitHub" \
--url "https://github.com" \
--username "[email protected]" \
--password "$GITHUB_PASSWORD"Create an address item:
secrets items add-address \
--title "Home" \
--name "Example User" \
--line1 "1 Main St" \
--city "New York" \
--state "NY" \
--postal-code "10001" \
--country "US" \
--email "[email protected]"List, search, inspect, or delete items:
secrets items list
secrets items list login
secrets items search github
secrets items get <id> # redacted payload
secrets items get <id> --show # decrypted payload
secrets items delete <id>Supported item kinds:
login, address, identity, payment_card, secure_note, api_key, customChrome Extension
The extension/ directory contains a Manifest V3 Chrome extension that can fill
logins, addresses, identities, and payment-card fields from structured vault
items. Legacy username/password pairs still work when stored as slash-delimited
secrets with a common prefix.
Run the local bridge:
secrets serveThen load extension/ from chrome://extensions with Developer Mode enabled,
open the extension settings, and paste the token from secrets serve token.
The extension talks only to http://127.0.0.1:27462 and asks for decrypted item
payloads only when you click Fill or Copy.
Optional TTL values can be attached when setting a secret:
secrets set temp/session "$TOKEN" --type token --ttl 24h
secrets gcMCP Usage
Install the MCP server into local AI agents:
secrets mcp install --target codex
secrets mcp install --target claude
secrets mcp install --target geminiAgents connect over stdio by running:
secrets mcpsecrets mcp is the canonical MCP entry used by generated agent
configuration. The package also exposes secrets-mcp as an explicit direct
binary; it uses stdio by default and accepts --http --port 8848 for the
Streamable HTTP transport.
Start the shared Streamable HTTP MCP server explicitly:
secrets mcp http --port 8848The MCP exposes these tools:
list_secrets(namespace?)
search_secrets(query)
get_secret(key)
set_secret(key, value, type?, label?, ttl?)
delete_secret(key)
list_vault_items(kind?)
search_vault_items(query)
get_vault_item(id)
set_vault_item(kind, title, data, id?, subtitle?, domains?, tags?, favorite?)
delete_vault_item(id)
audit_log(key?, limit?)
register_user(id, name, type?)
list_users(type?)
send_feedback(message, email?, category?)
scan_workspace_exposures(root?, cursor?, limit?, maxFileBytes?, maxFiles?, maxBytesScanned?, timeoutMs?)
scan_history_exposures(root?, cursor?, limit?, maxCommits?, timeoutMs?)list_secrets and search_secrets return metadata only and do not decrypt
stored values. get_secret returns the raw value, so use it only when the agent
needs to pass the secret into a tool or command.
The scan tools return compact JSON with a stable schema, bounded redacted
findings, deterministic secret-exposure: ids, remediation metadata,
path/line/commit references, and opaque chunk cursors. They do not return raw
matching values. MCP scan roots are constrained to the server working directory,
and workspace scans include hard file, byte, and timeout bounds. The same
scheduler-neutral contract is available programmatically:
import { scanWorkspaceExposures } from "@hasna/secrets/scanner";
const result = scanWorkspaceExposures({ root: process.cwd(), limit: 50 });Env-File Bridge
The vault can import from and export to the conventional machine-local
~/.secrets tree:
~/.secrets/{division}/{service}/live.env
~/.secrets/{division}/{business}/{service}/live.envImport .env files into the vault:
secrets import-env --dir ~/.secrets --dry-run
secrets import-env --dir ~/.secrets --overwriteExport vault entries back to .env files:
secrets export-env --dir ~/.secrets --dry-run
secrets export-env --dir ~/.secrets --forceStorage Sync
This package supports optional remote storage sync directly against a Postgres/RDS database. Local SQLite remains the default.
export HASNA_SECRETS_DATABASE_URL=postgres://...
secrets storage status
secrets storage push
secrets storage pull
secrets storage syncThe remote storage URL can also be provided as the short non-deprecated fallback
SECRETS_DATABASE_URL.
For a managed deployment, point HASNA_SECRETS_DATABASE_URL at your own
Postgres/RDS database. Deployment-specific infrastructure identifiers (the
database cluster name and the AWS Secrets Manager path that holds the runtime
database_url) are supplied by your hosting layer — this package ships no real
cluster names or secrets-manager paths. SECRETS_DATABASE_URL remains supported
as a rollback/local fallback. Do not print rows or values from the database;
status commands expose only redacted URLs, table names, and non-secret metadata.
MCP exposes the same flow through storage_status, storage_push,
storage_pull, and storage_sync.
Data Directory
This section is about the opt-in local vault (HASNA_SECRETS_LOCAL_VAULT=1);
a hosted run creates no file under any data home. The local data home resolves
through the in-package data-home resolver (XDG/macOS home layout; the only
@hasna/paths kind this package kept). The legacy default is ~/.hasna/secrets;
once the resolver (XDG) data home is adopted (HASNA_DATA_HOME set, or the vault
already migrated to ~/.local/share/hasna/secrets on Linux /
~/Library/Application Support/Hasna/secrets on macOS), the vault database, key
material and the AWS sync state resolve there instead. Nothing moves on disk
until the store is physically migrated. HASNA_HOME is not consulted here:
it replaces ~/.hasna only for the @hasna/contracts credential file
(<HASNA_HOME>/secrets/config/credentials), while the local vault stays at the
home the test-isolation guard protects; move it with HASNA_DATA_HOME or the
file-level overrides. No ~/.config/hasna (or other XDG config/state/cache)
location is composed by this package.
The ~/.secrets env-file bridge (import-env/export-env) is a separate legacy
credential store and is unchanged. File-level overrides (HASNA_SECRETS_DB_PATH,
HASNA_SECRETS_KEY_DIR, HASNA_SECRETS_AWS_SYNC_STATE) still win on top of
the effective root.
secrets path
secrets keyThe vault database lives at <data home>/vault.db. Key material lives in
<data home>/vault.key for local-key mode or <data home>/vault.key.enc for
KMS envelope-encryption mode.
Credentials (five tiers)
The CLI, the MCP server and the SDK all resolve their credential and their API
base URL through the @hasna/contracts client resolver — one implementation,
shared by every hosted Hasna app, re-read fresh on every call so a key
rotation heals immediately in a shell that is older than the rotation.
Precedence, highest first:
| # | Tier | Where |
| --- | --- | --- |
| 1 | argument | an explicit apiKey / profile passed in code (createSecretsClientFromEnv(env, { credentials: { apiKey } })) |
| 2 | env pointer | HASNA_SECRETS_API_KEY_OVERRIDE (a key you set on purpose), HASNA_PROFILE (selects credentials-<profile> on disk), HASNA_SECRETS_API_KEY_REF (a vault item key, resolved at request time) |
| 3 | macOS Keychain (darwin only) | generic-password item hasna.credentials.secrets.api-key, account HASNA_STATION, else the short hostname, else $USER |
| 4 | disk | ~/.hasna/secrets/config/credentials, owner-only 0400/0600. HASNA_HOME replaces ~/.hasna; HASNA_CONFIG_HOME replaces the config root (<HASNA_CONFIG_HOME>/secrets/credentials). XDG is never consulted. |
| 5 | environment | HASNA_SECRETS_API_KEY — a legitimate tier, not a deprecated one. It sits below disk so a rotated file beats a stale export. |
A deliberate tier (1 and 2) never falls through to another identity: if the key it names is revoked, the run fails rather than silently authenticating as someone else.
API base URL follows the same ladder — HASNA_SECRETS_API_URL, then the
Keychain api-url item, then the credentials file — and otherwise defaults to
the fleet gateway https://api.hasna.com/secrets (the client appends /v1).
A key from any tier is enough; URLs never need configuring. Configured
authorities must agree with each other.
# Store the key once, in the Keychain, and nothing else needs configuring:
security add-generic-password -U -a "$(hostname -s)" \
-s hasna.credentials.secrets.api-key -w "$KEY"
secrets list # -> https://api.hasna.com/secrets/v1
# Or a 0600 credentials file:
install -m 700 -d ~/.hasna/secrets/config
printf 'HASNA_SECRETS_API_KEY="%s"\n' "$KEY" > ~/.hasna/secrets/config/credentials
chmod 600 ~/.hasna/secrets/config/credentialsWith NO credential from any tier the CLI and MCP FAIL CLOSED — non-zero exit
with an error naming every tier that was consulted — instead of silently serving
local SQLite (owner ruling 2026-09-04). The LOCAL vault is served only behind the
explicit HASNA_SECRETS_LOCAL_VAULT=1 opt-in (standalone or offline use, local
serve/MCP bridges), which prints one line on stderr saying the run is local.
The opt-in yields to a credential: a station that holds a hosted key stays
hosted.
Retired and inert, never read: ~/.hasna/fleet-env, ~/.hasna/cloud,
~/.config/hasna, $XDG_CONFIG_HOME, and every *_MODE / *_STORAGE_MODE
variable — the transport is decided by the credential and the authority alone.
SECRETS_API_URL / SECRETS_API_KEY remain accepted as a silent alias for one
release; the canonical HASNA_SECRETS_* names are the supported spelling and
no longer shadowed by them.
Safety Notes
list,search,export, andscando not decrypt or print secret values by default.export --showandexport --plaintextare explicit plaintext escape hatches for local restorable backups.- CLI import refuses redacted export bundles so placeholders do not overwrite real secrets by accident.
getand MCPget_secretreturn raw secret values.- Never paste secret values into commits, logs, issues, PRs, or chat messages.
- Keep
.env,.env.local,.secrets/, and.connect/out of git.
Running the tests
A test process cannot reach a real vault. This is enforced by the code, not by
convention, because convention already failed: the suite wrote fixtures into a
hosted production vault on four separate runs, because a machine's shell
environment exports HASNA_SECRETS_API_URL / _API_KEY and getStore() reads
them. Since the package adopted the shared resolver there are two more AMBIENT
tiers to neutralize — the macOS Keychain and ~/.hasna/secrets/config/credentials
— so the preload redirects both at a throwaway location rather than trusting
them to be absent.
bun testWhat holds, and where:
| Guarantee | Enforced in |
| --- | --- |
| A test process may only reach a loopback vault. Any other host throws SecretsTestIsolationError. | src/test-isolation.ts, applied at the one HTTP egress point (createHasnaHttpTransport) and at ambient-env store resolution (getStore()). |
| A test that configures nothing still never opens the operator's ~/.hasna/secrets vault or key. It gets a throwaway per-process one. | src/db.ts, src/crypto.ts |
| The hosted-vault selectors are stripped from the environment, and the Keychain and disk credential tiers are redirected at a throwaway location (HASNA_HOME, HASNA_CONFIG_HOME, a HASNA_STATION account no item exists under), before any test file runs. | bunfig.toml → tests/setup/isolate-vault.ts |
Notes:
The preload is a convenience, not the guarantee. Delete
bunfig.tomland the suite fails loudly withSecretsTestIsolationErrorrather than writing to a hosted vault.There is no environment variable that turns the guard off. The only env key it reads,
HASNA_SECRETS_TEST_ISOLATION=1, can force it on.Test context is detected from the runner (the preload marker,
NODE_ENV=test) and from a*.test.tsentrypoint — never from something a test author must remember to write.Two thresholds, because the two guards fail differently:
| Signal | Loud guards — hosted-vault egress, explicit-path refusal | Silent guard — redirect vault files to a throwaway | | --- | --- | --- | | preload marker
HASNA_SECRETS_TEST_ISOLATION=1| yes | yes | |*.test.tsentrypoint (whatbun testsets) | yes | yes | | bareNODE_ENV=test| yes | no |So running the shipped CLI with
NODE_ENV=testin the environment — which every JS test runner exports across its whole process tree — reads and writes your real local vault, normally. It does not silently swap in an empty throwaway; that would makesecrets getreport a live credential as missing andsecrets setprint✓ Storedfor a value it discarded, both at exit code 0. WhatNODE_ENV=testdoes still do is bar the hosted vault: resolution throwsSecretsTestIsolationErrorand exits non-zero. Refusal, loudly, rather than a quiet wrong answer.A test that genuinely needs to exercise the hosted transport should inject a fake
fetchImpl, not point a real one at a remote host.
Cloud service (self_hosted)
Beyond the local vault, @hasna/secrets ships a deployable HTTP service and a
typed SDK. Four surfaces cover the same core:
secrets— the CLI.secrets-mcp— the MCP server (stdio by default,--httpfor Streamable HTTP).secrets-serve— the HTTP API. Unauthenticated probesGET /health,/ready,/version({status, version, mode}) andGET /openapi.json; a versioned/v1surface (secrets + vault-item CRUD, search, audit, users) behind strict API-key auth (@hasna/contracts). Scopes:secrets:read,secrets:write.@hasna/secrets— the typed, dependency-free SDK package root generated from the serve OpenAPI. The compatibility subpath@hasna/secrets/sdkexports the same API. The client resolves its credential and base URL through the shared@hasna/contractsresolver — never a DSN.
Storage is PURE REMOTE (Amendment A1) in cloud mode: secrets-serve reads
and writes the shared Postgres directly (no cache, no local mirror). Secret and
vault-item values are encrypted at rest (AES-256-GCM) with a master key
injected via HASNA_SECRETS_MASTER_KEY — the service fails closed without it.
# migrate the cloud database (one-shot), then serve
export HASNA_SECRETS_DATABASE_URL=postgres://... # or DATABASE_URL
export HASNA_SECRETS_API_SIGNING_KEY=$(openssl rand -hex 32)
export HASNA_SECRETS_MASTER_KEY=$(openssl rand -base64 32)
secrets-serve db migrate
secrets-serve # listens on $PORT (default 8080)
# issue an API key (@hasna/contracts issuer), then call the SDK
bunx @hasna/contracts issue-key --app secrets --agent my-agent --scopes 'secrets:read,secrets:write'--agent is the sole input that populates the agent column of every audit row
written under that key, so issue one key per caller you intend to tell apart. A
key shared between callers makes their accesses indistinguishable in the audit
log, and no client-side setting recovers the difference — see
What the agent column attributes.
import { createSecretsClientFromEnv, type SecretInput } from "@hasna/secrets";
// Credential + base URL come from the five-tier resolver; throws if none resolves.
const client = createSecretsClientFromEnv();
const input: SecretInput = {
key: "example/service/dev/api_key",
value: process.env.EXAMPLE_SERVICE_API_KEY!,
type: "api_key",
};
await client.putSecret(input);
const secret = await client.getSecret({ key: input.key });The client's baseUrl is the service authority — an origin
(https://secrets.your-deployment.example) or the gateway prefix
(https://api.hasna.com/secrets); a trailing /v1 is accepted and means the
same thing. Data routes are sent under <baseUrl>/v1/... (listSecrets() →
https://api.hasna.com/secrets/v1/secrets), and the public health() /
ready() / version() probes at <baseUrl>/health etc., with no credential
(the contract declares them public, and neither the serve nor the gateway
answers them under /v1).
An explicit baseUrl requires an explicit apiKey
(createSecretsClientFromEnv(env, { baseUrl, apiKey }), or new SecretsClient({ baseUrl, apiKey })).
The ambient fleet credential — the Keychain item, the credentials file,
HASNA_SECRETS_API_KEY — is pinned to the authority it resolved with and is
never attached to a caller-supplied one; naming an authority without a key
throws before any resolver tier is consulted (hasna/apps#1794).
Migrations live in migrations/ (canonical checksummed set in
src/server/cloud-migrations.ts). Container image: Dockerfile.package
(ARM64/bun); local stack: docker-compose.yml.
License
Apache-2.0 -- see LICENSE
