From 071ca293e84e64264911e1ee37a1974f2760d86a Mon Sep 17 00:00:00 2001 From: p4nda <4882246+p4nda@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:12:55 +0200 Subject: [PATCH 01/50] docs(readme): import the signing key before installing dagnode-release The dagnode-release bootstrap RPM is signed by the org key, so dnf verifies its signature at install time; on a fresh host without the key the package install fails the signature check, since the package that installs the key has not run yet. Add the `rpm --import` step ahead of the install, point readers at the repository README for the out-of-band fingerprint check, and wrap the long install URLs across lines so they don't scroll off-screen. Co-Authored-By: Claude Opus 4.8 --- README.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a3691202..f2dc47bc 100644 --- a/README.md +++ b/README.md @@ -43,14 +43,23 @@ Agent Tools Restricted runs autonomous coding agents under a dedicated, unprivil ## Package install -Two commands. The first installs the dag-node release package, which brings the signed DNF -repository definition and the org signing key with it -([source](https://github.com/dag-node/rpm-dagnode-release)); the second pulls the stack. One +Import the org signing key, then install the dag-node release package and the stack. The release +package is signed by the org key, so `dnf` verifies its signature at install time — importing the +key first satisfies that check, since the package that would otherwise install the key has not run +yet. The release package brings the signed DNF repository definition and the key with it +([source](https://github.com/dag-node/rpm-dagnode-release)); the last command pulls the stack. One repository serves EL 9 and EL 10, and both the packages and the repository metadata are -signature-verified. +signature-verified. Verify the key fingerprint out of band before importing — see the +[repository README](https://github.com/dag-node/rpm/blob/main/README.md#signing-key). ```bash -sudo dnf install https://rpm.dagnode.com/dagnode-release-latest.noarch.rpm +# Import the org signing key (verify its fingerprint out of band first — see the README above) +sudo rpm --import \ + https://rpm.dagnode.com/RPM-GPG-KEY-dag-node + +# Install the release package (repo definition + key), then the stack +sudo dnf install \ + https://rpm.dagnode.com/dagnode-release-latest.noarch.rpm sudo dnf install ai-tools ai-tools-selinux # the whole stack + SELinux confinement ``` From 27aae256820f289f6c7c885a8acdf86b5b47b51f Mon Sep 17 00:00:00 2001 From: p4nda <4882246+p4nda@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:53:11 +0200 Subject: [PATCH 02/50] fix(bootstrap): stop capturing .nvm as an embedded git repo in the control plane The control-plane snapshot's ignore file re-includes every dot-directory so any agent's config plane is captured generically. .nvm is the one dot-directory that is itself a git clone (nvm installs Node from git), so admitting its directory entry made `git add -A` record it as an embedded-repo gitlink and emit "warning: adding embedded git repository: .nvm" on every fresh bootstrap. The gitlink captures only a stray SHA, never .nvm's contents, so it is pure noise. Exclude .nvm outright -- it holds no durable control-plane asset. Other dot-directories (.npm, .cache, .local) carry no nested .git and are unaffected. Co-Authored-By: Claude Opus 4.8 --- src/usr/share/ai-tools/gitignore | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/usr/share/ai-tools/gitignore b/src/usr/share/ai-tools/gitignore index b23dddbe..828671a7 100644 --- a/src/usr/share/ai-tools/gitignore +++ b/src/usr/share/ai-tools/gitignore @@ -24,11 +24,15 @@ # Each agent owns a config directory under this home whose NAME its manifest declares # (.claude for Claude Code), so these patterns match ANY dot-directory rather than one name -- # a second agent's guardrails are captured without editing this base-owned file. The other -# dot-directories here (.nvm, .npm, .cache, .local) hold no file matching the re-includes -# below, and git tracks files rather than directories, so admitting their directory entries -# costs nothing. +# dot-directories here (.npm, .cache, .local) hold no file matching the re-includes below, and +# git tracks files rather than directories, so admitting their directory entries costs nothing. +# .nvm is the one exception: nvm installs Node from a git clone, so it carries a nested .git, +# and admitting its directory entry would make `git add` record it as an embedded-repo gitlink +# (a spurious submodule pointer) with a warning. It holds no durable control-plane asset, so it +# is excluded outright below. !/.*/ /.*/* +/.nvm/ # Agent-authored guardrails (root:ai-tools, not agent-writable) !/.*/settings.json !/.*/*-hook.sh From a890da1ee703ca8720fc31c5cb28792a389a53c8 Mon Sep 17 00:00:00 2001 From: p4nda <4882246+p4nda@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:53:11 +0200 Subject: [PATCH 03/50] fix(packaging): start ai-tools-relabel.path on fresh install The nodejs subpackage enabled the relabel watcher via %systemd_post but never started it, unlike ai-tools-base which starts the handback socket in its %posttrans. %systemd_post applies the preset (enable) but does not activate a .path unit, so on a fresh install without a reboot the watcher showed DOWN in `ai-tools --status` and a Node auto-upgrade that repointed the launcher went unwatched -- leaving the next launch to fail-close on a bin_t entrypoint. Add a %posttrans that starts the unit, mirroring the socket's start, so the watcher is live without a reboot. Guarded and idempotent. Co-Authored-By: Claude Opus 4.8 --- .claude/rules/updater.rule.md | 7 +++++-- packaging/ai-tools.spec | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/.claude/rules/updater.rule.md b/.claude/rules/updater.rule.md index 6153f3e1..686c9171 100644 --- a/.claude/rules/updater.rule.md +++ b/.claude/rules/updater.rule.md @@ -243,8 +243,11 @@ run it after an upgrade, both as root, never `SANDBOX_USER`: `ai-tools --relabel`. The watcher is **enabled by default** on install through the shipped systemd preset — `%systemd_post ai-tools-relabel.path` applies `85-ai-tools.preset`, which lists it beside the handback socket; without that explicit line the distribution's `disable *` default - would leave `%systemd_post` a no-op (the same enablement the socket needs) — and it is restarted - across upgrades (`%postun_with_restart`), so it needs no manual bootstrap. Should it be down + would leave `%systemd_post` a no-op (the same enablement the socket needs). Enabling a `.path` + unit does not start it, so the `ai-tools-integration-nodejs` `%posttrans` starts it — the twin of + `ai-tools-base`'s `%posttrans` starting the handback socket — making the watcher live on a fresh + install without a reboot; it is also restarted across upgrades (`%postun_with_restart`), so it + needs no manual bootstrap. Should it be down anyway, `services.lib.sh` surfaces it before the next Node bump would fail-close a launch on a mislabelled entrypoint: proactively at launch (`claude.sh` warns, warn-not-block, from the same registry) and in `ai-tools --status` (see [cli](cli.rule.md)). diff --git a/packaging/ai-tools.spec b/packaging/ai-tools.spec index c54afdd6..8fa5d1c5 100644 --- a/packaging/ai-tools.spec +++ b/packaging/ai-tools.spec @@ -669,6 +669,16 @@ fi %postun -n ai-tools-integration-nodejs %systemd_postun_with_restart ai-tools-relabel.path +%posttrans -n ai-tools-integration-nodejs +# Start the relabel watcher so it is live without a reboot -- the twin of ai-tools-base's +# posttrans starting the handback socket. The nodejs post scriptlet only ENABLES the unit +# (applies the preset); a .path unit must be started to begin watching, and until it does a Node +# auto-upgrade that repoints the launcher goes unwatched and the next launch fail-closes on a +# bin_t entrypoint. posttrans runs after the systemd daemon-reload file trigger, so the unit is +# known. Idempotent; guarded so a systemd-less build/image fails soft. +# (No macro names in this comment: rpm expands macros inside scriptlet comments too.) +systemctl start ai-tools-relabel.path 2>/dev/null || : + %post -n ai-tools-integration-dotnet # Create + SELinux-label the sandbox-side dotnet dirs (writable NuGet cache, read-only shared # tools) the session-env fragment relies on. Offline + idempotent; the helper recognizes a host From a4a4423f75facbe5291162f7c5217d3a43274653 Mon Sep 17 00:00:00 2001 From: p4nda <4882246+p4nda@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:22:58 +0200 Subject: [PATCH 04/50] feat(cli): claim projects for another operator with --for A service account that runs a coding agent has no password, so it cannot authenticate the no-NOPASSWD root helpers a claim invokes -- and a claim performed by a human registers the project in the HUMAN's allowed-projects, which is not the file that account's launch wrapper gates on. Such an account could therefore never be given a project: it hit a sudo password prompt it had no way to answer. `ai-tools --for ` performs the command on behalf of another enrolled operator, so the allowlist entry lands in THEIR registry. Everything downstream already keys off which operator's allowlist covers a path, so nothing else had to change: ai-tools-setfacl grants user:, the ownership handback restores to them, and their next launch finds the project claimed. The human claims once, with their own password; the account never meets a prompt. Accepted on --project-claim/-create, --project-unclaim/-remove, --lockdown, --reclaim and --list -- the verbs whose effect is decided by allowlist coverage. Refused elsewhere rather than ignored, since a --sandbox-create --for that silently cloned as the invoker would leave the tree owned by the wrong operator with nothing to show the flag was dropped. Also refused with --project-unclaim --force: that mode reaches a tree no allowlist names, so ai-tools-unclaim binds the walk to the invoking uid precisely so one operator cannot rewrite another's files, and --for must not reach past it. The new ai-tools-allowlist root helper backs the flag. Root is needed for the READ as much as the write -- an allowlist is 0600 inside a 0700 .config/ai-tools, so one operator cannot see another's at all -- hence a --print verb the CLI snapshots for its own decisions, with the mutations routed back through the helper against the real file. It carries no NOPASSWD grant, like the other project helpers, authorizes against the uid sudo sets rather than the spoofable SUDO_USER name, and refuses a bare root call, an unenrolled caller or target, the sandbox account, and a protected system directory -- leaving the registry byte-identical on every refusal. The flag widens one thing and the docs say so: an allowlist is an operator's own launch gate, and this lets one operator write into another's. That sits inside the standing "ai-ops operators are trusted" boundary, and every mutation is logged with both caller and target. Covered from both ends, per the project's paired-test rule: the helper's gates are driven into firing in tests/unit/allowlist-helper.sh, and tests/boundary /access.sh asserts as the agent that neither the helper nor any operator's allowed-projects is reachable. Co-Authored-By: Claude Opus 4.8 --- .claude/rules/cli.rule.md | 70 ++++- CLAUDE.md | 8 +- install.sh | 6 + packaging/ai-tools.spec | 3 +- src/usr/local/bin/ai-tools.sh | 212 ++++++++++++-- .../libexec/ai-tools/ai-tools-allowlist.sh | 260 ++++++++++++++++++ src/usr/local/share/man/man1/ai-tools.1 | 33 +++ tests/boundary/access.sh | 29 ++ tests/integration/cli.sh | 70 +++++ tests/integration/perms.sh | 1 + tests/unit/allowlist-helper.sh | 165 +++++++++++ 11 files changed, 830 insertions(+), 27 deletions(-) create mode 100755 src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh create mode 100644 tests/unit/allowlist-helper.sh diff --git a/.claude/rules/cli.rule.md b/.claude/rules/cli.rule.md index 408184db..e9367242 100644 --- a/.claude/rules/cli.rule.md +++ b/.claude/rules/cli.rule.md @@ -48,6 +48,9 @@ wrapper needs and which does require a fresh login). The **informational** comma (`--help`/`--version`/`--list`/`--providers`) stay open, so an unenrolled user can still read usage and inspect the host. +A third gate, `require_for_target`, runs immediately after it and validates a `--for` run (see +*Acting for another operator* below). It is a no-op without the flag. + ## Commands - `--project-claim [path]` (alias `--project-create`) — claim a real project in place @@ -220,6 +223,8 @@ advancing surfaces. The account's own in-place rewrite), and closes with a compact **Maintenance** pointer to the per-project verbs. Informational, so it stays open to a non-operator. - `--version` (the deploy-stamped package version; `dev` from a raw source tree), `--help`. +- `--for ` — a **modifier**, not a command: run the verb on behalf of another enrolled + operator (see *Acting for another operator* below). The CLI ships a man page, `ai-tools(1)` (`src/usr/local/share/man/man1/ai-tools.1` → `/usr/local/share/man/man1/`, deployed by @@ -230,6 +235,61 @@ It is hand-written troff — the CLI cannot be executed at package-build time fo match in both directions, so adding, renaming, or removing a CLI option obligates the same change in the page or the suite fails. +## Acting for another operator (`--for`) + +`--for ` performs a command **on behalf of** another enrolled operator: the allowlist +entry lands in *their* `~/.config/ai-tools/allowed-projects`, so `ai-tools-setfacl` grants +`user:`, the ownership handback restores to them, and their agent's launch gate covers the +path. It exists for a **service account that runs an agent but holds no password**: such an account +cannot authenticate the claim's own no-NOPASSWD root helpers, and a claim performed by a human +would otherwise register the project in the *human's* registry — not the one that account's launch +wrapper reads. A human operator claims once with `--for`, and that account's session then finds the +project fully claimed and never reaches a password prompt. + +The flag is separated from the verb's own arguments **before dispatch**, so every command reads one +already-decided owner rather than each parsing it. Two globals carry the result: `OWNER_USER` / +`OWNER_GROUP` name the operator the run acts for (the target, or the invoker), and every message +that names the owner a file ends up with — and every scan that matches on that owner +(`acl_drift_scan`, `grantable_ancestor`, the hand-back prompt's default) — reads them rather than +the invoking user. What a *root helper's* walk treats as the operator is still resolved per path +from that path's allowlist coverage (`operator.lib.sh`), never from either global. + +**The target's registry is unreadable to the invoker.** An allowlist is `0600` inside a `0700` +`.config/ai-tools` (seeded that way by `ai-tools-admin`), so one operator cannot read another's at +all — and every decision the CLI makes from it (is the path listed, which `!` exclusions apply, what +`--list` reports) would read an unreadable file as an empty one. A `--for` run therefore takes a +root-side **snapshot** through `ai-tools-allowlist --print` into a `0600` temp file removed on exit, +and points `ALLOWLIST` at it for reads. The snapshot is read-only input for that run: mutations go +back through the helper, which re-reads the real file and applies its own idempotency, and +`reg_allow`/`unreg_allow` refresh the snapshot after theirs — so a stale copy is never what a write +is based on. + +`require_for_target` gates the run, after `require_operator` (acting for another operator is itself +an operator action, so the invoker must be enrolled before the target is looked up). It accepts the +flag only on the verbs whose whole effect is decided by *which* operator's allowlist covers the +path — `--project-claim`/`-create`, `--project-unclaim`/`-remove`, `--lockdown`, `--reclaim`, +`--list` — and **refuses it elsewhere rather than ignoring it**: a `--sandbox-create --for` that +silently cloned as the invoker would leave the tree owned by the wrong operator with nothing to +show the flag was disregarded. The target must be **enrolled in `OPERATORS`**, since the ownership +helpers resolve a path's owner over that list and an entry written for an unenrolled name would be +a launch gate nothing can act on; the sandbox account and `root` are refused outright. + +`--for` is **refused with `--project-unclaim --force`**. That mode reaches a tree no allowlist +names, so `ai-tools-unclaim` cannot resolve an owner from an entry and binds the walk to the +**invoking uid** instead — the guard that stops one operator rewriting another's files. Honouring +`--for` there would have the CLI name one operator while the helper acted as another. + +Sandbox clones stay invoker-only: `--sandbox-create` clones as the invoking user with that user's +git credentials, so pointing it at another owner is more than a registry redirect and is not +attempted here. + +**What this widens, stated plainly.** An allowlist is an operator's own launch gate, and `--for` +lets one operator write into another's. That sits inside the model's standing "`ai-ops` operators +are trusted" boundary — an operator could already claim the project themselves — but it is a real +change in who curates a gate, so every mutation is logged with both the caller and the target. The +sandbox account reaches none of it: the helper is `750 root:root` inside a `750 root:root` +directory and the account holds no sudo rule. + ## Two project models **Claim in place** (`--project-claim`) registers an existing working tree where it lives. @@ -399,10 +459,10 @@ otherwise), so the grant adds it no access. ## Privilege model -The CLI itself is unprivileged. Seven of its root operations — `ai-tools-lockdown`, +The CLI itself is unprivileged. Eight of its root operations — `ai-tools-lockdown`, `ai-tools-relabel`, `ai-tools-setfacl`, `ai-tools-setgid`, `ai-tools-unclaim`, `ai-tools-safedir`, -and `ai-tools-reclaim` — run via `sudo` with **no** NOPASSWD grant by design, so sudo prompts for -the projects user's password; the sandbox account has no grant for any. The exception, `--relabel` → +`ai-tools-reclaim`, and `ai-tools-allowlist` — run via `sudo` with **no** NOPASSWD grant by design, +so sudo prompts for the projects user's password; the sandbox account has no grant for any. The exception, `--relabel` → `ai-tools-relabel-agent`, is: it has a dedicated fixed-path NOPASSWD rule (shared with the `nvm-update` timer, see [updater](updater.rule.md) / [launch](launch.rule.md)), so it runs **as root without a prompt** — kept safe by being a fixed path the projects user @@ -416,6 +476,10 @@ write the root-owned `.gitconfig`; on add it re-validates the path against the a the shared `operator.lib.sh` resolver, but edits a single entry rather than walking a tree. `ai-tools-reclaim` walks the project and hands each agent-owned path to `ai-tools-chown`, so the allowlist/secret/exclusion enforcement and the need for root are that helper's, not its own. +`ai-tools-allowlist` needs root for the **read** as much as the write, since an allowlist is `0600` +inside a `0700` directory in a home the invoker cannot traverse; it is reached only by a `--for` +run, and it authorizes against `SUDO_UID` — the uid sudo sets, not the spoofable `SUDO_USER` name — +refusing a bare root call outright. Repo-local `core.filemode=true` and the allowlist are plain writes the projects user performs unprivileged. `/usr/local/libexec/ai-tools` is `750 root:root`, so the projects user cannot even stat the diff --git a/CLAUDE.md b/CLAUDE.md index 04035283..885c14b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ the management CLI (`ai-tools`), and root-helper binary names (`ai-tools-chown`, | Toolchain provisioning + Node/claude updater, symlink repoint, post-upgrade relabel | `ai-tools-bootstrap.sh`, `nvm-update.sh`, `ai-tools-launcher-symlink.sh`, `ai-tools-relabel-agent.sh`, `nvm-update`/`ai-tools-relabel` units | [updater](.claude/rules/updater.rule.md) | | Provider manifests + fail-closed enablement (agents + integrations), the shared `KEY=value` config grammar, the `session-env.d` session-env seam, the claude-code custom API endpoint, and the dotnet integration | `lib/ai-tools/{conf,providers}.lib.sh`, `lib/ai-tools/{agents,integrations,session-env}.d/**`, `lib/ai-tools/claude-endpoint.lib.sh`, `ai-tools-dotnet.sh`, `operator.conf` `AI_TOOLS_{AGENTS,INTEGRATIONS}` | [providers](.claude/rules/providers.rule.md) | | Running .NET (CoreCLR) under confinement: the dotnet integration files ↔ the `tmpmap`/`apphost`/`netcore` SELinux groups, project-type→group map, denial breakdown | `lib/ai-tools/session-env.d/dotnet.env.sh`, `lib/ai-tools/filters.d/dotnet.rules`, `ai-tools-dotnet.sh`, `selinux/policy/ai_tools_{tmpmap,apphost,netcore}.te` | [dotnet](.claude/rules/dotnet.rule.md) | -| Management CLI, project lifecycle, relabel | `bin/ai-tools.sh`, `ai-tools-{setfacl,unclaim,safedir,relabel}.sh`, `relabel.lib.sh` | [cli](.claude/rules/cli.rule.md) | +| Management CLI, project lifecycle, relabel, acting for another operator (`--for`) | `bin/ai-tools.sh`, `ai-tools-{setfacl,unclaim,safedir,relabel,allowlist}.sh`, `relabel.lib.sh` | [cli](.claude/rules/cli.rule.md) | | Protected-paths backstop (refuse system dirs as targets) | `safe-paths.lib.sh` + the wrapper/CLI/elevated helpers | [safe-paths](.claude/rules/safe-paths.rule.md) | | Shared logging library | `log.lib.sh` | [logging](.claude/rules/logging.rule.md) | | User-facing message formatting (box, wrap, ties) | `msg.lib.sh` + its consumers | [messaging](.claude/rules/messaging.rule.md) | @@ -203,7 +203,11 @@ deliberate scope decisions, not gaps, so a reader tells bounded design from an o `bubblewrap`/`--system` isolation are deferred (see [confinement](.claude/rules/confinement.rule.md) and memory). - **`ai-ops` operators are trusted.** The model defends the host and other users from the - *agent*, not from an operator, who already holds the launch grant. + *agent*, not from an operator, who already holds the launch grant. `ai-tools --for ` + rests on this: it lets one operator write an entry into another's allowlist — their launch gate — + so a human can claim a project for a passwordless service account that runs an agent. The target + must be enrolled, every mutation is logged with both caller and target, and the agent reaches + none of it (see [cli](.claude/rules/cli.rule.md)). - **Toolchain provenance is checksum-, allowlist-, and signature-gated.** The updater checksum-verifies Node, gates npm install scripts behind an allowlist, and verifies the installed toolchain's npm registry signatures before activating it — failing closed on a diff --git a/install.sh b/install.sh index 0fb5383a..d8108f8b 100755 --- a/install.sh +++ b/install.sh @@ -589,6 +589,7 @@ do_summary() { _chk /usr/local/libexec/ai-tools/ai-tools-unclaim _chk /usr/local/libexec/ai-tools/ai-tools-safedir _chk /usr/local/libexec/ai-tools/ai-tools-reclaim + _chk /usr/local/libexec/ai-tools/ai-tools-allowlist _chk /usr/local/libexec/ai-tools/ai-tools-launcher-symlink _chk /usr/local/libexec/ai-tools/ai-tools-lockdown _chk /usr/local/libexec/ai-tools/ai-tools-relabel @@ -801,6 +802,11 @@ do_install() { "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-reclaim.sh" \ /usr/local/libexec/ai-tools/ai-tools-reclaim + log "/usr/local/libexec/ai-tools/ai-tools-allowlist" + install_subst 750 root root \ + "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh" \ + /usr/local/libexec/ai-tools/ai-tools-allowlist + log "/usr/local/libexec/ai-tools/ai-tools-launcher-symlink" install_subst 750 root root \ "${SCRIPT_DIR}/src/usr/local/libexec/ai-tools/ai-tools-launcher-symlink.sh" \ diff --git a/packaging/ai-tools.spec b/packaging/ai-tools.spec index 8fa5d1c5..b493719c 100644 --- a/packaging/ai-tools.spec +++ b/packaging/ai-tools.spec @@ -230,7 +230,7 @@ grep -rlZ '@AI_TOOLS_VERSION@' src \ install -d -m 0750 %{buildroot}%{ai_libexecdir} for h in ai-tools-chown ai-tools-setgid ai-tools-setfacl ai-tools-unclaim \ ai-tools-lockdown ai-tools-relabel ai-tools-safedir ai-tools-reclaim \ - ai-tools-admin; do + ai-tools-allowlist ai-tools-admin; do install -m 0750 src%{ai_libexecdir}/${h}.sh %{buildroot}%{ai_libexecdir}/${h} done install -m 0750 src%{ai_libexecdir}/ai-tools-handback.py %{buildroot}%{ai_libexecdir}/ai-tools-handback @@ -773,6 +773,7 @@ fi %attr(0750, root, root) %{ai_libexecdir}/ai-tools-relabel %attr(0750, root, root) %{ai_libexecdir}/ai-tools-safedir %attr(0750, root, root) %{ai_libexecdir}/ai-tools-reclaim +%attr(0750, root, root) %{ai_libexecdir}/ai-tools-allowlist %attr(0750, root, root) %{ai_libexecdir}/ai-tools-admin %{_sbindir}/ai-tools-admin %attr(0750, root, root) %{ai_libexecdir}/ai-tools-handback diff --git a/src/usr/local/bin/ai-tools.sh b/src/usr/local/bin/ai-tools.sh index 2a8ee8ea..bc52bb71 100755 --- a/src/usr/local/bin/ai-tools.sh +++ b/src/usr/local/bin/ai-tools.sh @@ -8,12 +8,20 @@ # secret lockdown -- through the sudo root helpers (no NOPASSWD: the operator is prompted for a # password; the sandbox account holds no grant). # -# Two preflight gates run before dispatch: require_bootstrap (provisioned install) and, for the +# Three preflight gates run before dispatch: require_bootstrap (provisioned install); for the # operator-acting commands (--project-*/--sandbox-*/--lockdown/--reclaim/--relabel), # require_operator -- the invoking user must be in OPERATORS in operator.conf, since the root -# helpers resolve the caller's identity from that list. --help/--version/--list/--providers stay +# helpers resolve the caller's identity from that list; and require_for_target, which validates a +# --for run and re-points the registry at its target. --help/--version/--list/--providers stay # open to any user. # +# --for performs a command ON BEHALF OF another enrolled operator: the allowlist entry +# lands in THEIR registry, so ai-tools-setfacl grants user:, the handback restores to them, +# and their agent's launch gate covers the path. It exists for a service account that runs an +# agent but holds no password to authenticate a claim of its own. The target's registry is +# unreadable to the invoker (0600 in a 0700 directory), so a --for run reads a root-side snapshot +# of it and routes its writes through ai-tools-allowlist. +# # Commands (each confirms before applying and reports the result): # --project-claim [path] claim a project in place -- grant the agent access (idempotent; # default: cwd); -y/--yes pre-answers its proceed prompt (delegated) @@ -110,6 +118,11 @@ readonly SAFEDIR_BIN="/usr/local/libexec/ai-tools/ai-tools-safedir" # under a project back to the operator via ai-tools-chown (the per-path trust boundary), needed for # the .git tree the per-session sweeps skip; useful before an ACL-unaware backup. readonly RECLAIM_BIN="/usr/local/libexec/ai-tools/ai-tools-reclaim" +# Root-only cross-operator allowlist helper, same sudo (no NOPASSWD) model. Reads and edits ANOTHER +# enrolled operator's allowed-projects for a --for run; root is needed for the READ too, since an +# allowlist is 0600 inside a 0700 .config/ai-tools. Only a --for run reaches it -- without the flag +# the CLI writes the invoker's own registry directly, as before. +readonly ALLOWLIST_BIN="/usr/local/libexec/ai-tools/ai-tools-allowlist" # Sentinel in a guard CLAUDE.md (see drop_lockdown_guard) so the lockdown step can # recognise and remove its own placeholder once secrets are secured. readonly GUARD_MARKER="ai-tools-lockdown-guard" @@ -119,11 +132,6 @@ readonly GUARD_MARKER="ai-tools-lockdown-guard" # write the registries with the wrong owner) and never as the sandbox account # (the agent must not manage its own allowlist). ME="$(id -un)" -# The invoking operator's own primary group, for the one message that must name it: the lockdown -# preamble, which states the owner a locked secret ends up with (:). Not a decision -# input anywhere -- what a walk treats as "the operator's group" is resolved per path from the -# path's owner, never from who happens to be running the CLI. -MY_GROUP="$(id -gn)" [[ "${ME}" == "root" ]] \ && { echo "ai-tools: do not run as root -- run as the projects user, without sudo" >&2 echo " (the CLI invokes sudo itself for the steps that need it)" >&2; exit 1; } @@ -133,10 +141,55 @@ MY_GROUP="$(id -gn)" HOME_DIR="$(getent passwd "${ME}" | cut -d: -f6)" [[ -d "${HOME_DIR}" ]] || { echo "ai-tools: cannot resolve home for ${ME}" >&2; exit 1; } readonly ME HOME_DIR -# One resolution point for readers AND writers (reg_allow/unreg_allow), so a fixture test that -# sets AI_TOOLS_ALLOWLIST never mutates the operator's real registry. Root-only test hook -- see -# the GITCONFIG note above for why the override grants the CLI's operator caller nothing new. -readonly ALLOWLIST="${AI_TOOLS_ALLOWLIST:-${HOME_DIR}/.config/ai-tools/allowed-projects}" + +# ── --for : act on another enrolled operator's project registry ──────── +# A service account that runs an agent has no password, so it cannot authenticate the claim's own +# root helpers -- and a project claimed by a human lands in the HUMAN's registry, which is not the +# one that account's launch gate reads. --for closes both: a human operator performs the claim ON +# BEHALF OF the target, whose allowlist then covers the path, so ai-tools-setfacl grants +# user:, the handback restores to , and that account's own launch finds the project +# already claimed and never reaches a password prompt. +# +# The flag is separated from the command's own arguments HERE, before the registry path below is +# resolved and before dispatch, so every command reads one already-decided owner instead of each +# parsing the flag itself. Validation (is the target enrolled, does this verb accept --for) needs +# conf.lib.sh and runs at the dispatch gate. +FOR_OPERATOR="" +_forless_args=() +while (( $# )); do + case "$1" in + --for) [[ -n "${2:-}" && "${2:-}" != -* ]] \ + || { echo "ai-tools: --for needs an operator name" >&2; exit 1; } + FOR_OPERATOR="$2"; shift 2 ;; + --for=*) FOR_OPERATOR="${1#--for=}" + [[ -n "${FOR_OPERATOR}" ]] \ + || { echo "ai-tools: --for needs an operator name" >&2; exit 1; } + shift ;; + *) _forless_args+=("$1"); shift ;; + esac +done +set -- "${_forless_args[@]}" +unset _forless_args + +# The operator this run acts FOR: the --for target, or the invoker. Every message that names the +# owner a file ends up with, and every scan that matches on that owner, reads these rather than ME +# -- on a --for run the tree belongs to the target, so naming the invoker would misreport who ends +# up holding the files. What a root helper's walk treats as "the operator" is still resolved per +# path from the path's own allowlist coverage, never from either of these. +OWNER_USER="${FOR_OPERATOR:-${ME}}" +OWNER_GROUP="$(id -gn "${OWNER_USER}" 2>/dev/null)" \ + || { echo "ai-tools: cannot resolve the primary group of ${OWNER_USER}" >&2; exit 1; } +readonly FOR_OPERATOR OWNER_USER OWNER_GROUP + +# The registry this run reads and writes. Without --for it is the invoker's own file, read and +# written directly. With --for, require_for_target re-points it at a root-side SNAPSHOT of the +# target's file: an allowlist is 0600 inside a 0700 .config/ai-tools, so one operator cannot read +# another's at all, and every decision made from it (is the path listed, which '!' exclusions +# apply, what --list reports) would otherwise read an unreadable file as an empty one. One +# resolution point for readers AND writers (reg_allow/unreg_allow), so a fixture test that sets +# AI_TOOLS_ALLOWLIST never mutates the operator's real registry. Root-only test hook -- see the +# GITCONFIG note above for why the override grants the CLI's operator caller nothing new. +ALLOWLIST="${AI_TOOLS_ALLOWLIST:-${HOME_DIR}/.config/ai-tools/allowed-projects}" # ── Output / prompt helpers ────────────────────────────────────────────────────── if [[ -t 1 ]]; then @@ -306,6 +359,18 @@ require_sandbox_clone() { reg_allow() { local dir="$1" + # A --for run edits a registry in a home this operator cannot even read, so the write goes + # through the root helper (which re-reads the real file and applies its own idempotency), and + # the snapshot is refreshed so the rest of this run sees the entry it just added. + if [[ -n "${FOR_OPERATOR}" ]]; then + if sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --add "${dir}" >/dev/null; then + snapshot_allowlist + say " allowed-projects: added for ${FOR_OPERATOR}" + else + die "could not add ${dir} to ${FOR_OPERATOR}'s allowed-projects" + fi + return 0 + fi [[ -f "${ALLOWLIST}" ]] || die "allowlist not found at ${ALLOWLIST} -- run install first" # Match through the shared grammar, not a raw line: a hand-added entry with a comment or # quotes is already listed, and appending would duplicate it (conf.lib.sh). @@ -326,6 +391,18 @@ allow_escape() { printf '%s' "$1" | sed 's/[]\.*^$|[]/\\&/g'; } unreg_allow() { local dir="$1" + # A --for run de-lists through the root helper, which applies the same raw-line matcher below + # to the real file; the snapshot is refreshed so a later read in this run agrees with it. + if [[ -n "${FOR_OPERATOR}" ]]; then + if sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --remove "${dir}" >/dev/null; then + snapshot_allowlist + say " allowed-projects: removed for ${FOR_OPERATOR}" + else + warn "could not remove ${dir} from ${FOR_OPERATOR}'s allowed-projects -- run:" + say " ${C_BOLD}sudo ${ALLOWLIST_BIN} --operator ${FOR_OPERATOR} --remove ${dir}${C_RST}" + fi + return 0 + fi [[ -f "${ALLOWLIST}" ]] || return 0 # Delete the RAW line(s) whose grammar entry matches ${dir}, not a line rebuilt from ${dir}: # a hand-added entry may carry a comment or quotes (conf.lib.sh), and anchoring on ${dir} @@ -486,7 +563,7 @@ acl_drift_scan() { [[ "${excl}" == "${dir}"/* ]] && skip+=( -o -path "${excl}" -prune ) done < <(grep '^!' "${ALLOWLIST}" 2>/dev/null || true) find "${dir}" -xdev \( "${skip[@]}" \) -o \ - \( -user "${ME}" -o -user "${SANDBOX_USER}" \) \ + \( -user "${OWNER_USER}" -o -user "${SANDBOX_USER}" \) \ ! -group "${SANDBOX_GROUP}" -perm /077 -print 2>/dev/null } @@ -568,14 +645,19 @@ agent_can_traverse() { return 1 } -# grantable_ancestor -- 0 if reg_reach may grant traverse on : the operator OWNS it and -# it is not a protected system directory (the safe-paths backstop). Fail-closed when the predicate -# is unavailable, so a broken install never widens a directory it cannot vet. +# grantable_ancestor -- 0 if reg_reach may grant traverse on : the project's OWNER owns +# it and it is not a protected system directory (the safe-paths backstop). Fail-closed when the +# predicate is unavailable, so a broken install never widens a directory it cannot vet. +# +# On a --for run the owner is the target, whose directories the invoker may not be able to setfacl; +# the grant is still offered, because the alternative -- declining a reachable path outright -- +# would report a working project as unreachable. An unprivileged setfacl that is refused falls to +# reg_reach's per-path warning, which prints the exact command to run as the owner or as root. grantable_ancestor() { local p="$1" declare -F ai_tools_protected_path_match >/dev/null 2>&1 || return 1 if ai_tools_protected_path_match "${p}" >/dev/null 2>&1; then return 1; fi - [[ "$(stat -c '%U' "${p}" 2>/dev/null || true)" == "${ME}" ]] + [[ "$(stat -c '%U' "${p}" 2>/dev/null || true)" == "${OWNER_USER}" ]] } # reach_scan -- detect the traverse gap between the sandbox account and : @@ -618,7 +700,7 @@ reg_reach() { elif ai_tools_protected_path_match "${REACH_BLOCKED}" >/dev/null 2>&1; then why="a protected system directory" else - why="owned by $(stat -c '%U' "${REACH_BLOCKED}" 2>/dev/null || echo '?'), not by ${ME}" + why="owned by $(stat -c '%U' "${REACH_BLOCKED}" 2>/dev/null || echo '?'), not by ${OWNER_USER}" fi headline_warn "WARNING: project unreachable for the sandbox account" \ "the sandbox account cannot traverse ${REACH_BLOCKED} (${why}), so it cannot reach ${dir}; an isolated clone under the sandbox area is the way in:" @@ -1367,7 +1449,7 @@ resolve_handback_group() { # Default YES: the natural completion of an unclaim. Still confirmed, because it rewrites # ownership and permissions across the tree. if confirm "Hand the files back to a group and remove the agent's write access?" y; then - hb_user="$(ask " Hand the files to which user's group?" "${ME}")" + hb_user="$(ask " Hand the files to which user's group?" "${OWNER_USER}")" if ! HANDBACK_GROUP="$(id -gn "${hb_user}" 2>/dev/null)"; then warn "no such user '${hb_user}' -- skipping the filesystem hand-back" HANDBACK_GROUP=""; HANDBACK_HINT=1 @@ -1542,6 +1624,16 @@ cmd_project_unclaim() { if [[ -n "${group_opt}" ]] && ! getent group "${group_opt}" >/dev/null 2>&1; then die "no such group: ${group_opt}" fi + # --force reaches a tree NO allowlist names, so ai-tools-unclaim cannot resolve its owner from + # an entry and binds the walk to the INVOKING uid instead -- the guard that stops one operator + # rewriting another's files. --for cannot be honoured there: the CLI would name one operator + # while the helper acted as another. Refused rather than silently ignored. + if ${force} && [[ -n "${FOR_OPERATOR}" ]]; then + die "--for cannot be combined with --force" \ + "an unlisted tree has no allowlist entry naming its owner, so the unclaim is bound to" \ + " you as the invoking operator; run it as ${FOR_OPERATOR}, or unclaim the registered" \ + " project without --force" + fi if ${dry} && ! ${force}; then die "-n/--dry-run applies to --force only" \ " a registered project's unclaim previews itself: it lists what it will do and asks before acting" @@ -1952,7 +2044,7 @@ cmd_lockdown() { # If it is genuinely missing, sudo reports it and run_lockdown returns non-zero. section "Lock down project secrets" say " ${d}" - say " ${C_DIM}secret-matching files -> 600, dirs -> 700, owner ${ME}:${MY_GROUP}${C_RST}" + say " ${C_DIM}secret-matching files -> 600, dirs -> 700, owner ${OWNER_USER}:${OWNER_GROUP}${C_RST}" if run_lockdown "${d}" "${passthru[@]}"; then ${dry} || clear_lockdown_guard "${d}" ok "lockdown done: ${d}" @@ -1984,7 +2076,7 @@ cmd_reclaim() { " list your registered projects with: ai-tools --list" section "Reclaim agent-written files" say " ${d}${C_DIM}$(${full} && printf ' (--full: incl. node_modules, .venv, ...)')${C_RST}" - say " ${C_DIM}-> ${ME}:${SANDBOX_GROUP} (secret-named files stay ${ME}:${ME} 600)${C_RST}" + say " ${C_DIM}-> ${OWNER_USER}:${SANDBOX_GROUP} (secret-named files stay ${OWNER_USER}:${OWNER_GROUP} 600)${C_RST}" # The helper reports the outcome itself -- the pre-scan count, the one whole-set # confirm, then "handed back N" / "nothing to reclaim" / "declined" -- so no blanket # success line here: the CLI states only what actually happened. @@ -2367,7 +2459,13 @@ cmd_status() { # and verbs -- no recovery machinery of its own. cmd_list() { [[ -f "${ALLOWLIST}" ]] || { say "no allowlist at ${ALLOWLIST}"; return 0; } - section "Registered projects" + # Name the operator on a --for run: the entries below are that account's launch gate, not the + # invoker's, and an unlabelled listing of someone else's projects reads as your own. + if [[ -n "${FOR_OPERATOR}" ]]; then + section "Registered projects for ${FOR_OPERATOR}" + else + section "Registered projects" + fi local raw entry excl kind safe sd shown=0 local -a cleanup=() @@ -2522,6 +2620,14 @@ ai-tools -- manage Claude Code sandbox projects (run as the projects user) --lockdown options: -n/--dry-run (preview only), -y/--yes (skip confirmation) --reclaim options: --full (also reclaim node_modules, .venv, ... not just the work tree + .git) + --for act on another enrolled operator's projects instead of your own: the + entry lands in THEIR allowed-projects, so the tree is granted to them and + their agent launches there. For a service account that runs an agent but + has no password to authenticate a claim of its own. Accepted on + --project-claim/-create, --project-unclaim/-remove, --lockdown, + --reclaim and --list; not with --project-unclaim --force. + Enrol the target first: sudo ai-tools-admin operator add + Sandbox workflow: /var/opt/ai-tools/README.md EOF } @@ -2566,6 +2672,65 @@ require_operator() { " sudo ai-tools-admin operator add ${ME}" } +# snapshot_allowlist -- point ALLOWLIST at a private copy of the --for target's registry, read +# through the root helper. The copy is read-only input for THIS run: every mutation goes back +# through the helper, which re-reads the real file, so a stale snapshot can never be what a write +# is based on -- and reg_allow/unreg_allow refresh it after theirs. mktemp creates it 0600, and the +# EXIT trap removes it, so another operator's project list does not outlive the command. +ALLOWLIST_SNAPSHOT="" +snapshot_allowlist() { + if [[ -z "${ALLOWLIST_SNAPSHOT}" ]]; then + ALLOWLIST_SNAPSHOT="$(mktemp)" || die "cannot create a temporary file for the allowlist snapshot" + trap 'rm -f -- "${ALLOWLIST_SNAPSHOT}"' EXIT + fi + # shellcheck disable=SC2024 # the redirect is meant to be the CALLER's: root reads the + # 0600 allowlist, this shell writes the snapshot it owns. `sudo tee` would create the temp + # file as root and leave the CLI unable to read back what it just asked for. + sudo "${ALLOWLIST_BIN}" --operator "${FOR_OPERATOR}" --print > "${ALLOWLIST_SNAPSHOT}" \ + || die "could not read ${FOR_OPERATOR}'s allowed-projects" + ALLOWLIST="${ALLOWLIST_SNAPSHOT}" +} + +# require_for_target -- validate a --for run and re-point ALLOWLIST at the target's +# registry. A no-op without the flag, so nothing below changes for an ordinary run. +# +# --for is accepted only on the verbs whose whole effect is decided by WHICH operator's allowlist +# covers the path: the registry pair, the two per-project root helpers that gate on allowlist +# coverage, and the listing. Elsewhere it is REFUSED rather than ignored -- a --sandbox-create +# --for that silently cloned as the invoker would leave the tree owned by the wrong operator with +# nothing to show the flag was disregarded. It is refused with --unlisted for a different reason, +# stated at that flag's own parse: ai-tools-unclaim binds an unlisted tree to the invoking uid +# precisely so one operator cannot rewrite another's files, and --for cannot be allowed to +# reach past that. +# +# The target must be ENROLLED in OPERATORS: ai-tools-setfacl and the handback helpers resolve a +# path's owner over that list, so an entry written for an unenrolled name would create a launch +# gate no ownership machinery can act on. +require_for_target() { + local verb="$1" + [[ -n "${FOR_OPERATOR}" ]] || return 0 + case "${verb}" in + --project-claim|--project-create|--project-unclaim|--project-remove|\ + --lockdown|--reclaim|--list) ;; + *) die "--for is not accepted on ${verb}" \ + "it applies to: --project-claim, --project-create, --project-unclaim," \ + " --project-remove, --lockdown, --reclaim, --list" ;; + esac + [[ "${FOR_OPERATOR}" != "${SANDBOX_USER}" ]] \ + || die "the sandbox account is not an operator and must not own projects" + [[ "${FOR_OPERATOR}" != "root" ]] || die "root is not an operator" + local conf="${AI_TOOLS_OPERATOR_CONF:-/etc/ai-tools/operator.conf}" + local -a ops=(); local op found=false + if ai_tools_conf_list ops "${conf}" OPERATORS 2>/dev/null; then + for op in "${ops[@]}"; do + [[ "${op}" == "${FOR_OPERATOR}" ]] && { found=true; break; } + done + fi + ${found} || die "${FOR_OPERATOR} is not a configured ai-tools operator -- enrol it first with:" \ + " sudo ai-tools-admin operator add ${FOR_OPERATOR}" + snapshot_allowlist +} + # Gate the operator-acting commands up front; the informational ones (--help/--version/--list/ # --providers) stay open so an unenrolled user can still read usage and inspect the host. case "${1:-}" in @@ -2574,6 +2739,11 @@ case "${1:-}" in --lockdown|--reclaim|--relabel) require_operator ;; esac +# Validate a --for run and re-point the registry at the target, after require_operator: acting for +# another operator is an operator action, so the invoker must be enrolled before the target is even +# looked up. +require_for_target "${1:-}" + # ── Dispatch ───────────────────────────────────────────────────────────────────── case "${1:-}" in --project-claim) shift; cmd_project_claim "$@" ;; diff --git a/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh b/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh new file mode 100755 index 00000000..43fce554 --- /dev/null +++ b/src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# /usr/local/libexec/ai-tools/ai-tools-allowlist +# Reads and edits ANOTHER enrolled operator's project allowlist +# (/.config/ai-tools/allowed-projects) on behalf of the operator invoking sudo. +# This is the one privileged seam behind `ai-tools ... --for `: it lets a human +# operator claim a project for a service account that has no password and therefore cannot +# authenticate the claim's own root helpers. +# +# Root is needed for READS as well as writes: an allowlist is 0600 inside a 0700 +# .config/ai-tools (ai-tools-admin seeds both), so one operator cannot see another's list at +# all. --print exists for exactly that, and the CLI snapshots it for the decisions a claim +# makes (is the path listed, which '!' exclusions apply) before routing the mutation back +# through --add/--remove. +# +# The allowlist is the LAUNCH GATE: an entry here is what lets that operator's agent start in +# the directory, and what makes the ownership handback restore files to them. Editing another +# operator's gate stays inside the trust model's "%ai-ops operators are trusted" boundary, but +# it is not something the sandbox account may ever reach, so the helper is 750 root:root, holds +# NO NOPASSWD grant (the invoking human authenticates, like ai-tools-lockdown/-setfacl/-relabel), +# and every mutation is logged with both the caller and the target. +# +# Every gate below resolves to LESS access on failure, never more: +# - no SUDO_UID (a bare root call, or an unclean sudo context) -> refuse, change nothing +# - the CALLER is not in OPERATORS -> refuse, change nothing +# - the TARGET is not in OPERATORS -> refuse, change nothing +# - the target is the sandbox account or root -> refuse, change nothing +# - the path is not a real directory, or is a protected system -> refuse, change nothing +# directory (safe-paths backstop) +# - a required library will not load -> refuse, change nothing +# A refused run leaves the target's allowlist byte-identical, so a failure can only ever leave +# the agent with fewer places to launch than the operator intended, never more. +# +# Usage: +# ai-tools-allowlist --operator --print +# ai-tools-allowlist --operator --add +# ai-tools-allowlist --operator --remove +# +# Deploy: +# sudo install -o root -g root -m 750 \ +# src/usr/local/libexec/ai-tools/ai-tools-allowlist.sh /usr/local/libexec/ai-tools/ai-tools-allowlist + +set -euo pipefail + +readonly SANDBOX_USER="@SANDBOX_USER@" + +die() { printf 'ai-tools-allowlist: %s\n' "$*" >&2; exit 1; } + +# ── Arguments ──────────────────────────────────────────────────────────────────── +# One target operator (--operator) and exactly one action. The action's path argument is +# attached to the flag rather than free-standing, so a missing value cannot silently shift +# into the operator slot. +OPERATOR="" +ACTION="" +TARGET_PATH="" +# _need_value [remaining args...]: die unless a value follows AND that value is not +# itself option-shaped. A leading '-' is a mistyped flag far more often than a real operator name +# or path, and taking it at face value would bind the wrong thing silently. The remaining args are +# passed through so an absent value is a zero-length expansion rather than an empty string. +_need_value() { + local flag="$1"; shift + (( $# )) || die "${flag} needs a value" + [[ "$1" != -* ]] || die "${flag} needs a value, not another option: $1" +} +while (( $# )); do + case "$1" in + --operator) _need_value "$1" "${@:2}"; OPERATOR="$2"; shift 2 ;; + --print) [[ -z "${ACTION}" ]] || die "only one action may be given" + ACTION=print; shift ;; + --add) [[ -z "${ACTION}" ]] || die "only one action may be given" + _need_value "$1" "${@:2}"; ACTION=add; TARGET_PATH="$2"; shift 2 ;; + --remove) [[ -z "${ACTION}" ]] || die "only one action may be given" + _need_value "$1" "${@:2}"; ACTION=remove; TARGET_PATH="$2"; shift 2 ;; + *) die "unknown argument: $1 +usage: ai-tools-allowlist --operator (--print | --add | --remove )" ;; + esac +done +[[ -n "${OPERATOR}" ]] || die "--operator is required" +[[ -n "${ACTION}" ]] || die "one of --print, --add , --remove is required" +readonly OPERATOR ACTION TARGET_PATH + +# ── Required libraries (fail closed) ───────────────────────────────────────────── +# Bare sources under `set -e`: an unloadable library aborts the helper before it touches +# anything, rather than leaving this pass unable to recognise a protected path or an +# unenrolled operator. +# shellcheck source=SCRIPTDIR/../../lib/ai-tools/conf.lib.sh +source /usr/local/lib/ai-tools/conf.lib.sh +# shellcheck source=SCRIPTDIR/../../lib/ai-tools/operator.lib.sh +source /usr/local/lib/ai-tools/operator.lib.sh +# shellcheck source=SCRIPTDIR/../../lib/ai-tools/safe-paths.lib.sh +source /usr/local/lib/ai-tools/safe-paths.lib.sh +declare -F ai_tools_conf_allowlist_has_entry >/dev/null 2>&1 \ + || die "config library defines no allowlist matcher -- refusing (fail closed)" +declare -F ai_tools_load_operators >/dev/null 2>&1 \ + || die "operator library defines no operator list -- refusing (fail closed)" +declare -F ai_tools_assert_safe_target >/dev/null 2>&1 \ + || die "safe-paths library defines no protected-path guard -- refusing (fail closed)" + +# Shared leveled logger: journald (always) + the root-only /var/log/ai-tools/allowlist.log. +# Best-effort -- a no-op fallback keeps the helper working if the lib is missing. +AI_TOOLS_LOG_TAG="ai-tools-allowlist" +AI_TOOLS_LOG_FILE="allowlist.log" +# shellcheck source=SCRIPTDIR/../../lib/ai-tools/log.lib.sh +if ! source /usr/local/lib/ai-tools/log.lib.sh 2>/dev/null; then + ai_tools_log() { :; }; ai_tools_log_debug() { :; }; ai_tools_log_info() { :; } + ai_tools_log_warn() { :; }; ai_tools_log_error() { :; } +fi + +# ── Caller gate ────────────────────────────────────────────────────────────────── +# The identity that authorizes this edit is the operator who invoked sudo, resolved from the +# kernel-supplied SUDO_UID rather than from SUDO_USER (a name is spoofable through the +# environment; the uid sudo sets is not). A direct root call carries no such context and is +# refused rather than defaulting to some operator. +caller_uid="${SUDO_UID:-}" +[[ -n "${caller_uid}" ]] \ + || die "run me through sudo as an operator (no SUDO_UID) -- nothing changed" +caller="$(id -un "${caller_uid}" 2>/dev/null)" \ + || die "unknown invoking uid ${caller_uid} -- nothing changed" +[[ "${caller}" != "${SANDBOX_USER}" ]] \ + || die "the sandbox account may not manage an allowlist -- nothing changed" + +ai_tools_load_operators 2>/dev/null \ + || die "no operators configured -- run: sudo ai-tools-admin operator add " + +_is_operator() { + local want="$1" op + for op in "${AI_TOOLS_OPERATORS[@]}"; do + [[ "${op}" == "${want}" ]] && return 0 + done + return 1 +} + +_is_operator "${caller}" \ + || die "${caller} is not a configured ai-tools operator -- nothing changed" + +# ── Target gate ────────────────────────────────────────────────────────────────── +# The target must be an enrolled operator: the whole point of the entry is that ai-tools-setfacl +# and the handback helpers later resolve THIS path to THIS operator, and they resolve only over +# OPERATORS. Writing an entry for an unenrolled name would create a launch gate no ownership +# machinery can act on. +[[ "${OPERATOR}" != "${SANDBOX_USER}" ]] \ + || die "the sandbox account is not an operator and must not own projects -- nothing changed" +[[ "${OPERATOR}" != "root" ]] \ + || die "root is not an operator -- nothing changed" +_is_operator "${OPERATOR}" \ + || die "${OPERATOR} is not a configured ai-tools operator -- enrol it first with: + sudo ai-tools-admin operator add ${OPERATOR}" + +target_home="$(getent passwd "${OPERATOR}" 2>/dev/null | cut -d: -f6)" \ + || die "cannot resolve ${OPERATOR} -- nothing changed" +[[ -n "${target_home}" && -d "${target_home}" ]] \ + || die "no home directory for ${OPERATOR} -- nothing changed" +target_group="$(id -gn "${OPERATOR}" 2>/dev/null)" \ + || die "cannot resolve the primary group of ${OPERATOR} -- nothing changed" + +# Resolve the target's allowlist through operator.lib's own path helper, so the +# AI_TOOLS_ALLOWLIST test hook applies here exactly as it does on every resolve_owner path and +# the helper cannot drift from what the root helpers read. +is_primary=secondary +[[ "${OPERATOR}" == "${AI_TOOLS_OPERATORS[0]}" ]] && is_primary=primary +allowlist="$(_ai_tools_operator_allowlist "${OPERATOR}" "${is_primary}")" +readonly caller allowlist target_home target_group + +# ── print ──────────────────────────────────────────────────────────────────────── +# Read-only, and the only action that does not require a path. An absent allowlist prints +# nothing and succeeds: "this operator has approved no projects" is a complete answer, and the +# caller (the CLI's snapshot) treats an empty list exactly as it treats a file of comments. +if [[ "${ACTION}" == print ]]; then + [[ -r "${allowlist}" ]] || exit 0 + cat -- "${allowlist}" + exit 0 +fi + +# ── Path gate (add/remove) ─────────────────────────────────────────────────────── +# Canonicalise before every check and before the write, so a symlink or '..' cannot smuggle a +# path past the protected-paths backstop and land a different directory in the launch gate. +canonical="$(realpath -e "${TARGET_PATH}" 2>/dev/null)" \ + || die "not an existing path: ${TARGET_PATH} -- nothing changed" +[[ -d "${canonical}" ]] \ + || die "not a directory: ${canonical} -- nothing changed" +ai_tools_assert_safe_target "${canonical}" "allowlist ${ACTION}" || exit 3 +readonly canonical + +# ensure_allowlist: create the target's .config/ai-tools and allowed-projects when absent, owned +# by the TARGET and with the modes ai-tools-admin seeds (0700 dir, 0600 file) -- so a project +# claimed for a freshly enrolled operator does not depend on that operator having logged in yet. +# The file is the target's own data; this helper only ever adds to it. +ensure_allowlist() { + local cfg="${allowlist%/*}" + [[ -d "${target_home}/.config" ]] \ + || install -d -o "${OPERATOR}" -g "${target_group}" -m 700 "${target_home}/.config" + [[ -d "${cfg}" ]] \ + || install -d -o "${OPERATOR}" -g "${target_group}" -m 700 "${cfg}" + [[ -f "${allowlist}" ]] \ + || install -o "${OPERATOR}" -g "${target_group}" -m 600 /dev/null "${allowlist}" +} + +# write_allowlist : replace the allowlist with 's contents, preserving +# the existing owner and mode. Written to a temp file in the SAME directory and renamed, so a +# reader (the launch wrapper gating a concurrent session) sees either the old file or the new +# one, never a half-written gate. +write_allowlist() { + local src="$1" tmp owner mode + owner="$(stat -c '%U:%G' "${allowlist}")" + mode="$(stat -c '%a' "${allowlist}")" + tmp="$(mktemp "${allowlist}.XXXXXX")" + cat -- "${src}" > "${tmp}" + chown "${owner}" "${tmp}" + chmod "${mode}" "${tmp}" + mv -f -- "${tmp}" "${allowlist}" +} + +case "${ACTION}" in + add) + ensure_allowlist + # Idempotent: an entry the shared grammar already reads as covering this path is left + # alone, so a re-claim does not duplicate the line. + if ai_tools_conf_allowlist_has_entry "${allowlist}" "${canonical}"; then + printf 'ai-tools-allowlist: %s is already listed for %s\n' "${canonical}" "${OPERATOR}" + exit 0 + fi + tmpfile="$(mktemp)" + trap 'rm -f -- "${tmpfile}"' EXIT + cat -- "${allowlist}" > "${tmpfile}" + printf '%s\n' "${canonical}" >> "${tmpfile}" + write_allowlist "${tmpfile}" + ai_tools_log_info "operator ${caller} added ${canonical} to ${OPERATOR}'s allowlist" + printf 'ai-tools-allowlist: added %s for %s\n' "${canonical}" "${OPERATOR}" + ;; + remove) + # A missing allowlist has nothing to remove -- report it and succeed, so an unclaim + # that runs twice is not an error. + if [[ ! -f "${allowlist}" ]]; then + printf 'ai-tools-allowlist: %s has no allowlist -- nothing to remove\n' "${OPERATOR}" + exit 0 + fi + # Match on the RAW lines, not on the canonical path: a listed line may carry a comment, + # quotes, or a symlinked spelling, so reconstructing it from the path would fail to + # match. Same matcher the CLI's own de-listing uses. + declare -a matched=() + if ! ai_tools_conf_allowlist_matching_lines matched "${allowlist}" "${canonical}"; then + printf 'ai-tools-allowlist: %s is not listed for %s\n' "${canonical}" "${OPERATOR}" + exit 0 + fi + tmpfile="$(mktemp)" + trap 'rm -f -- "${tmpfile}"' EXIT + while IFS= read -r line || [[ -n "${line}" ]]; do + keep=true + for m in "${matched[@]}"; do + [[ "${line}" == "${m}" ]] && { keep=false; break; } + done + ${keep} && printf '%s\n' "${line}" + done < "${allowlist}" > "${tmpfile}" + write_allowlist "${tmpfile}" + ai_tools_log_info "operator ${caller} removed ${canonical} from ${OPERATOR}'s allowlist" + printf 'ai-tools-allowlist: removed %s for %s\n' "${canonical}" "${OPERATOR}" + ;; +esac + +exit 0 diff --git a/src/usr/local/share/man/man1/ai-tools.1 b/src/usr/local/share/man/man1/ai-tools.1 index 1f48805a..c4dfefe2 100644 --- a/src/usr/local/share/man/man1/ai-tools.1 +++ b/src/usr/local/share/man/man1/ai-tools.1 @@ -240,6 +240,39 @@ without prompting, in both the normal and modes. Without it the command asks whether to hand back and whose group to use, and a run with no terminal takes the invoking user's group by default \(em so a script should name the group outright. +.TP +.BI \-\-for " operator" +Act on +.IR operator 's +projects instead of your own. The allowlist entry lands in +.IR operator 's +.I ~/.config/ai-tools/allowed-projects +rather than yours, so the project is granted to +.IR operator , +files the agent writes are handed back to +.IR operator , +and +.IR operator 's +own agent launches there without further setup. +.IP +This exists for a service account that runs a coding agent but has no password, +and so cannot authenticate the root helpers a claim of its own would invoke: a +human operator performs the claim on its behalf, once, and the account never +meets a password prompt. +.I operator +must already be enrolled +.RB ( "sudo ai\-tools\-admin operator add " \fIoperator\fR ), +since the ownership helpers resolve a project's owner from the +.B OPERATORS +list. +.IP +Accepted on +.BR \-\-project\-claim ", " \-\-project\-create ", " \-\-project\-unclaim , +.BR \-\-project\-remove ", " \-\-lockdown ", " \-\-reclaim ", and " \-\-list ; +refused elsewhere rather than ignored. It cannot be combined with +.BR "\-\-project\-unclaim \-\-force" , +whose tree carries no allowlist entry naming an owner and is therefore bound to +the invoking operator. .SH FILES .TP .I ~/.config/ai-tools/allowed-projects diff --git a/tests/boundary/access.sh b/tests/boundary/access.sh index ddeac034..f5f94b12 100644 --- a/tests/boundary/access.sh +++ b/tests/boundary/access.sh @@ -297,4 +297,33 @@ else pass "the agent cannot write owner-only.lib.sh (the seal predicate and residue strip)" fi +# ai-tools-allowlist edits an operator's allowed-projects -- the launch gate deciding where a +# session may start. Reaching it would let the agent approve its own projects, so this is the +# boundary half of the pair whose runtime half (each of the helper's gates fires) is in +# tests/unit/allowlist-helper.sh. The helper is 750 root:root inside a 750 root:root directory and +# the sandbox account holds no sudo rule, so it is unreachable three ways over; assert the two the +# filesystem can show. +alhelper=/usr/local/libexec/ai-tools/ai-tools-allowlist +if [[ ! -e "${alhelper}" ]]; then + skip "cross-operator allowlist helper not agent-reachable" "not installed at ${alhelper}" +elif runuser -u "${SANDBOX_USER}" -- test -x "${alhelper}" 2>/dev/null; then + fail "the agent can execute ${alhelper} -- it could write its own launch gate" +elif runuser -u "${SANDBOX_USER}" -- test -w "${alhelper}" 2>/dev/null; then + fail "the agent can write ${alhelper} -- it could rewrite the registry helper" +else + pass "the agent cannot execute or write ai-tools-allowlist (the cross-operator launch gate)" +fi + +# The gate itself: an operator's allowed-projects. The agent must not be able to add a project to +# any operator's registry -- with or without the helper. The primary operator's is the one this +# host is guaranteed to have. +opallow="${PROJECTS_HOME}/.config/ai-tools/allowed-projects" +if [[ ! -e "${opallow}" ]]; then + skip "operator allowlist not agent-writable" "not present at ${opallow}" +elif runuser -u "${SANDBOX_USER}" -- test -w "${opallow}" 2>/dev/null; then + fail "the agent can write ${opallow} -- it could approve its own projects" +else + pass "the agent cannot write the operator's allowed-projects (its own launch gate)" +fi + finish diff --git a/tests/integration/cli.sh b/tests/integration/cli.sh index 860f1a81..5a3cdad5 100644 --- a/tests/integration/cli.sh +++ b/tests/integration/cli.sh @@ -213,4 +213,74 @@ EOF fi fi +# --for : acting on another enrolled operator's registry. Every refusal here precedes the +# root helper entirely, so none of these reach a sudo prompt. The helper's own gates are asserted +# in tests/unit/allowlist-helper.sh; what this covers is the CLI deciding, up front, that a run +# must not proceed at all. +if command -v runuser >/dev/null 2>&1; then + section "CLI --for (acting for another operator)" + mktestdir + chmod 755 "${TESTDIR}" + fconf="${TESTDIR}/operator.conf" + printf 'OPERATORS="%s"\n' "${PROJECTS_USER}" > "${fconf}"; chmod 644 "${fconf}" + fproj="${TESTDIR}/forproj"; mkdir -p "${fproj}" + chown "${PROJECTS_USER}:${PROJECTS_USER}" "${fproj}" + fal="${TESTDIR}/for-allowlist"; : > "${fal}" + chown "${PROJECTS_USER}:${PROJECTS_USER}" "${fal}" + + run_for() { + runuser -u "${PROJECTS_USER}" -- env HOME="${PROJECTS_HOME}" \ + AI_TOOLS_OPERATOR_CONF="${fconf}" AI_TOOLS_ALLOWLIST="${fal}" \ + "${CLI}" "$@" 2>&1 + } + + # (1) An unenrolled target is refused, naming the enrolment command. Nothing may be written for + # a name the ownership helpers cannot later resolve to an owner. + out="$(run_for --project-claim --for definitely-not-an-operator "${fproj}")" && rc=0 || rc=$? + if [[ ${rc} -ne 0 ]] && grep -qi 'not a configured ai-tools operator' <<<"${out}"; then + pass "--for refuses an unenrolled target operator" + else + fail "--for accepted an unenrolled target (rc=${rc}): ${out}" + fi + if [[ -s "${fal}" ]]; then + fail "refused --for run still wrote to a registry: $(cat "${fal}")" + else + pass "refused --for run wrote no registry state" + fi + + # (2) The sandbox account can never be a --for target: it would be the agent owning projects. + out="$(run_for --project-claim --for "${SANDBOX_USER}" "${fproj}")" && rc=0 || rc=$? + if [[ ${rc} -ne 0 ]] && grep -qi 'not an operator' <<<"${out}"; then + pass "--for refuses the sandbox account as the target" + else + fail "--for accepted the sandbox account (rc=${rc}): ${out}" + fi + + # (3) Refused, not ignored, on a verb it does not apply to -- a --sandbox-create that silently + # cloned as the invoker would leave the tree owned by the wrong operator with nothing to show. + out="$(run_for --sandbox-create --for "${PROJECTS_USER}" "${fproj}")" && rc=0 || rc=$? + if [[ ${rc} -ne 0 ]] && grep -qi 'for is not accepted on' <<<"${out}"; then + pass "--for is refused on a verb that does not accept it (not silently ignored)" + else + fail "--for was not refused on --sandbox-create (rc=${rc}): ${out}" + fi + + # (4) --force binds an unlisted tree to the INVOKING uid inside ai-tools-unclaim, so honouring + # --for there would have the CLI name one operator while the helper acted as another. + out="$(run_for --project-unclaim --force --for "${PROJECTS_USER}" "${fproj}")" && rc=0 || rc=$? + if [[ ${rc} -ne 0 ]] && grep -qi 'cannot be combined with --force' <<<"${out}"; then + pass "--for refuses to combine with --project-unclaim --force" + else + fail "--for was accepted alongside --force (rc=${rc}): ${out}" + fi + + # (5) A bare --for with no name is a parse error, not an empty operator silently meaning "me". + out="$(run_for --project-claim --for)" && rc=0 || rc=$? + if [[ ${rc} -ne 0 ]] && grep -qi 'for needs an operator name' <<<"${out}"; then + pass "--for with no operator name is refused" + else + fail "--for with no name was not refused (rc=${rc}): ${out}" + fi +fi + finish diff --git a/tests/integration/perms.sh b/tests/integration/perms.sh index 3d9b15b8..27f54331 100644 --- a/tests/integration/perms.sh +++ b/tests/integration/perms.sh @@ -20,6 +20,7 @@ check_file /usr/local/libexec/ai-tools/ai-tools-setfacl root check_file /usr/local/libexec/ai-tools/ai-tools-unclaim root root 750 check_file /usr/local/libexec/ai-tools/ai-tools-safedir root root 750 check_file /usr/local/libexec/ai-tools/ai-tools-reclaim root root 750 +check_file /usr/local/libexec/ai-tools/ai-tools-allowlist root root 750 check_file /usr/local/libexec/ai-tools/ai-tools-launcher-symlink root root 750 check_file /usr/local/libexec/ai-tools/ai-tools-lockdown root root 750 # SELinux project-label helper: 750 root:root -- user-run via sudo, never by the agent (no diff --git a/tests/unit/allowlist-helper.sh b/tests/unit/allowlist-helper.sh new file mode 100644 index 00000000..aec82e72 --- /dev/null +++ b/tests/unit/allowlist-helper.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# tests/unit/allowlist-helper.sh +# Hermetic unit tests for the deployed ai-tools-allowlist helper: the privileged seam behind +# `ai-tools ... --for `, which reads and edits ANOTHER enrolled operator's +# allowed-projects. Because that file is a LAUNCH GATE, every gate on the way to it is asserted to +# fire and to leave the registry byte-identical: an absent sudo context, an unenrolled caller, an +# unenrolled or non-operator target, and a protected system directory as the path. +# +# This is the runtime half of the pair -- that each refusal actually fires. The boundary half, that +# the sandbox account cannot reach the helper at all, is in tests/boundary/access.sh. +# +# The add/remove/print mechanics are exercised with the target set to the PRIMARY operator, since +# that is the identity the AI_TOOLS_ALLOWLIST fixture hook models (operator.lib.sh redirects only +# the primary's path); the cross-operator routing itself is covered in tests/integration/cli.sh. +# Run against a /tmp testdir as root. + +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)/harness.sh" +require_root + +readonly HELPER="/usr/local/libexec/ai-tools/ai-tools-allowlist" +section "ai-tools-allowlist: cross-operator registry helper (unit)" + +if [[ ! -x "${HELPER}" ]]; then + skip "ai-tools-allowlist" "not installed at ${HELPER}"; finish; exit +fi + +mktestdir +proj="${TESTDIR}/proj" +mkdir -p "${proj}" +chmod 0755 "${TESTDIR}" "${proj}" + +mk_allowlist "# fixture allowlist" +mk_operator +readonly ALLOWFILE="${TESTDIR}/allowed-projects" +OPERATOR_UID="$(id -u "${PROJECTS_USER}")" +readonly OPERATOR_UID + +# run_helper : invoke the helper as root with an explicit SUDO_UID, the +# kernel-supplied caller identity it authorizes against. An empty unsets it, modelling a +# bare root call with no sudo context. +run_helper() { + local uid="$1"; shift + if [[ -z "${uid}" ]]; then + env -u SUDO_UID \ + AI_TOOLS_ALLOWLIST="${ALLOWFILE}" \ + AI_TOOLS_OPERATOR_CONF="${TESTDIR}/operator.conf" \ + "${HELPER}" "$@" 2>&1 + else + env SUDO_UID="${uid}" \ + AI_TOOLS_ALLOWLIST="${ALLOWFILE}" \ + AI_TOOLS_OPERATOR_CONF="${TESTDIR}/operator.conf" \ + "${HELPER}" "$@" 2>&1 + fi +} + +# refuses