fix(roles): keep the provisioning secrets out of the exec command line

Five roles handed a credential to `container exec` as `-e NAME=<value>`, or
as a positional argument. An argv element reaches the docker client's command
line, the process list, and the task result's `cmd` field, which ansible prints
from -v upward. Local deploys run with MASK_CREDENTIALS_IN_LOGS disabled, so the
retained `no_log` does not mask them there.

Where the container already carries the value, the flag added exposure and
nothing else, so it is gone rather than rerouted. `container exec` runs in the
container's own environment, and sys-svc-container's compose base wires every
role's rendered env file as `env_file`:

  web-app-akaunting   ADMIN_EMAIL, ADMIN_PASSWORD     templates/env.j2:27-28
  web-app-baserow     SECRET_KEY, BASEROW_SECRET_KEY  templates/env.j2:8-9

The rest are values no container holds, so they travel through the task's
`environment` and a valueless `-e NAME`, which the docker client resolves from
its own environment: baserow's MCP_ENDPOINT_KEY and BOOTSTRAP_ADMIN_PASSWORD,
pretix's MCP_TOKEN, and home assistant's HA_PASSWORD, HA_SERVICE_PASSWORD and
HA_TOKEN. Every script already read these names through os.environ, so none of
them changed.

web-app-homeassistant loses tasks/utils/mcp/realign_owner.yml rather than
having its secret rerouted. It held the owner password as a positional of
`hass --script auth`, and it is unreachable: 00_core.yml:32 includes
utils/owner.yml before utils/mcp.yml at :35, owner.yml ends in a hard assert on
`homeassistant_owner.rc == 0`, and that register comes from the same
provisioning script with the same HA_USERNAME and HA_PASSWORD. A hub that
refused the owner password therefore fails the play before the MCP tasks run,
so the LOGIN_REFUSED branch that was this file's only caller cannot be taken.
tasks/utils/owner/realign.yml is the remaining realign path and passes its
arguments as JSON on standard input. The two LOGIN_REFUSED clauses in
mcp/provision.yml go with it, because `rc != 0` is what they now mean.

files/python/provision_mcp.py becomes provision.py: owner/provision.yml runs it
with HA_OWNER_ONLY=1, so it has provisioned the owner since that task existed
and the name covered half its job.

Two co-located defects in the same tasks, which cannot be split from the
transport change at file granularity:

web-app-akaunting matched `'CHANGED' in stdout`, which is also true of the
`UNCHANGED` the snippet prints when the password already matches, so the task
reported a change on every deploy. It now matches the whole output line.
`expand_argument_vars: false` guards the argv because one element carries the
PHP snippet: ansible's expandvars runs over every element, and a `$name` the
environment happens to hold would be substituted into the code. Measured as a
no-op on today's content, since expandvars leaves an unknown name alone.

web-app-pretix read its result from `stdout_lines[-1]`, which is IPython's exit
prompt. `pretix shell_scoped` runs the script through IPython, which executes an
indented block only once a dedented line follows, so the block that created the
token was left unrun when it ended the input. The script's last statement is now
a top-level print of a marker, which forces the block before it to execute, and
the task matches `(?m)^PRETIX_RESULT=CHANGED`. The end anchor is left off on
purpose: a trailing carriage return defeats `$`, and a literal backslash would
have to survive both YAML and Jinja.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-09 22:36:31 +02:00
1 parent 845a654957
commit 177255329f
13 files changed
+78 -119

No files matched your search

