pantheon-opencode 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/AGENTS.md +9 -0
  2. package/README.md +12 -13
  3. package/bin/pantheon-init.mjs +14 -0
  4. package/opencode.json +1 -1
  5. package/package.json +13 -4
  6. package/scripts/changelog-extract.mjs +112 -0
  7. package/scripts/doctor.mjs +303 -13
  8. package/scripts/hooks/audit-imports.sh +43 -0
  9. package/scripts/hooks/format-multi-language.sh +96 -0
  10. package/scripts/hooks/log-session-start.sh +24 -0
  11. package/scripts/hooks/on-subagent-delegation-start.sh +91 -0
  12. package/scripts/hooks/on-subagent-delegation-stop.sh +37 -0
  13. package/scripts/hooks/run-type-check.sh +21 -0
  14. package/scripts/hooks/scan-secrets.sh +55 -0
  15. package/scripts/hooks/validate-post-conditions.sh +21 -0
  16. package/scripts/hooks/validate-talos-scope.sh +43 -0
  17. package/scripts/hooks/validate-tool-safety.sh +46 -0
  18. package/scripts/install/opencode.mjs +58 -29
  19. package/scripts/install/plugin.mjs +4 -2
  20. package/scripts/install-mcp.mjs +104 -56
  21. package/scripts/mcp_persistence_server.py +1 -0
  22. package/scripts/prune.mjs +359 -0
  23. package/scripts/version-check.mjs +146 -0
  24. package/scripts/versioning.mjs +13 -14
  25. package/src/mcp/requirements-mcp.txt +8 -5
  26. package/src/mcp/requirements-vision.txt +3 -3
  27. package/src/plugins/hook-runner.ts +186 -0
  28. package/src/plugins/pantheon-hooks.ts +153 -0
  29. package/src/plugins/tui/dist/tui.d.ts +8 -0
  30. package/src/plugins/tui/dist/tui.d.ts.map +1 -0
  31. package/src/plugins/tui/dist/tui.js +1198 -0
  32. package/src/plugins/tui/dist/tui.js.map +1 -0
  33. package/src/plugins/tui/dist/tui.tsx +1204 -0
  34. package/src/plugins/tui/package.json +2 -2
  35. package/src/plugins/tui/src/index.tsx +15 -0
  36. package/src/mcp/requirements-mcp-core.txt +0 -5
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # audit-imports.sh — Pantheon PostToolUse Import Audit Hook
3
+ # Blocks wildcard imports and flags suspicious patterns.
4
+ set -euo pipefail
5
+
6
+ FILE_PATH="${FILE_PATH:-${1:-}}"
7
+
8
+ if [[ -z "$FILE_PATH" ]] || [[ ! -f "$FILE_PATH" ]]; then
9
+ # Scan all Python/JS files in recent changes if no file specified
10
+ FILES=$(git diff --name-only HEAD 2>/dev/null | grep -E '\.(py|js|ts|jsx|tsx)$' || true)
11
+ if [[ -z "$FILES" ]]; then
12
+ exit 0
13
+ fi
14
+ else
15
+ FILES="$FILE_PATH"
16
+ fi
17
+
18
+ FOUND=0
19
+ for f in $FILES; do
20
+ [[ -f "$f" ]] || continue
21
+ EXT="${f##*.}"
22
+ case "$EXT" in
23
+ py)
24
+ if grep -qE '^from\s+\S+\s+import\s+\*' "$f"; then
25
+ echo "[IMPORT AUDIT] Wildcard import found in $f" >&2
26
+ FOUND=1
27
+ fi
28
+ ;;
29
+ js|jsx|ts|tsx)
30
+ # Flag require() without destructuring as suspicious
31
+ if grep -qE 'require\([^)]+\)' "$f"; then
32
+ echo "[IMPORT AUDIT] CommonJS require() found in $f — consider ES modules" >&2
33
+ fi
34
+ ;;
35
+ esac
36
+ done
37
+
38
+ if [[ $FOUND -eq 1 ]]; then
39
+ echo "[IMPORT AUDIT] Fix wildcard imports before committing." >&2
40
+ exit 1
41
+ fi
42
+
43
+ exit 0
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env bash
2
+ # format-multi-language.sh — Pantheon PostToolUse Formatting Hook
3
+ # Runs appropriate formatter on the modified file.
4
+ # Supports both Claude Code stdin protocol and legacy env-var input.
5
+ set -euo pipefail
6
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
7
+ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
8
+
9
+ # ── Claude Code stdin protocol ──────────────────────────────────────────
10
+ # Parse JSON from stdin when piped (PostToolUse hook format).
11
+ # Extracts file_path from Edit/Write or file paths from Bash commands.
12
+ if [ -p /dev/stdin ]; then
13
+ STDIN_JSON=$(cat)
14
+ TOOL_NAME=$(echo "$STDIN_JSON" | python3 -c "
15
+ import sys, json
16
+ d = json.load(sys.stdin)
17
+ print(d.get('tool_name', ''))
18
+ " 2>/dev/null || echo "")
19
+
20
+ case "$TOOL_NAME" in
21
+ Edit|Write)
22
+ JSON_PATH=$(echo "$STDIN_JSON" | python3 -c "
23
+ import sys, json
24
+ d = json.load(sys.stdin)
25
+ print(d.get('tool_input', {}).get('file_path', ''))
26
+ " 2>/dev/null || echo "")
27
+ if [ -n "$JSON_PATH" ]; then
28
+ FILE_PATH="$JSON_PATH"
29
+ fi
30
+ ;;
31
+ Bash)
32
+ COMMAND=$(echo "$STDIN_JSON" | python3 -c "
33
+ import sys, json
34
+ d = json.load(sys.stdin)
35
+ print(d.get('tool_input', {}).get('command', ''))
36
+ " 2>/dev/null || echo "")
37
+ if [ -n "$COMMAND" ]; then
38
+ # Extract first file-like path from bash command (supports relative and quoted paths)
39
+ CANDIDATE=$(python3 -c "
40
+ import re, shlex, sys
41
+ command = sys.argv[1]
42
+ try:
43
+ tokens = shlex.split(command)
44
+ except ValueError:
45
+ tokens = command.split()
46
+ path_re = re.compile(r'^([^\\n]+\\.[A-Za-z0-9]+)$')
47
+ for token in tokens:
48
+ if token.startswith('-'):
49
+ continue
50
+ if path_re.match(token):
51
+ print(token)
52
+ break
53
+ " "$COMMAND" 2>/dev/null || echo "")
54
+ if [ -n "$CANDIDATE" ]; then
55
+ FILE_PATH="$CANDIDATE"
56
+ fi
57
+ fi
58
+ ;;
59
+ esac
60
+ fi
61
+
62
+ FILE_PATH="${FILE_PATH:-${1:-}}"
63
+ if [[ -n "$FILE_PATH" ]] && [[ "$FILE_PATH" != /* ]]; then
64
+ if [[ -f "$REPO_ROOT/$FILE_PATH" ]]; then
65
+ FILE_PATH="$REPO_ROOT/$FILE_PATH"
66
+ fi
67
+ fi
68
+
69
+ if [[ -z "$FILE_PATH" ]] || [[ ! -f "$FILE_PATH" ]]; then
70
+ exit 0
71
+ fi
72
+
73
+ EXT="${FILE_PATH##*.}"
74
+ EXIT_CODE=0
75
+
76
+ case "$EXT" in
77
+ py)
78
+ if command -v ruff >/dev/null 2>&1; then
79
+ ruff format "$FILE_PATH" || true
80
+ elif command -v black >/dev/null 2>&1; then
81
+ black -q "$FILE_PATH" || true
82
+ fi
83
+ ;;
84
+ js|jsx|ts|tsx|json|jsonc|md|yaml|yml|css|html)
85
+ if command -v prettier >/dev/null 2>&1; then
86
+ prettier --write "$FILE_PATH" || true
87
+ fi
88
+ ;;
89
+ sh|bash)
90
+ if command -v shfmt >/dev/null 2>&1; then
91
+ shfmt -w "$FILE_PATH" || true
92
+ fi
93
+ ;;
94
+ esac
95
+
96
+ exit 0
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # log-session-start.sh — Pantheon SessionStart Logging Hook
3
+ # Logs session start with structured JSON.
4
+ set -euo pipefail
5
+
6
+ # Project-local by default; set XDG_STATE_HOME for system-wide logging
7
+ if [ -n "${LOG_DIR:-}" ]; then
8
+ LOG_DIR="$LOG_DIR"
9
+ elif [ -n "${XDG_STATE_HOME:-}" ]; then
10
+ LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
11
+ else
12
+ LOG_DIR="logs/agent-sessions"
13
+ fi
14
+ mkdir -p "$LOG_DIR"
15
+
16
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
17
+ SESSION_ID="${SESSION_ID:-$(date +%s)}"
18
+
19
+ cat >> "$LOG_DIR/sessions.log" << JSON
20
+ {"event":"SessionStart","timestamp":"$TIMESTAMP","session_id":"$SESSION_ID","platform":"${PLATFORM:-unknown}"}
21
+ JSON
22
+
23
+ echo "[LOG] Session start logged → $LOG_DIR/sessions.log" >&2
24
+ exit 0
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # on-subagent-delegation-start.sh — Pantheon SubagentStart Audit Hook
3
+ # Logs when Zeus delegates to a subagent.
4
+ set -euo pipefail
5
+
6
+ # Project-local by default; set XDG_STATE_HOME for system-wide logging
7
+ if [ -n "${LOG_DIR:-}" ]; then
8
+ LOG_DIR="$LOG_DIR"
9
+ elif [ -n "${XDG_STATE_HOME:-}" ]; then
10
+ LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
11
+ else
12
+ LOG_DIR="logs/agent-sessions"
13
+ fi
14
+ mkdir -p "$LOG_DIR"
15
+
16
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
17
+ AGENT_NAME="${AGENT_NAME:-unknown}"
18
+ TASK_DESC="${TASK_DESC:-${1:-}}"
19
+
20
+ cat >> "$LOG_DIR/delegations.log" << JSON
21
+ {"event":"SubagentStart","timestamp":"$TIMESTAMP","agent":"$AGENT_NAME","task":"$TASK_DESC"}
22
+ JSON
23
+
24
+ echo "[DELEGATION] $AGENT_NAME started → $LOG_DIR/delegations.log" >&2
25
+
26
+ # --- Dispatch-time validation: check target against routing.yml ---
27
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
28
+ REPO_ROOT="$(cd -- "$SCRIPT_DIR/../.." && pwd)"
29
+ ROUTING_FILE="$REPO_ROOT/routing.yml"
30
+ if [ -f "$ROUTING_FILE" ] && [ -n "$AGENT_NAME" ]; then
31
+ ESCAPED_AGENT_NAME=$(printf '%s\n' "$AGENT_NAME" | sed 's/[][(){}.^$*+?|\/\\-]/\\&/g')
32
+ if grep -qE "^[[:space:]]*-[[:space:]]+target:[[:space:]]+$ESCAPED_AGENT_NAME[[:space:]]*$" "$ROUTING_FILE" 2>/dev/null; then
33
+ echo "[VALIDATION] ✅ Target '$AGENT_NAME' found in routing.yml" >&2
34
+ else
35
+ echo "[VALIDATION] ⚠️ Target '$AGENT_NAME' NOT in routing.yml delegation rules" >&2
36
+ fi
37
+ elif [ ! -f "$ROUTING_FILE" ]; then
38
+ echo "[VALIDATION] ⚠️ Could not read routing.yml for validation" >&2
39
+ fi
40
+
41
+ # --- Cost estimation for delegation ---
42
+ declare -A TIER_MAP
43
+ TIER_MAP[athena]="premium"
44
+ TIER_MAP[themis]="premium"
45
+ TIER_MAP[hermes]="default"
46
+ TIER_MAP[aphrodite]="default"
47
+ TIER_MAP[demeter]="default"
48
+ TIER_MAP[prometheus]="default"
49
+ TIER_MAP[hephaestus]="default"
50
+ TIER_MAP[gaia]="default"
51
+ TIER_MAP[zeus]="default"
52
+ TIER_MAP[apollo]="fast"
53
+ TIER_MAP[nyx]="fast"
54
+ TIER_MAP[iris]="fast"
55
+ TIER_MAP[mnemosyne]="fast"
56
+ TIER_MAP[talos]="fast"
57
+
58
+ TIER="${TIER_MAP[$AGENT_NAME]:-default}"
59
+
60
+ case "$TIER" in
61
+ premium)
62
+ COST_MIN=0.50
63
+ COST_MAX=2.00
64
+ COST_EST=1.25
65
+ ;;
66
+ fast)
67
+ COST_MIN=0.02
68
+ COST_MAX=0.10
69
+ COST_EST=0.06
70
+ ;;
71
+ *)
72
+ COST_MIN=0.10
73
+ COST_MAX=0.50
74
+ COST_EST=0.30
75
+ ;;
76
+ esac
77
+
78
+ # Format cost with 2 decimal places
79
+ COST_EST_FMT=$(printf "%.2f" "$COST_EST")
80
+
81
+ if [ "$TIER" = "premium" ]; then
82
+ echo "[COST_WARN] High-cost delegation to $AGENT_NAME — estimated \$$COST_EST_FMT" >&2
83
+ fi
84
+
85
+ if (( $(echo "$COST_EST > 5.00" | bc -l 2>/dev/null) )); then
86
+ echo "[COST_ALERT] Expensive delegation to $AGENT_NAME — consider if subtask is possible" >&2
87
+ fi
88
+
89
+ echo "[COST] Delegation to $AGENT_NAME — tier: $TIER — estimated: \$$COST_EST_FMT" >&2
90
+
91
+ exit 0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env bash
2
+ # on-subagent-delegation-stop.sh — Pantheon SubagentStop Audit Hook
3
+ # Logs completion or failure of subagent tasks.
4
+ set -euo pipefail
5
+
6
+ # Project-local by default; set XDG_STATE_HOME for system-wide logging
7
+ if [ -n "${LOG_DIR:-}" ]; then
8
+ LOG_DIR="$LOG_DIR"
9
+ elif [ -n "${XDG_STATE_HOME:-}" ]; then
10
+ LOG_DIR="$XDG_STATE_HOME/pantheon/hooks"
11
+ else
12
+ LOG_DIR="logs/agent-sessions"
13
+ fi
14
+ mkdir -p "$LOG_DIR"
15
+
16
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
17
+ AGENT_NAME="${AGENT_NAME:-unknown}"
18
+ STATUS="${STATUS:-success}"
19
+ REASON="${REASON:-${1:-}}"
20
+
21
+ LOG_FILE="$LOG_DIR/delegations.log"
22
+ if [[ "$STATUS" == "failure" ]]; then
23
+ LOG_FILE="$LOG_DIR/delegation-failures.log"
24
+ fi
25
+
26
+ cat >> "$LOG_FILE" << JSON
27
+ {"event":"SubagentStop","timestamp":"$TIMESTAMP","agent":"$AGENT_NAME","status":"$STATUS","reason":"$REASON"}
28
+ JSON
29
+
30
+ echo "[DELEGATION] $AGENT_NAME stopped ($STATUS) → $LOG_FILE" >&2
31
+
32
+ # --- Post-condition validation: remind about Themis review ---
33
+ if echo "hermes aphrodite demeter prometheus" | grep -wq "$AGENT_NAME" 2>/dev/null; then
34
+ echo "[POST-CONDITION] 🔔 Reminder: '$AGENT_NAME' should call Themis for review after implementation" >&2
35
+ fi
36
+
37
+ exit 0
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # run-type-check.sh — Pantheon PostToolUse Type Check Hook
3
+ # Runs mypy (Python) or tsc (TypeScript) if config exists.
4
+ set -euo pipefail
5
+
6
+ # Only run if relevant config files exist
7
+ if [[ -f "pyproject.toml" ]] || [[ -f "setup.cfg" ]] || [[ -f ".mypy.ini" ]]; then
8
+ if command -v mypy >/dev/null 2>&1; then
9
+ echo "[TYPE CHECK] Running mypy..." >&2
10
+ mypy . --ignore-missing-imports || true
11
+ fi
12
+ fi
13
+
14
+ if [[ -f "tsconfig.json" ]]; then
15
+ if command -v tsc >/dev/null 2>&1; then
16
+ echo "[TYPE CHECK] Running tsc --noEmit..." >&2
17
+ tsc --noEmit || true
18
+ fi
19
+ fi
20
+
21
+ exit 0
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env bash
2
+ # scan-secrets.sh — Pantheon PreToolUse Secret Scanning Hook
3
+ # Detects hardcoded secrets in tool input.
4
+ set -euo pipefail
5
+
6
+ SECRET_PATTERNS=(
7
+ "AKIA[0-9A-Z]{16}"
8
+ "gh[pousr]_[A-Za-z0-9_]{36,}"
9
+ "glpat-[A-Za-z0-9_\\-]{20}"
10
+ "sk-[a-zA-Z0-9]{20,}"
11
+ "sk_live_[a-zA-Z0-9]{20,}"
12
+ "sk_test_[a-zA-Z0-9]{20,}"
13
+ "xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}"
14
+ "[a-zA-Z0-9_-]*api[_-]?key[a-zA-Z0-9_-]*\s*[:=]\s*[\"']?[a-zA-Z0-9_\\-]{16,}[\"']?"
15
+ "[a-zA-Z0-9_-]*password[a-zA-Z0-9_-]*\s*[:=]\s*[\"'][^\"']{8,}[\"']"
16
+ "[a-zA-Z0-9_-]*secret[a-zA-Z0-9_-]*\s*[:=]\s*[\"'][^\"']{8,}[\"']"
17
+ "bearer\s+[a-zA-Z0-9_\\-\\.]{20,}"
18
+ "eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*"
19
+ )
20
+
21
+ # Keep provider-specific markers assembled so this scanner is not self-matched
22
+ # by the repository secret test.
23
+ BIFROST_HEADER="x""-bf-""vk"
24
+ BIFROST_TOKEN_PREFIX="sk""-bf-"
25
+ SECRET_PATTERNS+=(
26
+ "$BIFROST_HEADER"
27
+ "${BIFROST_TOKEN_PREFIX}[A-Za-z0-9_-]{8,}"
28
+ )
29
+
30
+ INPUT="${1:-${TOOL_INPUT:-}}"
31
+ if [[ -z "$INPUT" ]] && [[ ! -t 0 ]]; then
32
+ INPUT=$(cat)
33
+ fi
34
+
35
+ if [[ -z "$INPUT" ]]; then
36
+ exit 0
37
+ fi
38
+
39
+ FOUND=0
40
+ for pattern in "${SECRET_PATTERNS[@]}"; do
41
+ if echo "$INPUT" | grep -iqE "$pattern"; then
42
+ MATCH=$(echo "$INPUT" | grep -ioE "$pattern" | head -1)
43
+ # Mask the match for safe logging
44
+ MASKED="${MATCH:0:4}****${MATCH: -4}"
45
+ echo "[SECRET SCAN] Potential secret detected: $MASKED (pattern: $pattern)" >&2
46
+ FOUND=1
47
+ fi
48
+ done
49
+
50
+ if [[ $FOUND -eq 1 ]]; then
51
+ echo "[SECRET SCAN] Hardcoded secret detected. Remove it and use environment variables or a vault." >&2
52
+ exit 1
53
+ fi
54
+
55
+ exit 0
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bash
2
+ # validate-post-conditions.sh — Stop hook
3
+ # Checks that Themis review was called after implementation
4
+ set -euo pipefail
5
+
6
+ # Read JSON input from stdin
7
+ INPUT=$(cat)
8
+ SESSION_ID=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null || echo "")
9
+
10
+ REVIEW_DIR=".pantheon/memory-bank/.tmp"
11
+ if [ -d "$REVIEW_DIR" ]; then
12
+ REVIEW_COUNT=$(ls "$REVIEW_DIR"/REVIEW-* 2>/dev/null | wc -l)
13
+ if [ "$REVIEW_COUNT" -eq 0 ]; then
14
+ echo "[POST-CONDITION] ⚠️ No REVIEW artifact found in $REVIEW_DIR/" >&2
15
+ echo "[POST-CONDITION] 💡 Remember: implementation agents MUST call @themis for review" >&2
16
+ else
17
+ echo "[POST-CONDITION] ✅ $REVIEW_COUNT REVIEW artifact(s) found" >&2
18
+ fi
19
+ fi
20
+
21
+ exit 0
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # validate-talos-scope.sh — PreToolUse hook for Talos
3
+ # Receives JSON on stdin, enforces Talos boundaries
4
+ set -euo pipefail
5
+
6
+ # Read JSON input from stdin (Claude Code hook protocol)
7
+ INPUT=$(cat)
8
+
9
+ # Extract tool info from JSON input
10
+ TOOL_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null || echo "")
11
+ TOOL_INPUT=$(echo "$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps(d.get('tool_input',{})))" 2>/dev/null || echo "")
12
+
13
+ # Only validate for Talos agent
14
+ AGENT_NAME=$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('agent_id',''))" 2>/dev/null || echo "")
15
+ if [ "$AGENT_NAME" != "talos" ]; then
16
+ # Not Talos — allow pass-through
17
+ echo "[TALOS SCOPE] Not Talos agent, skipping" >&2
18
+ exit 0
19
+ fi
20
+
21
+ # Get changed files from the tool input
22
+ if echo "$TOOL_INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('command','') or d.get('filePath','') or d.get('file_path',''))" 2>/dev/null | grep -qE '(migration|schema\.sql|alembic|models/.*\.py|auth|security|password|token|secret)'; then
23
+ echo "[TALOS SCOPE] ❌ Blocked: Talos cannot modify schema/security files" >&2
24
+ exit 2
25
+ fi
26
+
27
+ # Count files in the edit/write operation
28
+ FILE_COUNT=$(echo "$TOOL_INPUT" | python3 -c "
29
+ import sys, json
30
+ try:
31
+ d = json.load(sys.stdin)
32
+ # Count unique file paths in the input
33
+ files = set()
34
+ for key in ['filePath', 'file_path', 'command', 'content']:
35
+ if key in d and d[key]:
36
+ files.add(str(d[key]))
37
+ print(len(files))
38
+ except:
39
+ print('0')
40
+ " 2>/dev/null || echo "0")
41
+
42
+ echo "[TALOS SCOPE] ✅ Scope check passed" >&2
43
+ exit 0
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # validate-tool-safety.sh — Pantheon PreToolUse Security Hook
3
+ # Blocks destructive commands: rm -rf, DROP TABLE, TRUNCATE, etc.
4
+ set -euo pipefail
5
+
6
+ BLOCKED_PATTERNS=(
7
+ # "rm -rf /" appears mid-JSON in the hook stdin protocol (e.g.
8
+ # {"tool_input":{"command":"rm -rf /"}}), so the old `/$` end-of-line
9
+ # anchor never matched. Match "/" followed by a JSON delimiter (", },
10
+ # comma), whitespace, or end of line — catches bare "rm -rf /" anywhere
11
+ # in the payload while keeping "rm -rf /some/path" (safe) unblocked.
12
+ "rm\s+-rf\s+/([\"},\t[:space:]]|$)" # rm -rf / (root only)
13
+ "rm\s+-rf\s+/\*" # rm -rf /*
14
+ "rm\s+-rf\s+~([\",[:space:]]|$)" # rm -rf ~ (home only)
15
+ "rm\s+-rf\s+~/" # rm -rf ~/...
16
+ "rm\s+-rf\s+\.\.?/" # rm -rf ./ or rm -rf ../
17
+ "DROP\s+TABLE"
18
+ "DROP\s+DATABASE"
19
+ "TRUNCATE\s+TABLE"
20
+ "DELETE\s+FROM\s+.*;?\s*$"
21
+ "rm\s+.*\s+--no-preserve-root"
22
+ ":(){ :|:& };:"
23
+ ">\s*/dev/\(sd[a-z]\|hd[a-z]\|disk[0-9]*\)"
24
+ "dd\s+if=.*of=/dev/"
25
+ "mkfs\."
26
+ "fdisk\s+/dev/"
27
+ )
28
+
29
+ INPUT="${1:-${TOOL_INPUT:-}}"
30
+ if [[ -z "$INPUT" ]] && [[ ! -t 0 ]]; then
31
+ INPUT=$(cat)
32
+ fi
33
+
34
+ if [[ -z "$INPUT" ]]; then
35
+ exit 0
36
+ fi
37
+
38
+ for pattern in "${BLOCKED_PATTERNS[@]}"; do
39
+ if echo "$INPUT" | grep -iqE "$pattern"; then
40
+ echo "[SECURITY BLOCKED] Pattern matched: $pattern" >&2
41
+ echo "[SECURITY BLOCKED] Destructive command detected and blocked by Pantheon hook." >&2
42
+ exit 1
43
+ fi
44
+ done
45
+
46
+ exit 0
@@ -13,7 +13,7 @@ import {
13
13
  writeFileSync,
14
14
  } from 'node:fs'
15
15
  import { homedir } from 'node:os'
16
- import { join, resolve } from 'node:path'
16
+ import { basename, join, resolve } from 'node:path'
17
17
  import {
18
18
  bullet,
19
19
  colors,
@@ -193,8 +193,9 @@ export async function installOpenCode(
193
193
  const srcRouting = join(ROOT, 'src', 'routing.yml')
194
194
  const dstRouting = join(target, 'routing.yml')
195
195
  if (existsSync(srcRouting)) {
196
- writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun)
197
- stats.created++
196
+ const routingStatus = writeIfChanged(dstRouting, readFileSync(srcRouting, 'utf8'), dryRun)
197
+ if (routingStatus === 'created') stats.created++
198
+ else stats.skipped++
198
199
  }
199
200
  }
200
201
 
@@ -280,27 +281,12 @@ export async function installOpenCode(
280
281
 
281
282
  // -----------------------------------------------------------------------
282
283
  // -----------------------------------------------------------------------
283
- // 2.7 Ensure subagent_depth in opencode.json
284
+ // 2.7 subagent_depth is merged into opencode.json in the config section
285
+ // below (always runs) — see "Ensure critical top-level config sections".
286
+ // Previously this was a separate block gated on the config file already
287
+ // existing, so a FRESH install only gained subagent_depth on the SECOND
288
+ // init run (idempotence bug, issue #19).
284
289
  // -----------------------------------------------------------------------
285
- if (componentSet.has('agents')) {
286
- const cfgPath = join(target, 'opencode.json')
287
- if (existsSync(cfgPath)) {
288
- try {
289
- const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'))
290
- let changed = false
291
- if (cfg.subagent_depth === undefined) {
292
- cfg.subagent_depth = 2
293
- changed = true
294
- }
295
- if (changed && !dryRun) {
296
- writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`)
297
- success('subagent_depth: 2 added to opencode.json')
298
- }
299
- } catch (e) {
300
- warning(`Could not update opencode.json: ${e.message}`)
301
- }
302
- }
303
- }
304
290
 
305
291
  // 2.8 Install TUI plugins (--components plugins)
306
292
  // -----------------------------------------------------------------------
@@ -322,9 +308,16 @@ export async function installOpenCode(
322
308
  const targetTuiConfigPath = isGlobal
323
309
  ? join(target, 'tui.json')
324
310
  : join(target, '.opencode', 'tui.json')
325
- const pluginRef = 'plugins/pantheon-tui'
326
- unregisterPlugin(targetTuiConfigPath, pluginRef, { dryRun })
327
- const tuiStatus = registerPlugin(targetTuiConfigPath, pluginRef, { dryRun })
311
+ // Hermetic TUI plugin registration: opencode's tui loader treats relative
312
+ // entries ("plugins/pantheon-tui") as npm/github package specs and fails
313
+ // with NpmInstallFailedError ("Repository not found"). Register the plugin
314
+ // by the ABSOLUTE path of the built dist inside the installed package
315
+ // (derived from ROOT — never hardcoded), mirroring the loader's
316
+ // absolute-path support (same pattern as the hooks plugin fix).
317
+ const tuiPluginRef = join(ROOT, 'src', 'plugins', 'tui', 'dist', 'tui.tsx')
318
+ // Clean up the old broken relative ref (and stale dist refs) on upgrade.
319
+ unregisterPlugin(targetTuiConfigPath, 'plugins/pantheon-tui', { dryRun })
320
+ const tuiStatus = registerPlugin(targetTuiConfigPath, tuiPluginRef, { dryRun })
328
321
  if (tuiStatus === 'created') stats.created++
329
322
  else stats.skipped++
330
323
 
@@ -347,7 +340,17 @@ export async function installOpenCode(
347
340
  'pantheon_vision_server.py',
348
341
  ]
349
342
  const srcScriptsDir = join(ROOT, 'scripts')
343
+ // Canonical MCP server sources live in src/mcp/. Map them explicitly so the
344
+ // sync always copies from the canonical source, never from the stale root
345
+ // scripts/ copies (which previously propagated bugs like the missing
346
+ // `import uuid` in mcp_persistence_server.py). scrub-secrets.py is not in
347
+ // src/mcp/ and intentionally falls back to join(ROOT, 'scripts').
350
348
  const canonicalMcpScripts = {
349
+ 'mcp_resources_server.py': join(ROOT, 'src', 'mcp', 'mcp_resources_server.py'),
350
+ 'code_mode_server.py': join(ROOT, 'src', 'mcp', 'code_mode_server.py'),
351
+ 'memory_mcp_server.py': join(ROOT, 'src', 'mcp', 'memory_mcp_server.py'),
352
+ '_pantheon_paths.py': join(ROOT, 'src', 'mcp', '_pantheon_paths.py'),
353
+ 'mcp_persistence_server.py': join(ROOT, 'src', 'mcp', 'mcp_persistence_server.py'),
351
354
  'pantheon_vision_server.py': join(ROOT, 'src', 'mcp', 'pantheon_vision_server.py'),
352
355
  }
353
356
  const dstScriptsDir = join(runtimeTarget, 'scripts')
@@ -565,15 +568,41 @@ export async function installOpenCode(
565
568
  if (!config.default_agent && pantheonConfig.default_agent) {
566
569
  config.default_agent = pantheonConfig.default_agent
567
570
  }
571
+ // Merge subagent_depth HERE (the config merge always runs, including on a
572
+ // fresh install) instead of a file-existence-gated block, so run #1 already
573
+ // produces the final config — run #2 must be a byte-identical no-op (#19).
574
+ if (config.subagent_depth === undefined) {
575
+ config.subagent_depth = 2
576
+ }
577
+
578
+ // --------------------------------------------------------------------
579
+ // B.5.1 Hermetic plugin resolution (packaging fix)
580
+ // --------------------------------------------------------------------
581
+ // The packaged opencode.json may reference the pantheon-hooks plugin via a
582
+ // developer-machine absolute path (e.g. /home/<dev>/pantheon/src/plugins/...).
583
+ // Copying that verbatim into the user's config would break every global
584
+ // install on any other machine. Option A: rewrite the entry to the plugin
585
+ // file inside the INSTALLED package (derived from ROOT — never hardcoded),
586
+ // so both dev and global installs resolve to a path that actually exists.
587
+ // The source opencode.json keeps its dev path for local development; the
588
+ // transform happens at install/sync time only.
589
+ function resolveInstalledPlugin(plugin) {
590
+ const file = basename(plugin)
591
+ const packaged = join(ROOT, 'src', 'plugins', file)
592
+ return existsSync(packaged) ? packaged : plugin
593
+ }
568
594
 
569
595
  if (!Array.isArray(config.plugin)) {
570
596
  config.plugin = []
571
597
  }
572
598
  if (Array.isArray(pantheonConfig.plugin)) {
573
599
  for (const plugin of pantheonConfig.plugin) {
574
- if (!config.plugin.includes(plugin)) {
575
- config.plugin.push(plugin)
576
- }
600
+ const resolved = resolveInstalledPlugin(plugin)
601
+ const file = basename(resolved)
602
+ // Replace any pre-existing entry for the same plugin file (e.g. a stale
603
+ // dev-machine path from an earlier install) so upgrades stay hermetic.
604
+ config.plugin = config.plugin.filter((p) => basename(p) !== file)
605
+ config.plugin.push(resolved)
577
606
  }
578
607
  }
579
608
 
@@ -128,8 +128,10 @@ export function unregisterPlugin(tuiJsonPath, pluginId, { dryRun = false } = {})
128
128
  if (!Array.isArray(tuiConfig.plugin)) {
129
129
  tuiConfig.plugin = []
130
130
  }
131
- // Remove stale plugin refs (old dist/tui.tsx path)
132
- const staleRefs = [`${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
131
+ // Remove stale refs: the bare relative id (openCode < 1.19 wrote
132
+ // "plugins/pantheon-tui", which its tui loader misreads as an npm/github
133
+ // spec → NpmInstallFailedError) and the old dist paths.
134
+ const staleRefs = [pluginId, `${pluginId}/dist/tui.tsx`, `${pluginId}/dist/tui.js`]
133
135
  for (const stale of staleRefs) {
134
136
  const idx = tuiConfig.plugin.indexOf(stale)
135
137
  if (idx !== -1) tuiConfig.plugin.splice(idx, 1)