Skip to content

secrets

Mike Crowe edited this page Jul 9, 2026 · 8 revisions

Secrets setup (opt-in varlock + 1Password)

harnessed works fully without any secrets backend. This guide is for operators who want 1Password-backed secrets resolved into their stacks at launch — opt-in, env-only, never baked into an image or committed file. For the why (threat model, design rationale), see docs/harnessed-design.md §16.

What this does

With a .env.schema present, secrets resolve from 1Password via varlock and reach the pod members as environment variables only — never written to a profile, a container image layer, or a repo file. Absent the schema, harnessed is bit-for-bit today's behavior: varlock is never invoked, no op call, no env mutation. The opt-in switch is a single filesystem test.

Quickstart

# 1. Copy the shipped template into your XDG harnessed config dir.
mkdir -p ~/.config/harnessed
cp .env.schema.example ~/.config/harnessed/.env.schema

# 2. Edit the op:// refs to match your 1Password vault items.
#    Example (Private vault, items named "Snyk" and "SocketDev", field "credential"):
#       SNYK_TOKEN=op(op://Private/Snyk/credential)
#       SOCKET_SECURITY_API_KEY=op(op://Private/SocketDev/credential)
$EDITOR ~/.config/harnessed/.env.schema

# 3. Launch any stack. Resolved secrets reach the pod as env only.
harnessed claude_time

The .env.schema.example shipped at the repo root is the canonical template — copy it, don't author from scratch. The @plugin(@varlock/1password-plugin@1.2.0) + @initOp(allowAppAuth=true) decorators are required (they wire the op(op://…) resolver and tell varlock to use the mounted agent socket for app-auth).

How resolution works

harnessed runs varlock on the host to resolve op:// refs. This is required, not a preference: 1Password's desktop app authorizes the op CLI by calling application (your terminal), so app-auth (@initOp(allowAppAuth=true)) works on the host but cannot work from inside a container — there the desktop app has no host app to bind the grant to, and op fails with "cannot connect to 1Password app" no matter which socket is mounted. (The ~/.1password/agent.sock mounted into every stack is the SSH agent, for git signing — not the op app-auth transport.)

  1. The launcher detects ~/.config/harnessed/.env.schema (one [ -f ] test — inert when absent; with no schema, varlock is never invoked). It then layers per-project env on top (see "Per-project env" below) — the steps here describe the global schema; a project schema resolves identically, just rooted at the project directory.
  2. It runs varlock load --format env on the host, in the schema's directory. The first run prompts the 1Password desktop app to Authorize your terminal for CLI access — approve it once and the grant persists.
  3. The resolved dotenv is captured into a mode-0600 temp --env-file under $TMPDIR.
  4. That --env-file is spread into the launched container(s) — both pod members and the sidecar (svc up) — so resolved secrets reach the container as env only, never a profile, image layer, or repo file (T-05-05). This is launch-time only — harnessed build never touches secrets or invokes varlock at all (see "Scanner tokens" below for the separate, explicit path to a credentialed supply-chain scan).
  5. The temp file is unlinked after launch (T-05-06).

This needs varlock on the host (npm i -g varlock); op (already on most 1Password hosts) is driven by varlock via app-auth. The "podman-only host" invariant still holds for the no-secrets path — varlock is never touched without a schema. Hosts without host varlock fall back to the headless path below.

Per-project env (layered over the global schema)

Beyond the user-global ~/.config/harnessed/.env.schema, the launcher also discovers env from the project directory you launch in, and layers it on top of the global schema. Both sources reach the pod; on a key collision, the project value wins (podman applies multiple --env-file flags last-wins, and the launcher orders them [global, project]).

Two per-project cases, checked in order:

Project has… What happens
<project>/.env.schema Resolved via varlock load in the project dir — varlock cascades any .env / .env.local overlays on top of the schema, and op:// refs resolve exactly as the global schema does. Result is a mode-0600 temp --env-file, unlinked after launch.
<project>/.env only (no schema) No varlock invocation, no resolution — the file is copied into a mode-0600 temp --env-file with surrounding quotes and any export prefix stripped (so KEY="v" reaches the container as v, not "v" — podman keeps quotes literal). Your own .env is never modified; the temp is unlinked after launch.

