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:
2026-10-27 21:48:33 -04:00
parent 6a70dd2b2b
commit a08a69f143
11 changed files with 450 additions and 28 deletions
+77
View File
@@ -61,6 +61,83 @@ grep -c 'accept comment "aisb-direct"' "$RULES" | grep -qx 3 && _ok "exactly 3 d
t_name "no DNS hole when the gate is in front"
grep -E 'th dport 53' "$RULES" >/dev/null && _bad "DNS is closed in gate mode" || _ok "DNS is closed in gate mode"
t_name "the seeding script survives being passed in single quotes"
# Everything between `bash -c '` and `' aisb-seed` is one single-quoted argument of the
# outer script. One quote inside it closes that argument early and the rest of the file
# silently becomes code in the *outer* shell -- valid bash, wrong program, so neither
# `bash -n` nor shellcheck notices. Hence this test.
INNER="$WORK/seeding-inner.sh"
awk "/setpriv .*bash -c '/{f=1;next} /^ *' aisb-seed/{f=0} f" "$EP" > "$INNER"
[[ -s "$INNER" ]] && _ok "the seeding script was extracted" || _bad "the seeding script was extracted"
if grep -q "'" "$INNER"; then
_bad "it contains no single quote" "found: $(grep -n "'" "$INNER" | head -2 | tr '\n' ' ')"
else
_ok "it contains no single quote"
fi
bash -n "$INNER" && _ok "and it is valid bash on its own" || _bad "and it is valid bash on its own"
grep -q '\$lvl' "$INNER" && _ok "the thinking level is enforced inside it" || _bad "thinking level enforced"
t_name "settings.json is merged per key, not managed whole"
# Run the merge exactly as the entrypoint does, with the program extracted from the image
# script: T = what the sandbox has, B = what we last wrote, N = what the host says now.
MJQ="$WORK/merge.jq"
awk '/^SETTINGS_MERGE_JQ=/{f=1;next} f && /^.$/{exit} f' "$EP" > "$MJQ"
[[ "$(wc -l < "$MJQ")" -gt 3 ]] && _ok "the merge program was extracted" || _bad "the merge program was extracted"
M_IN="$WORK/m"; mkdir -p "$M_IN"
cat > "$M_IN/new.json" <<'EOF'
{ "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark",
"thinkingBudgets": { "xhigh": 65536 } }
EOF
cat > "$M_IN/wrote.json" <<'EOF'
{ "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark",
"thinkingBudgets": { "xhigh": 65536 } }
EOF
cat > "$M_IN/sandbox.json" <<'EOF'
{ "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "light/light",
"thinkingBudgets": { "xhigh": 65536 }, "lastChangelogVersion": "1.4.0", "mine": 7 }
EOF
merge_out() { jq -c -n --slurpfile T "$1" --slurpfile B "$2" --slurpfile N "$3" -f "$MJQ"; }
M="$(merge_out "$M_IN/sandbox.json" "$M_IN/wrote.json" "$M_IN/new.json")"
[[ "$(printf '%s' "$M" | jq -r .theme)" == "light/light" ]] \
&& _ok "a key edited in the sandbox stays edited" || _bad "sandbox edit kept: $M"
[[ "$(printf '%s' "$M" | jq -r .lastChangelogVersion)" == "1.4.0" ]] \
&& _ok "a key only pi wrote survives" || _bad "sandbox-only key: $M"
[[ "$(printf '%s' "$M" | jq -r .mine)" == "7" ]] \
&& _ok "and so does one the sandbox invented" || _bad "invented key: $M"
# host moves two keys, drops a third, keeps one the sandbox changed
cat > "$M_IN/new2.json" <<'EOF'
{ "defaultProvider": "gateway", "defaultModel": "deepthink",
"thinkingBudgets": { "xhigh": 131072 } }
EOF
M2="$(merge_out "$M_IN/sandbox.json" "$M_IN/wrote.json" "$M_IN/new2.json")"
[[ "$(printf '%s' "$M2" | jq -r .defaultModel)" == "deepthink" ]] \
&& _ok "an untouched key follows the host again" || _bad "host key arrives: $M2"
[[ "$(printf '%s' "$M2" | jq -r .thinkingBudgets.xhigh)" == "131072" ]] \
&& _ok "including a nested one (whole key, whole value)" || _bad "nested key: $M2"
[[ "$(printf '%s' "$M2" | jq -r 'has("theme")')" == "true" ]] \
&& _ok "a key the host dropped stays if the sandbox had changed it" || _bad "dropped-but-edited key: $M2"
# the same drop against a file the sandbox never touched: it goes away, as it did when
# the whole file was re-seeded -- otherwise settings only ever accumulate
cat > "$M_IN/clean.json" <<'EOF'
{ "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark",
"thinkingBudgets": { "xhigh": 65536 } }
EOF
M3="$(merge_out "$M_IN/clean.json" "$M_IN/wrote.json" "$M_IN/new2.json")"
[[ "$(printf '%s' "$M3" | jq -r 'has("theme")')" == "false" ]] \
&& _ok "an untouched key the host dropped is dropped" || _bad "untouched dropped key: $M3"
# no base copy (the first run after this shipped, or .wrote deleted): the host wins on
# every key it has, which is what repairs a settings file pi had frozen.
M4="$(merge_out "$M_IN/sandbox.json" /dev/null "$M_IN/new.json")"
[[ "$(printf '%s' "$M4" | jq -r '.thinkingBudgets.xhigh')" == "65536" ]] \
&& _ok "with no base, host preferences come back (the frozen-file repair)" || _bad "no-base merge: $M4"
[[ "$(printf '%s' "$M4" | jq -r .lastChangelogVersion)" == "1.4.0" ]] \
&& _ok "and pi's own keys are kept" || _bad "no-base, pi key: $M4"
printf '{oops' > "$M_IN/broken.json"
jq -n --slurpfile T "$M_IN/broken.json" --slurpfile B /dev/null --slurpfile N "$M_IN/new.json" \
-f "$MJQ" > /dev/null 2>&1 \
&& _bad "unparseable input fails loudly for the caller to handle" || _ok "unparseable input fails loudly for the caller to handle"
t_name "privilege drop is the last thing that happens"
tail -5 "$EP" | grep -q 'setpriv' && _ok "exec setpriv at the end" || _bad "setpriv last: $(tail -3 "$EP" | tr '\n' ' ')"
grep -q -- '--bounding-set=-all' "$EP" && _ok "bounding set cleared" || _bad "bounding set cleared"