Commit 12e8348d5553

Vincent Demeester <vincent@sbr.pm>
2026-08-10 12:24:29
feat(ai): wrap agent binaries directly for secrets
The secret-provisioning wrappers lived beside the real binaries under different names (pir, cr, geminir, oco), so anything resolving an agent by its canonical name — herdr restarting a session, shpool, the Emacs daemon — ran the bare binary with no credentials. Shadowed the binaries themselves instead and exposed the untouched upstream as <name>-orig, keeping the old names as aliases. Dropped agyr, whose agy target was no longer installed.
1 parent fec23b0
home/common/dev/ai.nix
@@ -8,6 +8,58 @@ let
   # Unified AI agent storage (XDG-compliant)
   # Physical storage in ai-sync (syncthing folder), symlinked to ai/
   aiSyncDir = "${config.xdg.dataHome}/ai-sync";
+
+  # Secret-provisioning wrappers that shadow the agent binaries themselves, so
+  # anything launching an agent by name (herdr session restart, shpool, Emacs)
+  # gets the credentials. The unwrapped binary stays available as <name>-orig.
+  osp = "redhat/google/osp";
+  geminiCli = config.custom.nss.wrap pkgs.llm-agents.gemini-cli;
+
+  agents = {
+    pi = config.custom.agents.wrap {
+      exe = "pi";
+      pkg = pkgs.llm-agents.pi;
+      aliases = [ "pir" ];
+      secrets = {
+        GOOGLE_CLOUD_PROJECT = "${osp}/project";
+        GOOGLE_CLOUD_LOCATION = "${osp}/location";
+        GEMINI_API_KEY = "${osp}/vdeemest-api-key";
+        SYNTHETIC_API_KEY = "ai/synthetic.new/api_key";
+        BRAVE_API_KEY = "ai/brave/api_key";
+      };
+    };
+
+    claude = config.custom.agents.wrap {
+      exe = "claude";
+      pkg = pkgs.llm-agents.claude-code;
+      aliases = [ "cr" ];
+      env.CLAUDE_CODE_USE_VERTEX = "1";
+      secrets = {
+        CLOUD_ML_REGION = "${osp}/location";
+        ANTHROPIC_VERTEX_PROJECT_ID = "${osp}/project";
+      };
+    };
+
+    gemini = config.custom.agents.wrap {
+      exe = "gemini";
+      pkg = geminiCli;
+      aliases = [
+        "geminir"
+        "ge"
+      ];
+      secrets.GEMINI_API_KEY = "${osp}/vdeemest-api-key";
+    };
+
+    opencode = config.custom.agents.wrap {
+      exe = "opencode";
+      pkg = pkgs.llm-agents.opencode;
+      aliases = [ "oco" ];
+      secrets = {
+        GOOGLE_CLOUD_PROJECT = "${osp}/core-project";
+        VERTEX_LOCATION = "${osp}/core-location";
+      };
+    };
+  };
 in
 {
   # Ensure claude-sync directory structure exists (legacy, still used by claude)
@@ -90,9 +142,14 @@ in
     # AI coding agents (from numtide/llm-agents.nix)
     llm-agents.claude-code
     llm-agents.claude-agent-acp
-    (config.custom.nss.wrap llm-agents.gemini-cli)
+    geminiCli
     llm-agents.opencode
     llm-agents.pi
+    # Wrappers shadowing pi/claude/gemini/opencode (see `agents` above)
+    agents.pi
+    agents.claude
+    agents.gemini
+    agents.opencode
     llm-agents.skills
     llm-agents.cursor-agent
     llm-agents.copilot-cli
home/modules/agent-wrapper.nix
@@ -0,0 +1,94 @@
+# Expose mkAgentWrapper as config.custom.agents.wrap.
+#
+# Historically the secret-provisioning wrappers lived next to the real binary
+# under a different name (pir, cr, geminir, ...). Anything that discovers an
+# agent by its canonical name — herdr restarting a session, shpool, the Emacs
+# daemon, an agent spawning subagents — bypassed them and ran the bare binary
+# with no credentials.
+#
+# This builds a package whose bin/<exe> IS the wrapper, and exposes the
+# untouched upstream binary as bin/<exe>-orig. The wrapper execs the store path
+# directly, so there is no PATH-shadowing recursion and -orig is a real bypass.
+#
+# The result is hiPrio so it wins the buildEnv collision against the upstream
+# package, which stays in home.packages to provide man pages, libexec, etc.
+{
+  pkgs,
+  lib,
+  ...
+}:
+{
+  options.custom.agents.wrap = lib.mkOption {
+    type = lib.types.raw;
+    readOnly = true;
+    description = "Shadow an agent binary with a secret-provisioning wrapper.";
+    default =
+      {
+        # Binary to shadow, e.g. "pi". Must exist as ${pkg}/bin/${exe}.
+        exe,
+        # Upstream package providing it.
+        pkg,
+        # ENV_VAR -> passage secret path. Empty means no pass-run indirection.
+        secrets ? { },
+        # ENV_VAR -> literal value, exported before the secrets are resolved.
+        env ? { },
+        # Extra names in bin/ pointing at the wrapper (muscle memory: pir, cr).
+        aliases ? [ ],
+      }:
+      let
+        real = "${pkg}/bin/${exe}";
+
+        exports = lib.concatMapStringsSep "\n" (
+          n: "export ${n}=${lib.escapeShellArg (toString env.${n})}"
+        ) (lib.attrNames env);
+
+        secretArgs = lib.concatMapStringsSep " \\\n  " (n: "-e ${n}=${secrets.${n}}") (
+          lib.attrNames secrets
+        );
+
+        # Nothing to provision: skip pass-run rather than paying a passage
+        # decrypt for nothing.
+        launch =
+          if secrets == { } then
+            ''exec ${real} "$@"''
+          else
+            ''
+              # passage needs its store and identities even when launched from an
+              # environment inheriting no session variables (Emacs daemon, shpool).
+              export PASSAGE_DIR="''${PASSAGE_DIR:-$HOME/.local/share/passage}"
+              export PASSAGE_IDENTITIES_FILE="''${PASSAGE_IDENTITIES_FILE:-$PASSAGE_DIR/identities}"
+
+              exec ${pkgs.pass-run}/bin/pass-run -q \
+                ${secretArgs} \
+                -- ${real} "$@"
+            '';
+
+        script = pkgs.writeShellScript "${exe}-wrapper" ''
+          # Agents re-invoke themselves for subagents; the environment is already
+          # populated by then, so re-running pass-run would only cost a decrypt
+          # per spawn (and can prompt where no tty is attached).
+          if [ -n "''${VDE_AGENT_WRAPPED:-}" ]; then
+            exec ${real} "$@"
+          fi
+          export VDE_AGENT_WRAPPED=1
+
+          ${exports}
+          ${launch}
+        '';
+      in
+      lib.hiPrio (
+        pkgs.runCommand "${exe}-wrapped"
+          {
+            meta = (pkg.meta or { }) // {
+              mainProgram = exe;
+            };
+          }
+          ''
+            mkdir -p $out/bin
+            ln -s ${script} $out/bin/${exe}
+            ln -s ${real} $out/bin/${exe}-orig
+            ${lib.concatMapStringsSep "\n" (a: "ln -s ${script} $out/bin/${a}") aliases}
+          ''
+      );
+  };
+}
home/default.nix
@@ -12,6 +12,7 @@
 {
   imports = [
     ./modules/nss-wrapper.nix
+    ./modules/agent-wrapper.nix
     ./common/shell
   ]
   ++ lib.optional (builtins.isString desktop) ./common/desktop
pkgs/my/scripts/bin/agyr
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-# Antigravity (agy) with secrets provisioned via pass-run
-# Usage: agyr [args...]
-
-exec pass-run -q \
-  -e GEMINI_API_KEY=redhat/google/osp/vdeemest-api-key \
-  -- agy "$@"
pkgs/my/scripts/bin/cr
@@ -1,11 +0,0 @@
-#!/usr/bin/env bash
-# Claude Code with Vertex AI environment
-# Usage: cr [args...]
-# This is a script version of the 'cr' alias for use with shpool and other tools
-
-export CLAUDE_CODE_USE_VERTEX=1
-
-exec pass-run -q \
-  -e CLOUD_ML_REGION=redhat/google/osp/location \
-  -e ANTHROPIC_VERTEX_PROJECT_ID=redhat/google/osp/project \
-  -- claude "$@"
pkgs/my/scripts/bin/crf
@@ -1,10 +1,6 @@
 #!/usr/bin/env bash
-# Claude Code with Vertex AI environment (Opus model - full power)
+# Claude Code with Vertex AI environment (Opus model - heavyweight)
 # Usage: crf [args...]
+# Vertex config and secrets are provided by the `claude` wrapper itself.
 
-export CLAUDE_CODE_USE_VERTEX=1
-
-exec pass-run -q \
-  -e CLOUD_ML_REGION=redhat/google/osp/location \
-  -e ANTHROPIC_VERTEX_PROJECT_ID=redhat/google/osp/project \
-  -- claude --model claude-opus-4-5 "$@"
+exec claude --model claude-opus-4-5 "$@"
pkgs/my/scripts/bin/crl
@@ -1,10 +1,6 @@
 #!/usr/bin/env bash
 # Claude Code with Vertex AI environment (Haiku model - lightweight)
 # Usage: crl [args...]
+# Vertex config and secrets are provided by the `claude` wrapper itself.
 
-export CLAUDE_CODE_USE_VERTEX=1
-
-exec pass-run -q \
-  -e CLOUD_ML_REGION=redhat/google/osp/location \
-  -e ANTHROPIC_VERTEX_PROJECT_ID=redhat/google/osp/project \
-  -- claude --model claude-haiku-4-5 "$@"
+exec claude --model claude-haiku-4-5 "$@"
pkgs/my/scripts/bin/ge
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-# Gemini with API key from passage
-# Usage: ge [args...]
-
-exec pass-run -q \
-  -e GEMINI_API_KEY=redhat/google/osp/vdeemest-api-key \
-  -- gemini "$@"
pkgs/my/scripts/bin/geminir
@@ -1,7 +0,0 @@
-#!/usr/bin/env bash
-# Gemini CLI with secrets provisioned via pass-run
-# Usage: geminir [args...]
-
-exec pass-run -q \
-  -e GEMINI_API_KEY=redhat/google/osp/vdeemest-api-key \
-  -- gemini "$@"
pkgs/my/scripts/bin/oco
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-# OpenCode with Vertex AI environment
-# Usage: oco [args...]
-
-exec pass-run -q \
-  -e GOOGLE_CLOUD_PROJECT=redhat/google/osp/core-project \
-  -e VERTEX_LOCATION=redhat/google/osp/core-location \
-  -- opencode "$@"
pkgs/my/scripts/bin/pir
@@ -1,17 +0,0 @@
-#!/usr/bin/env bash
-# Pi coding agent with secrets provisioned via pass-run
-# Usage: pir [args...]
-# Model/provider controlled by ~/.pi/agent/modes.json
-
-# Ensure passage finds its store and identities even when launched from
-# environments that don't inherit session variables (e.g. the Emacs daemon).
-export PASSAGE_DIR="${PASSAGE_DIR:-$HOME/.local/share/passage}"
-export PASSAGE_IDENTITIES_FILE="${PASSAGE_IDENTITIES_FILE:-$PASSAGE_DIR/identities}"
-
-exec pass-run -q \
-  -e GOOGLE_CLOUD_PROJECT=redhat/google/osp/project \
-  -e GOOGLE_CLOUD_LOCATION=redhat/google/osp/location \
-  -e GEMINI_API_KEY=redhat/google/osp/vdeemest-api-key \
-  -e SYNTHETIC_API_KEY=ai/synthetic.new/api_key \
-  -e BRAVE_API_KEY=ai/brave/api_key \
-  -- pi "$@"