Skip to content

secrets

Mike Crowe edited this page Jul 23, 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 agent 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.

Both execution modes resolve the same schema, differing only in how the values are delivered:

Mode Delivery On disk?
harnessed launch (container) a mode-0600 temp --env-file, spread into both pod members + the sidecar, unlinked after launch yes, briefly — podman needs a file
harnessed host-run (host-native) set directly in the launcher's os.environ, inherited by the exec'd agent never

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 json 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 values are 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).

Steps 3–5 are the container delivery. Under harnessed host-run there is no pod to hand a file to — os.environ is the box — so the resolved map is applied to the launcher's own environment and inherited by the agent it exec's. No temp file is created at all, which is strictly tighter than the container path: nothing to unlink, nothing to leak if the launch dies.

Two precedence rules apply on the host path:

  • A recipe's env: declaration beats a resolved secret of the same name — mirroring podman run -e beating --env-file in container mode.
  • A resolved secret beats a value already exported in your shell. The schema is the declared source of truth; a stale export SNYK_TOKEN=… silently winning is the failure mode hardest to spot from inside a session.

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 agent; on a key collision, the project value wins (podman applies multiple --env-file flags last-wins, and the launcher orders them [global, project]; host-run merges the two maps in the same order). This is identical in both modes.

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.
  • The temp-file wording in the table above describes container delivery. host-run selects the same sources in the same order and applies the merged result in-process — no temp file.

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 agent and did not leak.

Container (harnessed launch):

# 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)

Host-native (harnessed host-run) — ask the agent's own shell, since there is no container to exec into:

# 1. The value reached the agent process (from inside the session):
echo "${SNYK_TOKEN:+set}"    # expected: set

# 2. No temp env-file was ever created on this path:
ls /tmp/harnessed-env.* 2>/dev/null    # expected: no matches, during AND after the launch

If the value is missing where you expected it, 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.
  • No recipe in the stack declares the same key in its env: — a recipe declaration wins over a resolved secret in both modes, so the value you see may be the recipe's, not the schema's.

See also

Clone this wiki locally