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

@hasna/secrets

v0.6.8

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

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.

npm License

Install

bun install -g @hasna/secrets

CLI Usage

secrets --help
secrets docs

Common 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 sync

Values containing NUL cannot be represented in a child environment and are rejected before launch. Launch failures use a fixed diagnostic without printing the value; successful commands retain their arguments, output and exit status.

Consume 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 sync

The 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.

Use --secret-ref to select one exact AWS Secrets Manager ARN independently of the child variable. This performs one value request without listing secrets or trying alternate names:

secrets exec --provider your-source-profile --account "$AWS_ACCOUNT_ID" \
  --env SERVICE_CREDENTIAL --secret-ref "$AWS_SECRET_REF" -- my-tool sync

The reference can be a full secret ARN or the native ECS form <secret-arn>:<json-key>:<version-stage>:<version-id>. Keep all three suffix separators when selecting a JSON field or version; empty fields are allowed. Specify a version stage or version ID, never both. With neither, the selected stage is AWSCURRENT. The ARN supplies the exact region and secret identity; the suffix selects a top-level JSON field independently of the child --env. A selected field must contain a nonblank string. Its original whitespace is preserved. Missing fields, duplicate decoded object keys (including nested objects), binary values and other JSON types are refused before the child runs. Whole-secret reads without a JSON selector keep their existing behavior.

The configured profile must unambiguously match the requested account. Its configured region, when present, must match the ARN. The command also checks the caller's actual account before reading and verifies the returned ARN and version selection. Errors omit provider messages and secret content. Existing provider commands without --secret-ref keep their original selection rules.

exec supplies one binding per invocation. Only run a trusted child: its output is inherited, and that child can read or print the injected value. Nested exec commands also inherit earlier bindings in their intermediate processes.

Prove a secret exists or compare values without revealing them:

secrets get example/anthropic/test/api_key --check   # prints length + sha256 only

Copy 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" --verify

The 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-file

Explicitly 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 --show

List and search without printing secret values:

secrets list
secrets list example/anthropic
secrets search anthropic

Inspect audit history:

secrets audit example/anthropic/test/api_key

What 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.

When served by the hosted API 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 Hosted 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 the local vault the same column is instead filled from AGENT_ID ?? USER ?? hostname(), which is self-asserted. The two transports populate one column from two sources with different trust properties; read which transport served the row 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. Against the hosted API it is not reachable by any client-side change, because the client does not supply this value at all; in the local vault 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 --json

The 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:

  1. The contract version, grant ID, secret reference, and automation ID match.
  2. The action/connector pair exactly matches one allowedBindings entry, and the authenticated machine/profile pair exactly matches one allowedRuntimes entry. Keeping pairs intact prevents unintended cross-product permissions.
  3. Every requested scope is present in the matched binding's allowedScopes; grants cannot be widened by a request.
  4. The current time is in the half-open grant interval notBefore <= now < expiresAt. expiresAt is required; notBefore may be omitted to make the grant valid immediately.
  5. 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_secret plaintext 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 transport — the credential's issued-to subject against the hosted API, the process environment in the local vault — 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.rotated links 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:

  1. Extend AuditEntry and both audit tables with the explicit nullable columns above; do not add an unbounded JSON metadata column. Keep legacy CRUD rows and actions readable.
  2. Add one typed store operation that accepts only the six event variants. Make local and cloud implementations validate fields and write parameterized SQL.
  3. Emit from the future grant/resolver boundaries described in the table. Do not reinterpret the current raw get action as proof of secret.used.
  4. Make /v1/audit, the CLI, MCP, SDK, and OpenAPI return an explicitly selected audit DTO rather than SELECT *, preserving the allowlist end to end.
  5. 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 export

Export plaintext only when a local restorable artifact is explicitly needed:

secrets export --show --pretty > secrets-backup.json

Scan 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 50

Findings 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 can be projected to reviewed metadata for a transcript, task, or channel.

The scanner and persistence redactor recognize HTTP(S) URLs with nonempty signature or capability-token query parameters, and expiring Codesmith backend links (a nonempty expires parameter on the exact host backend.blacksmith.sh). Detection handles parameter ordering, encoded query names, HTML/JSON escaping, and wholly percent-encoded URLs. Findings use the signed_capability_url detector; persistence redaction removes the entire URL, including its path. Public PR links and unsigned links remain unchanged.

Scan captured stdout and stderr before displaying either, and still select only the fields needed for the task. A clean scan is limited to the supported detectors and inspected bytes; it does not make a full PR body or arbitrary tool output safe to render. These package detectors do not change an independently installed Gitleaks binary or its configuration.

Exact reviewed staged findings

