Skip to content

Commit 5c2fb50

Browse files
committed
feat: bridge Codex v0.5–v0.6 installer/uninstaller fail-closed hardening (2a/2)
Adopt the portable installer-safety improvements from Codex-Game-Studios v0.5–v0.6 (upstream commit 259cff8) into the OpenCode-native port, Phase 2a. - coexistence.sh: add ccgs_state_validate (exists/schema==2/valid JSON/no path-traversal/no symlink) and ccgs_state_owned_paths helpers. - uninstall.sh: fail-closed — remove the source-manifest fallback so missing, stale, malformed, path-traversing, or symlinked install-state aborts without removing files; drop the redundant emptiness-based AGENTS.md re-check (the Python helper already does proven-marker-only deletion); limit pruning to .opencode/ (never shared content roots). - install.sh: --replace-modified opt-in + Python preflight that aborts before any mutation on unowned collisions (always) and locally-modified package-owned files (unless --replace-modified), naming the exact paths. Preflight runs only for cross-target deploys; self-install (source==target) is untouched. - audit.sh: run_install_safety validator (5 static guards) wired into all and exposed as audit.sh install-safety. - README.md / UPGRADING.md: document --replace-modified and fail-closed uninstall. Verified: bash -n on all four scripts; audit.sh install-safety 5/5; audit.sh all 0 errors; scratch matrix 10/10 (fresh install, unowned-collision abort, modified-file abort, --replace-modified backup+replace, missing-state uninstall abort, valid-state uninstall clean removal). Phase 2b (transaction + rollback) is deferred to the next commit.
1 parent b2023fc commit 5c2fb50

6 files changed

Lines changed: 241 additions & 46 deletions

File tree

.opencode/audit.sh

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,51 @@ run_release() {
606606
fi
607607
}
608608

609+
run_install_safety() {
610+
printf '\n── Installer Safety ────────────────────────────────────────\n'
611+
local coex="$root/.opencode/lib/coexistence.sh"
612+
local inst="$root/.opencode/install.sh"
613+
local uninst="$root/.opencode/uninstall.sh"
614+
local problems=0
615+
616+
if grep -q 'ccgs_state_validate()' "$coex" 2>/dev/null; then
617+
pass "coexistence.sh defines ccgs_state_validate"
618+
else
619+
fail "coexistence.sh missing ccgs_state_validate (uninstall cannot fail closed)"
620+
problems=1
621+
fi
622+
623+
if grep -q 'ccgs_state_validate' "$uninst" 2>/dev/null; then
624+
pass "uninstall.sh validates state (fail-closed)"
625+
else
626+
fail "uninstall.sh does not call ccgs_state_validate"
627+
problems=1
628+
fi
629+
630+
if grep -q 'installed-files.json' "$uninst" 2>/dev/null; then
631+
fail "uninstall.sh references installed-files.json (ownership-by-manifest regression)"
632+
problems=1
633+
else
634+
pass "uninstall.sh does not infer ownership from source manifest"
635+
fi
636+
637+
if grep -q -- '--replace-modified' "$inst" 2>/dev/null; then
638+
pass "install.sh supports --replace-modified"
639+
else
640+
fail "install.sh missing --replace-modified opt-in"
641+
problems=1
642+
fi
643+
644+
if grep -q 'Preflight' "$inst" 2>/dev/null; then
645+
pass "install.sh runs a preflight pass"
646+
else
647+
fail "install.sh missing preflight conflict detection"
648+
problems=1
649+
fi
650+
651+
return $problems
652+
}
653+
609654
case "$command" in
610655
all)
611656
run_agents
@@ -618,6 +663,7 @@ case "$command" in
618663
run_resume_contract
619664
run_runtime
620665
run_config
666+
run_install_safety
621667
run_hooks
622668
run_smoke
623669
;;
@@ -631,10 +677,11 @@ case "$command" in
631677
resume-contract) run_resume_contract ;;
632678
runtime) run_runtime ;;
633679
config) run_config ;;
680+
install-safety) run_install_safety ;;
634681
hooks) run_hooks ;;
635682
smoke) run_smoke ;;
636683
release) run_release ;;
637-
*) printf 'Unknown command: %s\nAvailable: all, agents, skills, closeout, checkpoint, playtest, bug-lifecycle, handoff-review, resume-contract, runtime, config, hooks, smoke, release\n' "$command" >&2; exit 2 ;;
684+
*) printf 'Unknown command: %s\nAvailable: all, agents, skills, closeout, checkpoint, playtest, bug-lifecycle, handoff-review, resume-contract, runtime, config, install-safety, hooks, smoke, release\n' "$command" >&2; exit 2 ;;
638685
esac
639686

