#!/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:
#   GH_TOKEN="$(gh auth token)" claude-iso
#   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 single-credential injection: any env var that the user
  # set on the *invocation* line of `claude-iso` is preserved. We
  # detect this by comparing the inherited env to the parent shell's
  # via the documented contract: the user puts `KEY=value` on the
  # same line as `claude-iso`, so the variable is present in our env
  # exactly when it was passed explicitly.
  #
  # NB: this preserves *any* variable named in CLAUDE_ISO_ALLOW
  # (space-separated), so the user can route additional credentials
  # in for one session via:
  #     CLAUDE_ISO_ALLOW="GH_TOKEN AWS_PROFILE" GH_TOKEN=... claude-iso
  if [[ -n "${CLAUDE_ISO_ALLOW-}" ]]; 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=(${=CLAUDE_ISO_ALLOW})
    else
      # shellcheck disable=SC2206
      allow_list=($CLAUDE_ISO_ALLOW)
    fi
    for var in "${allow_list[@]}"; do
      eval "val=\${$var-}"
      if [[ -n "$val" ]]; then
        env_args+=("${var}=${val}")
      fi
    done
  fi

  # Common one-off injections that don't need CLAUDE_ISO_ALLOW: if
  # the user explicitly set GH_TOKEN/ANTHROPIC_API_KEY on the
  # invocation line we honour it. (We can tell because the parent
  # shell didn't have it — well, actually we can't reliably tell
  # without a shadow. The conservative read: include these only when
  # the user named them in CLAUDE_ISO_ALLOW.)

  # 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 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 runtime — 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
  claude-iso()   { agent_iso_run claude "$@"; }
  opencode-iso() { agent_iso_run opencode "$@"; }
  kiro-iso()     { 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
    agent_iso_run "$@"
  }
else
  _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
