main
1{
2 pkgs,
3 jail,
4}: let
5 inherit (pkgs) lib;
6
7 commonPkgs = with pkgs; [
8 bashInteractive
9
10 git
11 jujutsu
12
13 curl
14 wget
15
16 findutils
17 gnugrep
18 gawkInteractive
19 jq
20 ripgrep
21 which
22
23 gnused
24 gnutar
25 gzip
26 unzip
27
28 diffutils
29 just
30 ps
31
32 python3
33
34 bash-language-server
35 nixd
36 ty
37 ];
38
39 gitExe = lib.getExe pkgs.git;
40 sedExe = lib.getExe pkgs.gnused;
41 direnvExe = lib.getExe pkgs.direnv;
42 jqExe = lib.getExe pkgs.jq;
43
44 # Minimal toolchain to evaluate `.envrc` file
45 devshellPath = lib.makeBinPath (with pkgs; [
46 bashInteractive
47 coreutils
48 direnv
49 findutils
50 git
51 gnugrep
52 gnused
53 jq
54 nix
55 ]);
56
57 commonJailOptions = with jail.combinators; [
58 network
59 time-zone
60 no-new-session
61 ];
62
63 nixDaemonAccess = {
64 readwriteDirs = [
65 "/nix/var/nix/daemon-socket"
66 ];
67 readonlyDirs = [
68 "/nix"
69 "/etc/nix/nix.conf"
70 "/etc/nix/registry.json"
71 ];
72 pkgs = [pkgs.nix];
73 };
74
75 openUrls = with jail.combinators;
76 compose [
77 (add-pkg-deps [pkgs.xdg-utils])
78 (set-env "BROWSER" "browserchannel")
79 (jail-to-host-channel "browserchannel" ''
80 case "$1" in
81 https://*|http://*) ''${BROWSER:-xdg-open} "$1" ;;
82 esac
83 '')
84 ];
85
86 # For git worktree or jj workspace
87 workspaceResolver = jail.combinators.add-runtime ''
88 _ixBindSharedVcs() {
89 local git_common gitdir_ref gitdir jj_ref jj_store
90
91 git_common="$(${gitExe} -C "$PWD" rev-parse --git-common-dir 2>/dev/null || true)"
92 git_common="$(realpath "$git_common" 2>/dev/null || true)"
93
94 # A genuine linked worktree has a `.git` file pointing strictly inside
95 # the shared `.git`; a plain dir or crafted pointer binds nothing.
96 if [ -f "$PWD/.git" ] && [ -n "$git_common" ]; then
97 gitdir_ref="$(${sedExe} -n 's/^gitdir: //p' "$PWD/.git" 2>/dev/null | head -n1 || true)"
98 gitdir="$(realpath "$gitdir_ref" 2>/dev/null || true)"
99 case "$gitdir" in
100 "$git_common"/*) RUNTIME_ARGS+=(--bind "$git_common" "$git_common") ;;
101 esac
102 fi
103
104 # jj marks a linked workspace with a `.jj/repo` pointer file, relative to
105 # the `.jj` directory it sits in.
106 if [ -f "$PWD/.jj/repo" ]; then
107 jj_ref="$(cat "$PWD/.jj/repo" 2>/dev/null || true)"
108 case "$jj_ref" in
109 /*) : ;;
110 "") jj_ref="" ;;
111 *) jj_ref="$PWD/.jj/$jj_ref" ;;
112 esac
113 jj_store="$(realpath "$jj_ref" 2>/dev/null || true)"
114 if [ -d "$jj_store/store" ] && [ -d "$jj_store/op_store" ]; then
115 RUNTIME_ARGS+=(--bind "$jj_store" "$jj_store")
116
117 # A colocated repo keeps its git dir outside the store; jj reads it
118 # through the store's git_target pointer and needs it writable too.
119 jj_git=""
120 if [ -f "$jj_store/store/git_target" ]; then
121 jj_git="$(cat "$jj_store/store/git_target" 2>/dev/null || true)"
122 fi
123 if [ -n "$jj_git" ]; then
124 case "$jj_git" in
125 git:*) jj_git="$jj_store/store/git" ;;
126 /*) : ;;
127 *) jj_git="$jj_store/store/$jj_git" ;;
128 esac
129 jj_git="$(realpath "$jj_git" 2>/dev/null || true)"
130 if [ -d "$jj_git" ]; then
131 RUNTIME_ARGS+=(--bind "$jj_git" "$jj_git")
132 fi
133 fi
134 fi
135 fi
136 }
137 _ixBindSharedVcs
138 '';
139
140 # Prints NUL-separated bubblewrap args that load the project's dev shell,
141 # or nothing when direnv has not approved the project's `.envrc`.
142 devshellLoader = pkgs.writeShellApplication {
143 name = "ix-devshell-env";
144 runtimeInputs = [pkgs.coreutils];
145 text = ''
146 jail_path="''${IX_JAIL_PATH:-}"
147
148 # Only `.envrc` is trusted; loading a bare `.env` would leak its secrets.
149 status_json="$(${direnvExe} status --json 2>/dev/null || true)"
150 rc_path="$(printf '%s' "$status_json" | ${jqExe} -r '.state.foundRC.path // empty' 2>/dev/null || true)"
151 case "$rc_path" in
152 */.envrc) ;;
153 *) exit 0 ;;
154 esac
155 [ "$(printf '%s' "$status_json" | ${jqExe} -r '.state.foundRC.allowed // 9' 2>/dev/null || echo 9)" = 0 ] || exit 0
156
157 # `.envrc` can still pull in `.env` through `dotenv`; never forward its keys.
158 skip_keys='[]'
159 if [ -f "$PWD/.env" ]; then
160 skip_keys="$(${direnvExe} dotenv bash "$PWD/.env" 2>/dev/null \
161 | ${jqExe} -R -s 'gsub("export |;"; "\n") | split("\n") | map(split("=")[0]) | map(select(length > 0)) | unique' || true)"
162 [ -n "$skip_keys" ] || skip_keys='[]'
163 fi
164
165 # `env -i` yields a full diff; direnv reports only the delta otherwise.
166 pristine=(
167 "HOME=''${HOME:-}"
168 "USER=''${USER:-$(id -un)}"
169 "TERM=''${TERM:-dumb}"
170 "PATH=${devshellPath}"
171 )
172 for xdg in XDG_DATA_HOME XDG_CONFIG_HOME XDG_CACHE_HOME XDG_STATE_HOME; do
173 [ -n "''${!xdg:-}" ] && pristine+=("$xdg=''${!xdg}")
174 done
175
176 env_json="$(env -i "''${pristine[@]}" ${direnvExe} export json 2>/dev/null)" || exit 0
177 [ -n "$env_json" ] || exit 0
178
179 dev_path="$(printf '%s' "$env_json" | ${jqExe} -r '.PATH // empty' 2>/dev/null || true)"
180 if [ -n "$dev_path" ]; then
181 printf '%s\0%s\0%s\0' --setenv PATH "$dev_path''${jail_path:+:$jail_path}"
182 fi
183
184 # PATH is set above; the rest are paths the jail already controls.
185 printf '%s' "$env_json" | ${jqExe} -j --argjson skip "$skip_keys" '
186 to_entries[]
187 | select(.key | startswith("DIRENV_") | not)
188 | select(.value != null)
189 | select(.key as $k
190 | (["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "TEMPDIR", "NIX_BUILD_TOP"] + $skip)
191 | index($k) | not)
192 | "--setenv\u0000\(.key)\u0000\(.value)\u0000"
193 '
194 '';
195 };
196
197 devshellEnv = state: let
198 jailPath = state.env.PATH or "";
199 in
200 state
201 // {
202 runtime =
203 state.runtime
204 + ''
205 _ix_devshell_jail_path=${lib.escapeShellArg jailPath}
206 while IFS= read -r -d "" _ix_devshell_arg; do
207 RUNTIME_ARGS+=("$_ix_devshell_arg")
208 done < <(IX_JAIL_PATH="$_ix_devshell_jail_path" ${lib.getExe devshellLoader})
209 '';
210 };
211
212 mkJailedAgent = {
213 name,
214 pkg,
215 configPaths,
216 extraReadwriteDirs ? [],
217 extraReadonlyDirs ? [],
218 extraPkgs ? [],
219 env ? {},
220 fwdEnv ? [],
221 enableNix ? true,
222 enableDevshell ? true,
223 enableOpenUrls ? false,
224 enableWorkspaces ? true,
225 basePackages ? commonPkgs,
226 baseJailOptions ? commonJailOptions,
227 }: let
228 readonlyDirs =
229 [
230 "~/.config/agents"
231 "~/.config/git"
232 "~/.config/jj"
233 ]
234 ++ extraReadonlyDirs
235 ++ lib.optionals enableNix nixDaemonAccess.readonlyDirs;
236 readwriteDirs =
237 configPaths
238 ++ extraReadwriteDirs
239 ++ lib.optionals enableNix nixDaemonAccess.readwriteDirs;
240 extraPackages = extraPkgs ++ lib.optionals enableNix nixDaemonAccess.pkgs;
241 in {
242 jailed = jail name pkg (
243 with jail.combinators;
244 baseJailOptions
245 ++ lib.optionals enableOpenUrls [openUrls]
246 ++ lib.optionals enableWorkspaces [workspaceResolver]
247 ++ (map (p: readonly (noescape p)) readonlyDirs)
248 ++ [mount-cwd]
249 ++ (map (p: readwrite (noescape p)) readwriteDirs)
250 ++ [
251 (add-pkg-deps basePackages)
252 (add-pkg-deps extraPackages)
253 ]
254 ++ (map try-fwd-env fwdEnv)
255 ++ (lib.mapAttrsToList set-env env)
256 ++ lib.optionals (enableDevshell && enableNix) [devshellEnv]
257 );
258
259 unjailed = pkgs.writeShellScriptBin "${name}-unjailed" ''
260 exec ${lib.getExe pkg} "$@"
261 '';
262 };
263in {
264 inherit commonPkgs commonJailOptions mkJailedAgent;
265}