make xhigh the sandbox's thinking, and stop settings.json freezing
Two changes, one bug found through the other.
Thinking is policy now: defaultThinkingLevel is xhigh unless you say otherwise
(thinking / --thinking / AISB_THINKING, with pi's levels plus 'host' to decline
and 'off' meaning pi's own no-reasoning level). It is written into the bridged
settings and re-applied by the entrypoint on every run, so a run cannot set its
own reasoning down for the runs after it. Unknown levels are refused with the
list to pick from. Nothing is written for codex, claude or a command after --,
and --no-pi-config declines it along with the bridge, because pi's own defaults
are what that flag asks for.
The reason xhigh had never reached a sandbox is worse than a missing default:
settings.json was managed as one whole file, and pi rewrites its own settings
(a theme change, "seen the changelog"), so the first such write matched nothing
and froze every host preference out permanently -- provider, model, thinking
budgets and level included, a real session left holding just
{"lastChangelogVersion": "1.4.0"}. It is merged per key instead, against a copy
of what we last wrote: a key edited in the sandbox stays edited, a key never
touched follows the host again, an untouched key the host dropped is dropped, a
key pi invented survives, and a file with no base copy takes the host values --
which repairs the volumes already frozen. Unparseable JSON is re-seeded with a
warning since pi cannot use it either; a file the container cannot read is
reported with the fix, because nothing inside the sandbox can repair one.
One trap worth remembering: the seeding script is passed to bash -c in single
quotes, so a single quote inside it closes the argument early and the rest runs
in the outer shell -- valid bash, wrong program, clean bash -n and clean
shellcheck. tests/unit/06_entrypoint.sh extracts the seeding script and asserts
it is quote-free, and extracts the merge program to test its semantics without
a container; tests/e2e/12_sandbox.sh does the same in a real run, including
forcing the level back after the sandbox sets it to off.
LLM-Generated: true
This commit is contained in:
+148
-14
@@ -42,6 +42,7 @@ AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}"
|
||||
# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION
|
||||
# AISB_BASE_TTL_DAYS AISB_BASE_AUTO_UPDATE AISB_BASE_PULL_FALLBACK AISB_SKOPEO
|
||||
# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS
|
||||
# AISB_PI_CONFIG AISB_PI_TOKEN_ENV AISB_PI_TOKEN_AUTO AISB_PI_AGENT_DIR AISB_THINKING
|
||||
# AISB_AGENTS AISB_AGENTS_DIR AISB_AGENTS_DEST
|
||||
AISB_UID="${AISB_UID:-1000}"
|
||||
AISB_GID="${AISB_GID:-1000}"
|
||||
@@ -233,6 +234,17 @@ PI CONFIG
|
||||
--no-pi-token-env Do not pass it automatically; then pass it with --env or
|
||||
the run is refused rather than starting a pi with no key.
|
||||
--no-pi-config Do not bridge at all; pi starts with its own defaults.
|
||||
--thinking LEVEL Default thinking level in the sandbox: off, minimal, low,
|
||||
medium, high, xhigh (the default) or host. It is policy, not
|
||||
a preference: it is written over the bridged settings on
|
||||
every run, so an edit inside the sandbox cannot quietly lower
|
||||
it for the next one. 'host' leaves the key to your
|
||||
settings.json. pi only -- codex, claude and your own command
|
||||
read their own config and ai-sandbox does not touch it.
|
||||
|
||||
settings.json is merged per key rather than managed as a whole file: keys you edit
|
||||
inside the sandbox stay yours, every key you did not touch follows the host again, keys
|
||||
the host dropped go away, and keys the sandbox invented survive.
|
||||
|
||||
LIMITS
|
||||
--cpus N (4) --memory SIZE (4g) --pids N (512) --tmpfs-size SIZE (1g)
|
||||
@@ -274,7 +286,7 @@ CONFIG
|
||||
\$HOME/.config/ai-sandbox/{config,allow} and ./.ai-sandbox/{config,allow}
|
||||
Every default can also be set as an environment variable:
|
||||
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
|
||||
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE,
|
||||
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, AISB_THINKING,
|
||||
AISB_AGENTS, AISB_AGENTS_DIR, AISB_AGENTS_DEST, ...
|
||||
Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never),
|
||||
AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK
|
||||
@@ -315,7 +327,7 @@ cfg_read_kv() { # `key = value` file
|
||||
case "$key" in
|
||||
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\
|
||||
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args|base_ttl_days|base_auto_update|\
|
||||
pi_config|pi_token_env|pi_token_auto|pi_agent_dir|agents|agents_dir|agents_dest) CFG["$key"]="$val" ;;
|
||||
pi_config|pi_token_env|pi_token_auto|pi_agent_dir|thinking|agents|agents_dir|agents_dest) CFG["$key"]="$val" ;;
|
||||
*) warn "$file: unknown key: $key" ;;
|
||||
esac
|
||||
done < "$file"
|
||||
@@ -354,7 +366,7 @@ OPT_NO_CWD=0 OPT_SHELL=0 OPT_DRY=0 OPT_PRINT=0 OPT_NO_BUILD=0 OPT_KEEP=0 OPT_REA
|
||||
OPT_HOME_CACHE="" OPT_STRICT="${AISB_STRICT_ARGS:-0}" OPT_UNENFORCED=0 OPT_KEEP_ROOT=0
|
||||
OPT_TTY="" OPT_INTERACTIVE="" OPT_PRINT_EP=0 OPT_PRINT_CF=0 OPT_PASSTHROUGH=0
|
||||
OPT_AGENTS="" OPT_AGENTS_DIR=""
|
||||
OPT_PI_CONFIG="" OPT_PI_TOKEN_AUTO="" OPT_PI_TOKEN_ENV=""
|
||||
OPT_PI_CONFIG="" OPT_PI_TOKEN_AUTO="" OPT_PI_TOKEN_ENV="" OPT_THINKING=""
|
||||
declare -a OPT_allow=() OPT_env=() OPT_mount=() OPT_install=() OPT_label=() OPT_allow_files=()
|
||||
COMMAND=()
|
||||
|
||||
@@ -365,7 +377,8 @@ KNOWN_OPTS=(
|
||||
workdir home-cache ephemeral-home
|
||||
harness install base-image cpus memory pids tmpfs-size name label keep read-only shell
|
||||
interactive no-tty dry-run print-policy no-build allow-unenforced strict-args insecure-keep-root
|
||||
print-entrypoint print-containerfile pi-token-env no-pi-config no-pi-token-env help version
|
||||
print-entrypoint print-containerfile pi-token-env no-pi-config no-pi-token-env thinking
|
||||
help version
|
||||
)
|
||||
suggest() {
|
||||
local given="$1" o hit=0 hits=""
|
||||
@@ -389,6 +402,8 @@ parse_args() {
|
||||
--no-pi-token-env) OPT_PI_TOKEN_AUTO=0; shift ;;
|
||||
--pi-token-env) need "$1" "${2:-}"; OPT_PI_TOKEN_ENV="$2"; shift 2 ;;
|
||||
--pi-token-env=*) OPT_PI_TOKEN_ENV="${1#*=}"; shift ;;
|
||||
--thinking) need "$1" "${2:-}"; OPT_THINKING="$2"; shift 2 ;;
|
||||
--thinking=*) OPT_THINKING="${1#*=}"; shift ;;
|
||||
--harness=*) OPT_HARNESS="${1#*=}"; shift ;;
|
||||
--allow) need "$1" "${2:-}"; OPT_allow+=("$2"); shift 2 ;;
|
||||
--allow=*) OPT_allow+=("${1#*=}"); shift ;;
|
||||
@@ -513,6 +528,17 @@ resolve_opts() {
|
||||
|| die "pi token variable name is not a valid identifier: '${POL[pi_token_env]}'"
|
||||
POL[pi_token_auto]="${OPT_PI_TOKEN_AUTO:-${AISB_PI_TOKEN_AUTO:-$(cfgv pi_token_auto 1)}}"
|
||||
case "${POL[pi_token_auto]}" in 0|false|no) POL[pi_token_auto]=0 ;; *) POL[pi_token_auto]=1 ;; esac
|
||||
# --- thinking level (policy, not preference: see pi_config_bridge) ----------
|
||||
# pi's own default is medium, which is not what an agentic sandbox run is for, so
|
||||
# ai-sandbox states one. 'host' hands the decision back to settings.json; the entrypoint
|
||||
# enforces the value on every run unless it is empty.
|
||||
POL[thinking]="${OPT_THINKING:-${AISB_THINKING:-$(cfgv thinking xhigh)}}"
|
||||
case "${POL[thinking]}" in
|
||||
off|minimal|low|medium|high|xhigh|max) ;;
|
||||
host|inherit) POL[thinking]=host ;;
|
||||
*) die "--thinking must be off, minimal, low, medium, high, xhigh, max or host (got '${POL[thinking]}')" ;;
|
||||
esac
|
||||
POL[thinking_eff]="" # set by pi_config_bridge: the level actually written, if any
|
||||
|
||||
POL[base_ttl]="${AISB_BASE_TTL_DAYS:-$(cfgv base_ttl_days 7)}"
|
||||
[[ "${POL[base_ttl]}" =~ ^-?[0-9]+$ ]] || { warn "base_ttl_days must be an integer, using 7"; POL[base_ttl]=7; }
|
||||
@@ -745,6 +771,7 @@ write_policy_files() {
|
||||
printf 'AISB_ALLOW_DNS=%s\n' "$ALLOW_DNS"
|
||||
printf 'AISB_PROXY_IPS=%s\n' "${PROXY_IPS[*]-}"
|
||||
printf 'AISB_PROXY_PORTS=%s\n' "${PROXY_PORTS[*]-}"
|
||||
printf 'AISB_PI_THINKING=%s\n' "${POL[thinking_eff]:-}"
|
||||
} > "$SESSION_MOUNT/policy.env"
|
||||
chmod 644 "$SESSION_MOUNT/policy.env"
|
||||
printf '%s\n' ${DIRECT4[@]+"${DIRECT4[@]}"} > "$SESSION_MOUNT/direct4.list"
|
||||
@@ -867,20 +894,56 @@ command -v setpriv >/dev/null || fail "setpriv is missing from the image; rebuil
|
||||
# the entrypoint has no CAP_DAC_OVERRIDE, so root could not write there anyway. It is
|
||||
# also the honest identity -- these are the user's own files.
|
||||
#
|
||||
# A file is only replaced when it is missing or still identical to what we seeded last
|
||||
# time, so an edit made inside the sandbox survives; a change on the host wins next run.
|
||||
# The two files are managed differently, on purpose.
|
||||
#
|
||||
# models.json is a catalog, so it is managed whole: replaced when it is missing or still
|
||||
# identical to what we seeded, left alone once the sandbox has edited it.
|
||||
#
|
||||
# settings.json is a flat bag of preferences, and managing it whole went badly: pi
|
||||
# rewrites it itself (a theme change, "I have seen this changelog"), and from that moment
|
||||
# *every* host preference was frozen out -- provider, model, thinking budgets included,
|
||||
# with only the changelog line left in the file. So it is merged per key against a copy of
|
||||
# what we last wrote (.aisb-settings.json.wrote): a key the sandbox changed stays theirs,
|
||||
# a key it did not touch follows the host again, a key the host dropped disappears unless
|
||||
# the sandbox had changed it, and a key the sandbox invented survives.
|
||||
#
|
||||
# defaultThinkingLevel is the exception inside that merge: it is policy the host set for
|
||||
# the sandbox, so AISB_PI_THINKING is written over the result every run however the file
|
||||
# was edited. Empty (from --thinking host) makes it an ordinary preference again.
|
||||
|
||||
# The merge itself, as an argument rather than a heredoc: the seeding script below is
|
||||
# passed to bash -c in single quotes, so nesting a here-document inside it is unreadable.
|
||||
# Nothing inside that script may contain a single quote either -- one closes the argument
|
||||
# and the rest of the file silently becomes code in the *outer* shell, where $lvl is not
|
||||
# set. jq programs there are written unquoted (.defaultThinkingLevel=\$lvl) for that
|
||||
# reason, and tests/unit/06_entrypoint.sh checks the quoting rather than trusting it.
|
||||
# T = what is in the sandbox now, B = what we last wrote, N = what the host says now.
|
||||
SETTINGS_MERGE_JQ='
|
||||
($T[0] // {}) as $t | ($B[0] // {}) as $b | ($N[0] // {}) as $n
|
||||
| reduce ($t | keys[]) as $k ($t;
|
||||
if ($b | has($k)) and ($n | has($k) | not) and ($t[$k] == $b[$k])
|
||||
then del(.[$k]) else . end)
|
||||
| . as $kept
|
||||
| reduce ($n | keys[]) as $k ($kept;
|
||||
if ($b | has($k)) and ($kept | has($k)) and ($kept[$k] != $b[$k])
|
||||
then . else .[$k] = $n[$k] end)
|
||||
'
|
||||
|
||||
if [[ -d "$POLICY_DIR/pi" ]]; then
|
||||
setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups -- /bin/bash -c '
|
||||
dst="$HOME/.pi/agent"
|
||||
dst="$HOME/.pi/agent"; lvl="$2"; jq_merge="$3"
|
||||
mkdir -p "$dst" || { echo "[aisb] pi config: cannot create $dst" >&2; exit 0; }
|
||||
for f in models.json settings.json; do
|
||||
src="$1/pi/$f"
|
||||
[ -f "$src" ] || continue
|
||||
|
||||
# --- models.json: a catalog, managed as a whole file
|
||||
f=models.json; src="$1/pi/$f"
|
||||
if [ -f "$src" ]; then
|
||||
tgt="$dst/$f"; mark="$dst/.aisb-$f.seeded"
|
||||
want="$(sha256sum "$src" | cut -c1-16)"
|
||||
cur=""; [ -f "$tgt" ] && cur="$(sha256sum "$tgt" | cut -c1-16)"
|
||||
prev=""; [ -f "$mark" ] && prev="$(cat "$mark" 2>/dev/null)"
|
||||
if [ -z "$cur" ]; then
|
||||
if [ -e "$tgt" ] && [ ! -r "$tgt" ]; then
|
||||
echo "[aisb] pi config: cannot read $f -- its mode or SELinux label blocks this container, and nothing inside can repair it (remove it from the host, or use --ephemeral-home)" >&2
|
||||
elif [ -z "$cur" ]; then
|
||||
install -m 600 "$src" "$tgt" && printf %s "$want" > "$mark"
|
||||
echo "[aisb] pi config: seeded $f"
|
||||
elif [ "$cur" = "$prev" ]; then
|
||||
@@ -889,8 +952,47 @@ if [[ -d "$POLICY_DIR/pi" ]]; then
|
||||
else
|
||||
echo "[aisb] pi config: left $f alone (changed inside the sandbox; delete it to take the host version again)"
|
||||
fi
|
||||
done
|
||||
' aisb-seed "$POLICY_DIR" || log "pi config: seeding failed"
|
||||
fi
|
||||
|
||||
# --- settings.json: merged per key, then the policy level on top
|
||||
f=settings.json; src="$1/pi/$f"
|
||||
if [ -f "$src" ]; then
|
||||
tgt="$dst/$f"; wrote="$dst/.aisb-$f.wrote"; tmp="$dst/.aisb-$f.tmp"
|
||||
rm -f "$dst/.aisb-$f.seeded" # the whole-file marker is .wrote now
|
||||
if [ ! -e "$tgt" ]; then
|
||||
install -m 600 "$src" "$tgt" && cp "$tgt" "$wrote"
|
||||
echo "[aisb] pi config: seeded $f"
|
||||
elif [ ! -r "$tgt" ]; then
|
||||
echo "[aisb] pi config: cannot read $f -- its mode or SELinux label blocks this container, and nothing inside can repair it (remove it from the host, or use --ephemeral-home)" >&2
|
||||
else
|
||||
base=/dev/null; [ -f "$wrote" ] && base="$wrote"
|
||||
had=""; [ -n "$lvl" ] && had="$(jq -r ".defaultThinkingLevel // \"\"" "$tgt" 2>/dev/null)"
|
||||
if merged="$(jq -n --slurpfile T "$tgt" --slurpfile B "$base" --slurpfile N "$src" \
|
||||
-f <(printf %s "$jq_merge") 2>/dev/null)" && [ -n "$merged" ]; then
|
||||
if [ -n "$lvl" ]; then
|
||||
merged="$(printf %s "$merged" | jq --arg lvl "$lvl" .defaultThinkingLevel=\$lvl 2>/dev/null)"
|
||||
[ -z "$merged" ] && merged="$(printf null | jq --arg lvl "$lvl" .defaultThinkingLevel=\$lvl)"
|
||||
fi
|
||||
else
|
||||
# Unparseable, so pi could not have used it either, and this file is ours to
|
||||
# manage: take the host version rather than leave a broken one in place.
|
||||
if [ -n "$lvl" ]; then
|
||||
merged="$(jq --arg lvl "$lvl" .defaultThinkingLevel=\$lvl "$src" 2>/dev/null)"
|
||||
else
|
||||
merged="$(jq . "$src" 2>/dev/null)"
|
||||
fi
|
||||
[ -n "$merged" ] && echo "[aisb] pi config: $f was not usable JSON -- re-seeded from the host config, your in-sandbox changes to it are gone" >&2
|
||||
fi
|
||||
if [ -n "$merged" ] && printf "%s\n" "$merged" > "$tmp" \
|
||||
&& install -m 600 "$tmp" "$tgt" && rm -f "$tmp" && cp "$tgt" "$wrote"; then
|
||||
if [ -n "$lvl" ] && [ "$had" != "$lvl" ]; then
|
||||
echo "[aisb] pi config: thinking -> $lvl (policy: --thinking, or host)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
' aisb-seed "$POLICY_DIR" "${AISB_PI_THINKING:-}" "$SETTINGS_MERGE_JQ" \
|
||||
|| log "pi config: seeding failed"
|
||||
fi
|
||||
|
||||
exec setpriv --reuid="$UID_TARGET" --regid="$GID_TARGET" --clear-groups \
|
||||
@@ -1328,6 +1430,8 @@ pi_config_bridge() {
|
||||
fi
|
||||
|
||||
# --- settings.json: preferences travel, host paths do not
|
||||
local lvl="${POL[thinking]}"
|
||||
[[ "$lvl" == host ]] && lvl=""
|
||||
if [[ -r "$src/settings.json" ]]; then
|
||||
if ! jq -e . "$src/settings.json" > /dev/null 2>&1; then
|
||||
warn "pi config: $src/settings.json is not valid JSON -- pi starts without it"
|
||||
@@ -1336,12 +1440,28 @@ pi_config_bridge() {
|
||||
jq --arg home "$homep" '
|
||||
with_entries(select(((.value | type) == "string" and (.value | contains($home))) | not))
|
||||
' "$src/settings.json" > "$dst/settings.json" || die "pi config: could not rewrite $src/settings.json"
|
||||
chmod 644 "$dst/settings.json"
|
||||
dropped="$(jq -r --arg home "$homep" '[to_entries[]
|
||||
| select((.value | type) == "string" and (.value | contains($home))) | .key] | join(", ")' "$src/settings.json")"
|
||||
[[ -n "$dropped" ]] && warn "pi config: dropped settings $dropped -- they point inside the host home, which is not mounted"
|
||||
fi
|
||||
fi
|
||||
# The thinking level is applied to whatever survived, and on its own if the host has no
|
||||
# settings.json at all -- a sandbox that silently thinks at pi's default of medium is
|
||||
# not what --thinking is for. It is written here *and* carried to the entrypoint, which
|
||||
# re-applies it to a settings.json that already exists in the sandbox home.
|
||||
if [[ -n "$lvl" ]]; then
|
||||
if [[ -f "$dst/settings.json" ]]; then
|
||||
jq --arg lvl "$lvl" '.defaultThinkingLevel = $lvl' "$dst/settings.json" \
|
||||
> "$dst/settings.json.new" || die "pi config: could not set the thinking level"
|
||||
mv "$dst/settings.json.new" "$dst/settings.json"
|
||||
else
|
||||
jq -n --arg lvl "$lvl" '{defaultThinkingLevel: $lvl}' > "$dst/settings.json" \
|
||||
|| die "pi config: could not write the thinking level"
|
||||
fi
|
||||
chmod 644 "$dst/settings.json"
|
||||
POL[thinking_eff]="$lvl"
|
||||
log "pi config: thinking -> $lvl (policy; --thinking host to let settings.json decide)"
|
||||
fi
|
||||
|
||||
[[ -f "$dst/models.json" || -f "$dst/settings.json" ]] || { rmdir "$dst" 2>/dev/null || true; PI_GEN_DIR=""; return 0; }
|
||||
|
||||
@@ -1697,6 +1817,12 @@ print_policy() {
|
||||
printf 'mounts=%s\n' "$(join ';' ${P_mount[@]+"${P_mount[@]}"})"
|
||||
printf 'agents=%s\n' "${POL[agents_src]:-off}"
|
||||
if [[ -n "${POL[agents_src]:-}" ]]; then printf 'agents_dest=%s\n' "${POL[agents_dest]}"; fi
|
||||
if [[ -n "${POL[thinking_eff]:-}" ]]; then
|
||||
printf 'thinking=%s (enforced on every run)\n' "${POL[thinking]}"
|
||||
else
|
||||
printf 'thinking=%s (not applied: %s)\n' "${POL[thinking]}" \
|
||||
"$([[ "${POL[thinking]}" == host ]] && printf 'the config decides' || printf 'pi config bridging is off')"
|
||||
fi
|
||||
}
|
||||
|
||||
# ===========================================================================
|
||||
@@ -1857,6 +1983,14 @@ cmd_doctor() {
|
||||
chk WARN "pi credential: \$$ptok is unset or empty -- a bridged pi run is refused (export it, add it to $(cfg_or_home), or use --no-pi-config)"
|
||||
fi
|
||||
fi
|
||||
# The thinking policy: what a pi run in this directory will reason at, and whether
|
||||
# anything is actually there to say so.
|
||||
if [[ -n "${POL[thinking_eff]:-}" ]]; then
|
||||
chk INFO "pi thinking: ${POL[thinking]} (policy, re-applied every run; --thinking host lets settings.json decide)"
|
||||
else
|
||||
chk INFO "pi thinking: ${POL[thinking]} -- $([[ "${POL[thinking]}" == host ]] && printf 'pi takes it from the bridged settings.json' || printf 'not written, pi config bridging is off')"
|
||||
fi
|
||||
|
||||
# The .agents bridge, reported read-only like the pi bridge: doctor never mounts.
|
||||
if [[ -z "${POL[agents_src]:-}" ]]; then
|
||||
chk INFO "agents: your skills are not shared (agents=0, --no-agents, or no ${POL[agents_dir]})"
|
||||
|
||||
Reference in New Issue
Block a user