Files
stack/packages/mosaic/framework/tools/_scripts/mosaic-ensure-sequential-thinking
T
fred 585dac7a5d fix(launch): resolve the runtime binary once and execute the object that was checked
AMD1213-D defect D3. The fleet launch path asked `which` whether a runtime was
reachable and then spawned the bare name, letting the OS resolve it a second
time against an ambient PATH at a later moment. Two independent resolutions of
an attacker-influenced name with a gap in between is not a check.

Measured against the old code before changing it. A world-writable shim named
`codex` prepended to PATH:

    OLD checkRuntime  -> PASSED (which found it)
    OLD execRuntime   -> "SHIM EXECUTED -- this is not the real runtime"

The probe satisfied the check and then supplied the thing that ran.

Three call sites were exposed, not one: `checkRuntime`'s `which`; `execRuntime`
spawning 'codex'/'opencode' by name; and `execLeaseGatedRuntime` spawning
'python3' by name -- the interpreter that starts the lease gate itself, where a
shim does not bypass one check, it replaces the process that enforces all of
them. `minimalLaunchEnv` copies ambient PATH straight through, so the child
inherits the same search.

The fix: `resolveExecutableFromPath` searches only the PATH the child will
actually receive, validates the object the search lands on (regular file,
executable, not group/other-writable, owned by the launching user or root, with
no group/world-writable non-sticky directory and no foreign-owned directory on
its resolved path), and returns that path pinned to its dev/ino. Callers execute
the returned path, never the name again. Rules that are each a hole if dropped:
a relative PATH entry is skipped, since it resolves against wherever the
launcher was started; the first name match decides the outcome and an unsafe
first match is a refusal rather than a reason to keep looking, because falling
through would let a planted binary silently downgrade the search to whatever
came after it; a symlink is followed and the real file is what gets validated
and executed, since validating the link and executing the name repeats the
original bug one level down.

`checkRuntime` is kept unchanged on the operator path. `which` proves
reachability from the operator's own shell, which is the right question there
and the wrong one for a seat. The fleet lease-gate interpreter now comes from
the root-owned `trustedCapability('python3')` the helper already requires.

Two residuals, stated rather than engineered around:

  * `assertUnchangedSinceValidation` re-confirms dev/ino immediately before
    spawn. That narrows the validation-to-exec window; it does not close it.
    Closing it means executing a held descriptor and Node has no portable way to
    exec by descriptor. A same-UID replacement landing inside the remaining
    window is the same accepted boundary already documented for the fleet
    helper.
  * For claude and pi the runtime binary is still re-resolved inside
    launch-runtime.py after the trusted interpreter starts it. This change does
    not cover that path.

Twelve tests in launch.spec.ts, each written against a specific hole: safe
resolution; world-writable binary; safe binary under a world-writable
directory; no fall-through past an unsafe first match; relative PATH entry
ignored; symlink followed and real file validated; symlink to an unsafe target
refused; non-executable refused; directory sharing the name refused; a path
rather than a name refused; no PATH declared; not-found reported as not-found
rather than resolving something else.

One of those tests was written wrong first and is worth recording: creating the
open directory with `mkdirSync(path, { mode: 0o777 })` gets masked by the umask
to 0o755, so the case passed while testing nothing. It creates at 0o755 and
chmods after.

Verification: typecheck RC=0. Full package suite 1615 passed / 4 failed / 1619.
The four failures are the pre-existing host lease-identity leak into spawned
hooks, not this change -- the same spec re-run with only the five MOSAIC_LEASE_*
and MOSAIC_RUNTIME_GENERATION variables stripped from the environment, with no
code change, is 20/20.

Scope note: this commit carries the uncommitted D1/D4/D6 work already present in
the tree alongside D3, because it is interleaved in the same files and is one
amend package. D2 and D5 are not yet assessed.

Commit-only per scrappy's controlling packet (comms 20260813T212447Z dc43de):
not pushed, PR #1213 not updated, nothing re-authored.
2026-08-15 14:07:57 -05:00

