Repository navigation
Expand file tree
/
Copy pathagent-iso.sh
More file actions
executable file
·382 lines (367 loc) · 16.3 KB
/
Copy pathagent-iso.sh
File metadata and controls
executable file
·382 lines (367 loc) · 16.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
#!/usr/bin/env bash
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
# agent-iso.sh — launch an agent CLI with a clean environment.
#
# This is layer 0 of the secure-agent setup (see
# `docs/setup/secure-agent-setup.md`): strip every credential-shaped
# environment variable from the parent shell before exec'ing the
# agent, so it never sees `$AWS_*`, `$GH_TOKEN`, `$ANTHROPIC_API_KEY`,
# etc. that an unrelated terminal session may have exported into your
# interactive shell.
#
# The clean-env strip is agent-agnostic. Two entry points share the core:
# `claude-iso` (the default) launches Claude Code, and `opencode-iso`
# launches OpenCode. Only the Claude Code path additionally injects the
# in-process `--settings` sandbox grant below; OpenCode gets its
# filesystem isolation from the OS-level sandbox of the secure setup.
#
# Filesystem-level isolation (the bigger lift) is enforced by
# Claude Code's `sandbox` feature — see the `.claude/settings.json`
# block in `docs/setup/secure-agent-setup.md`. This wrapper is the
# environment-variable counterpart.
#
# Usage:
# - Source it from your shell rc:
# source /path/to/agent-iso.sh
# and then invoke `claude-iso` (or `opencode-iso`) instead of the
# agent CLI.
# - Or invoke directly: `bash agent-iso.sh [claude args ...]`. To
# isolate OpenCode when executing directly, either set
# `AGENT_ISO_AGENT=opencode` or invoke via a symlink named
# `opencode-iso`.
#
# To inject a single credential explicitly for one session:
# AGENT_ISO_ALLOW=GH_TOKEN claude-iso # token already set in the shell
# AGENT_ISO_ALLOW=AWS_PROFILE AWS_PROFILE=read-only claude-iso
#
# Current-repo auto-allow:
# Whenever the wrapper is invoked from inside a git working
# tree, claude-iso automatically grants the session's sandbox
# read access to that working tree's root (resolved via
# `git rev-parse --show-toplevel`). Without this, the agent
# can't read the source the user just `cd`'d into unless the
# repo path was hand-listed in `.claude/settings.json` ahead of
# time. Outside a git repo it's a silent no-op. The path is
# injected via a one-shot `--settings` merge — nothing on disk
# changes — and a stderr banner reports what was added.
#
# Worktree mode (`claude-iso -w` / `claude-iso --worktree`):
# Additive on top of the current-repo auto-allow above. When
# `-w` / `--worktree` is present in the args AND the wrapper is
# invoked from inside a git repo, claude-iso also grants read
# access to the *main* repo (resolved via
# `git rev-parse --git-common-dir`, so it works whether you
# launch from the main checkout or from a nested worktree).
# When run in the main repo, the toplevel and the main repo
# resolve to the same path and are deduped. Both paths ride
# into the session via a single `--settings` injection that
# Claude merges into the loaded settings stack at startup,
# before the sandbox is initialised.
# Core: launch agent CLI "$1" (claude / opencode / …) in a clean environment.
# The env-stripping below is identical for every agent — only the injected
# settings differ (the `--settings` sandbox grant is Claude-specific). The
# `claude-iso` / `opencode-iso` entry points are thin wrappers over this.
agent_iso_run() {
local agent="$1"
# The Kiro harness is invoked as `kiro` but ships the `kiro-cli` binary;
# normalise so both `AGENT_ISO_AGENT=kiro` and the `kiro-iso` entry point
# resolve the real executable. (Claude/OpenCode names match their binaries.)
[[ "$agent" == "kiro" ]] && agent="kiro-cli"
shift
# Resolve the agent binary on PATH before clobbering the env so
# the lookup uses the user's normal $PATH. Use a path-only lookup
# (bash `type -P`, zsh `whence -p`) instead of `command -v`: with
# `command -v`, an `alias claude=claude-iso` in the user's rc file
# (a documented setup option — see `docs/setup/secure-agent-setup.md`) would
# resolve back to the alias and recurse.
local agent_bin
if [[ -n "${ZSH_VERSION-}" ]]; then
agent_bin="$(whence -p "$agent" 2>/dev/null || true)"
else
agent_bin="$(type -P "$agent" 2>/dev/null || true)"
fi
if [[ -z "$agent_bin" ]]; then
echo "${agent}-iso: '${agent}' not found on PATH. Install per docs/setup/secure-agent-setup.md." >&2
return 127
fi
# The minimal env every interactive shell needs. We deliberately
# drop everything else — the goal is no implicit credential
# propagation.
local -a passthrough=(
HOME
PATH
SHELL
TERM
LANG
LC_ALL
LC_CTYPE
USER
LOGNAME
PWD
XDG_RUNTIME_DIR
XDG_CONFIG_HOME
XDG_CACHE_HOME
XDG_DATA_HOME
DISPLAY # for OAuth flows that pop a browser
WAYLAND_DISPLAY
# SSH_AUTH_SOCK is a fixed member of the Layer 0 passthrough (RFC-AI-0002
# § "Layer 0 — Clean-env wrapper"): git push/pull needs the ssh-agent socket.
# Layer 0 stays harness-agnostic. Gating the push itself is a *separate* layer
# (Layer 3 — permissions.ask / agent-guard), wired per-harness; a runtime with
# no Layer 3 adapter is responsible for providing its own push gate.
SSH_AUTH_SOCK
)
# Build an `env -i ... NAME=value ...` argv from the passthrough list.
# Use `eval` for the indirect lookup so this works under both bash and
# zsh — bash's `${!var}` indirect expansion is a "bad substitution" in
# zsh.
local -a env_args=()
local var val
for var in "${passthrough[@]}"; do
eval "val=\${$var-}"
if [[ -n "$val" ]]; then
env_args+=("${var}=${val}")
fi
done
# Explicit opt-in for runtime authentication and other required variables.
# Only named variables pass through; setting KEY=value alone is insufficient.
# The neutral name takes precedence, including an explicitly empty value.
# CLAUDE_ISO_ALLOW remains the backwards-compatible fallback for every CLI.
# Example (key already set): AGENT_ISO_ALLOW=GEMINI_API_KEY agent-iso gemini
local allowed_env_vars="${AGENT_ISO_ALLOW-${CLAUDE_ISO_ALLOW-}}"
if [[ -n "$allowed_env_vars" ]]; then
# Word-split portably: zsh doesn't split unquoted parameters by default
# (it needs ${=var}), whereas bash does. Build an array either way.
local -a allow_list
if [[ -n "${ZSH_VERSION-}" ]]; then
allow_list=(${=allowed_env_vars})
else
# shellcheck disable=SC2206
allow_list=($allowed_env_vars)
fi
for var in "${allow_list[@]}"; do
if [[ ! "$var" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
echo "${agent}-iso: invalid variable name in environment allow list" >&2
return 2
fi
eval "val=\${$var-}"
if [[ -n "$val" ]]; then
env_args+=("${var}=${val}")
fi
done
fi
# Sandbox auto-allow injection. See the "Current-repo auto-allow"
# and "Worktree mode" sections in the file header for the full
# rationale. The injection uses `claude --settings <json>`, which
# merges with the loaded settings stack at startup (i.e. before
# sandbox init), so the added paths are in scope for the session
# immediately — no on-disk settings.json edit is performed.
#
# We collect up to two candidate paths:
# - cwd_toplevel: the working tree root of $PWD (always when
# inside a git repo). Lets Claude read the source the user
# just `cd`'d into.
# - main_repo: the parent of the main repo's .git dir; added
# only when `-w`/`--worktree` is on the argv, so worktree
# sessions can see the original checkout.
# When both resolve to the same path (no worktree, or `-w` from
# the main repo) they collapse to a single entry.
local cwd_toplevel
cwd_toplevel="$(git -C "$PWD" rev-parse --show-toplevel 2>/dev/null || true)"
local has_worktree=0
local arg
for arg in "$@"; do
case "$arg" in
-w|--worktree|-w=*|--worktree=*) has_worktree=1; break ;;
esac
done
# `-w` / `--worktree` is an agent-iso control flag understood only by the
# Claude sandbox-grant path below (it widens the allowRead set to the main
# repo). For any other harness it is meaningless, and forwarding it into the
# launched CLI's argv risks it being misparsed as a native flag. Strip it for
# non-Claude agents so the generic entry point stays transparent.
if [[ "$agent" != "claude" && "$has_worktree" -eq 1 ]]; then
local -a _passargs=()
for arg in "$@"; do
case "$arg" in
-w|--worktree|-w=*|--worktree=*) ;; # drop the control flag
*) _passargs+=("$arg") ;;
esac
done
set -- "${_passargs[@]}"
# The main-repo git resolution below is Claude-only (settings injection is
# gated on `$agent == claude`), so once `-w` is stripped for a non-Claude
# agent there is nothing left to resolve — skip the git calls.
has_worktree=0
fi
local main_repo=""
if [[ "$has_worktree" -eq 1 ]]; then
local common_dir
common_dir="$(git -C "$PWD" rev-parse --git-common-dir 2>/dev/null || true)"
if [[ -n "$common_dir" ]]; then
case "$common_dir" in
/*) ;;
*) common_dir="$PWD/$common_dir" ;;
esac
main_repo="$(cd "$(dirname "$common_dir")" 2>/dev/null && pwd)"
fi
fi
local -a allow_read_paths=()
local candidate existing seen
for candidate in "$cwd_toplevel" "$main_repo"; do
[[ -z "$candidate" ]] && continue
seen=0
for existing in "${allow_read_paths[@]}"; do
if [[ "$existing" == "$candidate" ]]; then
seen=1
break
fi
done
[[ "$seen" -eq 0 ]] && allow_read_paths+=("$candidate")
done
# The `--settings` sandbox-allowRead injection is Claude Code's in-process
# sandbox feature and is applied only for that agent. Other harnesses (e.g.
# OpenCode) get their filesystem isolation from the OS-level sandbox of the
# secure-agent setup, which is configured out of band — so the clean-env
# launch below still applies, we just skip the Claude-only settings grant.
if [[ "$agent" == "claude" ]] && (( ${#allow_read_paths[@]} > 0 )); then
# Hand-roll the JSON array literal (escape backslashes and
# double quotes) so a pathological repo path can't break out
# of the string literal. Keeping it dependency-free — no jq.
local json_array="" banner_paths="" sep=""
local p escaped
for p in "${allow_read_paths[@]}"; do
escaped="${p//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
json_array+="${sep}\"${escaped}\""
banner_paths+="${sep}\"${p}\""
sep=","
done
set -- --settings "{\"sandbox\":{\"filesystem\":{\"allowRead\":[${json_array}]}}}" "$@"
if [[ -t 2 ]]; then
printf '\033[2m[claude-iso] added to sandbox allowRead: %s\033[0m\n' "$banner_paths" >&2
else
printf '[claude-iso] added to sandbox allowRead: %s\n' "$banner_paths" >&2
fi
fi
# When the user has aliased `claude=claude-iso`, an interactive
# session looks indistinguishable from a normal `claude` launch.
# Print a one-line banner on stderr (dim if a TTY) so it's obvious
# which mode the agent is starting in.
if [[ -t 2 ]]; then
printf '\033[2m[%s-iso] running in isolated env (%s)\033[0m\n' "$agent" "$agent_bin" >&2
else
printf '[%s-iso] running in isolated env (%s)\n' "$agent" "$agent_bin" >&2
fi
# `exec` replaces the current process with the agent. That is right when
# this script was *executed* (one less process, and the agent inherits the
# caller's exit-status slot) but wrong when it was *sourced*: there the
# current process is the user's own interactive shell, so `exec` would
# replace it — quitting the agent would close the terminal — and a failed
# exec (lost permission bit, bad interpreter, binary replaced mid-upgrade)
# would take the shell down with it. In sourced mode run the agent as a
# child and hand its exit status back to the shell instead.
if [[ -n "${_AGENT_ISO_SOURCED-}" ]]; then
env -i "${env_args[@]}" "$agent_bin" "$@"
return $?
fi
exec env -i "${env_args[@]}" "$agent_bin" "$@"
}
# Back-compat wrapper: the historical entry point defaults to `claude`, and
# still honours AGENT_ISO_AGENT for callers that set it.
claude_iso_main() { agent_iso_run "${AGENT_ISO_AGENT:-claude}" "$@"; }
# When sourced, expose one launcher per agent as a shell function (so a user
# can `alias claude=claude-iso` and/or `alias opencode=opencode-iso`). When
# executed directly, pick the agent from the invoked name (a symlink such as
# `opencode-iso` selects OpenCode) or from AGENT_ISO_AGENT, defaulting to
# `claude` so existing `bash agent-iso.sh …` invocations are unchanged.
#
# Generic entry point:
# agent-iso() / `bash agent-iso.sh agent-iso <cli> [args]` takes the
# harness CLI name as its first positional argument and works for ANY
# agentic harness — Codex, Cursor, Gemini CLI, Aider, or any future CLI.
# The credential-strip and clean-env launch are harness-agnostic; only the
# `--settings` sandbox allowRead injection is skipped (it is Claude-specific
# and already guarded by `if [[ "$agent" == "claude" ]]`).
if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then
# Sourced: every entry point below runs inside the user's interactive
# shell, so nothing on this path may `exec` or `exit` — both would take
# the shell with them. The flag tells `agent_iso_run` to launch the agent
# as a child and `return`; the guards below use `return`, never `exit`.
#
# Each entry point sets the flag itself, as a `local`, rather than relying
# on a global set here: a shell that replays these functions from a
# snapshot (Claude Code's Bash tool does, via `typeset -f`) gets the
# function bodies and the `alias claude=claude-iso`, but not this file's
# plain variables. With a global flag, `claude --version` in such a shell
# took the `exec` path and replaced the shell, dropping the rest of the
# command.
claude-iso() { local _AGENT_ISO_SOURCED=1; agent_iso_run claude "$@"; }
opencode-iso() { local _AGENT_ISO_SOURCED=1; agent_iso_run opencode "$@"; }
kiro-iso() { local _AGENT_ISO_SOURCED=1; agent_iso_run kiro "$@"; }
# Harness-agnostic entry point: agent-iso <cli> [cli-args]
# Guard the no-CLI case so it matches the direct-exec path (usage + exit 1)
# instead of falling through to agent_iso_run with an empty agent name.
agent-iso() {
if [[ $# -lt 1 ]]; then
printf 'Usage: agent-iso <cli> [cli-args]\n e.g.: agent-iso codex "my prompt"\n' >&2
return 1
fi
local _AGENT_ISO_SOURCED=1
agent_iso_run "$@"
}
else
# Executed directly: this process exists only to become the agent, so the
# `exec` in `agent_iso_run` and the `exit`s below are the correct exits.
_AGENT_ISO_SOURCED=
_aig_basename="$(basename "${0}")"
case "$_aig_basename" in
opencode-iso*) agent_iso_run opencode "$@" ;;
kiro-iso*) agent_iso_run kiro "$@" ;;
# Symlink named exactly `agent-iso` (no .sh extension) → first positional
# arg is the harness CLI name.
agent-iso)
if [[ $# -ge 1 ]]; then
_aig_cli="$1"; shift; agent_iso_run "$_aig_cli" "$@"
else
printf 'Usage: %s <cli> [cli-args]\n e.g.: agent-iso codex "my prompt"\n' \
"$_aig_basename" >&2
exit 1
fi
;;
*)
# Default direct-exec path (e.g. `bash agent-iso.sh`).
# If the first positional arg is the literal word "agent-iso", treat
# the next arg as the harness CLI name — generic sub-command:
# bash agent-iso.sh agent-iso codex [codex-args]
if [[ "${1-}" == "agent-iso" ]]; then
shift
if [[ $# -ge 1 ]]; then
_aig_cli="$1"; shift; agent_iso_run "$_aig_cli" "$@"
else
printf 'Usage: %s agent-iso <cli> [cli-args]\n e.g.: bash %s agent-iso codex "my prompt"\n' \
"$_aig_basename" "$_aig_basename" >&2
exit 1
fi
else
agent_iso_run "${AGENT_ISO_AGENT:-claude}" "$@"
fi
;;
esac
fi