Notes:

  • A project .env.schema takes precedence over a plain .env in the same project (varlock already cascades the .env), so only one project source is used.
  • The plain-.env path never runs varlock — a project with only a .env needs neither varlock on PATH nor any 1Password backend. Values are read literally (quotes stripped); there is no variable interpolation or op:// resolution on this path.
  • With no global schema and no project env, harnessed is bit-for-bit today's behavior.

Headless / CI fallback (OP_SERVICE_ACCOUNT_TOKEN)

For environments without the 1Password desktop app or without host varlock (CI, the nightly re-scan timer, a headless server), set OP_SERVICE_ACCOUNT_TOKEN in the launcher env. With a service-account token, resolution runs host-native in-process (HTTPS bearer auth — no desktop app, no app-auth, no socket). harnessed forwards the token only when it is already set — it never prompts and never echoes.

Caution (per CLAUDE.md "What NOT to Use"): a visible service-account token leaks into any process sharing the env. Scope it narrowly to the invocation — prefix it on the command line (OP_SERVICE_ACCOUNT_TOKEN=… harnessed claude_time) or inject via your CI secret store. Do not export it in your shell profile or ~/.bashrc, and do not leave it in a long-lived shell session.

Per-service secrets

Sidecar services (hindsight, openbrain, …) can declare their own schemas at ~/.config/<service>/.env.schema — e.g. ~/.config/hindsight/.env.schema for the hindsight sidecar. The schema syntax is identical; see the .env.schema.example header comment.

Scanner tokens — harnessed build never needs one

harnessed build never invokes varlock and never touches secrets, full stop — even when ~/.config/harnessed/.env.schema exists and declares SNYK_TOKEN. This is deliberate: building a stack (and verifying every recipe's skills/commands/rules/MCP servers installed correctly) must always succeed with zero credentials, so recipe verification never depends on 1Password, a service account token, or anything else being available or authorized. _build_derived_image issues a plain podman build unconditionally.

The derived image's final layer still runs the advisory in-image scan (snyk + osv-scanner + pip-audit) as part of every build — it just never gets a real SNYK_TOKEN, so snyk warn-skips (snyk skipped (no SNYK_TOKEN build secret)) while osv-scanner and pip-audit still run credential-free. That's correct, expected, non-interactive behavior, not a degraded state to fix.

A real, credentialed scan is a separate, explicit step: harnessed rescan re-scans already-built images online. If you want SNYK_TOKEN available for that, resolve it yourself before running it — e.g. varlock run -p ~/.config/harnessed/.env.schema -- harnessed rescan, or export SNYK_TOKEN/OP_SERVICE_ACCOUNT_TOKEN in your shell first. harnessed does not do that resolution on your behalf for rescan any more than it does for build.

Get a Snyk token at https://app.snyk.io/account/personal-access-tokens (Account settings → Personal Access Tokens → Generate).

Verification

After launching with a schema present, confirm secrets reached the pod and did not leak:

# 1. The value reached the pod env (replace <instance> + <KEY> with your own):
podman exec <instance> env | grep SNYK_TOKEN
# expected: SNYK_TOKEN=<the resolved value>

# 2. The value is NOT in the committed profile:
grep -r SNYK_TOKEN profiles/    # expected: no matches

# 3. The value is NOT baked into the image:
podman history harnessed-base:latest | grep -i snyk    # expected: no matches

# 4. The temp env-file is gone after launch:
ls /tmp/harnessed-env.* 2>/dev/null    # expected: no matches (unlinked post-launch)

If podman exec … env | grep <KEY> returns nothing but you expected it to, check that:

  • ~/.config/harnessed/.env.schema exists and has a non-@optional entry for <KEY>.
  • The 1Password desktop app is running and unlocked (the agent socket must be live for app-auth), OR OP_SERVICE_ACCOUNT_TOKEN is set in the launcher env.
  • The op(op://Vault/Item/field) ref points at a real vault item.

See also

Clone this wiki locally