149 lines
8.4 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# Fleet launches execute this source through an already-validated absolute bash
# capability and pass all interpreter capabilities explicitly. Do not add PATH
# lookup here: this helper is intentionally capability-minimal.
MODE="apply"
RUNTIME="all"
STRICT_CHECK=0
CLAUDE_CONFIG_DIR=""
PYTHON_BIN=""
NODE_BIN=""
NPX_BIN=""
TIMEOUT_BIN=""
PKG="@modelcontextprotocol/server-sequential-thinking"
err() { echo "[mosaic-seq] ERROR: $*" >&2; }
log() { echo "[mosaic-seq] $*"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--check) MODE="check"; shift ;;
--runtime) RUNTIME="${2:?--runtime requires a value}"; shift 2 ;;
--strict) STRICT_CHECK=1; shift ;;
--claude-config-dir) CLAUDE_CONFIG_DIR="${2:?--claude-config-dir requires a value}"; shift 2 ;;
--python-bin) PYTHON_BIN="${2:?--python-bin requires a value}"; shift 2 ;;
--node-bin) NODE_BIN="${2:?--node-bin requires a value}"; shift 2 ;;
--npx-bin) NPX_BIN="${2:?--npx-bin requires a value}"; shift 2 ;;
--timeout-bin) TIMEOUT_BIN="${2:?--timeout-bin requires a value}"; shift 2 ;;
*) err "Unknown argument: $1"; exit 2 ;;
esac
done
case "$RUNTIME" in all|claude|codex|opencode) ;; *) err "Invalid runtime: $RUNTIME"; exit 2;; esac
# Explicit fleet-seat operation is capability-minimal. Legacy operator repair
# keeps its documented PATH-based compatibility contract.
if [[ -n "$CLAUDE_CONFIG_DIR" || -n "$PYTHON_BIN$NODE_BIN$NPX_BIN$TIMEOUT_BIN" ]]; then
[[ -n "$PYTHON_BIN" && -n "$NODE_BIN" && -n "$NPX_BIN" ]] || { err "Fleet capabilities are required"; exit 2; }
else
PYTHON_BIN=python3
NODE_BIN=node
NPX_BIN=npx
TIMEOUT_BIN=timeout
fi
warm_package() {
local timeout_sec="${MOSAIC_SEQ_WARM_TIMEOUT_SEC:-15}"
if [[ -n "$TIMEOUT_BIN" ]]; then "$TIMEOUT_BIN" "$timeout_sec" "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1
else "$NPX_BIN" -y "$PKG" --help >/dev/null 2>&1; fi
}
claude_config_python='import json, os, stat, tempfile
from pathlib import Path
def die(): raise SystemExit(1)
def secure_dir(p):
p=Path(p)
if not p.is_absolute(): die()
# Every parent may be sticky /tmp, but none may be a symlink. The fleet
# config root itself must be private and owned by the invoking principal.
for q in [p, *p.parents]:
try: s=os.lstat(q)
except OSError: die()
if stat.S_ISLNK(s.st_mode) or not stat.S_ISDIR(s.st_mode): die()
if q != p and s.st_mode & 0o022 and not (s.st_mode & stat.S_ISVTX): die()
s=os.lstat(p)
if s.st_uid not in (os.geteuid(), 0) or s.st_mode & 0o022: die()
return p
def read_private(p):
try: fd=os.open(p, os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
except OSError: die()
try:
s=os.fstat(fd)
if not stat.S_ISREG(s.st_mode) or s.st_uid not in (os.geteuid(),0) or s.st_mode & 0o077 or s.st_size>1048576: die()
data=b""
while len(data)<=1048576:
c=os.read(fd,65536)
if not c: break
data+=c
if len(data)>1048576: die()
return data, (s.st_dev,s.st_ino)
finally: os.close(fd)
def entry_ok(data):
try: d=json.loads(data.decode()); e=d.get("mcpServers",{}).get("sequential-thinking",{})
except Exception: return False
return e.get("command")=="npx" and e.get("args")==["-y","@modelcontextprotocol/server-sequential-thinking"]
def explicit_check_or_apply(apply):
root=secure_dir(os.environ["CLAUDE_CONFIG_DIR"]); p=root/".claude.json"
if not apply: return 0 if entry_ok(read_private(str(p))[0]) else 1
old={}; identity=None
if os.path.lexists(p):
raw,identity=read_private(str(p))
try: old=json.loads(raw.decode())
except Exception: old={}
mcp=old.get("mcpServers") if isinstance(old.get("mcpServers"),dict) else {}
mcp["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; old["mcpServers"]=mcp
fd,tmp=tempfile.mkstemp(prefix=".claude.json.",dir=root)
try:
os.fchmod(fd,0o600); os.write(fd,(json.dumps(old,indent=2)+"\n").encode()); os.fsync(fd); os.close(fd)
try: now=os.lstat(p); current=(now.st_dev,now.st_ino)
except FileNotFoundError: current=None
if current!=identity: die()
os.replace(tmp,p)
finally:
try: os.close(fd)
except OSError: pass
try: os.unlink(tmp)
except FileNotFoundError: pass
return 0
if os.environ.get("CLAUDE_CONFIG_DIR"):
raise SystemExit(explicit_check_or_apply(os.environ.get("SEQ_APPLY")=="1"))
# Compatibility path is intentionally not fleet-authoritative.
p=Path.home()/".claude.json"
if not p.exists() and not os.environ.get("SEQ_APPLY")=="1": p=Path.home()/".claude"/"settings.json"
if os.environ.get("SEQ_APPLY")=="1":
try: d=json.loads(p.read_text()) if p.exists() else {}
except Exception: d={}
m=d.get("mcpServers") if isinstance(d.get("mcpServers"),dict) else {}
m["sequential-thinking"]={"command":"npx","args":["-y","@modelcontextprotocol/server-sequential-thinking"]}; d["mcpServers"]=m
p.parent.mkdir(parents=True,exist_ok=True); p.write_text(json.dumps(d,indent=2)+"\n")
raise SystemExit(0)
try: raise SystemExit(0 if entry_ok(p.read_bytes()) else 1)
except Exception: raise SystemExit(1)'
check_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=0 "$PYTHON_BIN" -c "$claude_config_python"; }
apply_claude_config() { CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" SEQ_APPLY=1 "$PYTHON_BIN" -c "$claude_config_python"; }
check_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; s=Path(os.environ["CODEX_CFG"]).read_text(); ok=bool(re.search(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]",s,re.M) and "command = \"npx\"" in s and "@modelcontextprotocol/server-sequential-thinking" in s); raise SystemExit(0 if ok else 1)'; }
apply_codex_config() { CODEX_CFG="${CODEX_HOME:-$HOME/.codex}/config.toml" "$PYTHON_BIN" -c 'import os,re; from pathlib import Path; p=Path(os.environ["CODEX_CFG"]); p.parent.mkdir(parents=True,exist_ok=True); out=[]; skip=False
for line in (p.read_text().splitlines() if p.exists() else []):
if re.match(r"^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]$",line): skip=True; continue
if skip and line.startswith("["): skip=False
if not skip: out.append(line)
p.write_text("\n".join(out).rstrip()+"\n\n[mcp_servers.sequential-thinking]\ncommand = \"npx\"\nargs = [\"-y\", \"@modelcontextprotocol/server-sequential-thinking\"]\n")'; }
check_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; d=json.loads(p.read_text()); e=d.get("mcp",{}).get("sequential-thinking"); expected={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; raise SystemExit(0 if e==expected else 1)' ; }
apply_opencode_config() { XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" "$PYTHON_BIN" -c 'import json,os; from pathlib import Path; p=Path(os.environ["XDG_CONFIG_HOME"])/"opencode/config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home()/".config/opencode/config.json"; p.parent.mkdir(parents=True,exist_ok=True); d=json.loads(p.read_text()) if p.exists() else {}; m=d.get("mcp") if isinstance(d.get("mcp"),dict) else {}; m["sequential-thinking"]={"type":"local","command":["npx","-y","@modelcontextprotocol/server-sequential-thinking"],"enabled":True}; d["mcp"]=m; p.write_text(json.dumps(d,indent=2)+"\n")'; }
check_runtime_config() { case "$RUNTIME" in all) check_claude_config && check_codex_config && check_opencode_config;; claude) check_claude_config;; codex) check_codex_config;; opencode) check_opencode_config;; esac; }
apply_runtime_config() { case "$RUNTIME" in claude) apply_claude_config;; codex) apply_codex_config;; opencode) apply_opencode_config;; all) apply_claude_config && apply_codex_config && apply_opencode_config;; esac; }
if [[ "$MODE" == check ]]; then
check_runtime_config
if [[ "$STRICT_CHECK" == 1 || "${MOSAIC_SEQ_CHECK_WARM:-0}" == 1 ]]; then warm_package || { err "sequential-thinking package warm-up failed in strict mode"; exit 1; }; fi
log "sequential-thinking MCP is configured and available (${RUNTIME})"; exit 0
fi
warm_package || { err "sequential-thinking package warm-up failed"; exit 1; }
apply_runtime_config
log "sequential-thinking MCP configured (${RUNTIME})"