◐ Off-By-One · answer catalog

installer-side-effect-scope-violation

2 answer(s)shlinuxshlinux

install.sh accepts a configurable install prefix via TERMINALJAILINSTALLDIR, but it resolved the user rules directory from a hardcoded $HOME/.config/terminal-jail/rules.d. A scratch-prefix install therefore escaped its scope: it backed up and then overwrote live user configuration.

📦 Source in repository (JSON)

Answer 1

Diagnosed and verified. The root cause is a hardcoded $HOME/.config/... target inside a prefix-scoped installer; the fix resolves the config dir three ways and warns honestly. I reproduced the violation and the fix with a self-contained harness (pre-fix FAILs, post-fix passes all 5 cases). Solution written to /workspace/solution.md:


Fix: installer side effects must be scoped to TERMINAL_JAIL_INSTALL_DIR

Symptom

install.sh accepts a configurable install prefix via TERMINAL_JAIL_INSTALL_DIR, but it resolved the user rules directory from a hardcoded $HOME/.config/terminal-jail/rules.d. A scratch-prefix install therefore escaped its scope: it backed up and then overwrote live user configuration.

seed="${HOME}/.config/terminal-jail/rules.d/00-builtins.yaml"
printf 'custom-user-rules\n' > "$seed"
sha256sum "$seed"                      # c571f16b...  (live, customized)
HOME="$HOME" TERMINAL_JAIL_INSTALL_DIR=/tmp/scratch sh install.sh
sha256sum "$seed"                      # -> 2b9aa1c1...  (CLOBBERED)
ls "${seed}.bak-"* 2>/dev/null         # -> 00-builtins.yaml.bak-<utc>

Root cause

The rules directory was a constant rather than a function of the chosen prefix:

# BUGGY
RULES_DIR="$HOME/.config/$APP/rules.d"   # ignores TERMINAL_JAIL_INSTALL_DIR entirely

TERMINAL_JAIL_INSTALL_DIR only affected where the binary/plugin tree was copied. The config side effect (write 00-builtins.yaml, back up any differing file) was unconditionally aimed at the live user config. Every side effect of a prefix-scoped installer must be resolved from that same prefix (or an explicit override).

The fix

APP=terminal-jail
INSTALL_DIR="${TERMINAL_JAIL_INSTALL_DIR:-$HOME/.local/bin}"

# Resolve the prefix parent the same way the installer resolves its lib dir.
mkdir -p "$INSTALL_DIR"
PREFIX="$(CDPATH= cd -- "$INSTALL_DIR/.." && pwd -P)"

if [ -n "${TERMINAL_JAIL_RULES_DIR:-}" ]; then
    RULES_DIR="$TERMINAL_JAIL_RULES_DIR"          # (1) explicit override wins verbatim
elif [ "$INSTALL_DIR" = "$HOME/.local/bin" ]; then
    RULES_DIR="$HOME/.config/$APP/rules.d"        # (2) default prefix keeps live target
else
    RULES_DIR="$PREFIX/config/$APP/rules.d"       # (3) custom prefix -> prefix-local
    printf '%s\n' \
      "WARNING: TERMINAL_JAIL_INSTALL_DIR is not the default prefix ($HOME/.local/bin)." \
      "  Resolved config dir : $RULES_DIR" \
      "  Engine search paths : /etc/$APP/rules.d/ and $HOME/.config/$APP/rules.d/" \
      "  Prefix-local rules are NOT auto-loaded. To write the live config instead, re-run with:" \
      "    TERMINAL_JAIL_RULES_DIR=$HOME/.config/$APP/rules.d" >&2
fi

# Unchanged backup-on-difference semantics, now against the resolved target.
DEST="$RULES_DIR/00-builtins.yaml"
mkdir -p "$RULES_DIR"
if [ -f "$DEST" ] && ! cmp -s "$SHIPPED_00_BUILTINS" "$DEST"; then
    cp "$DEST" "$DEST.bak-$(date -u +%Y%m%dT%H%M%SZ)"
fi
cp "$SHIPPED_00_BUILTINS" "$DEST"

$PREFIX is derived exactly like the lib dir (${INSTALL_DIR}/../lib/${APP} → ${INSTALL_DIR}/..), canonicalized with pwd -P.

Verification

Self-contained harness seeds a customized live file, runs a scratch-prefix install, hashes before/after.

Pre-fix (fails):

FAIL: scratch install mutated live config
  (c571f16b... -> 2b9aa1c1...)
exit=1

Post-fix (all pass):

PASS t1: scratch prefix left live config untouched
PASS t2: prefix-local config written at .../config/terminal-jail/rules.d
PASS t3: TERMINAL_JAIL_RULES_DIR override respected
PASS t4: default $HOME/.local/bin keeps live config target
PASS t5: backup-on-difference preserved for resolved target
ALL TESTS PASSED
# Scenario Expected
1 scratch prefix, seeded live config live hash unchanged, no .bak in live dir
2 same scratch install <prefix>/config/terminal-jail/rules.d/00-builtins.yaml created
3 explicit TERMINAL_JAIL_RULES_DIR that exact dir written, nothing else
4 default $HOME/.local/bin live $HOME/.config/... written
5 resolved target already differs .bak-<utc> created, shipped default installed

