Commit c8995a1

HPCesia <me@hpcesia.com>
2026-10-08 17:19:55
agents: jail all agents
Assisted-by: crush:deepseek-v4.1-flash
1 parent 5f6a332
modules/dev/agents/crush/default.nix
@@ -1,4 +1,8 @@
-{den, ...}: {
+{
+  den,
+  inputs,
+  ...
+}: {
   den.aspects.dev.agents.crush = {
     includes = [den.aspects.dev.agents];
 
@@ -19,8 +23,31 @@
       lib,
       config,
       ...
-    }: {
-      packages = [pkgs.nur.repos.charmbracelet.crush];
+    }: let
+      mkJailedAgent =
+        (import ../_jail.nix {
+          inherit pkgs;
+          jail = inputs.jail-nix.lib.init pkgs;
+        }).mkJailedAgent;
+
+      crush = pkgs.nur.repos.charmbracelet.crush.overrideAttrs (old: {
+        meta = (old.meta or {}) // {mainProgram = "crush";};
+      });
+
+      agent = mkJailedAgent {
+        name = "crush";
+        pkg = crush;
+        configPaths = [
+          "${config.xdg.config.directory}/crush"
+          "${config.xdg.data.directory}/crush"
+        ];
+        extraPkgs = [
+          pkgs.deno
+          pkgs.nur.repos.hpcesia.dcg
+        ];
+      };
+    in {
+      packages = [agent.jailed agent.unjailed];
 
       xdg.config.files = {
         "crush/crush.json" = {
modules/dev/agents/_jail.nix
@@ -0,0 +1,262 @@
+{
+  pkgs,
+  jail,
+}: let
+  inherit (pkgs) lib;
+
+  commonPkgs = with pkgs; [
+    bashInteractive
+
+    git
+    jujutsu
+
+    curl
+    wget
+
+    findutils
+    gnugrep
+    gawkInteractive
+    jq
+    ripgrep
+    which
+
+    gnused
+    gnutar
+    gzip
+    unzip
+
+    diffutils
+    just
+    ps
+
+    bash-language-server
+    nixd
+    ty
+  ];
+
+  gitExe = lib.getExe pkgs.git;
+  sedExe = lib.getExe pkgs.gnused;
+  direnvExe = lib.getExe pkgs.direnv;
+  jqExe = lib.getExe pkgs.jq;
+
+  # Minimal toolchain to evaluate `.envrc` file
+  devshellPath = lib.makeBinPath (with pkgs; [
+    bashInteractive
+    coreutils
+    direnv
+    findutils
+    git
+    gnugrep
+    gnused
+    jq
+    nix
+  ]);
+
+  commonJailOptions = with jail.combinators; [
+    network
+    time-zone
+    no-new-session
+  ];
+
+  nixDaemonAccess = {
+    readwriteDirs = [
+      "/nix/var/nix/daemon-socket"
+    ];
+    readonlyDirs = [
+      "/nix"
+      "/etc/nix/nix.conf"
+    ];
+    pkgs = [pkgs.nix];
+  };
+
+  openUrls = with jail.combinators;
+    compose [
+      (add-pkg-deps [pkgs.xdg-utils])
+      (set-env "BROWSER" "browserchannel")
+      (jail-to-host-channel "browserchannel" ''
+        case "$1" in
+          https://*|http://*) ''${BROWSER:-xdg-open} "$1" ;;
+        esac
+      '')
+    ];
+
+  # For git worktree or jj workspace
+  workspaceResolver = jail.combinators.add-runtime ''
+    _ixBindSharedVcs() {
+      local git_common gitdir_ref gitdir jj_ref jj_store
+
+      git_common="$(${gitExe} -C "$PWD" rev-parse --git-common-dir 2>/dev/null || true)"
+      git_common="$(realpath "$git_common" 2>/dev/null || true)"
+
+      # A genuine linked worktree has a `.git` file pointing strictly inside
+      # the shared `.git`; a plain dir or crafted pointer binds nothing.
+      if [ -f "$PWD/.git" ] && [ -n "$git_common" ]; then
+        gitdir_ref="$(${sedExe} -n 's/^gitdir: //p' "$PWD/.git" 2>/dev/null | head -n1 || true)"
+        gitdir="$(realpath "$gitdir_ref" 2>/dev/null || true)"
+        case "$gitdir" in
+          "$git_common"/*) RUNTIME_ARGS+=(--bind "$git_common" "$git_common") ;;
+        esac
+      fi
+
+      # jj marks a linked workspace with a `.jj/repo` pointer file, relative to
+      # the `.jj` directory it sits in.
+      if [ -f "$PWD/.jj/repo" ]; then
+        jj_ref="$(cat "$PWD/.jj/repo" 2>/dev/null || true)"
+        case "$jj_ref" in
+          /*) : ;;
+          "") jj_ref="" ;;
+          *) jj_ref="$PWD/.jj/$jj_ref" ;;
+        esac
+        jj_store="$(realpath "$jj_ref" 2>/dev/null || true)"
+        if [ -d "$jj_store/store" ] && [ -d "$jj_store/op_store" ]; then
+          RUNTIME_ARGS+=(--bind "$jj_store" "$jj_store")
+
+          # A colocated repo keeps its git dir outside the store; jj reads it
+          # through the store's git_target pointer and needs it writable too.
+          jj_git=""
+          if [ -f "$jj_store/store/git_target" ]; then
+            jj_git="$(cat "$jj_store/store/git_target" 2>/dev/null || true)"
+          fi
+          if [ -n "$jj_git" ]; then
+            case "$jj_git" in
+              git:*) jj_git="$jj_store/store/git" ;;
+              /*) : ;;
+              *) jj_git="$jj_store/store/$jj_git" ;;
+            esac
+            jj_git="$(realpath "$jj_git" 2>/dev/null || true)"
+            if [ -d "$jj_git" ]; then
+              RUNTIME_ARGS+=(--bind "$jj_git" "$jj_git")
+            fi
+          fi
+        fi
+      fi
+    }
+    _ixBindSharedVcs
+  '';
+
+  # Prints NUL-separated bubblewrap args that load the project's dev shell,
+  # or nothing when direnv has not approved the project's `.envrc`.
+  devshellLoader = pkgs.writeShellApplication {
+    name = "ix-devshell-env";
+    runtimeInputs = [pkgs.coreutils];
+    text = ''
+      jail_path="''${IX_JAIL_PATH:-}"
+
+      # Only `.envrc` is trusted; loading a bare `.env` would leak its secrets.
+      status_json="$(${direnvExe} status --json 2>/dev/null || true)"
+      rc_path="$(printf '%s' "$status_json" | ${jqExe} -r '.state.foundRC.path // empty' 2>/dev/null || true)"
+      case "$rc_path" in
+        */.envrc) ;;
+        *) exit 0 ;;
+      esac
+      [ "$(printf '%s' "$status_json" | ${jqExe} -r '.state.foundRC.allowed // 9' 2>/dev/null || echo 9)" = 0 ] || exit 0
+
+      # `.envrc` can still pull in `.env` through `dotenv`; never forward its keys.
+      skip_keys='[]'
+      if [ -f "$PWD/.env" ]; then
+        skip_keys="$(${direnvExe} dotenv bash "$PWD/.env" 2>/dev/null \
+          | ${jqExe} -R -s 'gsub("export |;"; "\n") | split("\n") | map(split("=")[0]) | map(select(length > 0)) | unique' || true)"
+        [ -n "$skip_keys" ] || skip_keys='[]'
+      fi
+
+      # `env -i` yields a full diff; direnv reports only the delta otherwise.
+      pristine=(
+        "HOME=''${HOME:-}"
+        "USER=''${USER:-$(id -un)}"
+        "TERM=''${TERM:-dumb}"
+        "PATH=${devshellPath}"
+      )
+      for xdg in XDG_DATA_HOME XDG_CONFIG_HOME XDG_CACHE_HOME XDG_STATE_HOME; do
+        [ -n "''${!xdg:-}" ] && pristine+=("$xdg=''${!xdg}")
+      done
+
+      env_json="$(env -i "''${pristine[@]}" ${direnvExe} export json 2>/dev/null)" || exit 0
+      [ -n "$env_json" ] || exit 0
+
+      dev_path="$(printf '%s' "$env_json" | ${jqExe} -r '.PATH // empty' 2>/dev/null || true)"
+      if [ -n "$dev_path" ]; then
+        printf '%s\0%s\0%s\0' --setenv PATH "$dev_path''${jail_path:+:$jail_path}"
+      fi
+
+      # PATH is set above; the rest are paths the jail already controls.
+      printf '%s' "$env_json" | ${jqExe} -j --argjson skip "$skip_keys" '
+        to_entries[]
+        | select(.key | startswith("DIRENV_") | not)
+        | select(.value != null)
+        | select(.key as $k
+            | (["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "TEMPDIR", "NIX_BUILD_TOP"] + $skip)
+            | index($k) | not)
+        | "--setenv\u0000\(.key)\u0000\(.value)\u0000"
+      '
+    '';
+  };
+
+  devshellEnv = state: let
+    jailPath = state.env.PATH or "";
+  in
+    state
+    // {
+      runtime =
+        state.runtime
+        + ''
+          _ix_devshell_jail_path=${lib.escapeShellArg jailPath}
+          while IFS= read -r -d "" _ix_devshell_arg; do
+            RUNTIME_ARGS+=("$_ix_devshell_arg")
+          done < <(IX_JAIL_PATH="$_ix_devshell_jail_path" ${lib.getExe devshellLoader})
+        '';
+    };
+
+  mkJailedAgent = {
+    name,
+    pkg,
+    configPaths,
+    extraReadwriteDirs ? [],
+    extraReadonlyDirs ? [],
+    extraPkgs ? [],
+    env ? {},
+    fwdEnv ? [],
+    enableNix ? true,
+    enableDevshell ? true,
+    enableOpenUrls ? false,
+    enableWorkspaces ? true,
+    basePackages ? commonPkgs,
+    baseJailOptions ? commonJailOptions,
+  }: let
+    readonlyDirs =
+      [
+        "~/.config/agents"
+        "~/.config/git"
+        "~/.config/jj"
+      ]
+      ++ extraReadonlyDirs
+      ++ lib.optionals enableNix nixDaemonAccess.readonlyDirs;
+    readwriteDirs =
+      configPaths
+      ++ extraReadwriteDirs
+      ++ lib.optionals enableNix nixDaemonAccess.readwriteDirs;
+    extraPackages = extraPkgs ++ lib.optionals enableNix nixDaemonAccess.pkgs;
+  in {
+    jailed = jail name pkg (
+      with jail.combinators;
+        baseJailOptions
+        ++ lib.optionals enableOpenUrls [openUrls]
+        ++ lib.optionals enableWorkspaces [workspaceResolver]
+        ++ (map (p: readonly (noescape p)) readonlyDirs)
+        ++ [mount-cwd]
+        ++ (map (p: readwrite (noescape p)) readwriteDirs)
+        ++ [
+          (add-pkg-deps basePackages)
+          (add-pkg-deps extraPackages)
+        ]
+        ++ (map try-fwd-env fwdEnv)
+        ++ (lib.mapAttrsToList set-env env)
+        ++ lib.optionals (enableDevshell && enableNix) [devshellEnv]
+    );
+
+    unjailed = pkgs.writeShellScriptBin "${name}-unjailed" ''
+      exec ${lib.getExe pkg} "$@"
+    '';
+  };
+in {
+  inherit commonPkgs commonJailOptions mkJailedAgent;
+}
modules/dev/agents/default.nix
@@ -1,4 +1,6 @@
 {
+  flake-file.inputs.jail-nix.url = "sourcehut:~alexdavid/jail.nix";
+
   den.aspects.dev.agents = {
     hjem = {lib, ...}: let
       skills = builtins.listToAttrs (
modules/dev/agents/goose.nix
@@ -1,4 +1,8 @@
-{den, ...}: {
+{
+  den,
+  inputs,
+  ...
+}: {
   den.aspects.dev.agents.goose = {
     includes = [den.aspects.dev.agents];
 
@@ -37,10 +41,23 @@
         GOOSE_TELEMETRY_ENABLED = false;
         GOOSE_MODE = "smart_approve";
       };
+
+      mkJailedAgent =
+        (import ./_jail.nix {
+          inherit pkgs;
+          jail = inputs.jail-nix.lib.init pkgs;
+        }).mkJailedAgent;
+
+      agent = mkJailedAgent {
+        name = "goose";
+        pkg = goose-cli;
+        configPaths = [
+          "${config.xdg.config.directory}/goose"
+          "${config.xdg.state.directory}/goose"
+        ];
+      };
     in {
-      packages = [
-        goose-cli
-      ];
+      packages = [agent.jailed agent.unjailed];
 
       xdg.config.files."goose/permission.yaml" = {
         type = "copy";
modules/dev/agents/opencode.nix
@@ -1,4 +1,8 @@
-{den, ...}: {
+{
+  den,
+  inputs,
+  ...
+}: {
   den.aspects.dev.agents.opencode = {
     includes = [den.aspects.dev.agents];
 
@@ -19,8 +23,25 @@
       config,
       pkgs,
       ...
-    }: {
-      packages = [pkgs.opencode];
+    }: let
+      mkJailedAgent =
+        (import ./_jail.nix {
+          inherit pkgs;
+          jail = inputs.jail-nix.lib.init pkgs;
+        }).mkJailedAgent;
+
+      agent = mkJailedAgent {
+        name = "opencode";
+        pkg = pkgs.opencode;
+        configPaths = [
+          "${config.xdg.config.directory}/opencode"
+          "${config.xdg.data.directory}/opencode"
+          "${config.xdg.state.directory}/opencode"
+        ];
+        env.OPENCODE_DISABLE_LSP_DOWNLOAD = "true";
+      };
+    in {
+      packages = [agent.jailed agent.unjailed];
 
       environment.sessionVariables = {
         OPENCODE_DISABLE_LSP_DOWNLOAD = "true";
modules/dev/agents/pi.nix
@@ -1,4 +1,8 @@
-{den, ...}: {
+{
+  den,
+  inputs,
+  ...
+}: {
   den.aspects.dev.agents.pi = {
     includes = [den.aspects.dev.agents];
 
@@ -19,20 +23,36 @@
       lib,
       config,
       ...
-    }: {
-      packages = [
-        (pkgs.symlinkJoin {
-          name = "pi-coding-agent";
-          buildInputs = [pkgs.makeWrapper];
-          paths = [pkgs.pi-coding-agent];
-          postBuild = ''
-            wrapProgram $out/bin/pi \
-              --set PI_CODING_AGENT_DIR ${config.xdg.config.directory}/pi \
-              --suffix PATH : ${lib.makeBinPath [pkgs.nodejs]} \
-              --run 'export NPM_CONFIG_PREFIX="''${NPM_CONFIG_PREFIX:-''${XDG_DATA_HOME:-$HOME/.local/share}/pi/npm}"'
-          '';
-        })
-      ];
+    }: let
+      pi-coding-agent = pkgs.symlinkJoin {
+        name = "pi-coding-agent";
+        buildInputs = [pkgs.makeWrapper];
+        paths = [pkgs.pi-coding-agent];
+        postBuild = ''
+          wrapProgram $out/bin/pi \
+            --set PI_CODING_AGENT_DIR ${config.xdg.config.directory}/pi \
+            --suffix PATH : ${lib.makeBinPath [pkgs.nodejs]} \
+            --run 'export NPM_CONFIG_PREFIX="''${NPM_CONFIG_PREFIX:-''${XDG_DATA_HOME:-$HOME/.local/share}/pi/npm}"'
+        '';
+        meta.mainProgram = "pi";
+      };
+
+      mkJailedAgent =
+        (import ./_jail.nix {
+          inherit pkgs;
+          jail = inputs.jail-nix.lib.init pkgs;
+        }).mkJailedAgent;
+
+      agent = mkJailedAgent {
+        name = "pi";
+        pkg = pi-coding-agent;
+        configPaths = [
+          "${config.xdg.config.directory}/pi"
+          "${config.xdg.data.directory}/pi"
+        ];
+      };
+    in {
+      packages = [agent.jailed agent.unjailed];
 
       xdg.config.files."pi/settings.json" = {
         type = "copy";
flake.lock
@@ -243,6 +243,21 @@
         "type": "github"
       }
     },
+    "jail-nix": {
+      "locked": {
+        "lastModified": 1788928033,
+        "narHash": "sha256-UQRytK4SsshV1CeX6aKF+P855Sbcqg9aA731eYTakNg=",
+        "owner": "~alexdavid",
+        "repo": "jail.nix",
+        "rev": "7cd53acef3ad51b9c775be759f5643f8d3bcf6eb",
+        "type": "sourcehut"
+      },
+      "original": {
+        "owner": "~alexdavid",
+        "repo": "jail.nix",
+        "type": "sourcehut"
+      }
+    },
     "ncro": {
       "inputs": {
         "nixpkgs": [
@@ -396,6 +411,7 @@
         "helix-plugins-nix": "helix-plugins-nix",
         "hjem": "hjem",
         "import-tree": "import-tree",
+        "jail-nix": "jail-nix",
         "ncro": "ncro",
         "nixpak": "nixpak",
         "nixpkgs": "nixpkgs",
flake.nix
@@ -37,6 +37,7 @@
       inputs.nixpkgs.follows = "nixpkgs";
     };
     import-tree.url = "github:denful/import-tree";
+    jail-nix.url = "sourcehut:~alexdavid/jail.nix";
     ncro = {
       url = "github:manic-systems/ncro";
       inputs.nixpkgs.follows = "nixpkgs";