@@ -41,7 +41,7 @@ flowchart LR
| Guarantee | Enforced by |
| --- | --- |
| Only Assist-exposed entities are reachable | `ensure_mcp_entry` binds the MCP config entry to `llm_hass_api: ["assist"]` in [provision_mcp.py](../../roles/web-app-homeassistant/files/python/provision_mcp.py) |
| Only Assist-exposed entities are reachable | `ensure_mcp_entry` binds the MCP config entry to `llm_hass_api: ["assist"]` in [provision.py](../../roles/web-app-homeassistant/files/python/provision.py) |
| Mutating tools stay off | the MCP long-lived token belongs to a dedicated account pinned to Home Assistant's `system-read-only` group; `ensure_service_account` re-asserts `group_ids` through `config/auth/update` on every run, so an account promoted by hand is demoted again |
| The endpoint refuses an anonymous caller | [test-mcp-guest.js](../../roles/web-app-homeassistant/files/playwright/test-mcp-guest.js) |
| The stored bearer still authenticates | `tasks/utils/mcp/probe.yml`, which hard-fails the deploy when the hub rejects it |
+11 -7
View File
@@ -33,14 +33,18 @@
resolve_compose_cid: "{{ AKAUNTING_CONTAINER }}"
- name: "🔑 Akaunting | Realign the administrator password with the declared one"
ansible.builtin.shell: > # nocheck: container-exec-resolver AKAUNTING_LOCAL_CID resolved at delegate_to runtime
container exec
-e ADMIN_EMAIL={{ AKAUNTING_ADMIN_EMAIL | quote }}
-e ADMIN_PASSWORD={{ AKAUNTING_ADMIN_PASSWORD | quote }}
{{ AKAUNTING_LOCAL_CID | quote }}
php artisan tinker --execute={{ lookup('file', 'php/realign_admin_password.php') | quote }}
ansible.builtin.command:
expand_argument_vars: false
argv:
- container
- exec
- "{{ AKAUNTING_LOCAL_CID }}" # nocheck: container-exec-resolver AKAUNTING_LOCAL_CID resolved at delegate_to runtime
- php
- artisan
- tinker
- "--execute={{ lookup('file', 'php/realign_admin_password.php') }}"
register: akaunting_admin_password
changed_when: "'CHANGED' in akaunting_admin_password.stdout"
changed_when: "'CHANGED' in akaunting_admin_password.stdout_lines"
failed_when: >-
akaunting_admin_password.rc != 0
or 'FAILED' in akaunting_admin_password.stdout
+33 -19
View File
@@ -7,14 +7,18 @@
resolve_compose_cid: "{{ BASEROW_CONTAINER }}"
- name: "▶️ Run Baserow Django migrations before bootstrapping users"
ansible.builtin.shell:
cmd: >-
container exec -i
-e DJANGO_SETTINGS_MODULE={{ BASEROW_DJANGO_SETTINGS_MODULE | quote }}
-e SECRET_KEY={{ BASEROW_SECRET_KEY | quote }}
-e BASEROW_SECRET_KEY={{ BASEROW_SECRET_KEY | quote }}
{{ BASEROW_LOCAL_CID | quote }}
{{ BASEROW_PYTHON }} {{ BASEROW_MANAGE_PY }} locked_migrate --noinput
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "DJANGO_SETTINGS_MODULE={{ BASEROW_DJANGO_SETTINGS_MODULE }}"
- "{{ BASEROW_LOCAL_CID }}" # nocheck: container-exec-resolver BASEROW_LOCAL_CID resolved at delegate_to runtime
- "{{ BASEROW_PYTHON }}"
- "{{ BASEROW_MANAGE_PY }}"
- locked_migrate
- --noinput
register: baserow_migrate_result
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
changed_when: >
@@ -52,17 +56,27 @@
resolve_compose_cid: "{{ BASEROW_CONTAINER }}"
- name: "🎬 Bootstrap Baserow superuser '{{ BASEROW_BOOTSTRAP_ADMIN_USERNAME }}'"
ansible.builtin.shell:
cmd: >-
container exec -i
-e DJANGO_SETTINGS_MODULE={{ BASEROW_DJANGO_SETTINGS_MODULE | quote }}
-e SECRET_KEY={{ BASEROW_SECRET_KEY | quote }}
-e BASEROW_SECRET_KEY={{ BASEROW_SECRET_KEY | quote }}
-e BOOTSTRAP_ADMIN_USERNAME={{ BASEROW_BOOTSTRAP_ADMIN_USERNAME | quote }}
-e BOOTSTRAP_ADMIN_EMAIL={{ BASEROW_BOOTSTRAP_ADMIN_EMAIL | quote }}
-e BOOTSTRAP_ADMIN_PASSWORD={{ BASEROW_BOOTSTRAP_ADMIN_PASSWORD | quote }}
{{ BASEROW_LOCAL_CID | quote }}
{{ BASEROW_PYTHON }} {{ BASEROW_MANAGE_PY }} shell -c {{ lookup('file', role_path ~ '/files/python/bootstrap_admin.py') | quote }}
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "DJANGO_SETTINGS_MODULE={{ BASEROW_DJANGO_SETTINGS_MODULE }}"
- -e
- "BOOTSTRAP_ADMIN_USERNAME={{ BASEROW_BOOTSTRAP_ADMIN_USERNAME }}"
- -e
- "BOOTSTRAP_ADMIN_EMAIL={{ BASEROW_BOOTSTRAP_ADMIN_EMAIL }}"
- -e
- BOOTSTRAP_ADMIN_PASSWORD
- "{{ BASEROW_LOCAL_CID }}" # nocheck: container-exec-resolver BASEROW_LOCAL_CID resolved at delegate_to runtime
- "{{ BASEROW_PYTHON }}"
- "{{ BASEROW_MANAGE_PY }}"
- shell
- -c
- "{{ lookup('file', role_path ~ '/files/python/bootstrap_admin.py') }}"
environment:
BOOTSTRAP_ADMIN_PASSWORD: "{{ BASEROW_BOOTSTRAP_ADMIN_PASSWORD }}"
register: baserow_bootstrap_admin_result
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
changed_when: "'created' in baserow_bootstrap_admin_result.stdout"
+6 -6
View File
@@ -15,23 +15,21 @@
- -e
- "DJANGO_SETTINGS_MODULE={{ BASEROW_DJANGO_SETTINGS_MODULE }}"
- -e
- "SECRET_KEY={{ BASEROW_SECRET_KEY }}"
- -e
- "BASEROW_SECRET_KEY={{ BASEROW_SECRET_KEY }}"
- -e
- "MCP_OWNER_EMAIL={{ BASEROW_MCP_OWNER_EMAIL }}"
- -e
- "MCP_WORKSPACE_NAME={{ BASEROW_MCP_WORKSPACE_NAME }}"
- -e
- "MCP_ENDPOINT_NAME={{ BASEROW_MCP_ENDPOINT_NAME }}"
- -e
- "MCP_ENDPOINT_KEY={{ BASEROW_MCP_ENDPOINT_KEY }}"
- MCP_ENDPOINT_KEY
- "{{ BASEROW_LOCAL_CID }}" # nocheck: container-exec-resolver
- "{{ BASEROW_PYTHON }}"
- "{{ BASEROW_MANAGE_PY }}"
- shell
- -c
- "{{ lookup('file', 'python/provision_mcp.py') }}"
environment:
MCP_ENDPOINT_KEY: "{{ BASEROW_MCP_ENDPOINT_KEY }}"
register: baserow_mcp_provision
retries: 30
delay: 10
@@ -52,11 +50,13 @@
- -e
- "MCP_ENDPOINT_PATH={{ BASEROW_MCP_ENDPOINT_PATH }}"
- -e
- "MCP_ENDPOINT_KEY={{ BASEROW_MCP_ENDPOINT_KEY }}"
- MCP_ENDPOINT_KEY
- "{{ BASEROW_LOCAL_CID }}" # nocheck: container-exec-resolver
- "{{ BASEROW_PYTHON }}"
- "-"
stdin: "{{ lookup('file', 'python/probe_mcp_endpoint.py') }}"
environment:
MCP_ENDPOINT_KEY: "{{ BASEROW_MCP_ENDPOINT_KEY }}"
register: baserow_mcp_probe
retries: 30
delay: 10
+1 -1
View File
@@ -13,7 +13,7 @@ This role deploys Home Assistant as a container on its own canonical domain behi
- **Containerized hub:** Runs the official Home Assistant image on both the Docker Compose and Swarm stacks behind the reverse proxy.
- **Reverse-proxy trust:** Renders a `configuration.yaml` with `default_config`, `use_x_forwarded_for`, and the project-wide trusted-proxy CIDRs listed in `trusted_proxies`. In swarm the hub joins the proxy's own overlay, so the forwarded request arrives from that network rather than from the hub's, and trusting only the hub's subnet makes Home Assistant answer every proxied request with HTTP 400.
- **Persistent configuration:** Mounts `/config` as a named volume and hands it to the container backup service when volume backups are part of the deployment.
- **Owner provisioned on every deploy:** Once the hub answers, the deploy runs `files/python/provision_mcp.py` inside the container with `HA_OWNER_ONLY=1`. On a fresh hub the script creates the owner from the platform administrator's username and `credentials.administrator_password`, names the hub after the corporate design title when the design service is on, and closes the remaining onboarding steps. On every later deploy it signs in with the same credentials and reports no change. When the hub refuses them, the deploy writes the password into the hub's auth store with Home Assistant's `auth` script, restarts the hub and signs in again. Both calls receive their values on standard input.
- **Owner provisioned on every deploy:** Once the hub answers, the deploy runs `files/python/provision.py` inside the container with `HA_OWNER_ONLY=1`. On a fresh hub the script creates the owner from the platform administrator's username and `credentials.administrator_password`, names the hub after the corporate design title when the design service is on, and closes the remaining onboarding steps. On every later deploy it signs in with the same credentials and reports no change. When the hub refuses them, the deploy writes the password into the hub's auth store with Home Assistant's `auth` script, restarts the hub and signs in again. Both calls receive their values on standard input.
- **Closed onboarding wizard:** A Playwright spec asserts that every onboarding step is done and that `/onboarding.html` hands a visitor over to the sign-in page.
- **Corporate design:** With the design service on, `configuration.yaml` carries a theme whose variables point at the design tokens, and a start-up automation selects it as the default theme for light and dark mode. The sign-in page takes the same variables from the role stylesheet; logo, favicon and page title come from the design assets through the stylesheet and `templates/design.js.j2`. A theme or mode picked in the profile keeps its own palette, and the script mirrors the picked mode onto the design tokens.
- **MCP server contract:** Declares the built-in MCP server at `/api/mcp` over streamable HTTP as an internal, bearer-token-authenticated endpoint, and generates the token that MCP clients present. Adding the MCP Server integration itself stays a Home Assistant onboarding step.
-2
View File
@@ -1,6 +1,4 @@
# TODO
- MCP task secrets: `tasks/utils/mcp/provision.yml` and `tasks/utils/mcp/realign_owner.yml` hand `HA_PASSWORD` and `HA_SERVICE_PASSWORD` to `container exec` with `-e`, `realign_owner.yml` passes the owner password as an argument of `hass --script auth`, and `tasks/utils/mcp/probe.yml` passes `HA_TOKEN` with `-e`, so each value is part of the task command. Feed them on standard input the way `tasks/utils/owner/provision.yml` and `tasks/utils/owner/realign.yml` do.
- Script name: `files/python/provision_mcp.py` also provisions the owner since `tasks/utils/owner/provision.yml` runs it with `HA_OWNER_ONLY=1`. Rename it to a name that covers both uses.
- Product name: the sidebar title, the `alt` text of the sign-in logo, the web app manifest (`name`, icons) and the launch screen logo still show Home Assistant; the frontend offers no setting for them, and the theme and the sign-in stylesheet do not reach them.
- Sign-in stylesheet: `templates/style.css.j2` repeats the `--wa-color-*` assignments Home Assistant derives on `html`, because the sign-in page takes no theme. Compare the list with the frontend when the image version moves.
@@ -8,11 +8,13 @@
- -e
- "HA_PORT={{ HOMEASSISTANT_PORT_INTERNAL }}"
- -e
- "HA_TOKEN={{ homeassistant_mcp_token }}"
- HA_TOKEN
- "{{ HOMEASSISTANT_LOCAL_CID }}" # nocheck: container-exec-resolver
- python3
- "-"
stdin: "{{ lookup('file', 'python/probe_mcp_token.py') }}"
environment:
HA_TOKEN: "{{ homeassistant_mcp_token }}"
register: homeassistant_token_probe
retries: 30
delay: 10
@@ -10,13 +10,13 @@
- -e
- "HA_USERNAME={{ HOMEASSISTANT_ADMIN_USERNAME }}"
- -e
- "HA_PASSWORD={{ HOMEASSISTANT_ADMIN_PASSWORD }}"
- HA_PASSWORD
- -e
- "HA_TOKEN_CLIENT_NAME={{ HOMEASSISTANT_TOKEN_CLIENT_NAME }}"
- -e
- "HA_SERVICE_USERNAME={{ HOMEASSISTANT_MCP_ACCOUNT_USERNAME }}"
- -e
- "HA_SERVICE_PASSWORD={{ HOMEASSISTANT_MCP_ACCOUNT_PASSWORD }}"
- HA_SERVICE_PASSWORD
- -e
- "HA_SERVICE_NAME={{ HOMEASSISTANT_MCP_ACCOUNT_NAME }}"
- -e
@@ -24,27 +24,19 @@
- "{{ HOMEASSISTANT_LOCAL_CID }}" # nocheck: container-exec-resolver
- python3
- "-"
stdin: "{{ lookup('file', 'python/provision_mcp.py') }}"
stdin: "{{ lookup('file', 'python/provision.py') }}"
environment:
HA_PASSWORD: "{{ HOMEASSISTANT_ADMIN_PASSWORD }}"
HA_SERVICE_PASSWORD: "{{ HOMEASSISTANT_MCP_ACCOUNT_PASSWORD }}"
register: homeassistant_provision
retries: 30
delay: 10
until: homeassistant_provision.rc == 0 or (HOMEASSISTANT_LOGIN_REFUSED in homeassistant_provision.stderr)
failed_when:
- homeassistant_provision.rc != 0
- HOMEASSISTANT_LOGIN_REFUSED not in homeassistant_provision.stderr
until: homeassistant_provision.rc == 0
changed_when: "'CHANGED' in homeassistant_provision.stdout"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
delegate_to: "{{ HOMEASSISTANT_HOST_NODE }}"
run_once: true
- name: "🔑 Realign the owner password the hub still holds from its onboarding"
ansible.builtin.include_tasks:
file: realign_owner.yml
apply:
delegate_to: "{{ HOMEASSISTANT_HOST_NODE }}"
when: HOMEASSISTANT_LOGIN_REFUSED in homeassistant_provision.stderr
run_once: true
- name: "🔧 Take the minted token from the provisioning output"
ansible.builtin.set_fact:
homeassistant_mcp_token: "{{ homeassistant_provision.stdout_lines | last | trim }}"
@@ -1,59 +0,0 @@
---
- name: "🔑 Write the rotated owner password into the hub's auth store"
ansible.builtin.command:
argv:
- container
- exec
- -i
- "{{ HOMEASSISTANT_LOCAL_CID }}" # nocheck: container-exec-resolver
- hass
- --script
- auth
- --config
- "{{ HOMEASSISTANT_CONFIG_DIR }}"
- change_password
- "{{ HOMEASSISTANT_ADMIN_USERNAME }}"
- "{{ HOMEASSISTANT_ADMIN_PASSWORD }}"
changed_when: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🔄 Restart the hub so it reads the store it no longer has in memory"
ansible.builtin.command:
argv:
- container
- restart
- "{{ HOMEASSISTANT_LOCAL_CID }}" # nocheck: container-exec-resolver
changed_when: true
- name: "🔑 Mint the long-lived token now that the hub accepts the rotated password"
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "HA_PORT={{ HOMEASSISTANT_PORT_INTERNAL }}"
- -e
- "HA_USERNAME={{ HOMEASSISTANT_ADMIN_USERNAME }}"
- -e
- "HA_PASSWORD={{ HOMEASSISTANT_ADMIN_PASSWORD }}"
- -e
- "HA_TOKEN_CLIENT_NAME={{ HOMEASSISTANT_TOKEN_CLIENT_NAME }}"
- -e
- "HA_SERVICE_USERNAME={{ HOMEASSISTANT_MCP_ACCOUNT_USERNAME }}"
- -e
- "HA_SERVICE_PASSWORD={{ HOMEASSISTANT_MCP_ACCOUNT_PASSWORD }}"
- -e
- "HA_SERVICE_NAME={{ HOMEASSISTANT_MCP_ACCOUNT_NAME }}"
- -e
- "HA_SERVICE_GROUP={{ HOMEASSISTANT_MCP_ACCOUNT_GROUP }}"
- "{{ HOMEASSISTANT_LOCAL_CID }}" # nocheck: container-exec-resolver
- python3
- "-"
stdin: "{{ lookup('file', 'python/provision_mcp.py') }}"
register: homeassistant_provision
retries: 30
delay: 10
until: homeassistant_provision.rc == 0
changed_when: "'CHANGED' in homeassistant_provision.stdout"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -15,7 +15,7 @@
os.environ.update(
{name: str(value) for name, value in json.loads({{ HOMEASSISTANT_OWNER_ENVIRONMENT | to_json | to_json }}).items()}
)
{{ lookup('file', 'python/provision_mcp.py') }}
{{ lookup('file', 'python/provision.py') }}
register: homeassistant_owner
retries: 30
delay: 10
@@ -18,9 +18,17 @@ a single organizer chosen deterministically by slug. With no organizer there is
nothing to attach it to, and no tool call could name one either, so that case
converges to a no-op instead of failing.
``shell_scoped`` feeds this to IPython, which executes an indented block only
once a dedented line follows it. A block that ends the input is left unrun, and
the last line of the output is IPython's exit prompt rather than a result, so
the final statement is a top-level print of a marker the task matches on.
Environment:
MCP_TEAM: team name to converge.
MCP_TOKEN: token value to pin on it.
Output:
PRETIX_RESULT=CHANGED or PRETIX_RESULT=UNCHANGED on its own line.
"""
import os
@@ -35,9 +43,7 @@ TOKEN_NAME = "mcp-upstream" # noqa: S105 a label, not a secret
changed = False
organizer = Organizer.objects.order_by("slug").first()
if organizer is None:
print("UNCHANGED")
else:
if organizer is not None:
team, created = Team.objects.get_or_create(
organizer=organizer,
name=TEAM,
@@ -58,4 +64,4 @@ else:
)
changed = changed or created
print("CHANGED" if changed else "UNCHANGED")
print("PRETIX_RESULT=CHANGED" if changed else "PRETIX_RESULT=UNCHANGED")
+5 -3
View File
@@ -21,17 +21,19 @@
- -e
- "MCP_TEAM={{ PRETIX_MCP_UPSTREAM_TEAM }}"
- -e
- "MCP_TOKEN={{ PRETIX_MCP_UPSTREAM_KEY }}"
- MCP_TOKEN
- "{{ PRETIX_IDENTITY_LOCAL_CID }}" # nocheck: container-exec-resolver
- pretix
- shell_scoped
- --override
stdin: "{{ lookup('file', 'python/provision_mcp_identity.py') }}"
environment:
MCP_TOKEN: "{{ PRETIX_MCP_UPSTREAM_KEY }}"
register: pretix_mcp_identity
retries: 30
delay: 10
until: pretix_mcp_identity.rc == 0
changed_when: pretix_mcp_identity.stdout_lines[-1] | default('') == 'CHANGED'
until: "pretix_mcp_identity.rc == 0 and (pretix_mcp_identity.stdout is search('(?m)^PRETIX_RESULT=(UN)?CHANGED'))"
changed_when: "pretix_mcp_identity.stdout is search('(?m)^PRETIX_RESULT=CHANGED')"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
delegate_to: "{{ PRETIX_IDENTITY_HOST_NODE }}"
run_once: true