diff --git a/ansible/playbooks/provision-agent-host.yml b/ansible/playbooks/provision-agent-host.yml index c6c59d2..2d017b2 100644 --- a/ansible/playbooks/provision-agent-host.yml +++ b/ansible/playbooks/provision-agent-host.yml @@ -56,11 +56,12 @@ # Environment variable on the control node: # multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}" - # 1Password service account token for `op`. Off until the service - # account exists and the delivery design is agreed (WDWL-37). Same shape as - # the PAT: a per-host dictionary in group_vars/all/vault. - # multica_op_deliver_token: true - # multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}" + # 1Password service account token for `op` (WDWL-37). One shared token, + # vault_op_service_account_claude in group_vars/all/vault, scoped by + # 1Password to the agents' vault. The role writes it to a 0600 file and + # installs an `op` shim that reads it; it never reaches the daemon unit. + multica_op_deliver_token: true + multica_op_service_account_token: "{{ vault_op_service_account_claude }}" # The role's own PAT assert is no_log, so a missing dictionary entry would # surface there only as "censored". This checks key presence, never the diff --git a/ansible/roles/multica_agent_host/README.md b/ansible/roles/multica_agent_host/README.md index 06f20f6..5c7a5c8 100644 --- a/ansible/roles/multica_agent_host/README.md +++ b/ansible/roles/multica_agent_host/README.md @@ -38,7 +38,7 @@ otherwise clean at the `production` profile. | `multica` CLI, pinned, from the GitHub release with `checksums.txt` verification | yes | | Agent CLIs (`claude`, `hermes`, …) | yes | | 1Password CLI (`op`), from 1Password's signed apt/yum repository | yes (`multica_op_install`) | -| 1Password service account token, as a 0600 file for the daemon user | opt-in (`multica_op_deliver_token`); not exported to the daemon | +| 1Password service account token, as a 0600 file for the daemon user, plus an `op` shim that reads it | opt-in (`multica_op_deliver_token`); not exported to the daemon | | `workspaces_root`, on the intended mount, writable by the daemon user | yes | | Multica authentication (`multica login --token`) | yes | | CLI config (`server_url`, `app_url`, `workspaces_root`, `device_name`, …) | yes | @@ -241,6 +241,7 @@ See `defaults/main.yml` for the annotated list. The ones you will actually set: | `multica_op_install` | `true` | 1Password CLI from 1Password's apt/yum repo. | | `multica_op_version` | `""` | `""` tracks the repo (upgrades on every run); set e.g. `2.41.1` to pin. | | `multica_op_deliver_token` | `false` | Write `multica_op_service_account_token` to `multica_op_token_file`. | +| `multica_op_shim` | `true` | With the token delivered, install the `op` shim at `multica_op_shim_path`. | ### Adding an agent CLI @@ -399,13 +400,13 @@ The role can deliver the token, but by default does not: ```yaml multica_op_deliver_token: true -multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}" +multica_op_service_account_token: "{{ vault_op_service_account_claude }}" ``` writes it to `multica_op_token_file` (`~multica/.config/op-service-account/token`, directory 0700, file 0600, -owned by the daemon user). That is all it does. **The role does not put the -token into the daemon's environment**, for two reasons: +owned by the daemon user) and installs an `op` shim. **The role does not put +the token into the daemon's environment**, for two reasons: 1. The daemon's systemd user unit is written and owned by the multica CLI (`multica daemon autostart`). The role must not template or edit it, or it @@ -413,16 +414,35 @@ token into the daemon's environment**, for two reasons: 2. Anything in the daemon's environment is inherited by every agent process it spawns, where a casual `env` or `printenv` puts it in a transcript. -How the token reaches `op` is a separate decision, not yet made. Until then -an agent can use it explicitly: +`op` reads the token only from `OP_SERVICE_ACCOUNT_TOKEN`; it has no +token-file option. So the role installs a shim at `multica_op_shim_path` +(`~multica/.local/bin/op`, root-owned 0755), which the CLI-written unit +already puts first on `PATH`. The shim: + +- reads the token file and exports `OP_SERVICE_ACCOUNT_TOKEN` to + `multica_op_binary` (`/usr/bin/op`) only, then `exec`s it. Agents call plain + `op run` / `op inject` / `op read` and the token never enters their shell; +- fails closed (exit 78, no token in the message) when the file is missing + or empty; +- for `op run` and `op plugin run`, splices `/usr/bin/env -u + OP_SERVICE_ACCOUNT_TOKEN` in after the first `--`. `op run` otherwise + hands its whole environment, token included, to the command it starts. + Because it needs that `--` to find the command, the shim refuses + `op run ` without one (exit 64). + +The play checks, as the daemon user, that `op` resolves to the shim and that +the token is not in that environment. + +The shim keeps the token out of transcripts by accident; it is not a +boundary. Any process running as the daemon user can read the token file on +purpose. What actually limits exposure is the service account's vault access +in 1Password: keep it read-only. Check it as the daemon user: ```bash -OP_SERVICE_ACCOUNT_TOKEN="$(cat ~/.config/op-service-account/token)" op run -- +op vault list --permission read_items # vaults it can read +op vault list --permission edit_items # should be empty ``` -`op` reads the token only from `OP_SERVICE_ACCOUNT_TOKEN`; it has no -token-file option. - ## Known gaps - **Provider CLI login.** Unavoidable for subscription auth. @@ -444,7 +464,7 @@ token-file option. | `multica_auth` | `multica login` | | `multica_daemon` | autostart + start | | `multica_verify` | the assertions | -| `multica_op` | 1Password CLI, and the token file when enabled | +| `multica_op` | 1Password CLI, and the token file and shim when enabled | | `multica` | all of the above | Preflight and the service-account step are tagged `always`: they build diff --git a/ansible/roles/multica_agent_host/defaults/main.yml b/ansible/roles/multica_agent_host/defaults/main.yml index 3cfdd77..1b52554 100644 --- a/ansible/roles/multica_agent_host/defaults/main.yml +++ b/ansible/roles/multica_agent_host/defaults/main.yml @@ -158,13 +158,18 @@ multica_op_apt_base_url: "https://downloads.1password.com/linux/debian" multica_op_rpm_base_url: "https://downloads.1password.com/linux/rpm/stable" # Service account token delivery. Off by default. When on, the token is written -# to multica_op_token_file (0600, owned by the daemon user) and nowhere else. -# The role does NOT export it into the daemon environment; see the README. -# Supply the VALUE from the playbook, like multica_pat, e.g. -# multica_op_service_account_token: "{{ vault_op_service_account_tokens[inventory_hostname_short] }}" +# to multica_op_token_file (0600, owned by the daemon user) and read from there +# by the op shim below. The role does NOT export it into the daemon +# environment; see the README. Supply the VALUE from the playbook, e.g. +# multica_op_service_account_token: "{{ vault_op_service_account_claude }}" multica_op_deliver_token: false multica_op_service_account_token: "" multica_op_token_file: "{{ multica_user_home }}/.config/op-service-account/token" +# With the token delivered, install a shim at multica_op_shim_path (first on +# the daemon unit's PATH) that exports the token to multica_op_binary only. +multica_op_shim: true +multica_op_shim_path: "{{ multica_user_home }}/.local/bin/op" +multica_op_binary: /usr/bin/op # ----------------------------------------------------------------------------- # Node.js (installed only when a selected agent CLI needs npm) diff --git a/ansible/roles/multica_agent_host/tasks/op_token.yml b/ansible/roles/multica_agent_host/tasks/op_token.yml index 2839430..039a39d 100644 --- a/ansible/roles/multica_agent_host/tasks/op_token.yml +++ b/ansible/roles/multica_agent_host/tasks/op_token.yml @@ -35,3 +35,47 @@ mode: "0600" no_log: true become: true + +# --- op shim ----------------------------------------------------------------- +# `op` reads the token only from OP_SERVICE_ACCOUNT_TOKEN. The shim, first on +# the daemon unit's PATH, exports it for the real binary alone, so agents call +# plain `op` and the token never enters their shell environment. The daemon +# unit itself stays the CLI's (WDWL-18). + +- name: 1Password token | Create the daemon user's bin directory + ansible.builtin.file: + path: "{{ multica_op_shim_path | dirname }}" + state: directory + owner: "{{ multica_user }}" + group: "{{ multica_group }}" + mode: "0755" + become: true + when: multica_op_shim | bool + +- name: 1Password token | Install the op shim + ansible.builtin.template: + src: op-shim.sh.j2 + dest: "{{ multica_op_shim_path }}" + owner: root + group: root + mode: "0755" + become: true + when: multica_op_shim | bool + +# As the daemon user, through its own environment: plain `op` must resolve to +# the shim, and the token must not be in that environment. +- name: 1Password token | Check op resolves to the shim as the daemon user + ansible.builtin.shell: + cmd: 'command -v op; env | grep -c "^OP_SERVICE_ACCOUNT_TOKEN=" || true' + become: true + become_user: "{{ multica_user }}" + environment: "{{ _multica_user_env }}" + register: _multica_op_shim_check + changed_when: false + check_mode: false + failed_when: >- + _multica_op_shim_check.rc != 0 + or _multica_op_shim_check.stdout_lines != [multica_op_shim_path, '0'] + when: + - multica_op_shim | bool + - not ansible_check_mode diff --git a/ansible/roles/multica_agent_host/templates/op-shim.sh.j2 b/ansible/roles/multica_agent_host/templates/op-shim.sh.j2 new file mode 100644 index 0000000..5a87267 --- /dev/null +++ b/ansible/roles/multica_agent_host/templates/op-shim.sh.j2 @@ -0,0 +1,55 @@ +#!/bin/sh +# {{ ansible_managed }} +# +# 1Password CLI shim for the Multica daemon user (multica_agent_host role, +# WDWL-37). Reads the service account token from a 0600 file and exports it +# to {{ multica_op_binary }} only, so it never sits in an agent's shell +# environment. +token_file="{{ multica_op_token_file }}" + +if [ ! -r "$token_file" ]; then + echo "op: no 1Password service account token at $token_file" >&2 + exit 78 +fi +IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < "$token_file" +if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then + echo "op: 1Password service account token file $token_file is empty" >&2 + exit 78 +fi +export OP_SERVICE_ACCOUNT_TOKEN + +# `op run` (and `op plugin run`) hand their own environment, token included, +# to the command they start. Find the subcommand, skipping global flags that +# take a value, and stop at the first `--`. +sub1=""; sub2=""; skip=0; help=0 +for a in "$@"; do + if [ "$skip" = 1 ]; then skip=0; continue; fi + case "$a" in + --) break ;; + -h|--help) help=1 ;; + --account|--config|--encoding|--format|--session) skip=1 ;; + -*) ;; + *) if [ -z "$sub1" ]; then sub1=$a; elif [ -z "$sub2" ]; then sub2=$a; fi ;; + esac +done + +if [ "$sub1" = run ] || { [ "$sub1" = plugin ] && [ "$sub2" = run ]; }; then + # Rebuild the arguments with `env -u` spliced in after the first `--`, so + # the child starts without the token. + n=$#; i=0; spliced=0 + while [ "$i" -lt "$n" ]; do + a=$1; shift + set -- "$@" "$a" + if [ "$spliced" = 0 ] && [ "$a" = "--" ]; then + set -- "$@" /usr/bin/env -u OP_SERVICE_ACCOUNT_TOKEN + spliced=1 + fi + i=$((i + 1)) + done + if [ "$spliced" = 0 ] && [ "$help" = 0 ]; then + echo "op: use 'op run [flags] -- '; the shim needs the '--' to keep the service account token out of " >&2 + exit 64 + fi +fi + +exec "{{ multica_op_binary }}" "$@"