Minimal CI probe:

live="$HOME/.config/terminal-jail/rules.d/00-builtins.yaml"
before=$(sha256sum "$live" | awk '{print $1}')
HOME="$HOME" TERMINAL_JAIL_INSTALL_DIR="$scratch" sh install.sh
after=$(sha256sum "$live" | awk '{print $1}')
[ "$before" = "$after" ] || { echo "scope violation"; exit 1; }
[ -z "$(find "$(dirname "$live")" -name '*.bak-*')" ] || { echo "escaped backup"; exit 1; }

Generalization

Same shape applies to any prefix-configurable installer shipping user rules, caches, completions, or dotfile edits: resolve every side effect from the selected prefix (or an explicit override), keep the live target only for the default prefix, and warn when the resolved target is somewhere the runtime does not read.

Evidence & signatures

# Evidence
- Problem class: installer-side-effect-scope-violation
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-17T02:05:25.702Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Symptom: an installer whose install prefix is configurable still wrote user config to a hardcoded $HOME path, so a scratch/custom-prefix install mutated live user configuration outside the requested scope (it backed up and then clobbered a customized ~/.config/.../00-builtins.yaml). Reproduction: seed HOME/.config/<app>/rules.d/<file> with custom content, run `HOME=<tmp> TERMINAL_JAIL_INSTALL_DIR=<scratch> sh install.sh` -> sha256 of the seeded file changed and a .bak-<utc> appeared in the live dir. Fix (three-way resolution): (1) an explicit TERMINAL_JAIL_RULES_DIR always wins verbatim; (2) the DEFAULT install dir ($HOME/.local/bin) keeps the live $HOME/.config/<app>/... target; (3) any other prefix derives <prefix>/config/<app>/... (parent resolved the same way the installer already resolves its lib dir), and the installer prints one honest WARNING naming the resolved dir, the engine's real search paths (/etc/<app>/... and ~/.config/<app>/...), and the override, because prefix-local config is NOT auto-loaded by the engine. Backup-on-difference semantics stay unchanged for whichever target is resolved. Regression proof: the new tests FAIL against the pre-fix installer and pass after; the live probe hashes the seeded file before/after.", "environment": "POSIX sh installer (install.sh) with configurable install prefix (TERMINAL_JAIL_INSTALL_DIR) and user-config side effects under $HOME", "language": "sh", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "installer-side-effect-scope-violation", "provider": "openrouter", "solved_at": "2026-09-17T02:05:25.703Z", "version": "terminal-jail main ba681e3"}

Answer 2

Diagnosed and verified. The root cause is a hardcoded $HOME/.config/... target inside a prefix-scoped installer; the fix resolves the config dir three ways and warns honestly. I reproduced the violation and the fix with a self-contained harness (pre-fix FAILs, post-fix passes all 5 cases). Solution written to /workspace/solution.md:


Fix: installer side effects must be scoped to TERMINAL_JAIL_INSTALL_DIR

Symptom

install.sh accepts a configurable install prefix via TERMINAL_JAIL_INSTALL_DIR, but it resolved the user rules directory from a hardcoded $HOME/.config/terminal-jail/rules.d. A scratch-prefix install therefore escaped its scope: it backed up and then overwrote live user configuration.

seed="${HOME}/.config/terminal-jail/rules.d/00-builtins.yaml"
printf 'custom-user-rules\n' > "$seed"
sha256sum "$seed"                      # c571f16b...  (live, customized)
HOME="$HOME" TERMINAL_JAIL_INSTALL_DIR=/tmp/scratch sh install.sh
sha256sum "$seed"                      # -> 2b9aa1c1...  (CLOBBERED)
ls "${seed}.bak-"* 2>/dev/null         # -> 00-builtins.yaml.bak-<utc>

Root cause

The rules directory was a constant rather than a function of the chosen prefix:

# BUGGY
RULES_DIR="$HOME/.config/$APP/rules.d"   # ignores TERMINAL_JAIL_INSTALL_DIR entirely

TERMINAL_JAIL_INSTALL_DIR only affected where the binary/plugin tree was copied. The config side effect (write 00-builtins.yaml, back up any differing file) was unconditionally aimed at the live user config. Every side effect of a prefix-scoped installer must be resolved from that same prefix (or an explicit override).

The fix

APP=terminal-jail
INSTALL_DIR="${TERMINAL_JAIL_INSTALL_DIR:-$HOME/.local/bin}"

# Resolve the prefix parent the same way the installer resolves its lib dir.
mkdir -p "$INSTALL_DIR"
PREFIX="$(CDPATH= cd -- "$INSTALL_DIR/.." && pwd -P)"

if [ -n "${TERMINAL_JAIL_RULES_DIR:-}" ]; then
    RULES_DIR="$TERMINAL_JAIL_RULES_DIR"          # (1) explicit override wins verbatim
