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.
The proxy
Section titled “The proxy”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:
- CONNECT. The HTTP client inside
q15-execasks the proxy to open a tunnel to the destination host. - 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.
- Injection. Headers, basic-auth headers or placeholders are filled in from the secret store.
- 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 file
Section titled “The policy file”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: - headerRead it as three layers:
secretsnames the aliases the policy may use. The alias is not the secret:github_tokenresolves fromGITHUB_TOKENorGITHUB_TOKEN_FILEin the proxy’s own environment, which is why nothing in this file is sensitive.rulessays which hosts are intercepted and how the request is rewritten:set_headerwith a{{ secret.alias }}template,set_basic_auth, orreplace_placeholderfor a value that has to appear in a query string or a path.envis what a command in the shell actually sees. The executor getsGH_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.
Where the secret files go
Section titled “Where the secret files go”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.
ls deploy/compose/secrets/*.example # the eight templatesgrep -n "_FILE" deploy/compose/docker-compose.image-first.yml # who reads which oneCopy 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:
q15-auth login --auth-path deploy/compose/auth/auth.jsonq15-auth status --auth-path deploy/compose/auth/auth.jsonq15-auth logout --auth-path deploy/compose/auth/auth.jsonProviders 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:
podman compose exec q15-web /usr/local/bin/q15-web auth enroll 'Laptop'podman compose exec q15-web /usr/local/bin/q15-web auth listpodman compose exec q15-web /usr/local/bin/q15-web auth revoke DEVICE_IDSee Chat for the ceremony, the constraints on acceptable authenticators, and what the store does at runtime.
What none of this protects
Section titled “What none of this protects”- An injected credential can be used. The agent cannot read the token, and it can still ask for
api.github.comand 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.