640687
printf '\n── Result: %d error(s) ──\n' "$errors"

.opencode/install.sh

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ source "$script_dir/lib/coexistence.sh"
1616

1717
# ── Parse arguments ──────────────────────────────────────────────
1818
dry_run=0
19+
replace_modified=0
1920
target_arg=""
2021
opus_model=""
2122
sonnet_model=""
@@ -28,6 +29,7 @@ haiku_variant=""
2829
while [ "$#" -gt 0 ]; do
2930
case "$1" in
3031
--dry-run) dry_run=1; shift ;;
32+
--replace-modified) replace_modified=1; shift ;;
3133
--tier-opus) opus_model="${2:-}"; shift 2 ;;
3234
--tier-sonnet) sonnet_model="${2:-}"; shift 2 ;;
3335
--tier-haiku) haiku_model="${2:-}"; shift 2 ;;
@@ -203,6 +205,7 @@ manifest_path = '$manifest'
203205
preserved_path = '$preserved_file'
204206
created_path = '$created_file'
205207
dry_run = 0
208+
replace_modified = $replace_modified
206209
207210
marker_start = '$ccgs_marker_start'
208211
marker_end = '$ccgs_marker_end'
@@ -243,6 +246,81 @@ def is_coexist_mode(m):
243246
with open(manifest_path) as f:
244247
data = json.load(f)
245248
249+
# ── Preflight: classify paths and detect conflicts before any mutation ──
250+
# A path is package-owned when prior install-state records it. On a fresh
251+
# target (no valid state) every existing manifest path that is not a
252+
# coexist-preserved shared file counts as an unowned collision. We never
253+
# infer ownership from file contents.
254+
_state_path = os.path.join(target, '.opencode', 'install-state.json')
255+
owned_hashes = {}
256+
if os.path.isfile(_state_path) and not os.path.islink(_state_path):
257+
try:
258+
with open(_state_path) as _sf:
259+
_state = json.load(_sf)
260+
if _state.get('schema_version') == 2:
261+
owned_hashes = _state.get('installed_file_hashes', {}) or {}
262+
except Exception:
263+
owned_hashes = {}
264+
has_state = bool(owned_hashes)
265+
266+
def _sha256_of(p):
267+
h = hashlib.sha256()
268+
with open(p, 'rb') as fh:
269+
for _chunk in iter(lambda: fh.read(65536), b''):
270+
h.update(_chunk)
271+
return h.hexdigest()
272+
273+
_collisions = [] # existing paths not proven package-owned
274+
_modified = [] # package-owned paths changed locally
275+
for entry in data.get('files', []):
276+
rel = entry['path']
277+
mode_flag = entry.get('mode', 'copy')
278+
src = os.path.join(source, rel)
279+
dst = os.path.join(target, rel)
280+
if not os.path.exists(src):
281+
continue
282+
if is_foreign(rel):
283+
continue # execution refuses; not a deploy conflict
284+
if mode_flag == 'marker':
285+
continue # marker splice, not a wholesale conflict
286+
if rel == 'opencode.json':
287+
continue # only-if-absent; preserved when present
288+
if is_coexist_mode(mode) and os.path.exists(dst) and not rel.startswith('.opencode/'):
289+
continue # preserved shared content
290+
if not os.path.exists(dst):
291+
continue # create — no conflict
292+
# dst exists and the standard-copy branch would overwrite it — classify
293+
if has_state and rel in owned_hashes:
294+
recorded = owned_hashes.get(rel)
295+
try:
296+
_dst_hash = _sha256_of(dst)
297+
except OSError:
298+
continue
299+
if _dst_hash == recorded:
300+
continue # unchanged since we installed it — normal refresh
301+
_src_hash = _sha256_of(src)
302+
if _dst_hash == _src_hash:
303+
continue # already current
304+
_modified.append(rel)
305+
else:
306+
_collisions.append(rel)
307+
308+
if _collisions or (_modified and not replace_modified):
309+
print('\n-- Preflight conflict: no files were changed --', file=sys.stderr)
310+
if _collisions:
311+
print('Unowned collisions (existing files not proven package-owned):', file=sys.stderr)
312+
for _c in sorted(set(_collisions)):
313+
print(f' - {_c}', file=sys.stderr)
314+
print('Resolve by removing/renaming these files, or install into a clean target.', file=sys.stderr)
315+
if _modified and not replace_modified:
316+
print('Locally-modified package-owned files (would be overwritten):', file=sys.stderr)
317+
for _m in sorted(set(_modified)):
318+
print(f' - {_m}', file=sys.stderr)
319+
print('Review with --dry-run, then re-run with --replace-modified to back up and replace', file=sys.stderr)
320+
print('only state-proven package paths. --replace-modified never overwrites an unowned shared file.', file=sys.stderr)
321+
sys.exit(1)
322+
# end preflight
323+
246324
import filecmp
247325
248326
copied = 0