The @hasna/secrets/scanner export includes adjudicateStagedScan(rawScan, binding, record) for a gate that verifies its own independently reviewed, already-landed fixture records. The helper annotates findings; it does not establish review authority or grant permission to commit. There is no CLI approval option or automatic record discovery.

The consumer must verify the landed record and measure the exact staged candidate before calling the helper. StagedReviewBinding carries the full base commit, candidate manifest SHA-256, every scanned source blob SHA-256, and the SHA-256 of each finding's UTF-8 line without its newline. Patch line hashes retain the + or - marker. ReviewedFixtureDispositionRecord binds those measurements, the independent review's SHA-256, and each finding's exact path, detector, line, column, line hash and disposition. The supported dispositions are synthetic-test-fixture and code-reference, restricted to medium-severity credential_assignment findings. Other detectors remain unresolved.

StagedScanAdjudication.rawScan retains the original result, all findings and the original count. dispositionedFindings and unresolvedFindings are separate audit lists. Invalid records, mismatched bindings, missing findings, duplicate entries, binary findings and incomplete or suppressed scans refuse. Additional unreviewed findings remain unresolved. Refusals contain fixed codes and never record prose or source values. An arbitrary JSON document is not approval; only the consumer's verified landed-record policy can make that decision.

The original stagedScanExitCode and secrets scan staged behavior is unchanged: any raw finding still exits 1. A consumer must retain that evidence and report a distinct reviewed disposition rather than claiming zero findings or rewriting the scanner's exit status.

Exempting a credential-detection fixture

A redaction test, a scanner test, an operator stub or a validation predicate that names a PEM banner legitimately contains credential-SHAPED text. The detector set cannot tell such a fixture from a leak by shape alone, so a file may declare itself exempt. There are exactly two markers, and neither is a waiver list: both are written into the file they apply to, both are scoped to one line or one file, and every exemption they produce is REPORTED.

hasna:allow-secret — put it on a line, in any comment form. It exempts the findings whose match starts on that line, and no other line.

hasna:allow-secret-file — declare it as the first content of a comment line, conventionally in the file header. It exempts the findings in ONE IDENTIFIED FILE, and no other file.

The declaration is the marker as the opening content of the line, once leading whitespace and comment introducers are stripped:

<comment introducer> hasna:allow-secret-file

Concretely, // hasna:allow-secret-file, # hasna:allow-secret-file, /* hasna:allow-secret-file */, * hasna:allow-secret-file, -- and ; forms, or the token alone on its own line. A marker that is merely MENTIONED — in prose, inside a string literal, or in a comment body with other text in front of it — exempts nothing, so a document that explains the marker does not exempt itself.

What "one identified file" means is the load-bearing part. A staged blob, a workspace file and each named secrets scan input FILE path are each one file, and a declaration in them is honoured. A text/buffer payload and stdin are NOT: they are streams, and one stream may carry a paste, a diff, a concatenation or a whole tool result, so a declaration inside it cannot be attributed to anything. The asymmetry is deliberate. Honouring the declaration in a stream would let ONE line silence every unrelated finding beside it — and scan input is precisely the gate a caller points at tool output before trusting it.

Exemptions are never silent. suppressions lists one entry per exempted finding, with the finding's own detector, path, line and column plus the marker and the line it was declared on; suppressionCount carries the total even when the list is bounded by --limit. JSON output on a clean run is therefore able to say what it did not report, which is what makes the escape reviewable rather than a hole.

Three limits are deliberate. The inline marker needs no file identity, so it is honoured however the bytes arrived. scan history inspects only the lines its git-grep query selects, so it resolves the inline marker but not a file-level declaration that the query did not select. And the scanner accepts honorMarkers: false for content it is VALIDATING rather than gating: assertMetadataSafe, which scans untrusted free text before it is stored, sets it, because a check whose subject can exempt itself is not a check. A consumer that scans other people's bytes rather than an authored file — the hook-scanoutput PostToolUse hook, which scans tool output — sets it too.

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 \
  --json

The 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_key

Key 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_secret

Hasna 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, project

infra 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/master

AWS 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-run

Credential 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.

For a single-key push that replaces an existing AWS value, pass the reviewed AWSCURRENT version ID with --expect-version <id>. The push refuses a missing or stale expected version and guards promotion against concurrent changes. For a pull, the same flag pins the read to that exact AWSCURRENT version and refuses a different returned version. Single-key plans are offline: they show the supplied version but do not discover or verify the remote current version.

