@kjrjay/varlock-betterpass
v0.2.0
Published
Varlock plugin for approved local credential reads from BetterPass
Maintainers
Readme
BetterPass Varlock plugin
This Varlock plugin reads selected BetterPass credential fields on macOS after you approve each request in the BetterPass app. It uses the signed helper bundled with BetterPass. This npm package also includes the optional betterpass CLI for managing HTTP(S) and Postgres proxies together.
Setup
Install BetterPass 1.1.0 or later. In BetterPass Settings, turn on Enable Varlock integration. This setting starts off. Keep BetterPass running and sign in to iCloud. In a credential's detail view, use the copy icon beside its ID. The ID is an exact, case-sensitive reference; the plugin does not search by title.
JavaScript projects
Install the plugin from npm:
npm install @kjrjay/varlock-betterpassThen add it to .env.schema:
# @plugin(@kjrjay/varlock-betterpass)
# ---
DATABASE_PASSWORD=betterPass("opaque-entry-id")
DATABASE_USER=betterPass("opaque-entry-id", field=username)Standalone Varlock CLI
Install the exact plugin version into Varlock's plugin cache:
varlock install-plugin @kjrjay/[email protected]Use the matching version in .env.schema:
# @plugin(@kjrjay/[email protected])
# ---
DATABASE_PASSWORD=betterPass("opaque-entry-id")The npm package includes the built helper client; consumers do not need Bun or a build step. The BetterPass helper itself is bundled with the app, not this package.
Custom app locations
@initBetterPass() is optional when BetterPass is installed at /Applications/BetterPass.app. The plugin calls the bundled helper at /Applications/BetterPass.app/Contents/Helpers/betterpass-varlock. For a Debug build or another app location, set its absolute path:
# @initBetterPass(helperPath="/path/to/BetterPass.app/Contents/Helpers/betterpass-varlock")Unified CLI
The betterpass executable ships in this package alongside the plugin and betterpass-proxy. It requires Varlock 1.21 or newer and uses the existing Varlock HTTP(S) proxy, BetterPass resolver plugin, and Postgres proxy engine. There are no new runtime dependencies or changes to Varlock core.
For a local npm installation, invoke ./node_modules/.bin/betterpass instead of bare betterpass. For standalone use, install this package globally as well as installing its matching plugin version into Varlock's plugin cache; cache installation alone does not expose the executable on PATH.
npm install --global @kjrjay/varlock-betterpassFrom this checkout, run bun run build first, then use bun src/betterpass-cli.ts in place of betterpass.
Start once, attach clients
# Trusted terminal: owns both proxies and HTTP request approval prompts.
betterpass start
# Another terminal: agents and ordinary programs get the safe session environment.
betterpass run -- claude
betterpass run -- python app.py
betterpass run -- psql -c 'SELECT 1'
betterpass status
betterpass stopstart resolves and validates the schema through Varlock and the plugin once. It starts the Postgres engine in the trusted broker and Varlock's HTTP proxy with that resolved snapshot. Before announcing readiness, it checks that the HTTP backend loaded the same source-file fingerprints; a schema edit during startup stops both proxies. Attaching clients does not request BetterPass values again.
run asks Varlock to attach to the HTTP session and inject its placeholders, proxy URL, CA trust settings, and ordinary configuration. A private bridge then replaces each allowlisted Postgres URL variable with a local connection URL and provides PG_PROXY_TOKEN. It also sets PGHOST, PGPORT, PGUSER=varlock, PGDATABASE to the first handle, PGPASSWORD to the local token, and PGSSLMODE=disable, so psql works without another injection command. Raw upstream fields, conflicting libpq connection settings, internal schema values, injected graphs, and Varlock cache/encryption keys are removed before the application starts. The canonical pg_url is always removed first, even when excluded from PG_PROXY_HANDLES, exported by the terminal, public/passthrough, or running HTTP-only; it is restored only as a local URL for an explicitly exposed handle. Transport settings such as PGSSLNEGOTIATION are stripped, while ordinary values such as a public PGCONNECT_TIMEOUT remain usable. All known libpq setting names are forbidden as database handles. Other sensitive variables follow Varlock's policy, including any explicitly configured @proxy=passthrough exceptions.
Postgres is enabled when the schema declares pg_url, or its resolved environment (including terminal overrides) contains pg_url, PG_PROXY_HANDLES, or the legacy upstream host field. Otherwise only HTTP starts. The CLI generates the local token automatically; you do not need to export it. Existing PG_PROXY_HANDLES and PG_PROXY_CA_FILE configuration still applies. By default both ports are ephemeral, unless the schema or terminal environment sets PG_PROXY_PORT or you pass --http-port / --pg-port. Handle names must not collide with standard libpq connection variables, HTTP proxy variables, or runtime controls such as PATH, HOME, NODE_OPTIONS, _VARLOCK_*, BETTERPASS_*, and __BETTERPASS_*.
Mark ordinary values @public (or @sensitive=false) to get their real values in run; Varlock treats unspecified sensitivity as sensitive. For example:
# @public
APP_ENV=developmentOne-shot and plaintext modes
# Attach if a matching broker exists; otherwise start both proxies and stop them on exit.
betterpass run -- claude
# Force a separate one-shot session.
betterpass run --new -- python app.py
# Explicit plaintext injection for a trusted application, not an agent.
betterpass exec -- node app.jsA one-shot broker does not take stdin away from the child. HTTP rules requiring interactive request approval are denied; use a separate trusted betterpass start terminal for those rules. BetterPass's launch-time GUI approvals still apply. exec delegates to plain varlock run --inject vars, does not start proxies, and can expose real credentials. It is refused inside a proxied client, as are start and stop.
Use --path <file-or-directory> on start, exec, or a fresh run. Sessions are discovered by the closest containing working directory. Multiple sessions in that directory require --session <id>; status shows their IDs. Startup options cannot change an existing session. HTTP-only sessions can be started with --http-only.
The broker runs in the foreground. Ctrl-C or stop shuts down both proxies; failure of the HTTP backend also stops Postgres. A private IPC supervisor also terminates the HTTP backend if the broker is killed abruptly. Postgres sockets close with the broker process; any stale discovery record can be removed with stop --session <id>. Attached clients monitor the broker and terminate if it becomes unavailable. On POSIX, cleanup tracks owned descendants by PID, UID, and start time, including descendants reparented during shutdown, and escalates surviving processes after five seconds. It leaves controlling-terminal behavior intact and never signals the terminal's shared process group. This is lifecycle cleanup, not a sandbox or a guarantee against deliberate daemonization. Schema reload is disabled for the unified session so HTTP policy and database credentials cannot diverge; restart both to apply edits.
Session records live in ~/.betterpass/sessions (0700 directory, 0600 files). They contain discovery metadata and a local control capability, never upstream URLs or passwords. Only an authenticated loopback endpoint provides the local Postgres client token; status never prints tokens. BETTERPASS_STATE_DIR overrides the private state directory, and BETTERPASS_VARLOCK_BIN selects a particular Varlock executable. These CLI controls belong in the terminal environment, not in the schema. Runtime loader/trust controls such as NODE_OPTIONS, NODE_PATH, NODE_EXTRA_CA_CERTS, NODE_TLS_REJECT_UNAUTHORIZED, BUN_OPTIONS, OPENSSL_CONF, LD_PRELOAD, and DYLD_INSERT_LIBRARIES are also rejected in the schema in this first wrapper. Configure them only in the trusted terminal environment; application-specific startup hooks and trust changes must not be implicitly applied to credential-holding backends. Otherwise the CLI prefers a project/package-local Varlock installation, then PATH.
This first wrapper supports local, unsandboxed clients. It does not yet forward Varlock's sandbox, remote tunnel, or reload options. HTTP clients retain Varlock's proxy/CA compatibility limits, and the Postgres engine retains the limitations below. A shared OS user is not a security boundary; enforce external isolation when that threat matters.
Implementation layout
The CLI is split by responsibility: src/betterpass-cli.ts parses and dispatches commands, src/cli-broker.ts owns the proxies and safe client environment, src/cli-sessions.ts manages private discovery/control records, and src/cli-backend.ts isolates the Varlock adapter and shared bounded loopback/process helpers. The BetterPass bridge/batcher and Postgres protocol/authentication/configuration remain separate. Backend-specific policy can change without changing the public commands.
Resolver
betterPass(credentialId, field=password) accepts an exact credential ID and a field selector. The one-argument form reads the built-in password field. Built-in selectors are password, username, website, and notes. Any other name selects a custom field by its exact, case-sensitive label, without trimming or Unicode normalization. Both text and secret custom fields are supported, and all returned values remain sensitive.
# @required @sensitive @type=url(allowedProtocols=[postgres, postgresql])
pg_url=betterPass("opaque-entry-id", field=pg_url)
DEPLOYMENT_KEY=betterPass("opaque-entry-id", field="deployment key")Use field="custom:password" to select a custom field labeled password instead of the built-in password. The custom: prefix always forces custom-field lookup; a label that itself starts with custom: needs a second prefix, for example field="custom:custom:label".
Selectors must be nonblank, at most 256 UTF-8 bytes (including any prefix), and contain no control characters, line separators, or bidirectional formatting controls. A missing field or more than one custom field with the same exact label is an error. Varlock identifies the failing resolver and rejects the whole batch without returning values. Empty passwords and custom values are preserved, although Varlock may reject an empty required value.
Custom fields require an updated BetterPass app and its bundled helper, as well as this updated plugin. The helper and app share the field parser, so rebuild both through the BetterPass scheme and run the rebuilt app. An older helper/app rejects custom selectors. For a Debug build, set @initBetterPass(helperPath="/absolute/path/to/BetterPass.app/Contents/Helpers/betterpass-varlock") to the helper bundled with that running app.
Every value is marked sensitive by default, including metadata fields. Varlock groups reads made in the same resolution turn into batches of up to 32 fields. Each batch needs one approval. Larger groups may require several approvals. The plugin does not keep a cache across requests or retry a failed approval automatically.
When Varlock requests a batch, it prints a code such as B0D8-E637 to stderr. Check that the same code appears in the BetterPass approval window, then review the listed fields. BetterPass may first ask you to unlock. The approval expires after 90 seconds. Declining or closing the window releases no values.
Approval releases the selected values in plaintext to the requesting local process. If you run an application through Varlock, that application receives the values through its environment and can copy or transmit them. BetterPass cannot verify that the process calling its bundled helper is Varlock, so review every request before approving it. This integration is for interactive local macOS use, not CI or unattended jobs.
Minimal Postgres credential proxy
The package also provides betterpass-proxy. It is a separate loopback proxy, not a new protocol inside varlock proxy. Python and Rust clients connect to it using a local capability token. Only the proxy receives the real database password. It has no additional runtime dependencies.
In a trusted terminal, configure .env.schema:
# @plugin(@kjrjay/varlock-betterpass)
# ---
# @required @sensitive @type=url(allowedProtocols=[postgres, postgresql])
pg_url=betterPass("opaque-entry-id", field=pg_url)
# Second connection, using its own BetterPass entry/field:
# @required @sensitive @type=url(allowedProtocols=[postgres, postgresql])
analytics_pg_url=betterPass("analytics-entry-id", field=pg_url)
PG_PROXY_HANDLES=pg_url,analytics_pg_url
# @required @sensitive
PG_PROXY_TOKEN=Start the proxy with a random local token. Save that token for the client, not the real database password. For the local npm installation described above, use the executable's explicit path:
export PG_PROXY_TOKEN="$(openssl rand -hex 32)"
varlock run -- ./node_modules/.bin/betterpass-proxyFor standalone Varlock, varlock install-plugin installs only into its plugin cache; it does not put the proxy executable on your shell's PATH. Install the package globally as well, using the same version as your cached plugin, and ensure npm's global bin directory is on PATH:
npm install --global @kjrjay/[email protected]
export PG_PROXY_TOKEN="$(openssl rand -hex 32)"
varlock run -- betterpass-proxyFrom this checkout, after installing dependencies and building the plugin, use varlock run -- bun src/postgres-proxy-cli.ts.
PG_PROXY_HANDLES is the explicit allowlist of schema-variable names exposed as connection handles. It defaults to pg_url; to expose both examples, set it to pg_url,analytics_pg_url and configure both resolvers. Names are case-sensitive env identifiers, up to 128 ASCII letters/digits/underscores, cannot start with a digit, and cannot use the reserved PG_PROXY_ prefix. Unlisted env variables are never registered automatically. Every listed handle must be configured; a missing or invalid URL stops startup rather than silently using another connection.
Each handle contains a full postgres://user:password@host:port/database or postgresql://... upstream URL, resolved by Varlock in the trusted daemon process. The proxy percent-decodes the username, password, and database, and defaults the upstream port to 5432. Supported URL query options are sslmode=require, verify-ca, or verify-full, and sslrootcert=system. All modes still use full certificate and hostname verification. sslrootcert=system trusts the runtime's public roots plus OS-trusted certificates, matching Node's system-CA mode. It requires a runtime with tls.getCACertificates support (recent Bun or Node 22.19+). Other query options, duplicate options, and root-certificate file paths in the URL are rejected rather than silently ignored. For a private CA file, use PG_PROXY_CA_FILE instead; it cannot be combined with sslrootcert=system.
For compatibility, when only the pg_url handle is configured and its URL is absent, the CLI still supports PG_PROXY_UPSTREAM_HOST, PG_PROXY_UPSTREAM_USER, PG_PROXY_UPSTREAM_DATABASE, PG_PROXY_UPSTREAM_PASSWORD, and optional PG_PROXY_UPSTREAM_PORT. These fields become the target for the pg_url handle. A supplied but invalid URL never falls back. The CLI removes configured URL and legacy credential variables from its environment before starting and never logs their values.
The listener is 127.0.0.1:15432. Optional environment variables are PG_PROXY_PORT and PG_PROXY_CA_FILE (a PEM root CA file for private databases). An empty or whitespace-only CA file stops startup instead of falling back to runtime roots. Upstream TLS and hostname verification are mandatory. Local TLS is deliberately unsupported, so explicitly disable TLS only on the loopback connection.
Clients always use username varlock, the desired handle as the database name, and the local token as the password. They do not need the real upstream username, database, password, or host:
postgresql://varlock:<local-token>@127.0.0.1:15432/pg_url?sslmode=disable
postgresql://varlock:<local-token>@127.0.0.1:15432/analytics_pg_url?sslmode=disableThe proxy rewrites the startup user/database and authenticates upstream using the selected handle's private credentials. Unknown handles, other usernames, and wrong tokens are rejected before connecting upstream. One token authorizes all allowlisted handles. Starting a connection does not fetch secrets again: the daemon holds the URLs approved at launch. Changes require a restart. Query results can still disclose upstream identities through functions such as current_user; this is credential isolation, not result redaction.
For psql, keep the token out of command-line arguments:
PGPASSWORD="$PG_PROXY_TOKEN" psql 'postgresql://[email protected]:15432/pg_url?sslmode=disable' -c 'SELECT 1'For Python with psycopg:
import os
import psycopg
with psycopg.connect(host="127.0.0.1", port=15432,
user="varlock", dbname="pg_url",
password=os.environ["PG_PROXY_TOKEN"], sslmode="disable") as conn:
print(conn.execute("SELECT 1").fetchone())For Rust with tokio-postgres:
let mut config = tokio_postgres::Config::new();
config.host("127.0.0.1").port(15432).user("varlock").dbname("pg_url")
.password(std::env::var("PG_PROXY_TOKEN")?)
.ssl_mode(tokio_postgres::config::SslMode::Disable);
let (client, connection) = config.connect(tokio_postgres::NoTls).await?;
tokio::spawn(async move { let _ = connection.await; });
let row = client.query_one("SELECT 1", &[]).await?;
let value: i32 = row.get(0);The library entry point is @kjrjay/varlock-betterpass/postgres-proxy, exporting startPostgresProxy({ connections: { pg_url: { host, user, database, password, ... }, analytics_pg_url: { ... } }, token, listenPort }). Each target can set its own port and ca. It returns { port, close() }; the library defaults to an ephemeral loopback port. Library callers using the old single-target options must move them under connections.pg_url.
Scope and limitations
- Each allowlisted handle has a fixed upstream, user, and database. Callers cannot override these or request arbitrary BetterPass IDs. No transparent interception, HTTP proxy wiring, or Varlock proxy session integration.
- Upstream cleartext password, MD5, and SCRAM-SHA-256 authentication work over verified TLS. SCRAM passwords are limited to printable ASCII in this small implementation. Client-certificate authentication and SCRAM channel binding are not implemented.
- Startup parameters are limited to
user,database,client_encoding, andapplication_name. The proxy rewritesuseranddatabaseto the selected target's values.options, replication, and cancellation requests are rejected. The upstream cancellation key is withheld; use a server-sidestatement_timeoutif needed. - After authentication, messages are relayed without SQL inspection. Prepared statements and COPY pass through, but there are no query approvals, read-only enforcement, or result redaction. Use a least-privilege database role.
- The local token grants database access through this proxy. Keep it private. Loopback is not isolation: an agent sharing the proxy's OS user may be able to inspect its memory or environment. Run the daemon outside the agent's sandbox and enforce isolation if that threat matters. Containers cannot reach host loopback without additional networking configuration.
- Run this daemon through plain
varlock runin a trusted terminal, not inside an agent'svarlock proxy runenvironment. Do not pass upstream secrets into the client process.
Publishing
From a checkout with Bun installed:
bun install --frozen-lockfile
npm login --registry=https://registry.npmjs.org/
npm publishThe prepublishOnly script type-checks, builds, and runs the tests before uploading. The prepack hook also builds automatically when you run npm pack to create a local .tgz for testing or sharing; npm pack is not a required step before npm publish. Keep generated dist/ files untracked. Only dist, the README, license, and package metadata are included. Increment the package version in package.json before subsequent releases; it is independent of the BetterPass app version.
Common errors
| Error | What to do |
| --- | --- |
| Integration unavailable | Install or open BetterPass, enable Varlock integration, and check helperPath if you set it. |
| Denied or unlock cancelled | Run the Varlock command again and approve or unlock in BetterPass. |
| Expired | Run the command again and respond within 90 seconds. |
| Credential not found | Copy the exact ID again from BetterPass. |
| Field unavailable | Choose a field present on that credential. |
| Cloud unavailable | Check your iCloud connection and BetterPass account state. |
| Session changed | Unlock BetterPass and retry. |
| Busy | Wait for the current BetterPass request to finish, then retry. |