.opencode/lib/coexistence.sh

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,59 @@ ccgs_install_state_file() {
370370
printf '%s/%s\n' "${target_root:-$PWD}" "$ccgs_install_state_rel"
371371
}
372372

373+
ccgs_state_validate() {
374+
# Validate install-state.json. Returns: 0 valid, 1 invalid, 2 missing.
375+
# Prints a reason to stderr when invalid. Args: target_root (optional)
376+
local root="${1:-${target_root:-$PWD}}"
377+
local state_file="$root/$ccgs_install_state_rel"
378+
if [ ! -f "$state_file" ]; then
379+
printf 'install-state missing: %s\n' "$state_file" >&2
380+
return 2
381+
fi
382+
if [ -L "$state_file" ]; then
383+
printf 'install-state is a symlink (unsafe): %s\n' "$state_file" >&2
384+
return 1
385+
fi
386+
python3 - "$state_file" <<'PY'
387+
import json, sys
388+
from pathlib import Path
389+
p = Path(sys.argv[1])
390+
try:
391+
data = json.loads(p.read_text(encoding="utf-8"))
392+
except Exception as e:
393+
print(f"malformed install-state JSON: {e}", file=sys.stderr); sys.exit(1)
394+
if data.get("schema_version") != 2:
395+
print(f"unsupported schema_version: {data.get('schema_version')}", file=sys.stderr); sys.exit(1)
396+
for key in ("installed_file_hashes", "shared_paths_created", "shared_paths_preserved"):
397+
val = data.get(key, {})
398+
items = val.keys() if isinstance(val, dict) else val
399+
for rel in items:
400+
s = str(rel)
401+
if s.startswith("/") or ".." in s.split("/"):
402+
print(f"unsafe path in {key}: {s}", file=sys.stderr); sys.exit(1)
403+
sys.exit(0)
404+
PY
405+
}
406+
407+
ccgs_state_owned_paths() {
408+
# Emit package-owned paths from install-state, one per line.
409+
# Caller MUST validate state first. Args: target_root (optional)
410+
local root="${1:-${target_root:-$PWD}}"
411+
local state_file="$root/$ccgs_install_state_rel"
412+
python3 - "$state_file" <<'PY'
413+
import json, sys
414+
from pathlib import Path
415+
try:
416+
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
417+
except Exception:
418+
sys.exit(1)
419+
items = data.get("installed_file_hashes", {})
420+
paths = items.keys() if isinstance(items, dict) else items
421+
for rel in sorted(paths):
422+
print(rel)
423+
PY
424+
}
425+
373426
ccgs_write_install_state() {
374427
# Args: target_root source_root mode patch_mode opus_model sonnet_model haiku_model primary_model
375428
# opus_variant sonnet_variant haiku_variant preserved_file created_file

.opencode/uninstall.sh

Lines changed: 27 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -37,45 +37,32 @@ install_mode="$(ccgs_detect_mode)"
3737
printf '── OpenCode Game Studios Uninstall ──\n\n'
3838
ccgs_print_mode_summary "$install_mode"
3939

40-
# ── Read install state for file list ─────────────────────────────
40+
# ── Read install state for file list (fail-closed) ───────────────
4141
state_file="$target_root/.opencode/install-state.json"
42-
manifest="$source_root/.opencode/manifest/installed-files.json"
4342

44-
# Get paths we created (from install state or manifest fallback)
4543
paths_file="$(mktemp "${TMPDIR:-/tmp}/ccgs-uninstall-paths.XXXXXX")"
4644
trap 'rm -f "$paths_file"' EXIT
4745

48-
if [ -f "$state_file" ]; then
49-
# Read file hashes from install-state (exact list of what we deployed)
50-
python3 -c "
51-
import json, sys
52-
with open('$state_file') as f:
53-
state = json.load(f)
54-
for path in sorted(state.get('installed_file_hashes', {}).keys()):
55-
print(path)
56-
" > "$paths_file" 2>/dev/null
57-
elif [ -f "$manifest" ]; then
58-
# Fallback: use manifest
59-
python3 -c "
60-
import json
61-
with open('$manifest') as f:
62-
data = json.load(f)
63-
for entry in data.get('files', []):
64-
print(entry['path'])
65-
" > "$paths_file" 2>/dev/null
46+
# Fail-closed: uninstall requires valid install-state ownership data.
47+
# Missing, stale, malformed, path-traversing, or symlinked state aborts
48+
# without removing project files. Never infer ownership from the source
49+
# manifest or from file contents.
50+
if ccgs_state_validate "$target_root"; then
51+
ccgs_state_owned_paths "$target_root" > "$paths_file"
52+
else
53+
rc=$?
54+
reason="invalid"
55+
[ "$rc" -eq 2 ] && reason="missing"
56+
printf '\nERROR: cannot uninstall safely — install-state is %s.\n' "$reason" >&2
57+
printf 'Uninstall requires valid .opencode/install-state.json ownership data.\n' >&2
58+
printf 'Restore it from .opencode/backups/ or resolve ownership manually.\n' >&2
59+
printf 'No files were changed.\n' >&2
60+
exit 1
6661
fi
6762

6863
if [ ! -s "$paths_file" ]; then
69-
printf 'No install state or manifest found — stripping models only.\n'
70-
# Still strip models + remove generated files
71-
if [ "$dry_run" -eq 0 ]; then
72-
ccgs_strip_all_agents "$target_root"
73-
ccgs_remove_primary_model "$target_root"
74-
for f in .opencode/models.json .opencode/install-state.json; do
75-
[ -f "$target_root/$f" ] && rm "$target_root/$f"
76-
done
77-
fi
78-
printf 'Done (models stripped, no file list to remove).\n'
64+
printf 'install-state valid but records no owned files — nothing to remove.\n'
65+
printf 'No files were changed.\n'
7966
exit 0
8067
fi
8168

@@ -150,13 +137,11 @@ while IFS= read -r path; do
150137

151138
case "$path" in
152139
AGENTS.md|*/AGENTS.md)
153-
# Remove marker block, preserve rest of file
140+
# Remove our marker block only. ccgs_remove_marker_block deletes the
141+
# file solely when its pre-strip content was nothing but the marker
142+
# block; it keeps any user-authored content and never uses emptiness
143+
# or file contents as ownership proof.
154144
ccgs_remove_marker_block "$target_file" 2>/dev/null || true
155-
# If file is now empty or just a stub, remove it
156-
if [ -f "$target_file" ]; then
157-
local_content="$(tr -d '\n\r\t ' < "$target_file" 2>/dev/null || true)"
158-
[ -z "$local_content" ] && rm -f "$target_file"
159-
fi
160145
;;
161146
opencode.json)
162147
# Don't remove opencode.json (user may have other config)
@@ -187,15 +172,12 @@ done
187172
ccgs_remove_gitignore_allowlist "$target_root" 2>/dev/null || true
188173