For a one-use initial creation from a hosted Secrets key, use the separate secrets aws create-only command. It requires the exact Secrets /v1 API URL, current hosted version, STS assumed-role caller ARN, and a stable UUID for the one operation. The AWS name is exactly the hosted key. The command checks the hosted value in memory, verifies the AWS caller with the same credentials used for CreateSecret, refuses an existing or deletion-pending AWS name, and returns only {arn,version_id} JSON after exact-version metadata and in-memory value readback. It neither reads aws.json nor writes aws-sync-state.json; aws push retains its existing sync behavior. A timeout or incomplete readback requires operator reconciliation of the exact AWS name and operation ID before any retry.

secrets aws create-only example/app/prod/database-url \
  --expect-secrets-url https://api.hasna.com/secrets/v1 \
  --expect-hosted-version 1 \
  --expect-caller-arn "$EXPECTED_CALLER_ARN" \
  --operation-id 4322fc79-1b4c-42d5-a636-a066e027840e \
  --profile example-issuer-profile --region us-east-1

For an existing empty container, use secrets aws initialize-empty. It is a separate opt-in operation; push and sync keep their existing version guards. The command requires the same hosted URL/version, exact assumed-role caller and operation UUID as create-only, plus --expect-secret-arn <full-ARN> and --preserve-metadata <new-absolute-file>. The ARN must match the exact hosted key, caller account, partition and selected region. Use a private recovery directory; the command exclusively creates a mode-600 metadata original and verifies its bytes before writing a value. Existing files are never overwritten.

Initialization refuses missing, deleting, service-managed, replicated, rotating, or nonempty containers, including unlabeled deprecated versions. It checks the complete version inventory and unchanged container metadata immediately before the write. A unique candidate stage and the operation UUID pin the one value write. AWS may make the first version current automatically; otherwise the command promotes it without naming a version to remove. AWS refuses that promotion when a different current already exists. See the AWS PutSecretValue, UpdateSecretVersionStage, and ListSecretVersionIds contracts.

Only {arn,version_id} JSON is returned after agreeing metadata readback and a hosted-version recheck. No AWS secret value is read. An uncertain response is reconciled once using version metadata; the command never retries a value write, removes a label, deletes a candidate, or changes sync state. If it refuses after writing, retain the metadata original, operation ID and candidate and reconcile the exact ARN and all version labels before any retry. This is first-value initialization, not a replacement or rotation command.

Secret Types

Supported types:

api_key, password, token, credential, other

Structured 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, custom

Chrome 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 serve

Then 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 gc

MCP Usage

Install the MCP server into local AI agents:

secrets mcp install --target codex
secrets mcp install --target claude
secrets mcp install --target gemini

Agents connect over stdio by running:

secrets mcp

secrets 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 8848

The 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 });

Shared storage and migration

Ordinary CLI, MCP, and default library access use the authenticated API. Clients never connect directly to PostgreSQL or select a local SQLite vault. The service owns its database and encryption configuration.

For an existing vault, use the explicit migrate-vault --source ... --key-file ... protocol described in lossless migration. Keep the source until destination verification and the separate retirement checks finish. This command does not delete the source.

Data Directory

Ordinary clients keep account credentials and necessary client configuration on disk, but never create or copy vault.db or its journal/WAL/SHM sidecars. Browser serve tokens and AWS client configuration use a separate config-directory helper that cannot migrate vault/key files.

Explicit LocalStore construction remains a storage-library compatibility handle for existing local fixtures and migration tooling. Its historical data/key path rules are not ordinary CLI/MCP selectors. HASNA_SECRETS_LOCAL_VAULT, HASNA_SECRETS_DB_PATH, and OPEN_SECRETS_DB are rejected by ordinary store resolution, including when API credentials are also configured. Use an explicit migration source rather than deleting credentials to select a different vault. The scanner reads explicitly requested files as bytes and does not initialize SQLite. The migration reader opens an existing file read-only without schema upgrades or implicit key creation.

secrets key

The 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.

An environment-built SDK client binds the service authority and credential together immediately before each request. Same-authority key rotations work without rebuilding it; changing the configured authority fails before sending credentials and requires constructing a new client.

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/credentials

Without an account credential, ordinary CLI and MCP data access fail closed. Configure the canonical API credentials or saved account credential tier. Local selection flags (--local, --local-vault, --db, --db-path, --storage-mode) are rejected. Utility help and explicitly sourced migration/scanning remain separate from ordinary store resolution.

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.

Maintenance commands act on the active vault. API maintenance now verifies the configured runtime key and all four payload tables. encrypt-vault requires secrets:migrate and repairs plaintext atomically; unreadable ciphertext fails closed. KMS backing is not attested, and KMS setup still requires a separate operator-managed capability. See verified encryption maintenance.

