-
Notifications
You must be signed in to change notification settings - Fork 0
secrets
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.
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 |
# 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_timeThe .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).
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.)
- The launcher detects
~/.config/harnessed/.env.schema(one[ -f ]test — inert when absent; with no schema,varlockis 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. - It runs
varlock load --format jsonon 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. - The resolved values are captured into a mode-0600 temp
--env-fileunder$TMPDIR. - That
--env-fileis 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 buildnever touches secrets or invokes varlock at all (see "Scanner tokens" below for the separate, explicit path to a credentialed supply-chain scan). - 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 — mirroringpodman run -ebeating--env-filein 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.
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.schematakes precedence over a plain.envin the same project (varlock already cascades the.env), so only one project source is used. - The plain-
.envpath never runs varlock — a project with only a.envneeds neithervarlockon PATH nor any 1Password backend. Values are read literally (quotes stripped); there is no variable interpolation orop://resolution on this path. - With no global schema and no project env,
harnessedis bit-for-bit today's behavior. - The temp-file wording in the table above describes container delivery.
host-runselects the same sources in the same order and applies the merged result in-process — no temp file.
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 notexportit in your shell profile or~/.bashrc, and do not leave it in a long-lived shell session.
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.
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.
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 imageharnessed 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.
# ~/.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:createandpackages:listpermissions. -
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, butSOCKET_CLI_API_TOKENis 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.
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 launchIf the value is missing where you expected it, check that:
-
~/.config/harnessed/.env.schemaexists and has a non-@optionalentry for<KEY>. - The 1Password desktop app is running and unlocked (the agent socket must be live for
app-auth), OR
OP_SERVICE_ACCOUNT_TOKENis 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.
Claude Code inside a container needs credentials. There are two paths; one supersedes the other.
CLAUDE_CODE_OAUTH_TOKEN is the supported way to authenticate a containerized Claude Code.
Obtain it once with claude setup-token (generates a long-lived, ~1-year token). Because the token
never expires mid-session, it needs no in-container refresh — which is what makes the credential-file
approach below unnecessary.
Supply it via one of these routes (in precedence order within that set):
-
--env-filevia varlock/1Password — the recommended route. Store the token in 1Password, declare it in~/.config/harnessed/.env.schema:CLAUDE_CODE_OAUTH_TOKEN=op(op://Private/ClaudeCode/token)It is long-lived, so it belongs in a secret store rather than a shell profile.
-
Host environment —
export CLAUDE_CODE_OAUTH_TOKEN=<token>in your shell. The launcher forwards it as a bare-e CLAUDE_CODE_OAUTH_TOKENso the value never appears on the process command line. Less secure than the secret-store route because it is visible inenv.
When either supply route is detected, the credential-file mount described below is skipped
entirely — nothing from ~/.claude/.credentials.json is copied into the container.
The token applies only to the claude and omp harnesses; other harnesses do not authenticate
Claude Code and need neither path.
Hosts that have not yet run claude setup-token fall back to a per-instance copy of
~/.claude/.credentials.json, mounted rw into the container. This path:
-
Warns on every launch. The launcher prints a message naming the fix:
claude setup-token→ store asCLAUDE_CODE_OAUTH_TOKEN. - Re-seeds on expiry. If the seeded copy has expired, harnessed re-copies from the host's current credentials before launching. The original one-time seed (which could silently leave an instance permanently logged out after its copy aged) is gone.
- Cannot be made correct long-term. Host and container refresh their copies independently; concurrent refresh-token rotation is undocumented, so divergence is a matter of time.
The mount is in launcher.py:_claude_creds_seed_mount. It is skipped when
_claude_oauth_token_configured returns true (token present in host env or in a resolved
--env-file).
Migration. Run claude setup-token once, store the result in 1Password under a
CLAUDE_CODE_OAUTH_TOKEN field, declare it in ~/.config/harnessed/.env.schema. The mount
disappears on the next launch.
- docs/harnessed-design.md §16 — the design rationale (the why).
- docs/harnessed-design.md §7 — supply-chain scanners + the warn-and-skip contract.
- .env.schema.example — the canonical schema template.
Start Here
Guides
- Recipe authoring
- Service authoring
- Stacks
- Extending stacks (proposed)
- Recipe catalog
- System prompt & rules (proposed)
- Secrets
- AWS SSO
- Pulumi (host login forwarding)
- Egress & exposing services
- Container filesystem
- Git hooks
- Troubleshooting
- Pin management (harnessed update)
Codebase Map
Planning & Roadmap
- open work: GitHub Issues
Research & Prompts
- research/ (home-folder requirements per harness, browse in-repo)
- prompts/ (reusable prompt templates, browse in-repo)