189174
# ── Prune empty OpenCode-owned directories ───────────────────────
190-
if is_coexist; then
191-
# Only prune .opencode/ in coexistence mode (leave shared dirs alone)
175+
# Pruning is limited to the OpenCode runtime directory (.opencode/).
176+
# Shared content roots (design/, docs/, production/, src/, assets/, tests/,
177+
# tools/, "CCGS Skill Testing Framework/") are never deleted even when empty
178+
# — emptiness is not ownership proof and they may be user scaffolds.
179+
if [ -d "$target_root/.opencode" ]; then
192180
find "$target_root/.opencode" -depth -type d -empty -delete 2>/dev/null || true
193-
else
194-
# Full prune of all framework dirs
195-
for dir in .opencode "CCGS Skill Testing Framework" assets design docs production prototypes src tests tools; do
196-
[ -d "$target_root/$dir" ] || continue
197-
find "$target_root/$dir" -depth -type d -empty -delete 2>/dev/null || true
198-
done
199181
fi
200182

201183
printf '\nDone. OpenCode Game Studios removed.\n'

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,19 @@ The installer deploys all framework files alongside existing content. It detects
267267
prior installs, foreign runtimes (Claude Code, Codex), and preserves shared
268268
project files. See [Coexistence](#coexistence) below.
269269

270+
The installer preflights the target before mutating anything: an **unowned
271+
collision** (an existing file at a package path not proven ours) or a
272+
**locally-modified package-owned file** aborts the deploy with the exact paths
273+
and changes nothing. To back up and replace state-proven package paths you
274+
intentionally edited, re-run with `--replace-modified`:
275+
276+
```bash
277+
bash .opencode/install.sh --dry-run --replace-modified /path/to/existing-game-project
278+
bash .opencode/install.sh --replace-modified /path/to/existing-game-project
279+
```
280+
281+
`--replace-modified` never overwrites an unowned shared file.
282+
270283
### What the installer modifies
271284

272285
1. **`.opencode/agents/*.md`** — injects `model:` and `variant:` into each
@@ -301,6 +314,13 @@ In coexistence mode, the uninstaller preserves shared paths it didn't create,
301314
removes only OpenCode-owned files, and extracts the marker block from
302315
`AGENTS.md` without deleting user content.
303316

317+
Uninstall is **fail-closed**: it requires valid `.opencode/install-state.json`
318+
ownership data. Missing, stale, malformed, path-traversing, or symlinked state
319+
aborts without removing project files — restore the state from
320+
`.opencode/backups/` or resolve ownership manually. `AGENTS.md` is deleted only
321+
when its entire content was the CCGS marker block; any user-authored content is
322+
kept.
323+
304324
## Coexistence
305325

306326
OpenCode Game Studios is intentionally additive. The installer detects the

UPGRADING.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,21 @@ live. After any upgrade, trust the target project and start a **new
133133
`opencode` session** before verifying hooks, rules, permission profile, and
134134
agents. Keep the exact failing command and output if verification is blocked.
135135

136+
Upgrades are fail-closed. A cross-target deploy aborts before mutating when it
137+
finds an unowned collision (an existing file at a package path not proven ours)
138+
or a locally-modified package-owned file. Review the abort with `--dry-run`,
139+
then opt into backup-first replacement for state-proven package paths only:
140+
141+
```bash
142+
bash .opencode/install.sh --dry-run --replace-modified /path/to/project
143+
bash .opencode/install.sh --replace-modified /path/to/project
144+
```
145+
146+
`--replace-modified` never adopts an unowned shared path; merge those manually.
147+
Uninstall is also fail-closed: it requires valid `.opencode/install-state.json`
148+
ownership data and aborts without removing files when state is missing, stale,
149+
malformed, path-traversing, or symlinked.
150+
136151
---
137152

138153
## v0.4.1

0 commit comments

Comments
 (0)