Safety Notes

  • list, search, export, and scan do not decrypt or print secret values by default.
  • export --show and export --plaintext are explicit plaintext escape hatches for local restorable backups.
  • CLI import refuses redacted export bundles so placeholders do not overwrite real secrets by accident.
  • get and MCP get_secret return 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 test

What 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.toml and the suite fails loudly with SecretsTestIsolationError rather 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.ts entrypoint — 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.ts entrypoint (what bun test sets) | yes | yes | | bare NODE_ENV=test | yes | no |

    So running the shipped CLI with NODE_ENV=test in 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 make secrets get report a live credential as missing and secrets set print ✓ Stored for a value it discarded, both at exit code 0. What NODE_ENV=test does still do is bar the hosted vault: resolution throws SecretsTestIsolationError and 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.

Hosted service

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, --http for Streamable HTTP).
  • secrets-serve — the HTTP API. Public probes GET /health and /ready ({status, service} anonymously, with the same status code as a keyed call; version, mode, latencyMs, pendingMigrations and reason need an API key the service verifies) and GET /version ({status, version, mode} for every caller); GET /openapi.json needs a verified API key on the origin and through https://api.hasna.com/secrets alike, and anonymous callers get the /v1 401. Every one of these answers is Cache-Control: no-store, and those that depend on the key also send Vary: Authorization, x-api-key; a versioned /v1 surface (secrets + vault-item CRUD, search, audit, users, and secret version history — GET /v1/secrets/versions, GET /v1/secrets/versions/check, POST /v1/secrets/restore, and the audited POST /v1/secrets/versions/purge, which is a dry run unless confirm is set and is refused without a preservation receipt) behind strict API-key auth (@hasna/contracts). Scopes: secrets:read, secrets:write, secrets:migrate, and secrets:metadata (below).
  • @hasna/secrets — the typed, dependency-free SDK package root generated from the serve OpenAPI. The compatibility subpath @hasna/secrets/sdk exports the same API. The client resolves its credential and base URL through the shared @hasna/contracts resolver — never a DSN.

Storage is PURE REMOTE (Amendment A1) on the hosted service: 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'

The metadata-only scope

secrets:metadata is for a client that must never hold a value, such as a phone that shows what the vault contains. It allows exactly six routes, all GET: /v1/secrets (and ?namespace=), /v1/secrets/search, /v1/secrets/versions, /v1/secrets/versions/check (length and sha256, the get --check evidence), /v1/audit and /v1/users. The server refuses it with 403 insufficient_scope on every route that returns a value (GET /v1/secrets/get, /v1/items*, /v1/migrations/vault, /v1/encryption/*) and on every write, and the transaction store re-checks the same scopes, so the key cannot reach a value through any path. secrets:read still implies the metadata routes.

# a metadata-only key, delivered to one Secrets reference; the token is never printed
bunx @hasna/contracts issue-key --app secrets --agent my-phone --scopes 'secrets:metadata' \
  --secrets-ref 'secrets/agents/{agent}/{kid}' --issuance-id '<stable-id>' --json

Revoke it like any key: ApiKeyStore.revoke(kid, reason) on the api_keys row; the next request answers 401 revoked.

--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). Without a credential, health() and ready() return {status, service}; version() returns {status, version, mode}.

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

Migration protocol: lossless vault migration. This is separate from generic export/import and never deletes the source vault.

Shared expiry collection

secrets gc calls POST /v1/secrets/prune-expired with secrets:write. The service compares expiration using its own clock, deletes only the authenticated tenant's expired rows and commits their audit records in the same transaction. A key renewed while collection waits for its row lock is rechecked and preserved. Invalid expiry data or audit failure aborts the transaction. This requires the updated service; clients do not fall back to deleting a stale metadata list. Secret version history and key ownership records remain preserved.

Required PostgreSQL acceptance tests

bun run test:postgres executes all three dedicated PostgreSQL suites (lossless migration, encryption maintenance, and expiry pruning) without skips. CI runs this gate in secrets live PostgreSQL; missing suites, missing case output, failures, and skips fail the gate. Repository branch protection must require the secrets-live-postgres job before this becomes a protected merge requirement.

Use only a disposable PostgreSQL fixture with the secrets_test administrative user and secrets_test database on literal 127.0.0.1 with an explicit port: SECRETS_TEST_DATABASE_URL="$DISPOSABLE_POSTGRES_URL" bun run test:postgres. The runner rejects other targets and clears ambient credentials. Each suite creates and removes unique schemas and non-superuser/non-BYPASSRLS serving roles; the administrative fixture role is used only for schema/role setup and inspection. No production credentials or database resets are used. The older test:pg store proof remains available independently.