Skip to content

Credentials

q15 holds your credentials in more than one place, and only one of them is the agent. Knowing which is which is the whole of this page.

There are two kinds:

  • The model provider key. The agent holds this one. It has to: it is the thing that calls the provider.
  • Everything else — the tokens for the hosts the agent reaches through the shell. Those live in q15-proxy, and the agent never reads them.

q15-proxy sits between q15-exec and the internet. Its job is credential injection: attaching the right secret to the right request at the network layer.

q15’s posture is broad access. The proxy is not an allow-list. Unmatched hosts pass through untouched, because the point is not to restrict where the agent goes; it is to make sure the agent never holds the credential it would need to impersonate you once it gets there. See Security for why that distinction is the design.

Mechanically:

  1. CONNECT. The HTTP client inside q15-exec asks the proxy to open a tunnel to the destination host.
  2. Selective interception. For hosts a rule matches, the proxy terminates TLS with a leaf certificate signed by its own CA, so it can read and rewrite the plaintext request. For hosts without a rule, it tunnels the connection and never sees inside it.
  3. Injection. Headers, basic-auth headers or placeholders are filled in from the secret store.
  4. Forward, and scrub. The request goes on to the real server with the credential attached, and the response comes back with credential-bearing headers (Authorization, Proxy-Authorization, X-Api-Key) stripped before the agent sees any of it.

The proxy generates its own CA (ECDSA, 365 days) on first start and keeps it in /var/lib/q15/proxy/. That certificate is published to the executor so its HTTP clients trust the proxy’s leaves; without it, every intercepted request would fail on a certificate error.

The policy lives at /etc/q15/proxy/policy.yaml.

proxy:
no_proxy:
- localhost
- 127.0.0.1
- ::1
- q15-proxy
- q15-exec
set_lowercase_proxy_env: true
secrets:
- github_token
rules:
- name: github-api
match_hosts:
- api.github.com
env:
- name: GH_TOKEN
secret: github_token
rules:
- github-api
in:
- header

Read it as three layers:

  • secrets names the aliases the policy may use. The alias is not the secret: github_token resolves from GITHUB_TOKEN or GITHUB_TOKEN_FILE in the proxy’s own environment, which is why nothing in this file is sensitive.
  • rules says which hosts are intercepted and how the request is rewritten: set_header with a {{ secret.alias }} template, set_basic_auth, or replace_placeholder for a value that has to appear in a query string or a path.
  • env is what a command in the shell actually sees. The executor gets GH_TOKEN, but its value is a placeholder, not the token. When a request to a matching host goes out, the proxy swaps the placeholder for the real value. You can print that environment variable in a command and learn nothing.

A policy with no rule for a host changes nothing for that host: the request goes out untouched, with whatever credentials the command itself sent.

The Compose deployment mounts one file per secret, read-only, and passes its path in an environment variable:

In the YAML On the host In the container
key_env: OLLAMA_API_KEY secrets/ollama_api_key OLLAMA_API_KEY_FILE=/run/q15-secrets/…
brave_api_key_env: BRAVE_API_KEY secrets/brave_api_key BRAVE_API_KEY_FILE=/run/q15-secrets/…

Every binary resolves a secret from NAME, and failing that from the file named by NAME_FILE; the file’s contents are trimmed, and an empty file is an error rather than an empty secret. That single rule is the whole contract, and it is why the templates are safe to commit and the real files are git-ignored.

in your deployment directory
ls deploy/compose/secrets/*.example # the eight templates
grep -n "_FILE" deploy/compose/docker-compose.image-first.yml # who reads which one

Copy an example to its real name, put one value in it, chmod 600 it, and recreate the container that reads it. Nothing reloads a secret in place.

The two credential stores, and the tools that write them

Section titled “The two credential stores, and the tools that write them”

One of these proves to a model provider that q15 is allowed to call it. The other proves to q15 that the browser on the other end is you. They are not substitutes, and neither is a password.

Store Created by Lives in Authenticates
auth.json q15-auth login on the host /etc/q15/auth/ in the agent q15 as a client of a model provider
The browser credential store q15-web auth enroll on the host q15_web_state (/var/lib/q15-web) You, as the owner of the browser client

q15-auth handles the OpenAI OAuth flow for openai-codex providers, and refreshes it in place:

Terminal window
q15-auth login --auth-path deploy/compose/auth/auth.json
q15-auth status --auth-path deploy/compose/auth/auth.json
q15-auth logout --auth-path deploy/compose/auth/auth.json

Providers configured with key_env never touch auth.json; their secret comes from the file contract above. Mount the auth/ directory, never the single file: the tool replaces auth.json atomically, and a file bind mount can go on pointing at the old inode.

The browser store is written by the web tier’s own CLI, over a private Unix socket:

Terminal window
podman compose exec q15-web /usr/local/bin/q15-web auth enroll 'Laptop'
podman compose exec q15-web /usr/local/bin/q15-web auth list
podman compose exec q15-web /usr/local/bin/q15-web auth revoke DEVICE_ID

See Chat for the ceremony, the constraints on acceptable authenticators, and what the store does at runtime.

  • An injected credential can be used. The agent cannot read the token, and it can still ask for api.github.com and get a real answer, with your permissions, into its transcript. Narrow the policy to the hosts and paths the agent genuinely needs.
  • Anything the agent fetches is in the transcript. A response body is content, and content is what the model reads.
  • The proxy is not an allow-list. Unmatched hosts pass through. If you want egress restricted, that is a host firewall rule, not a proxy feature.
  • The files themselves are readable by whoever can read them. On the host that means your user and root; in the container, the process that reads them. This is why they are files with modes rather than a service with an API.
  • Security for the threat model this fits into.
  • Chat for the browser credential store in detail.
  • Reference for the keys and the templates.