Skip to content

secrets

Mike Crowe edited this page Jul 13, 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_CLI_API_TOKEN=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 — the build stays credential-free; rescan does the credentialed scan

harnessed ships four supply-chain scanners. Two are credential-free, two are token-gated:

Scanner Token During build During rescan
osv-scanner
pip-audit
snyk SNYK_TOKEN ❌ never
socket SOCKET_CLI_API_TOKEN ❌ never

harnessed build never invokes varlock and never touches secrets, full stop. 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 being available or authorized. _build_derived_image issues a plain podman build unconditionally.

So the build's own in-image scan layer prints, every time:

  · snyk: skipped (no SNYK_TOKEN — credentialed scan runs in `harnessed rescan`)
  · socket: skipped (no SOCKET_CLI_API_TOKEN — credentialed scan runs in `harnessed rescan`)

That is correct, expected behavior — not a degraded state to fix.

The credentialed scan

harnessed rescan resolves your tokens on the host from ~/.config/harnessed/.env.schema (via varlock) — or a bare ~/.config/harnessed/.env — and injects them into a throwaway container from the image as a mode-0600 --env-file, which is unlinked afterwards. varlock never runs in-container (1Password app-auth binds the grant to the calling host application; see "How resolution works").

harnessed rescan harnessed-claude-mystack:latest   # one image
harnessed rescan                                   # every harnessed-labelled image

harnessed build runs this for you against the image it just built, right after a successful build — so a normal build does get a full snyk + socket scan, just as a separate credentialed pass rather than inside the image layer. --no-security-scans skips it. It is advisory: it reports posture and never fails the build.

Declaring the tokens

# ~/.config/harnessed/.env.schema
SNYK_TOKEN=op(op://Private/Snyk/credential)
SOCKET_CLI_API_TOKEN=op(op://Private/SocketDev/credential)
  • Snyk token: https://app.snyk.io/account/personal-access-tokens (Account settings → Personal Access Tokens → Generate).
  • Socket token: https://socket.dev/dashboard/settings/api-tokens — needs the full-scans:create and packages:list permissions.
  • SOCKET_SECURITY_API_KEY (the name Socket's GitHub Action uses, and what earlier versions of this guide told you to declare) is still accepted as an alias, but SOCKET_CLI_API_TOKEN is canonical and wins when both are set.
  • You do not need SOCKET_CLI_ORG_SLUG: the scan derives your org from the token. Set it only to pin a specific org when the token can see more than one.

Socket has no offline mode — it uploads a synthesized manifest of the installed tree, so each scanned tree costs 1 Socket quota unit. Scans are created with --tmp, which keeps them off your org's alerts page.

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