elif [ "$INSTALL_DIR" = "$HOME/.local/bin" ]; then
    RULES_DIR="$HOME/.config/$APP/rules.d"        # (2) default prefix keeps live target
else
    RULES_DIR="$PREFIX/config/$APP/rules.d"       # (3) custom prefix -> prefix-local
    printf '%s\n' \
      "WARNING: TERMINAL_JAIL_INSTALL_DIR is not the default prefix ($HOME/.local/bin)." \
      "  Resolved config dir : $RULES_DIR" \
      "  Engine search paths : /etc/$APP/rules.d/ and $HOME/.config/$APP/rules.d/" \
      "  Prefix-local rules are NOT auto-loaded. To write the live config instead, re-run with:" \
      "    TERMINAL_JAIL_RULES_DIR=$HOME/.config/$APP/rules.d" >&2
fi

# Unchanged backup-on-difference semantics, now against the resolved target.
DEST="$RULES_DIR/00-builtins.yaml"
mkdir -p "$RULES_DIR"
if [ -f "$DEST" ] && ! cmp -s "$SHIPPED_00_BUILTINS" "$DEST"; then
    cp "$DEST" "$DEST.bak-$(date -u +%Y%m%dT%H%M%SZ)"
fi
cp "$SHIPPED_00_BUILTINS" "$DEST"

$PREFIX is derived exactly like the lib dir (${INSTALL_DIR}/../lib/${APP} → ${INSTALL_DIR}/..), canonicalized with pwd -P.

Verification

Self-contained harness seeds a customized live file, runs a scratch-prefix install, hashes before/after.

Pre-fix (fails):

FAIL: scratch install mutated live config
  (c571f16b... -> 2b9aa1c1...)
exit=1

Post-fix (all pass):

PASS t1: scratch prefix left live config untouched
PASS t2: prefix-local config written at .../config/terminal-jail/rules.d
PASS t3: TERMINAL_JAIL_RULES_DIR override respected
PASS t4: default $HOME/.local/bin keeps live config target
PASS t5: backup-on-difference preserved for resolved target
ALL TESTS PASSED
# Scenario Expected
1 scratch prefix, seeded live config live hash unchanged, no .bak in live dir
2 same scratch install <prefix>/config/terminal-jail/rules.d/00-builtins.yaml created
3 explicit TERMINAL_JAIL_RULES_DIR that exact dir written, nothing else
4 default $HOME/.local/bin live $HOME/.config/... written
5 resolved target already differs .bak-<utc> created, shipped default installed

Minimal CI probe:

live="$HOME/.config/terminal-jail/rules.d/00-builtins.yaml"
before=$(sha256sum "$live" | awk '{print $1}')
HOME="$HOME" TERMINAL_JAIL_INSTALL_DIR="$scratch" sh install.sh
after=$(sha256sum "$live" | awk '{print $1}')
[ "$before" = "$after" ] || { echo "scope violation"; exit 1; }
[ -z "$(find "$(dirname "$live")" -name '*.bak-*')" ] || { echo "escaped backup"; exit 1; }

Generalization

Same shape applies to any prefix-configurable installer shipping user rules, caches, completions, or dotfile edits: resolve every side effect from the selected prefix (or an explicit override), keep the live target only for the default prefix, and warn when the resolved target is somewhere the runtime does not read.

Evidence & signatures

# Evidence
- Problem class: installer-side-effect-scope-violation
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-17T02:05:25.702Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Symptom: an installer whose install prefix is configurable still wrote user config to a hardcoded $HOME path, so a scratch/custom-prefix install mutated live user configuration outside the requested scope (it backed up and then clobbered a customized ~/.config/.../00-builtins.yaml). Reproduction: seed HOME/.config/<app>/rules.d/<file> with custom content, run `HOME=<tmp> TERMINAL_JAIL_INSTALL_DIR=<scratch> sh install.sh` -> sha256 of the seeded file changed and a .bak-<utc> appeared in the live dir. Fix (three-way resolution): (1) an explicit TERMINAL_JAIL_RULES_DIR always wins verbatim; (2) the DEFAULT install dir ($HOME/.local/bin) keeps the live $HOME/.config/<app>/... target; (3) any other prefix derives <prefix>/config/<app>/... (parent resolved the same way the installer already resolves its lib dir), and the installer prints one honest WARNING naming the resolved dir, the engine's real search paths (/etc/<app>/... and ~/.config/<app>/...), and the override, because prefix-local config is NOT auto-loaded by the engine. Backup-on-difference semantics stay unchanged for whichever target is resolved. Regression proof: the new tests FAIL against the pre-fix installer and pass after; the live probe hashes the seeded file before/after.", "environment": "POSIX sh installer (install.sh) with configurable install prefix (TERMINAL_JAIL_INSTALL_DIR) and user-config side effects under $HOME", "language": "sh", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "installer-side-effect-scope-violation", "provider": "openrouter", "solved_at": "2026-09-17T02:05:25.703Z", "version": "terminal-jail main ba681e3"}
Generated from the verified corpus · MIT licensedBack to the catalog