Credentials
How model credentials get into the sandbox, which one wins, and how to revoke or replace each credential (spec FR-008, FR-008b, FR-025). No credential is ever stored in a git repo or in the image.
1. Default: log in once inside the container#
pi-sandbox start ~/pi/ws/private/sandbox-test
# inside pi:
/loginpi writes the result to ~/.pi/agent/auth.json inside the pi-agent-state volume. The
login persists across sessions and rebuilds and is shared by both domains
(state.md). The summary line shows credentials=stored-login.
2. Alternative: inject an API key from the Keychain for one session#
Store the key once (you are prompted for the value, so it never lands in shell history):
security add-generic-password -s pi-sandbox -a anthropic-api-key -U -wOr let a password manager provide it: set ANTHROPIC_API_KEY_CMD in .env, for example
ANTHROPIC_API_KEY_CMD="op read op://Private/anthropic/credential" or
ANTHROPIC_API_KEY_CMD="bw get password anthropic-api-key". When it is set, the wrapper runs it
on the Mac instead of reading the Keychain; a failing or empty command stops with exit 20.
With Bitwarden (bw on the PATH, a *_CMD in .env containing bw , interactive terminal),
start and shell check bw status (which honours an exported BW_SESSION); if the vault is locked or
the BW_SESSION is stale, bw unlock --raw asks for the master
password once and the session key is kept in BW_SESSION for that run only. Nothing is stored. Without
a terminal nothing is asked and a locked vault fails as before.
Start with the flag:
pi-sandbox start ~/pi/ws/private/sandbox-test --key-from-keychainThe wrapper reads the item on the host and passes it as ANTHROPIC_API_KEY through its
environment (declared in compose.yaml; never on the command line, never in a file). The key
lives only in that container’s process environment and disappears when the session ends. The
summary line shows credentials=keychain. A locked keychain or a missing item stops the start
with exit code 20; there is no silent fallback.
Telegram bot token (pi-telegram)#
The pi-config package @llblab/pi-telegram (/telegram-setup, /telegram-connect, see pi-config
docs/config-reference.md) lets you follow and steer a session through a Telegram bot. The bot
token is a secret; your user id is not and goes into ~/.pi/agent/telegram.json
(profiles.default.allowedUserId), which otherwise holds only the reference $TELEGRAM_BOT_TOKEN.
- Create the bot with
@BotFatherand keep the token in a password manager. - Set a command that prints it in
.env, for exampleTELEGRAM_BOT_TOKEN_CMD="op read op://Private/pi-telegram/token"orTELEGRAM_BOT_TOKEN_CMD="bw get password pi-telegram"..envholds the command, never the token. - Start a session as usual. At every
startandshellthe wrapper runs the command on the Mac and passes the output into that session asTELEGRAM_BOT_TOKEN(environment only, never argv or a file).
Rules: optional (unset = no token, the bridge’s Telegram part stays off); no Keychain fallback;
a failing or empty command stops with exit 22; a TELEGRAM_BOT_TOKEN already set on the host is
discarded. The agent can read the token during the session, like the API key above; use a bot
dedicated to this and revoke it with /revoke in @BotFather if exposed.
Other providers: model proxy proxy#
pi-provider-model-proxy adds the provider model-proxy. Run /login model-proxy inside the sandbox and
choose Sign in with an API key: the proxy URL and key go to auth.json in the state
volume, like the default login. Browser SSO with a 127.0.0.1 callback does not work in the
sandbox (no published ports); use the API key or the proxy’s verification-code SSO flow.
/logout model-proxy removes the local credential; revoke the key on the proxy.
Cloud CLIs: stackit, hcloud, gh#
The image has the STACKIT CLI (stackit), the Hetzner Cloud CLI (hcloud) and the GitHub CLI
(gh) for every domain. Their configuration and sign-in live in the domain’s volume (~/.config/stackit,
~/.config/hcloud and ~/.config/gh are links into pi-domain-state-<domain>), so a sign-in made in an work
session is never visible in private sessions and the other way round. Sign in once per domain,
inside a session (pi-sandbox shell <project> or ! in pi):
- STACKIT:
stackit auth activate-service-account --service-account-token <token>(or--service-account-key-pathwith a key file you copy into the project for the moment and delete afterwards).stackit auth logindoes not work in the sandbox: it waits for a browser callback on ports 8000–8020 inside the container, and no ports are published. Set the default project withstackit config set --project-id <id>.stackit auth logoutremoves the sign-in. - Hetzner Cloud:
hcloud context create <name>and paste an API token of the project (read only where that is enough).hcloud context delete <name>removes it. - GitHub:
gh auth login --with-tokenand paste a fine-grained token (read-only access to the repositories you need) on stdin;gh auth loginwithout--with-tokendoes not work in the sandbox (browser flow).gh auth logoutremoves it. Without a sign-inghreads public repos only.
Revoke the service account or API token (or the GitHub token) in the provider’s console when you no longer need it.
Cluster CLIs: kubectl, talosctl#
The image has kubectl and talosctl (and yamllint) for every domain. Like the cloud CLIs,
their configuration lives in the domain’s volume: ~/.kube and ~/.talos are links into
pi-domain-state-<domain> (cli/kube, cli/talos), so a kubeconfig placed in an work session is
never visible in private sessions. The tools use their default locations (~/.kube/config,
~/.talos/config), so no environment variable is needed. Set up once per domain, inside a session
(pi-sandbox shell <project> or ! in pi):
- kubectl: copy a kubeconfig to
~/.kube/config(chmod 600), or create contexts withkubectl config set-cluster|set-credentials|set-context. Several contexts in one file are fine; the agent useskubectl --context <name>. Prefer a context with a read-only ServiceAccount or role. Exec-plugin credentials (oidc-loginetc.) do not work: the plugin binaries are not in the image and a browser callback cannot reach the container; use a token or certificate. - talosctl: copy a talosconfig to
~/.talos/config(chmod 600) or runtalosctl config merge <file>. Prefer a role ofos:readerwhere that is enough.
This kubeconfig is shared by every session of the domain. For per-customer or per-project
clusters use a level kube.json instead: the session then reaches only that cluster, through a
read-only proxy sidecar that holds the kubeconfig, read on the Mac at every start; the agent
never sees it (config.md).
Delete the file (rm ~/.kube/config) to sign out. Copy the config into the project only for the
moment and delete it afterwards; do not commit it.
SSH keys for servers#
Keys for logging in to customer or project servers are configured per level in ssh.json and
read on the Mac at every start, like the kubeconfig; only an ssh-agent sidecar holds them and pi
can use but not read them (config.md). Use a dedicated key per
server or customer without a passphrase, and revoke it on the server (authorized_keys) when it
is no longer needed. Your own ~/.ssh and SSH agent are never used.
Hermit toolsets#
Repos that use Hermit (bin/hermit shims) install their
pinned tools on first use. The download cache is HERMIT_STATE_DIR, set by the entrypoint to the
domain volume (cli/hermit), so a tool fetched in one domain is not reused in another.
The image pins Hermit itself: HERMIT_VERSION with the SHA-256 of its release download, and the
package index (cashapp/hermit-packages) as a GitHub archive at a fixed commit, in
/opt/hermit-packages. The entrypoint seeds the state dir with that binary and turns
self-update off, so the stub never fetches stable from GitHub. The index is read from the image
only by the session’s toolsets (config.md). A repo’s own
hermit.hcl can name other manifest sources, and Hermit then syncs those from their HEAD; the
image cannot pin them. Treat a Hermit-managed repo like its scripts, since its tools run with the
session’s rights, and be stricter with customer repos. The baked kubectl, talosctl and others
stay as the fallback for repos without Hermit.
Forge tokens for workspace cloning#
pi-sandbox workspace clone|refresh reads a forge token per domain and host on the Mac
(WS_<ID>_<HOST_ID>_TOKEN_CMD in .env, else the Keychain item ws-<domain>-<host>-token) and
passes it only to the workspace container; sessions never get it. Read-only repository access is
enough. Details: workspace.md.
Push from sessions#
Sessions can push to the forge when you give a domain a push credential for the git host of the
project (pi-config spec 011). It is off by default; without a credential there is no agent, no
token, no hook and no GIT_SSH_COMMAND in the session. It is set up like the workspace (ghorg)
settings: per domain and git host, and the remote form chooses the kind of credential.
The host is the first folder of the project path (ws/<domain>/<host>/<group>/…/<repo>, the layout
pi-sandbox workspace clone creates); a project without a host folder never pushes. <ID> is the
domain name in uppercase (- → _), <HOST_ID> the host in uppercase (. and - → _).
| Remote form | Setting in .env | Credential |
|---|---|---|
ssh (default) | WS_<ID>_<HOST_ID>_PUSH_KEY=<path> or WS_<ID>_<HOST_ID>_PUSH_KEY_CMD="<command>" | an unencrypted private key: a file, or a command that prints it |
https | WS_<ID>_<HOST_ID>_PUSH_TOKEN_CMD="<command>", else the Keychain item ws-<domain>-<host>-push-token; optional WS_<ID>_<HOST_ID>_PUSH_USER | an access token (user oauth2, x-access-token on GitHub, or the setting) |
The remote form is WS_<ID>_<HOST_ID>_REMOTE (this domain) or WS_<HOST_ID>_REMOTE (all domains),
default ssh; the same setting workspace clone uses for the remote URLs. A credential of the
other kind is ignored with a warning. Example for the work domain on gitlab.com:
WS_WORK_GITLAB_COM_PUSH_KEY=~/.ssh/pi-work-gitlab # remote ssh
# or
WS_WORK_GITLAB_COM_REMOTE=https
WS_WORK_GITLAB_COM_PUSH_TOKEN_CMD="op read op://Private/pi-gitlab/token"Set-up (once per domain and host, on the Mac)
- Create a dedicated credential, never your personal one. For ssh a passphrase-less key:
ssh-keygen -t ed25519 -N '' -C pi-sandbox-work-gitlab -f ~/.ssh/pi-work-gitlab. For https an access token with only the scope to push (write:repositoryon Codeberg,write_repositoryon GitLab, a fine-grained token on GitHub). - Give the forge the least access that works: a machine user that is a collaborator with write access only on the repositories sessions may push to, or one read-write deploy key per repository. Customer domains must not share a credential with other domains.
- Turn on server-side branch protection for the default branch of every such repository (Codeberg/Forgejo: Settings → Branches; GitLab and GitHub the same): nobody may push directly, changes arrive by pull request. This is the real guarantee; the in-session guard below is defence in depth.
- Set the variables in
.env. Hosts other than codeberg.org, github.com and gitlab.com (host keys pinned in the image and checked against what the forges publish) need their public host keys:pi-sandbox known-hosts add <host>(checks the fingerprints with you and stores them for every session, see below) or a known_hosts-format file inWS_<HOST_ID>_KNOWN_HOSTS_FILE(only for ssh push to that host). - Start a session in a project below
ws/<domain>/<host>/: the summary line ends withpush=ssh(<host>)orpush=https(<host>).
What happens. The wrapper reads the credential on the Mac (exit 60 if it cannot, 61 if it is not
usable; with ssh-keygen on the host it also proves a key loads without a passphrase) and passes
it to this session only, with PUSH_MODE and PUSH_HOST. Inside:
- ssh: the entrypoint loads the key into an
ssh-agentin the session tmpfs and unsets the variable, so pi and its tools never inherit it. git uses the agent with strict host-key checking, no ssh config file and no agent forwarding;https://andssh://push URLs of the host are rewritten togit@<host>:. - https: the token goes to a
0600file in the session tmpfs and the variable is unset. A git credential helper (git-credential-push) serves it only for https, the project’s own host and its own repository path (<group>/<repo>), never for another repository or host.git@<host>:andssh://push URLs are rewritten tohttps://; git never prompts. - Both: nothing is written into any repository’s
.git: git is configured through environment variables, andcore.hooksPathpoints to a global hook shim that runs the repository’s own hooks first (prek and friends keep working) and then applies the pre-push guard: no push to the remote’s default branch or tomain,master,trunk,develop,dev,production,prod,stable,release*(replace the list withPUSH_PROTECTED_BRANCHES), no force or non-fast-forward push, no deleting or moving remote refs or tags. The session context tells the agent that push is on and what it may do.
What this does not protect. The agent controls git inside the session and can bypass a client
hook; and the credential is readable by the agent (the entrypoint’s /proc/<pid>/environ still
holds it after it is unset, and the ssh-agent socket can sign). Anyone who can run commands in the
session can push anywhere the credential is allowed to; the https helper limits the token’s
automatic use to the project’s repository, not what the token itself may do. Hence: dedicated
credential, least access, branch protection on the forge. Details and the residual risk:
security.md.
Revoke. Remove the key or token on the forge and delete the file or Keychain item; unset the
WS_*_PUSH_* lines to turn push off.
Precedence and switching#
pi uses a stored login before an environment key. So:
| Situation | Result |
|---|---|
| No flag | Stored login is used (or pi asks you to /login). Any ANTHROPIC_API_KEY in your host shell is discarded. |
--key-from-keychain, no stored login | Injected key is used. |
--key-from-keychain and a stored login | Start refuses with exit 21: stored login in agent state takes precedence over injected key; run /logout first or start without –key-from-keychain. |
To switch from the stored login to Keychain injection: start normally, run /logout, then use
--key-from-keychain from then on. To switch back: start without the flag and /login.
Revoke and replace#
| Credential | Replace | Revoke |
|---|---|---|
Stored login (auth.json in the volume) | /logout, then /login | /logout in pi; or delete the file: pi-sandbox shell <project> → rm ~/.pi/agent/auth.json; then revoke the session or key at the provider |
| Keychain API key | security add-generic-password -s pi-sandbox -a anthropic-api-key -U -w | Revoke the key in the provider’s console, then security delete-generic-password -s pi-sandbox -a anthropic-api-key |
Memory sync credentials (brain-<store>-token) | see memory.md | see memory.md |
| Everything in the agent state | — | docker volume rm pi-agent-state — deletes the login, all session history, trust decisions and installed packages (state.md) |
Remaining risk#
The kubeconfig, talosconfig, STACKIT, Hetzner Cloud and GitHub sign-ins are stored in the domain volume and readable by the agent in
every session of that domain, like auth.json; give those credentials only the rights the agent’s
tasks need.
An injected key is readable by the agent for the duration of the session (it is in the process environment). A stored login is readable by the agent in every session (it is in the agent state). Both are accepted in v1 and listed in security.md.
SSH host keys for every session#
pi-sandbox known-hosts add <host> makes git’s ssh in every session trust that host, with or
without push (submodules, other forges, self-hosted servers). It scans the host on the Mac, shows
the fingerprints and stores them in known_hosts.d/<host> only after you confirmed them; with
--fingerprint SHA256:… (from the server admin or the forge) it stores only matching keys and asks
nothing. A scan alone proves nothing: a man in the middle answers it too, so compare the
fingerprints with a source you trust. list and remove manage the entries.
Every start passes all entries as PI_KNOWN_HOSTS_B64 (public data, no secret). The entrypoint
merges them with the pinned keys of codeberg.org, github.com and gitlab.com into the session’s
known_hosts and sets GIT_SSH_COMMAND for strict checking (no ssh config, no agent unless push
sets one). Plain ssh typed by the agent uses only the image’s pinned keys. Changes apply to the
next session; entries are never trusted on first use.