diff --git a/.claude/hooks/__tests__/write-guard.test.sh b/.claude/hooks/__tests__/write-guard.test.sh deleted file mode 100755 index 36ff9d3e..00000000 --- a/.claude/hooks/__tests__/write-guard.test.sh +++ /dev/null @@ -1,190 +0,0 @@ -#!/bin/bash -# write-guard.test.sh — bash harness for the CLAUDE-path write guard. -# -# Exercises the two-hook chain in the SAME order .claude/settings.json runs it: -# protect-files.sh (immutable-core deny-overlay) THEN guardian.sh (schema -# allow-check). A target is ALLOWED only if BOTH hooks exit 0; if EITHER exits -# non-zero it is BLOCKED — exactly the precedence the real session sees -# (deny-overlay > allow > default-deny). -# -# Self-contained: builds a throwaway workspace with a schema.md fixture, drives -# each hook in a CLEAN env (env -i) so no ambient WRITE_GUARD_BYPASS / -# PROTECT_FILES_BYPASS / CRON_NAME leaks in, and asserts allow/deny. The temp -# workspace has no git origin and is not under /.ralphex/worktrees/, so neither -# hook's bypass fires — the real allow-check is what runs. -# -# Run: bash .claude/hooks/__tests__/write-guard.test.sh -# Exits non-zero if any assertion fails. - -set -u - -HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -PROTECT="$HOOK_DIR/protect-files.sh" -GUARDIAN="$HOOK_DIR/guardian.sh" - -PASS=0 -FAIL=0 - -WS="$(mktemp -d)" -trap 'rm -rf "$WS"' EXIT - -# --- workspace fixture ----------------------------------------------------- -mkdir -p "$WS/memory" "$WS/docs" "$WS/.claude/rules/custom" "$WS/.claude/skills/custom" "$WS/legacy" - -cat > "$WS/schema.md" <<'SCHEMA' -# Workspace schema - -```write-allowlist -memory/ # narrative + auto memory -docs/ -.claude/rules/custom/ -.claude/skills/ -*.md # root-level markdown only -schema.md -``` -SCHEMA - -# An existing, non-immutable, non-schema file — to test the overwrite exemption. -echo "old" > "$WS/legacy/old.txt" - -# --- chain runner ---------------------------------------------------------- -# Echoes ALLOW or BLOCK for "$1"=tool, "$2"=path RELATIVE to the workspace. -# Extra args after $2 are passed as VAR=value into the hook env (e.g. a bypass). -run_chain() { - local tool="$1" rel="$2" - shift 2 - local fp="$WS/$rel" - local input - input="$(printf '{"tool_name":"%s","tool_input":{"file_path":"%s"}}' "$tool" "$fp")" - - if ! printf '%s' "$input" | env -i PATH="$PATH" CLAUDE_PROJECT_DIR="$WS" "$@" bash "$PROTECT" >/dev/null 2>&1; then - echo BLOCK - return - fi - if ! printf '%s' "$input" | env -i PATH="$PATH" CLAUDE_PROJECT_DIR="$WS" "$@" bash "$GUARDIAN" >/dev/null 2>&1; then - echo BLOCK - return - fi - echo ALLOW -} - -assert() { - local expected="$1" actual="$2" desc="$3" - if [[ "$expected" == "$actual" ]]; then - PASS=$((PASS + 1)) - echo "ok - $desc" - else - FAIL=$((FAIL + 1)) - echo "FAIL - $desc (expected $expected, got $actual)" - fi -} - -# --- assertions ------------------------------------------------------------ - -# Deny-by-default: a path not in schema.md is blocked. -assert BLOCK "$(run_chain Write unregistered/x.txt)" "non-schema path blocked" - -# Directory-prefix allow. -assert ALLOW "$(run_chain Write memory/notes.md)" "schema dir-prefix path allowed (memory/)" -assert ALLOW "$(run_chain Write memory/sub/deep.md)" "schema dir-prefix nested allowed" -assert ALLOW "$(run_chain Write memory)" "bare dir name matches its prefix line" - -# Immutable core wins over the allow-list (README.md immutable though *.md allowed). -assert BLOCK "$(run_chain Write README.md)" "immutable README.md blocked despite *.md" - -# Case-variant of an immutable FILE must still be blocked (APFS: README.MD == README.md). -# Without case-folding in protect-files.sh this slips past the deny-overlay and the -# *.md allow-line in schema.md re-allows the immutable file. -assert BLOCK "$(run_chain Write README.MD)" "case-variant immutable README.MD blocked despite *.md" - -# Immutable file entries are ROOT-ONLY-EXACT — docs/README.md is NOT immutable. -assert ALLOW "$(run_chain Write docs/README.md)" "docs/README.md allowed (docs/ in schema; immutable file is root-only)" - -# .claude/ split: custom allowed, hooks immutable. -assert ALLOW "$(run_chain Write .claude/rules/custom/x.md)" ".claude/rules/custom/ allowed" -assert BLOCK "$(run_chain Write .claude/rules/platform/x.md)" ".claude/rules/platform/ immutable blocked" -assert BLOCK "$(run_chain Write .claude/hooks/x.sh)" ".claude/hooks/ immutable blocked" -assert ALLOW "$(run_chain Write .claude/skills/custom/index.ts)" ".claude/skills/custom/ allowed" -assert BLOCK "$(run_chain Write .claude/skills/workspace-health/scripts/x.ts)" ".claude/skills/workspace-health/scripts/ immutable blocked" - -# Root-only glob: *.md matches a root-level file but NOT a nested one. -assert ALLOW "$(run_chain Write top.md)" "root-level *.md allowed" -assert BLOCK "$(run_chain Write sub/top.md)" "nested *.md NOT matched by root-only glob (sub/ unregistered)" - -# Exact-path line. -assert ALLOW "$(run_chain Write schema.md)" "exact root-file schema.md allowed" - -# Case-insensitive (APFS) directory-prefix match. -assert ALLOW "$(run_chain Write Memory/Notes.MD)" "case-insensitive dir-prefix match (APFS)" - -# APFS case-variant WORKSPACE-PREFIX coverage. On case-insensitive APFS an absolute -# tool path whose workspace-root prefix case-varies (e.g. /users vs /Users) names the -# SAME file as $CLAUDE_PROJECT_DIR. The containment check that derives REL_PATH must -# fold case — otherwise the path is treated as outside-workspace and BOTH the -# immutable deny-overlay (protect-files.sh) and the schema deny-by-default check -# (guardian.sh) are bypassed. These drive the chain with a case-varied prefix while -# CLAUDE_PROJECT_DIR stays $WS, so they exercise pure string logic (no reliance on -# the underlying volume actually being case-insensitive). -WS_CASE="$(printf '%s' "$WS" | tr '[:lower:]' '[:upper:]')" -if [[ "$WS_CASE" == "$WS" ]]; then - WS_CASE="$(printf '%s' "$WS" | tr '[:upper:]' '[:lower:]')" -fi -run_chain_prefix() { - # $1=tool, $2=absolute file_path (workspace prefix may be case-varied). - local tool="$1" fp="$2" input - input="$(printf '{"tool_name":"%s","tool_input":{"file_path":"%s"}}' "$tool" "$fp")" - if ! printf '%s' "$input" | env -i PATH="$PATH" CLAUDE_PROJECT_DIR="$WS" bash "$PROTECT" >/dev/null 2>&1; then - echo BLOCK; return - fi - if ! printf '%s' "$input" | env -i PATH="$PATH" CLAUDE_PROJECT_DIR="$WS" bash "$GUARDIAN" >/dev/null 2>&1; then - echo BLOCK; return - fi - echo ALLOW -} -if [[ "$WS_CASE" != "$WS" ]]; then - assert BLOCK "$(run_chain_prefix Write "$WS_CASE/README.md")" "case-variant workspace prefix on immutable README.md blocked (APFS)" - assert BLOCK "$(run_chain_prefix Write "$WS_CASE/unregistered/x.txt")" "case-variant workspace prefix on non-schema path blocked (APFS deny-by-default)" - assert ALLOW "$(run_chain_prefix Write "$WS_CASE/memory/notes.md")" "case-variant workspace prefix on schema path still allowed (APFS)" -else - echo "FAIL - could not produce a case-varied workspace prefix from \$WS" - FAIL=$((FAIL + 1)) -fi - -# Existing-file overwrite exemption (legacy/old.txt is not in schema, not immutable). -assert ALLOW "$(run_chain Write legacy/old.txt)" "existing-file overwrite allowed (exemption)" - -# Traversal escape is blocked even though it resolves outside the allow-check. -assert BLOCK "$(run_chain Write ../escape.txt)" "path traversal blocked" - -# WRITE_GUARD_BYPASS=1 unblocks a guardian-denied (but non-immutable) path. -assert ALLOW "$(run_chain Write unregistered/y.txt WRITE_GUARD_BYPASS=1)" "WRITE_GUARD_BYPASS=1 unblocks non-immutable path" - -# Bypass does NOT override the immutable-core deny (protect-files.sh runs first). -assert BLOCK "$(run_chain Write README.md WRITE_GUARD_BYPASS=1)" "WRITE_GUARD_BYPASS does NOT override immutable core" - -# Fail-closed: schema.md is PRESENT but its write-allowlist block is empty (only a -# comment / blank lines). The block must yield zero allow-lines → deny everything -# non-immutable (distinct from the missing-schema.md case, which is also fail-closed). -WS2="$(mktemp -d)" -cat > "$WS2/schema.md" <<'SCHEMA' -# schema with an empty write-allowlist block -```write-allowlist -# only a comment — no real entries - -``` -SCHEMA -empty_block_verdict() { - local input - input="$(printf '{"tool_name":"Write","tool_input":{"file_path":"%s"}}' "$WS2/newfile.txt")" - if printf '%s' "$input" | env -i PATH="$PATH" CLAUDE_PROJECT_DIR="$WS2" bash "$GUARDIAN" >/dev/null 2>&1; then - echo ALLOW - else - echo BLOCK - fi -} -assert BLOCK "$(empty_block_verdict)" "schema.md present but write-allowlist block empty → fail-closed" -rm -rf "$WS2" - -echo "---" -echo "$PASS passed, $FAIL failed" -[[ $FAIL -eq 0 ]] diff --git a/.claude/hooks/guardian.sh b/.claude/hooks/guardian.sh deleted file mode 100755 index b56fa43e..00000000 --- a/.claude/hooks/guardian.sh +++ /dev/null @@ -1,233 +0,0 @@ -#!/bin/bash -# guardian.sh — PreToolUse hook (schema-enforced write guard — ALLOW-CHECK half) -# -# Deny-by-default, path-granular write guard for the CLAUDE path. A NEW file may -# be created only if its workspace-relative path matches an entry in schema.md's -# fenced ```write-allowlist``` block; otherwise it is BLOCKED with an actionable -# message. This is the ALLOW half of the two-hook claude-path guard: -# -# protect-files.sh (runs FIRST, per .claude/settings.json) = immutable-core -# deny-overlay — the 10 upstream-owned paths ALWAYS block. -# guardian.sh (this hook, runs SECOND) = schema allow-check -# — everything not in schema.md's allow-list is denied. -# -# Precedence (deny-overlay > allow > default-deny) comes from hook ORDER: -# protect-files.sh blocks an immutable path before this hook can allow it. -# -# Match semantics (D17 — identical to the Pi-path classifier `isAllowedPath` in -# bot/src/pi-extensions/guard.ts; against the workspace-relative POSIX path, -# case-insensitively for APFS). Three allow-line kinds: -# - Directory prefix (trailing slash, e.g. `memory/`): matches the bare dir -# name itself OR anything under it (`memory` and `memory/x.md`). -# - Root-only glob (a bare glob with `*`/`?`, e.g. `*.md`): matches a -# ROOT-LEVEL file only — the relative path has no `/` AND the glob matches. -# - Exact path (no slash, no glob, e.g. `MEMORY.md`): matches that exact -# relative path only (never a prefix match). -# -# Bash-redirect gap (D16 — tracked v1 known-gap): this hook inspects ONLY -# `tool_input.file_path` (the Write/Edit target). A bash redirect such as -# `echo x > unregistered/y` is NOT seen here, so bash writes are UNGUARDED on -# the claude path. The Pi path DOES cover them (guard.ts `extractBashWriteTargets`); -# closing this gap in the bash hook is deliberately deferred (see the design plan -# docs/plans/2026-06-02-pi-claude-write-guard-enforcers.md). -# -# Symlink limitation: paths are matched LEXICALLY (only `..`/`//`/`/./` are -# collapsed — no realpath). A symlink at an allow-listed path that points into a -# protected/unregistered dir is matched on its own name, not its target. This is -# OUT of the threat model on purpose — the guard is anti-drift / footgun-prevention -# for a trusted operator, NOT a defense against a malicious agent deliberately -# planting symlinks. The Pi classifier (guard.ts) shares this lexical-match design. -# -# Rules: -# - Edit tool: always allowed (edits existing content) -# - Write tool: allowed if file exists (overwrite) or path matches schema.md -# - Only checks files within the workspace - -# Fail-closed: if jq is missing, block rather than bypass -if ! command -v jq &>/dev/null; then - echo "BLOCKED by write-guard: jq not found — cannot parse hook input" >&2 - exit 2 -fi - -INPUT=$(cat) - -TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') - -# Fail-closed: if tool_name is empty, input parsing failed — block rather than bypass. -# The hook matcher guarantees tool_name is always present for valid invocations. -if [[ -z "$TOOL_NAME" ]]; then - echo "BLOCKED by write-guard: could not parse tool_name from input" >&2 - exit 2 -fi - -# Edit tool always targets existing content — never block -if [[ "$TOOL_NAME" == "Edit" ]]; then - exit 0 -fi - -# Write requires file_path — fail-closed if missing (defense-in-depth) -if [[ -z "$FILE_PATH" ]]; then - echo "BLOCKED by write-guard: Write tool called without file_path" >&2 - exit 2 -fi - -# Determine workspace root (no hardcoded fallback — must have CLAUDE_PROJECT_DIR) -WORKSPACE="${CLAUDE_PROJECT_DIR%/}" -if [[ -z "$WORKSPACE" ]]; then - echo "BLOCKED by write-guard: CLAUDE_PROJECT_DIR not set" >&2 - exit 2 -fi - -# Only check files within this workspace. An absolute path elsewhere (e.g. -# /tmp/log) is out of scope — matching the Pi classifier's outside-workspace allow. -# Containment is decided CASE-INSENSITIVELY (APFS): a FILE_PATH whose workspace-root -# prefix case-varies (e.g. `/users/...` for the real `/Users/...`) names the SAME -# file on case-insensitive APFS, so a case-sensitive prefix test would wrongly treat -# it as outside-workspace and allow it — bypassing the deny-by-default allow-check. -# Fold case for the test (mirrors guard.ts), then strip by LENGTH (case-folding -# preserves length) so REL_PATH keeps its original-case tail. -shopt -s nocasematch -if [[ "$FILE_PATH" != "$WORKSPACE/"* ]]; then - exit 0 -fi -shopt -u nocasematch - -# Extract path relative to workspace root -REL_PATH="${FILE_PATH:$(( ${#WORKSPACE} + 1 ))}" - -# PRESERVE: block path traversal BEFORE the existing-file check (defense-in-depth: -# -e resolves ".." so an attacker could escape the workspace via existing targets). -# Only match ".." as a path component, not inside filenames like "file..bak". -if [[ "$REL_PATH" == ../* ]] || [[ "$REL_PATH" == */../* ]] || [[ "$REL_PATH" == */.. ]] || [[ "$REL_PATH" == ".." ]]; then - echo "BLOCKED by write-guard: path contains '..' traversal: ${REL_PATH}" >&2 - exit 2 -fi - -# PRESERVE: normalize path — collapse // and /./ segments -while [[ "$REL_PATH" == *//* ]]; do - REL_PATH="${REL_PATH//\/\//\/}" -done -while [[ "$REL_PATH" == *"/./"* ]]; do - REL_PATH="${REL_PATH//\/.\//\/}" -done -REL_PATH="${REL_PATH#./}" - -# PRESERVE: if file already exists, it's an overwrite — always allowed -if [[ -e "$FILE_PATH" ]]; then - exit 0 -fi - -# --- Bypass (mirrors protect-files.sh; all triggers logged to stderr) ------ -# Without an escape, a workspace with NO schema.md — e.g. the upstream dev repo -# itself, or a ralphex worktree — would fail CLOSED on every new file, bricking -# the very workflows that maintain these hooks. protect-files.sh already carries -# the same three triggers; guardian.sh runs AFTER it and so needs them too. -# 1. WRITE_GUARD_BYPASS=1 — explicit one-off opt-out -# 2. CLAUDE_PROJECT_DIR under /.ralphex/worktrees/ — ralphex pipeline -# 3. git origin == upstream dev repo (fitz123/claude-code-bot) -bypass="" -if [[ "${WRITE_GUARD_BYPASS:-0}" == "1" ]]; then - bypass="env WRITE_GUARD_BYPASS=1" -elif [[ "$WORKSPACE" == */.ralphex/worktrees/* ]]; then - bypass="ralphex worktree ($WORKSPACE)" -else - origin_url="$(git -C "$WORKSPACE" remote get-url origin 2>/dev/null || true)" - case "$origin_url" in - *fitz123/claude-code-bot.git | *fitz123/claude-code-bot | *fitz123/claude-code-bot/) - bypass="upstream dev repo (origin=$origin_url)" - ;; - esac -fi -if [[ -n "$bypass" ]]; then - echo "write-guard: bypass active — $bypass" >&2 - exit 0 -fi - -# --- Schema-enforced allow-check (deny-by-default) ------------------------- -# Suggest the schema.md line that would unblock this path (the first dir -# component as a directory-prefix when nested, else the exact path) for the -# actionable block message below. -if [[ "$REL_PATH" == */* ]]; then - SUGGEST="${REL_PATH%%/*}/" -else - SUGGEST="$REL_PATH" -fi - -block_denied() { - cat >&2 <&2 </dev/null; then - echo "BLOCKED by protect-files: jq not found — cannot parse hook input" >&2 - exit 2 -fi - -INPUT=$(cat) -FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') || true - -# Fail-closed: if jq failed to parse, FILE_PATH may be empty due to malformed input -# Distinguish "no file_path field" from "parse error" by re-checking jq exit code -if ! echo "$INPUT" | jq -e '.tool_input' &>/dev/null; then - echo "BLOCKED by protect-files: failed to parse hook input JSON" >&2 - exit 2 -fi - -if [ -z "$FILE_PATH" ]; then - exit 0 -fi - -# Normalize path: prevent bypass via non-canonical paths -# Collapse multiple slashes: // → / -while [[ "$FILE_PATH" == *//* ]]; do - FILE_PATH="${FILE_PATH//\/\///}" -done -# Collapse /./ → / -while [[ "$FILE_PATH" == *"/./"* ]]; do - FILE_PATH="${FILE_PATH//\/.\///}" -done -# Resolve /component/.. sequences -while [[ "$FILE_PATH" == *"/.."* ]]; do - _prev="$FILE_PATH" - FILE_PATH=$(printf '%s' "$FILE_PATH" | sed 's|/[^/][^/]*/\.\./|/|;s|/[^/][^/]*/\.\.$||') - [[ "$FILE_PATH" == "$_prev" ]] && break -done - -# Compute repo-rooted relative path so subsequent globs anchor to the -# repository root, not to an arbitrary path segment. Without this, a glob -# like `*/bot/*` would also match `reference/bot/notes.md` (the literal -# `bot` segment can occur anywhere in the tree). The frontmatter in -# bot-code-readonly.md is rooted (`bot/**` etc), so the hook must match -# the same way. -# -# Fail-closed on $CLAUDE_PROJECT_DIR — if unset, no bypass and no rooted -# matching (no $PWD fallback, since $PWD can be agent-controlled whereas -# CLAUDE_PROJECT_DIR is set by the Claude Code harness from the session's -# project root). When unset, we strip a leading `/` so absolute paths still -# enter the relative-pattern case, and rely on the literal pattern strings. -PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-}" -PROJECT_ROOT="${PROJECT_ROOT%/}" -# Containment is decided CASE-INSENSITIVELY (APFS). macOS APFS is case-insensitive, -# so an absolute FILE_PATH whose workspace-root prefix case-varies (e.g. `/users/...` -# for the real `/Users/...`) names the SAME file as $CLAUDE_PROJECT_DIR. A -# case-sensitive prefix test would miss it, fall through to the leading-`/` strip -# branch, and the resulting full-path REL_PATH would match NEITHER the immutable -# case-block below NOR a schema line — bypassing both guards. `nocasematch` folds -# case for the prefix test; we then strip by LENGTH (case-folding preserves length) -# so REL_PATH keeps the original-case tail for the (case-insensitive) match + the -# error message. Mirrors guard.ts's classifyTargetPath, which lowercases both sides -# before relative(). -shopt -s nocasematch -if [ -n "$PROJECT_ROOT" ] && [[ "$FILE_PATH" == "$PROJECT_ROOT"/* ]]; then - REL_PATH="${FILE_PATH:$(( ${#PROJECT_ROOT} + 1 ))}" -else - REL_PATH="${FILE_PATH#/}" -fi -shopt -u nocasematch - -# --- 1. Skills — cron-only block (interactive sessions can still edit) --- -case "$REL_PATH" in - .claude/skills/*) - if [ -n "$CRON_NAME" ]; then - echo "Blocked: cron '$CRON_NAME' cannot modify skill files: $FILE_PATH" >&2 - exit 2 - fi - ;; -esac - -# --- 2. Upstream-owned platform files — block ALL sessions --- -# Mirror of `bot-code-readonly.md` paths frontmatter. Keep these two lists -# in lockstep — the rule is the doc, the hook is the enforcement. If you -# legitimately need to change one of these files: do it in upstream -# (fitz123/claude-code-bot) → PR → merge → `git fetch upstream && git merge`. - -# Bypass paths where editing these files IS the intended workflow. -# Three triggers — all log to stderr so bypass is visible in transcripts: -# 1. PROTECT_FILES_BYPASS=1 — explicit opt-out for one-off cases -# 2. $CLAUDE_PROJECT_DIR contains `/.ralphex/worktrees/` — ralphex pipeline -# 3. git remote.origin.url at $CLAUDE_PROJECT_DIR is the upstream repo -bypass="" - -if [ "${PROTECT_FILES_BYPASS:-0}" = "1" ]; then - bypass="env PROTECT_FILES_BYPASS=1" -elif [ -n "$PROJECT_ROOT" ]; then - if [[ "$PROJECT_ROOT" == */.ralphex/worktrees/* ]]; then - bypass="ralphex worktree ($PROJECT_ROOT)" - else - origin_url="$(git -C "$PROJECT_ROOT" remote get-url origin 2>/dev/null || true)" - case "$origin_url" in - *fitz123/claude-code-bot.git|*fitz123/claude-code-bot|*fitz123/claude-code-bot/) - bypass="upstream dev repo (origin=$origin_url)" - ;; - esac - fi -fi - -if [ -n "$bypass" ]; then - echo "protect-files: bypass active — $bypass" >&2 - exit 0 -fi - -# Case-insensitive (APFS): README.MD and README.md are the SAME file, so this -# deny-overlay MUST fold case the way guard.ts (isProtectedPath) and guardian.sh's -# allow-check already do. Without it, a case-variant slips past this deny and a -# schema.md glob (e.g. `*.md`) re-allows the immutable file — breaking the -# "immutable core can never be unlocked via schema.md" invariant. -# Directory entries also match their bare name (a root file literally named -# `bot`), mirroring isProtectedPath's `lc === base` — full parity with the Pi path. -shopt -s nocasematch -case "$REL_PATH" in - bot|bot/*) match=1 ;; - .claude/hooks|.claude/hooks/*) match=1 ;; - .claude/rules/platform|.claude/rules/platform/*) match=1 ;; - .claude/skills/workspace-health/scripts|.claude/skills/workspace-health/scripts/*) match=1 ;; - .github/workflows|.github/workflows/*) match=1 ;; - .githooks|.githooks/*) match=1 ;; - .gitleaks.toml) match=1 ;; - .gitleaksignore) match=1 ;; - README.md) match=1 ;; - config.local.yaml.example) match=1 ;; - *) match=0 ;; -esac -shopt -u nocasematch - -if [ "$match" = "1" ]; then - echo "BLOCKED by protect-files: '$FILE_PATH' is upstream-owned (see .claude/rules/platform/bot-code-readonly.md)." >&2 - echo "Change it in fitz123/claude-code-bot via PR, then 'git fetch upstream && git merge upstream/main'." >&2 - exit 2 -fi - -exit 0 diff --git a/.claude/rules/platform/bot-code-readonly.md b/.claude/rules/platform/bot-code-readonly.md index db62f420..077fff01 100644 --- a/.claude/rules/platform/bot-code-readonly.md +++ b/.claude/rules/platform/bot-code-readonly.md @@ -18,11 +18,7 @@ These files come from upstream (`fitz123/claude-code-bot`). Never edit them here To change: PR in public repo (`~/src/claude-code-bot/`) → merge → `git fetch upstream && git merge upstream/main` in workspace. -The `paths:` list above is the canonical set of upstream-owned paths. Any local edit to these breaks the next `git merge upstream/main` (divergence/conflicts) and risks losing your change. The `protect-files.sh` hook enforces this list for **all** sessions — `Edit`/`Write` on a matching path fails fast with a pointer back to this rule. - -These same 10 paths form the **immutable core** (deny-overlay) of the schema-enforced write-guard. Both enforcers — `guard.ts` (Pi path, `PROTECTED_PREFIXES`) and the `protect-files.sh` / `guardian.sh` chain (claude path) — hardcode the full 10 and check them *before* the `schema.md` allow-list, so the deny-overlay always wins (deny-overlay > allow > default-deny) and these paths can never be unlocked via `schema.md`. `guard.ts` now pins all 10 (previously a narrowed 4). The directory entries (trailing slash) match as path-prefixes; the four file entries (`.gitleaks.toml`, `.gitleaksignore`, `README.md`, `config.local.yaml.example`) match root-only-exact — e.g. `README.md` blocks the root file but not `docs/README.md`. Matching is **case-insensitive** in all three enforcers (`guard.ts`, `protect-files.sh`, `guardian.sh`) — on case-insensitive APFS `README.MD` is the same file as `README.md`, so a case-variant cannot slip past the deny-overlay. - -One deliberate claude-path vs Pi-path asymmetry: the `guardian.sh` schema allow-check only gates the creation of **new** files — an `Edit`, or a `Write` to an already-existing non-immutable file, is exempt (overwrite is allowed). The Pi classifier (`guard.ts`) has no such overwrite exemption. The immutable core is unaffected (it always blocks, whether the file exists or not), so this narrows only the *deny-by-default* half on the claude path — consistent with the anti-drift / footgun-prevention threat model (not a defense against a malicious agent). +The `paths:` list above is the canonical set of upstream-owned paths. Any local edit to these breaks the next `git merge upstream/main` (divergence/conflicts) and risks losing your change. No package runtime guard enforces this list; use the public-repo PR flow for changes. Files that **look upstream but are workspace-local** (excluded from the list above) and ARE safe to edit: diff --git a/.claude/rules/platform/runtime-context.md b/.claude/rules/platform/runtime-context.md index a88f47e9..e77a1d0b 100644 --- a/.claude/rules/platform/runtime-context.md +++ b/.claude/rules/platform/runtime-context.md @@ -11,10 +11,10 @@ You are running as a **coding-agent backend** spawned by the Telegram/Discord bo ## What this means -- Your exact tools depend on the Pi spawn mode. Interactive Pi RPC sessions load the bot's Pi extensions. Pi print-mode crons load only the A1 guard extension and do not have A2/A3/browser/MCP/subagent parity. +- Your exact tools depend on the Pi spawn mode. Interactive Pi RPC sessions load the bot's Pi extensions. Pi print-mode crons do not have web-tools/subagent/browser/MCP parity. - You are NOT running in a terminal. Messages come from Telegram users, not a keyboard - Your responses are sent back to Telegram via the bot's stream relay -- Your workspace is your current working directory. Other agents live in sibling directories alongside it; check the bot's `config.yaml` (in the main workspace) for the full agent roster and which Telegram chats route to which agent +- Your workspace is your current working directory from `agents.*.workspaceCwd`. Other agents live wherever the control workspace config points; check the control workspace `config.yaml` / `config.local.yaml` for the full agent roster and which Telegram chats route to which agent - Bot tools are available: `bot/scripts/deliver.sh` for Telegram messaging, `launchctl` for cron management - Pi print-mode crons do not have automatic memory recall. Use `MEMORY.md` as the index and read specific memory files on demand. diff --git a/.claude/settings.json b/.claude/settings.json index 2abbd03e..9f066663 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,20 +1,5 @@ { "hooks": { - "PreToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" - }, - { - "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/guardian.sh" - } - ] - } - ], "PostToolUse": [ { "matcher": "Edit|Write", diff --git a/.claude/skills/bot-operations/SKILL.md b/.claude/skills/bot-operations/SKILL.md index 054b0de4..3df8dbea 100644 --- a/.claude/skills/bot-operations/SKILL.md +++ b/.claude/skills/bot-operations/SKILL.md @@ -44,7 +44,7 @@ crons.yaml → generate-plists.ts → launchd plist → run-cron.sh → cron-run | `bot/scripts/generate-plists.ts` | Renders crons.yaml → `~/Library/LaunchAgents/ai.minime.cron.*.plist` | | `bot/scripts/run-cron.sh` | launchd entry point. Sets HOME/PATH, scrubs legacy runtime env, calls cron-runner.ts | | `bot/src/cron-runner.ts` | Loads cron def, spawns Pi print mode for LLM crons or `/bin/bash` for script crons | -| `bot/scripts/deliver.sh` | Sends result to Telegram (token from Keychain, splits >4096 chars) | +| `bot/scripts/deliver.sh` | Sends result to Telegram using `TELEGRAM_BOT_TOKEN` supplied by `cron-runner.ts` after SOPS/env token resolution; splits >4096 chars | ### Adding / updating a cron @@ -66,7 +66,7 @@ crons.yaml → generate-plists.ts → launchd plist → run-cron.sh → cron-run timeout: 300000 # Execution timeout ms (optional) ``` -For LLM crons, `engine` must be omitted or `pi`; `engine: claude` fails validation. `CRON_PI_DISABLED=1` is unsupported. `PI_EXTENSIONS_DISABLED=1` fails LLM crons because the A1 guard extension is required. +For LLM crons, `engine` must be omitted or `pi`; `engine: claude` fails validation. `CRON_PI_DISABLED=1` is unsupported. ### Environment handling diff --git a/.gitignore b/.gitignore index 25665e50..e49d4fd0 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ memory/diary/* reference/* !reference/governance/ reference/governance/* +!reference/governance/decisions.md !reference/governance/decisions.md.example orphan-allowlist.local.txt diff --git a/CLAUDE.md b/CLAUDE.md index 6fe0e51f..8a9a9904 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,9 +20,7 @@ To activate one, copy it into `.claude/rules/custom/`. ## Hooks -Five hooks are wired in `.claude/settings.json`: -- `protect-files.sh` — immutable-core deny-overlay: blocks all sessions from writing the 10 upstream-owned paths, plus cron/autonomous agents from modifying skill files (PreToolUse, Edit|Write) -- `guardian.sh` — schema-enforced deny-by-default write-guard: blocks new files whose path is not in `schema.md`'s `write-allowlist` block (PreToolUse, Edit|Write); `WRITE_GUARD_BYPASS=1` to bypass +Three hooks are wired in `.claude/settings.json`: - `auto-stage.sh` — stages files after Edit/Write (PostToolUse) - `session-end-commit.sh` — commits staged changes on session exit (SessionEnd) - `session-start-recovery.sh` — recovers orphaned staged changes (SessionStart) @@ -33,14 +31,14 @@ Five hooks are wired in `.claude/settings.json`: - `/status` is local-only: read quota data with `readQuotaStatus()` and never call Pi, Codex, the network, or Pi `get_state` from a status command. - Live Pi sessions stay on `transport: auto`; only `bot/scripts/codex-quota-sampler.ts` creates an isolated sampler cwd with `transport: "sse"`. - `bot/.claude/extensions/codex-usage.ts` is sampler-only and must not be added to the normal Pi RPC extension list. -- Pi subagent child spawns load only A1 guard + A2 web-tools via `PI_SUBAGENT_CHILD_WRAPPER_RELPATHS`; do not add A3 `subagent/index.ts` to child sessions. Pi crons use the separate A1-only `PI_CRON_WRAPPER_RELPATHS`. +- Pi subagent child spawns load web-tools via `PI_SUBAGENT_CHILD_WRAPPER_RELPATHS`; do not add A3 `subagent/index.ts` to child sessions. - Bundled scout/planner/reviewer agents allow `web_search` and `web_fetch`; worker has no explicit tools allowlist. - Use `thinking` for Pi agents; `effort` is obsolete and rejected by config validation. -- Runtime bot tokens use `bot/src/secrets.ts`: SOPS first, then configured env; legacy `*tokenService` Keychain fields are rejected. Telegram/Discord SOPS files resolve relative to the bot config file, while Tavily uses `config/secrets.sops.yaml` relative to each Pi session `workspaceCwd` and should contain only `tavily.api_key`. -- Workspace contract defaults live in `bot/src/workspace-contract.ts`: CLI `--workspace`, then `MINIME_WORKSPACE_ROOT`, then source-checkout fallback. `MINIME_CONFIG_PATH`, `MINIME_CRONS_PATH`, and `MINIME_SCHEMA_PATH` resolve relative to the workspace root; relative agent `workspaceCwd` values are resolved against that root and agent workspaces must stay inside it before runtime spawns. +- Runtime bot tokens use `bot/src/secrets.ts`: SOPS first, then configured env; legacy `*tokenService` Keychain fields are rejected. Under ADR-081, Telegram, Discord, and Tavily secret references are global control-workspace references. Tavily resolves `/config/secrets.sops.yaml` key `tavily.api_key` via `MINIME_WORKSPACE_ROOT`; it never reads agent cwd or receives plaintext through env/argv. +- Workspace contract defaults live in `bot/src/workspace-contract.ts`: CLI `--workspace`, then `MINIME_WORKSPACE_ROOT`, then source-checkout fallback. Under ADR-081 this root is the control/app workspace. `MINIME_CONFIG_PATH` and `MINIME_CRONS_PATH` are control-workspace overrides. Relative agent `workspaceCwd` values resolve against the control workspace; absolute agent workspaces may live outside it after existence/directory validation. - Package extension artifacts are generated under `bot/dist/extensions/pi` by `npm run build` / `npm pack`; source development still uses `bot/.claude/extensions`. - Bot validation commands: `cd bot && npm test`, `npm run typecheck`, and `npm run validate-config`. -- Package validation commands: `cd bot && npm run build`, `npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace`, and `npm pack --dry-run`. +- Package validation commands: `cd bot && npm run build`, `npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace`, `npm run check:schema-guard-contract`, and `npm pack --dry-run`. - Sampler dry-run check: `cd bot && CODEX_QUOTA_TEXTFILE_DIR=/tmp/codex-quota-test CODEX_QUOTA_STATE_FILE=/tmp/codex-quota-test/state.json npx tsx scripts/codex-quota-sampler.ts --dry-run`. ## Skills diff --git a/README.md b/README.md index 5aca3b26..d5c2060d 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ cd ~/.minime/bot && npm install cp config.local.yaml.example config.local.yaml ``` -Edit `config.local.yaml` — set `workspaceCwd` to the absolute path of your repo and `chatId` to your Telegram user ID (send `/start` to [@userinfobot](https://t.me/userinfobot) to find it). +Edit `config.local.yaml` — set `workspaceCwd` to the absolute path of the repo or project directory the agent should work in, and `chatId` to your Telegram user ID (send `/start` to [@userinfobot](https://t.me/userinfobot) to find it). `crons.yaml` ships with example crons (all disabled). Create `crons.local.yaml` for your own crons: @@ -134,7 +134,7 @@ creation_rules: age: age1replace_with_your_public_recipient ``` -`config.yaml` already points Telegram at `config/secrets.sops.yaml` key `telegram.bot_token`. This bot runtime file is resolved relative to the bot config file, not relative to agent workspaces. Create it with SOPS/age so the decrypted document contains only bot platform token paths: +`config.yaml` already points Telegram at `config/secrets.sops.yaml` key `telegram.bot_token`. This bot runtime file is resolved relative to the control workspace, not relative to agent workspaces. Create it with SOPS/age so the decrypted document contains only control-workspace runtime secrets: ```bash mkdir -p config @@ -148,9 +148,11 @@ telegram: bot_token: ENC[...] discord: bot_token: ENC[...] +tavily: + api_key: ENC[...] ``` -Tavily web-tool secrets use a separate SOPS file in each agent workspace, described in [A2 setup](#pi-extensions-a1-a3). Do not copy Telegram or Discord bot tokens into agent workspaces. +Tavily web-tool secrets use the same control-workspace path by default: `config/secrets.sops.yaml` key `tavily.api_key`, described in [web-tools setup](#web-tools-setup-optional). Do not copy Telegram, Discord, or Tavily secret values into agent workspaces. **4. Initialize Pi auth** @@ -263,7 +265,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.minime.telegram-bot.p Pi cron behavior: - - LLM crons always run Pi print mode with `pi -p --no-session --no-extensions`, fixed model `openai-codex/gpt-5.5`, the agent `systemPrompt`/workspace context, and only the explicit A1 guard extension. + - LLM crons always run Pi print mode with `pi -p --no-session --no-extensions`, fixed model `openai-codex/gpt-5.5`, and the agent `systemPrompt`/workspace context. - Agent `thinking` maps to `--thinking`; absent values default to `medium`, and invalid configured values fail validation. - The `pi` binary must be on the launchd cron `PATH`, and Pi auth must exist at `~/.pi/agent/auth.json` for the launchd user. Run `pi /login` as that user before enabling LLM crons. - Set `enabled: false`, convert the cron to `type: script`, or unload the cron plist to stop a problematic cron. Engine values other than `pi` are rejected. @@ -272,7 +274,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.minime.telegram-bot.p - Empty stdout with empty stderr is a successful no-delivery run. - `NO_REPLY` is a successful no-delivery LLM run. - - Pi stderr-only success, non-zero exit, signal exit, spawn timeout, missing A1 guard, or disabled A1 guard are failures and trigger the existing `⚠️ Cron FAIL` notification plus the failure metric. + - Pi stderr-only success, non-zero exit, signal exit, or spawn timeout are failures and trigger the existing `⚠️ Cron FAIL` notification plus the failure metric. 2. Generate launchd plists: ```bash @@ -341,7 +343,7 @@ Token resolution checks SOPS first, then a configured environment variable: | Field | Source | When to use | |---|---|---| -| `secrets.sopsFile` + `telegramTokenSopsKey` / `discord.tokenSopsKey` | SOPS/age file read with `sops -d --extract` | Canonical private-workspace deployment backend for bot platform tokens | +| `secrets.sopsFile` + `telegramTokenSopsKey` / `discord.tokenSopsKey` | SOPS/age file read with `sops -d --extract` | Canonical control-workspace deployment backend for bot platform tokens | | `telegramTokenEnv` / `discord.tokenEnv` | Environment variable name read from `process.env` | Explicit environment override for launchd, Linux, containers, or systemd | Example: @@ -355,19 +357,19 @@ discord: tokenEnv: DISCORD_BOT_TOKEN ``` -SOPS key paths are dot paths whose segments must match `[A-Za-z0-9_-]+`, such as `telegram.bot_token`. A configured `*SopsKey` requires `secrets.sopsFile`; invalid key syntax or a missing `secrets.sopsFile` is a config error. Runtime lookup failures such as a missing file, decrypt failure, or blank decrypted value fall back to the configured env var, then fail with sanitized source/key/env/failure-kind details if no source resolves. +SOPS key paths are dot paths whose segments must match `[A-Za-z0-9_-]+`, such as `telegram.bot_token`. A configured `*SopsKey` requires `secrets.sopsFile`; relative SOPS file paths resolve against the control workspace, not the config file directory or any agent workspace. Invalid key syntax or a missing `secrets.sopsFile` is a config error. Runtime lookup failures such as a missing file, decrypt failure, or blank decrypted value fall back to the configured env var, then fail with sanitized source/key/env/failure-kind details if no source resolves. Legacy `telegramTokenService` and `discord.tokenService` Keychain settings are rejected with migration errors. Telegram token resolution is required only when Telegram bindings are configured; Discord-only deployments can set `bindings: []` and provide a Discord token source. ## Memory architecture -The bot maintains persistent context across sessions through a memory system rooted at the workspace. +The bot maintains persistent context across sessions through a memory system rooted at each agent workspace. -- `MEMORY.md` at the workspace root is a curated index of memory files. Keep it concise — the Pi context assembler loads it into the agent's initial context on every session. +- `MEMORY.md` at the agent workspace root is a curated index of memory files. Keep it concise — the Pi context assembler loads it into the agent's initial context on every session. - `memory/auto/` holds typed memory files (`user`, `feedback`, `project`, `reference`) with frontmatter, written by the agent or the `memory-consolidation` nightly cron. - `memory/diary/` holds narrative digests from consolidation runs. -`MEMORY.md` is auto-loaded via the `@MEMORY.md` line in `CLAUDE.md`. `CLAUDE.md` remains the workspace context entry point even though Pi/Codex is now the runtime; the Pi context assembler follows that import and includes the workspace memory index. +`MEMORY.md` is auto-loaded via the `@MEMORY.md` line in `CLAUDE.md`. `CLAUDE.md` remains the agent workspace context entry point even though Pi/Codex is now the runtime; the Pi context assembler follows that import and includes the workspace memory index. **Do not remove the `@MEMORY.md` line from `CLAUDE.md`.** Without it, your workspace `MEMORY.md` will not be auto-loaded and the agent will start every session with no memory index. See [.claude/rules/platform/memory-protocol.md](.claude/rules/platform/memory-protocol.md) for the full protocol. @@ -389,13 +391,15 @@ Installed-package commands use the same surface: ```bash minime-bot --help -minime-bot config validate --workspace /path/to/workspace -minime-bot workspace validate --workspace /path/to/workspace +minime-bot config validate --workspace /path/to/control-workspace +minime-bot workspace validate --workspace /path/to/control-workspace ``` -`--workspace` takes precedence over `MINIME_WORKSPACE_ROOT`; if neither is set in the current source checkout, the workspace defaults to the repository root. To validate the repository root itself, make sure it has the expected workspace files, including `schema.md` and configured agent `workspaceCwd` directories. `MINIME_CONFIG_PATH`, `MINIME_CRONS_PATH`, and `MINIME_SCHEMA_PATH` override the corresponding workspace files and resolve relative to the workspace root when not absolute. +`--workspace` takes precedence over `MINIME_WORKSPACE_ROOT`; under ADR-081 it names the control/app workspace that owns config, crons, bindings, runtime state, and global secret references. If neither is set in the current source checkout, the workspace defaults to the repository root. Relative agent `workspaceCwd` values resolve against the control workspace. Absolute agent workspaces are allowed to live outside the control workspace after existence/directory validation. + +`MINIME_CONFIG_PATH` and `MINIME_CRONS_PATH` override the control workspace config and crons files. Relative override values resolve against the control workspace. They are non-secret path references and are propagated to Pi children only when explicitly configured. -Validation is structural by default. These commands load config with secret resolution disabled, parse crons and the workspace `schema.md` write allow-list, and print effective paths without decrypting SOPS files or printing secret values. Hard failures include an absent or invalid workspace root, missing or invalid config, malformed crons, missing/empty/malformed schema while guards are enabled, missing configured agent workspaces, agent workspaces outside the resolved workspace root, a missing Pi extension directory, or validator/live-guard schema path disagreement. A missing crons file is a warning. Setting `PI_EXTENSIONS_DISABLED=1` skips schema enforcement as a warning for workspace validation, but Pi LLM crons still require the A1 guard. +Validation is structural by default. These commands load config with secret resolution disabled, parse crons, and print effective paths without decrypting SOPS files or printing secret values. The validator hard-fails missing or non-directory control workspaces, invalid or missing config paths, invalid crons paths, missing or non-directory configured agent workspaces, and missing package Pi extension directories. It warns for missing crons files and missing optional agent context files or rules directories. ### Provider backends @@ -424,30 +428,29 @@ The typed Pi RPC module ([bot/src/pi-rpc-protocol.ts](bot/src/pi-rpc-protocol.ts The Pi binary (`@earendil-works/pi-coding-agent`) is resolved from `PATH`; the bot prepends `/opt/homebrew/bin` to the spawned process's `PATH`, so ensure `pi` is reachable there or on the inherited `PATH`. Auth is managed by Pi itself, which reads `~/.pi/agent/auth.json` (the bot does not create or manage that file). -#### Pi extensions (A1-A3) +#### Pi extensions -Every `pi --mode rpc` spawn suppresses Pi's ambient extension discovery with `--no-extensions`, then loads three first-party extensions so Pi sessions reach parity with the workspace guard, web-tools, and subagent capabilities expected by deployed agents. They are loaded as repeatable `--extension ` args appended by `buildPiSpawnArgs` (see [resolvePiExtensionArgs](bot/src/pi-rpc-protocol.ts)) — loading is deliberately per-spawn rather than via Pi's auto-discovery dirs. Source checkout runs load the TypeScript wrappers under `bot/.claude/extensions/`; built and installed package runs load generated wrappers under `bot/dist/extensions/pi/` or `node_modules/minime/dist/extensions/pi/`, including copied subagent `agents/*.md` and `prompts/*.md` resources. +Every `pi --mode rpc` spawn suppresses Pi's ambient extension discovery with `--no-extensions`, then loads first-party extensions as repeatable `--extension ` args appended by `buildPiSpawnArgs` (see [resolvePiExtensionArgs](bot/src/pi-rpc-protocol.ts)). Loading is deliberately per-spawn rather than via Pi's auto-discovery dirs. Source checkout runs load the TypeScript wrappers under `bot/.claude/extensions/`; built and installed package runs load generated wrappers under `bot/dist/extensions/pi/` or `node_modules/minime/dist/extensions/pi/`, including copied subagent `agents/*.md` and `prompts/*.md` resources. | Extension | Wrapper | What it does | |---|---|---| -| **A1 guard** | `bot/.claude/extensions/guardian-protect-files.ts` | A `tool_call` handler that blocks edit/write and bash redirects (`>`, `>>`, `tee`, `mv`, `cp`) into the 10-path immutable core of upstream-owned paths (`bot/`, `.claude/hooks/`, `.claude/rules/platform/`, `.claude/skills/workspace-health/scripts/`, `.github/workflows/`, `.githooks/`, `.gitleaks.toml`, `.gitleaksignore`, `README.md`, `config.local.yaml.example`) and `..` traversal escapes. It also drives the **schema-enforced deny-by-default** write-guard: it parses the workspace `schema.md` ```` ```write-allowlist ```` block and blocks any write/edit/bash target whose workspace-relative path is not in it (deny-overlay > allow > default-deny). Directory entries (trailing slash) match as prefixes; the four file entries match root-only-exact (`README.md` blocks the root file but not `docs/README.md`). A missing/empty block fails **closed** (immutable core still blocks; everything else is denied with an actionable "add it to `schema.md`" message). Path matching canonicalizes `.`/`..`/`//` and is case-insensitive (APFS). Disable first-party wrappers with `PI_EXTENSIONS_DISABLED=1`; ambient discovery remains disabled. | -| **A2 web-tools** | `bot/.claude/extensions/web-tools.ts` | Registers `web_search` + `web_fetch`, Tavily-backed. The API key is read from SOPS key `tavily.api_key` in `config/secrets.sops.yaml` relative to the Pi session cwd, which is the agent's `workspaceCwd`. A missing key warn-logs a sanitized message but leaves the tools registered; failures return a graceful "unavailable" result instead of throwing. | -| **A3 subagent** | `bot/.claude/extensions/subagent/` | The vendored official `subagent` extension (directory), adapted only to spawn an isolated `pi -p` child on the `openai-codex` provider. Exposes the `subagent` tool (`single` / `parallel` / `chain`) that the Agent/Task delegation skills invoke. Each child spawn passes `--no-extensions`, then explicitly loads A1 guard + A2 web-tools so delegated research can use `web_search` / `web_fetch` without bypassing the write guard; children never load A3 `subagent/index.ts`, so recursive spawning stays disabled. Child wrapper resolution fails closed if a required wrapper is missing. Child errors warn-log. | +| **web-tools** | `bot/.claude/extensions/web-tools.ts` | Registers `web_search` + `web_fetch`, Tavily-backed. The wrapper reads `tavily.api_key` from `config/secrets.sops.yaml` under the control workspace passed through `MINIME_WORKSPACE_ROOT`, independent of the Pi session cwd. A missing key warn-logs a sanitized message but leaves the tools registered; failures return a graceful "unavailable" result instead of throwing. | +| **subagent** | `bot/.claude/extensions/subagent/` | The vendored official `subagent` extension (directory), adapted only to spawn an isolated `pi -p` child on the `openai-codex` provider. Exposes the `subagent` tool (`single` / `parallel` / `chain`) that the Agent/Task delegation skills invoke. Children load web-tools only; they never load `subagent/index.ts`, so recursive spawning stays disabled. Child wrapper resolution fails closed if a required wrapper is missing. Child errors warn-log. | -**A2 setup (optional):** add a [Tavily](https://tavily.com) API key to a Tavily-only private SOPS file at `/config/secrets.sops.yaml` to enable `web_search` / `web_fetch` for that agent. The decrypted shape should contain only the web-tool secret: +**web-tools setup (optional):** add a [Tavily](https://tavily.com) API key to the control-workspace SOPS file at `/config/secrets.sops.yaml` using key `tavily.api_key`. The decrypted shape can share the same file as Telegram and Discord tokens, or contain only the web-tool secret if those tokens use explicit environment overrides: ```yaml tavily: api_key: ENC[...] ``` -Keep this file separate from the bot runtime SOPS file used for Telegram and Discord tokens. Omit it to leave the tools registered-but-unavailable. +Omit `tavily.api_key` to leave the tools registered-but-unavailable. The web-tools wrapper never reads secrets from agent workspaces and never receives the plaintext Tavily key through env or argv. -**Kill-switch:** set `PI_EXTENSIONS_DISABLED=1` in the bot's environment to spawn Pi RPC chat sessions with no explicit first-party wrappers; the spawn still passes `--no-extensions`, so ambient discovery does not load other extensions. With extensions enabled, a configured wrapper missing on disk makes the spawn **fail loudly** rather than silently dropping the guard (A1 is the write guard — a silent skip would spawn an unguarded session). Pi crons are stricter: LLM crons require A1 and fail closed if `PI_EXTENSIONS_DISABLED=1`. +**Kill-switch:** set `PI_EXTENSIONS_DISABLED=1` in the bot's environment to spawn Pi RPC chat sessions with no explicit first-party wrappers; the spawn still passes `--no-extensions`, so ambient discovery does not load other extensions. A configured wrapper missing on disk fails loudly instead of silently dropping part of the first-party extension contract. **Rollback:** -- **Disable Pi extensions (no deploy):** set `PI_EXTENSIONS_DISABLED=1` in the bot's launchd environment, then `bot/scripts/restart-bot.sh --plist` (env-var changes are plist-level — see [Start / Stop](#start--stop)). Pi RPC chat spawns drop all three first-party wrappers immediately while still blocking ambient extension discovery. +- **Disable Pi extensions (no deploy):** set `PI_EXTENSIONS_DISABLED=1` in the bot's launchd environment, then `bot/scripts/restart-bot.sh --plist` (env-var changes are plist-level — see [Start / Stop](#start--stop)). Pi RPC chat spawns drop explicit first-party wrappers immediately while still blocking ambient extension discovery. - **Cron rollback:** set `enabled: false`, unload the cron plist, or change the job to `type: script` and reload its plist. LLM crons only run through Pi. - **Code:** `git revert ` in this repo → `git fetch upstream && git merge upstream/main` in the workspace → `bot/scripts/restart-bot.sh`. @@ -752,9 +755,9 @@ No cron system, no multi-agent, no workspace management, no Discord. Single-user | Multi-agent with isolated workspaces | Yes | Yes | | Cron system | launchd plists (per-cron process isolation) | In-process scheduler | | Crash safety | Atomic JSON writes, launchd auto-restart | Atomic writes, in-flight turn tracking, process registry | -| Workspace health | Filesystem guardian hooks + structural audits | Agent health with exponential backoff | +| Workspace health | Structural audits | Agent health with exponential backoff | | Memory consolidation | Nightly summarization cron | File sync | | Platforms | Telegram + Discord | Telegram + Matrix | | Runtime support | Pi/Codex | Claude Code, Codex, Gemini | -Neither project is strictly better than the other — feature sets are comparable. Ductor covers more CLIs and has deeper crash recovery (in-flight turn tracking, process registry, stream coalescing). Minime is narrower: a TypeScript wrapper around Pi/Codex sessions that delegates process isolation to launchd and workspace protection to filesystem hooks and Pi extensions. +Neither project is strictly better than the other — feature sets are comparable. Ductor covers more CLIs and has deeper crash recovery (in-flight turn tracking, process registry, stream coalescing). Minime is narrower: a TypeScript wrapper around Pi/Codex sessions that delegates process isolation to launchd and workspace checks to explicit validation. diff --git a/bot/.claude/extensions/guardian-protect-files.ts b/bot/.claude/extensions/guardian-protect-files.ts deleted file mode 100644 index e931c8bf..00000000 --- a/bot/.claude/extensions/guardian-protect-files.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * A1 — guardian + protect-files write guard (Pi extension wrapper). - * - * Thin, jiti-loaded wrapper (intentionally OUTSIDE `bot/src`, so excluded from - * `tsc --noEmit` and the `npm test` glob — see `bot/src/pi-extensions/README.md`). - * All logic lives in the unit-tested pure helper `guard.ts`; this file only - * wires a Pi `tool_call` handler to {@link classifyToolCall} and returns Pi's - * `{ block, reason }` result. - * - * Loaded into every `pi --mode rpc` spawn via `--extension` (see - * `resolvePiExtensionArgs` in `bot/src/pi-rpc-protocol.ts`). Disable the whole - * extension set with `PI_EXTENSIONS_DISABLED=1`. - * - * This wrapper drives the SCHEMA-ENFORCED deny-by-default model: it parses the - * workspace `schema.md` ```` ```write-allowlist ```` block and injects it as - * `writeAllowlist`. It injects exactly ONE model per session — it does NOT also - * inject the legacy `orphanAllowlist` (root-component) model. That model's - * matching logic still lives in `guard.ts` for not-yet-migrated callers; this - * wrapper has migrated off it. See the design plan - * `docs/plans/2026-06-02-pi-claude-write-guard-enforcers.md`. - */ - -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { classifyToolCall } from "../../src/pi-extensions/guard.js"; -import { - readWriteAllowlistEntriesForGuard, - resolveWriteAllowlistSchemaPath, -} from "../../src/pi-extensions/write-allowlist-schema.js"; -import { PI_GUARD_WORKSPACE_ROOT_ENV } from "../../src/pi-rpc-protocol.js"; - -/** - * Tools that carry a write target: `write`/`edit` (at `input.path`) and `bash` - * (via redirect / `tee` / `mv` / `cp`, parsed by `guard.ts`). The schema - * allow-check only matters for these; read/grep/ls never reach it, so the - * `schema.md` read is skipped on that hot path. - */ -const WRITE_TARGET_TOOLS = new Set(["write", "edit", "bash"]); - -/** - * Per-process cache of the parsed write-allowlist, keyed by schema path. The - * `schema.md` block is read once per spawn (Pi sessions are short-lived); an - * edit to `schema.md` is picked up on the next spawn, not mid-session. - */ -const writeAllowlistCache = new Map(); - -/** - * Read the workspace write allow-list from the resolved schema path. When - * MINIME_SCHEMA_PATH is set, it is resolved exactly like the workspace contract: - * absolute paths are used as-is and relative paths are based on the guard root. - * - * Returns the parsed lines, or an EMPTY array when `schema.md` is missing / - * unreadable or has no `write-allowlist` block. The empty array is DELIBERATE - * (not `undefined`): per the write-guard fail-safe, a missing/empty allow-list - * must DENY-BY-DEFAULT (fail closed) inside {@link classifyToolCall} — the - * immutable core still blocks and every other target is denied with an - * actionable "add it to schema.md" message. Injecting `undefined` would instead - * turn the deny-by-default model OFF (re-enabling allow-all for non-immutable - * paths) — the opposite of fail-closed. The pure classifier never touches the - * filesystem; this wrapper does the I/O and injects the result. - */ -function readWriteAllowlist(workspaceRoot: string): string[] { - const schemaPath = resolveWriteAllowlistSchemaPath(workspaceRoot, process.env); - const cached = writeAllowlistCache.get(schemaPath); - if (cached !== undefined) { - return cached; - } - const lines = readWriteAllowlistEntriesForGuard(schemaPath); - writeAllowlistCache.set(schemaPath, lines); - return lines; -} - -export default function (pi: ExtensionAPI): void { - pi.on("tool_call", async (event, ctx) => { - // Protection is anchored at the IMMUTABLE workspace root carried in - // PI_GUARD_WORKSPACE_ROOT. Top-level agents and subagent children can both run - // from a child cwd, so relative targets still resolve against the real - // `ctx.cwd` (where the OS writes them). - const guardRoot = process.env[PI_GUARD_WORKSPACE_ROOT_ENV]?.trim() || ctx.cwd; - - // Schema-enforced DENY-BY-DEFAULT (the new model). Read the `schema.md` - // write-allowlist lazily for the write-target tools only (never on the - // read/grep hot path), cached per process. Inject `writeAllowlist` ONLY — - // never the legacy `orphanAllowlist`: the wrapper drives exactly one model - // per session, and a missing/empty list fails CLOSED in classifyToolCall. - const writeAllowlist = - WRITE_TARGET_TOOLS.has(event.toolName) && guardRoot - ? readWriteAllowlist(guardRoot) - : undefined; - - const decision = classifyToolCall( - { - toolName: event.toolName, - input: event.input as Record | undefined, - }, - { workspaceRoot: guardRoot, resolveRoot: ctx.cwd, writeAllowlist }, - ); - - if (decision.block) { - // RPC mode has no UI (ctx.hasUI === false); the block reason flows back to - // the model via Pi's ToolCallEventResult. Surface a notice when a UI exists. - if (ctx.hasUI && decision.reason) { - ctx.ui.notify(decision.reason, "warning"); - } - return { block: true, reason: decision.reason }; - } - - return undefined; - }); -} diff --git a/bot/.claude/extensions/subagent/README.md b/bot/.claude/extensions/subagent/README.md index ae594f5e..2fe4df69 100644 --- a/bot/.claude/extensions/subagent/README.md +++ b/bot/.claude/extensions/subagent/README.md @@ -29,12 +29,11 @@ from upstream: bundled `agents/` dir first regardless of `agentScope`; the bundled `prompts/` dir is registered via a `resources_discover` handler in `index.ts`. 3. **Child extension subset**: each child `pi -p` spawn passes `--no-extensions`, - then explicitly loads only A1 guard + A2 web-tools via - `PI_SUBAGENT_CHILD_WRAPPER_RELPATHS`. Children do not load A3 - `subagent/index.ts`, so recursive subagent spawning stays disabled. Missing - guard or web-tools wrappers fail closed during spawn arg resolution; a missing - Tavily key leaves web tools registered but returning graceful unavailable - results. + then explicitly loads only web-tools via `PI_SUBAGENT_CHILD_WRAPPER_RELPATHS`. + Children do not load `subagent/index.ts`, so recursive subagent spawning stays + disabled. Missing web-tools wrappers fail during spawn arg resolution; a + missing Tavily key leaves web tools registered but returning graceful + unavailable results. **Tool/param contract:** the registered tool is named `subagent`; modes are `single` (`{ agent, task }`), `parallel` (`{ tasks: [...] }`), and `chain` @@ -180,7 +179,7 @@ Agents are markdown files with YAML frontmatter: name: my-agent description: What this agent does tools: read, grep, find, ls -model: claude-haiku-4-5 +model: gpt-5.5 --- System prompt for the agent goes here. @@ -197,10 +196,10 @@ Later sources override earlier ones with the same name: user overrides bundled; | Agent | Purpose | Model | Tools | |-------|---------|-------|-------| -| `scout` | Fast codebase recon | Haiku | read, grep, find, ls, bash, web_search, web_fetch | -| `planner` | Implementation plans | Sonnet | read, grep, find, ls, web_search, web_fetch | -| `reviewer` | Code review | Sonnet | read, grep, find, ls, bash, web_search, web_fetch | -| `worker` | General-purpose | Sonnet | (all default) | +| `scout` | Fast codebase recon | Codex default | read, grep, find, ls, bash, web_search, web_fetch | +| `planner` | Implementation plans | Codex default | read, grep, find, ls, web_search, web_fetch | +| `reviewer` | Code review | Codex default | read, grep, find, ls, bash, web_search, web_fetch | +| `worker` | General-purpose | Codex default | (all default) | ## Workflow Prompts diff --git a/bot/.claude/extensions/subagent/index.ts b/bot/.claude/extensions/subagent/index.ts index cff03aad..863e2ed0 100644 --- a/bot/.claude/extensions/subagent/index.ts +++ b/bot/.claude/extensions/subagent/index.ts @@ -38,12 +38,8 @@ import { type SubagentChildErrorWarn, type SubagentRunResult, } from "../../../src/pi-extensions/subagent-args.js"; -// The child must load the A1 write guard so a delegated task cannot bypass the -// guard the parent session runs under (resolved here — honoring the kill-switch -// + fail-closed missing-wrapper check — and injected into the spawn args). import { buildPiSubagentChildSpawnEnv, - PI_GUARD_WORKSPACE_ROOT_ENV, PI_SUBAGENT_CHILD_WRAPPER_RELPATHS, resolvePiExtensionArgs, } from "../../../src/pi-rpc-protocol.js"; @@ -313,32 +309,20 @@ async function runSingleAgent( // Provider wiring lives in the pure helper: --provider openai-codex + // the normalized codex model (agent.model is left provider-agnostic). The - // child loads the guarded web-tool subset, and its env is allowlisted so - // web-capable child agents never inherit ambient secrets from the parent. + // child loads web-tools only, and its env is allowlisted so web-capable + // child agents never inherit ambient secrets from the parent. const args = buildSubagentSpawnArgs(agent, task, { systemPromptPath: tmpPromptPath ?? undefined, extensionArgs: resolvePiExtensionArgs({ relpaths: PI_SUBAGENT_CHILD_WRAPPER_RELPATHS }), }); const invocation = getPiInvocation(args); - // Pin the child guard's protected-workspace root to the PARENT workspace so a - // caller-supplied `cwd` cannot move the A1 guard root and let a delegated - // absolute write reach a protected dir (e.g. `/bot/x`). `defaultCwd` is the - // parent's ctx.cwd; prefer an already-pinned env value if one is present (so a - // hypothetical nested spawn would propagate the immutable root, not a cwd). - const guardWorkspaceRoot = process.env[PI_GUARD_WORKSPACE_ROOT_ENV] || defaultCwd; - const result = await runSubagentChild({ - spawn: (command, spawnArgs, opts) => - spawn(command, spawnArgs, { - cwd: opts.cwd, - env: buildPiSubagentChildSpawnEnv(guardWorkspaceRoot), - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }), + spawn, command: invocation.command, args: invocation.args, cwd: cwd ?? defaultCwd, + env: buildPiSubagentChildSpawnEnv(), signal, agentName, onMessage: onUpdate diff --git a/bot/.claude/extensions/web-tools.ts b/bot/.claude/extensions/web-tools.ts index 4916a418..8e974271 100644 --- a/bot/.claude/extensions/web-tools.ts +++ b/bot/.claude/extensions/web-tools.ts @@ -5,7 +5,7 @@ * `tsc --noEmit` and the `npm test` glob — see `bot/src/pi-extensions/README.md`). * All request/parse/error logic lives in the unit-tested pure helper `tavily.ts`; * this file only: - * 1. obtains the Tavily API key for this Pi process — warn-logs if absent; + * 1. obtains the control-workspace Tavily API key for this Pi process — warn-logs if absent; * 2. registers the `web_search` + `web_fetch` tools so the model can call them; * 3. delegates each `execute` to the pure helper and returns its `text`. * @@ -32,7 +32,7 @@ import { readTavilyApiKeyFromSops } from "../../src/pi-extensions/tavily-secret. /** Read the Tavily key for this Pi process; returns undefined if absent. */ function readTavilyApiKey(): string | undefined { - return readTavilyApiKeyFromSops({ cwd: process.cwd() }); + return readTavilyApiKeyFromSops(); } export default function (pi: ExtensionAPI): void { diff --git a/bot/package.json b/bot/package.json index 1735748c..2aee7eeb 100644 --- a/bot/package.json +++ b/bot/package.json @@ -13,6 +13,7 @@ "scripts": { "test": "MINIME_TEST_MEDIA_BASE=/tmp/bot-media-test node --experimental-test-module-mocks --import tsx --test src/__tests__/*.test.ts", "build": "node scripts/clean-package-dist.mjs && tsc && node scripts/build-package-artifacts.mjs", + "check:schema-guard-contract": "node scripts/check-no-active-schema-guard-contract.mjs", "lint": "tsc --noEmit", "typecheck": "tsc --noEmit", "prepare": "npm run build", diff --git a/bot/scripts/build-package-artifacts.mjs b/bot/scripts/build-package-artifacts.mjs index a77a7923..898fca39 100644 --- a/bot/scripts/build-package-artifacts.mjs +++ b/bot/scripts/build-package-artifacts.mjs @@ -10,7 +10,6 @@ const sourceExtensionDir = join(packageRoot, ".claude", "extensions"); const artifactExtensionDir = join(packageRoot, "dist", "extensions", "pi"); const wrappers = [ - ["guardian-protect-files.ts", "guardian-protect-files.js"], ["web-tools.ts", "web-tools.js"], [join("subagent", "agents.ts"), join("subagent", "agents.js")], [join("subagent", "index.ts"), join("subagent", "index.js")], diff --git a/bot/scripts/check-no-active-schema-guard-contract.mjs b/bot/scripts/check-no-active-schema-guard-contract.mjs new file mode 100644 index 00000000..7eaf27c6 --- /dev/null +++ b/bot/scripts/check-no-active-schema-guard-contract.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(scriptDir, ".."); +const repoRoot = process.argv[2] ? resolve(process.argv[2]) : resolve(packageRoot, ".."); + +const skippedDirs = new Set([ + ".beads", + ".git", + ".ralphex", + "dist", + "memory", + "node_modules", +]); + +const binaryExtensions = new Set([ + ".gif", + ".jpg", + ".jpeg", + ".png", + ".tgz", +]); + +const allowedPathPrefixes = [ + `docs${sep}plans${sep}`, +]; + +const allowedPaths = new Set([ + "orphan-allowlist.txt", + `reference${sep}governance${sep}decisions.md`, + `bot${sep}scripts${sep}check-no-active-schema-guard-contract.mjs`, + `bot${sep}src${sep}__tests__${sep}package-install.test.ts`, + `bot${sep}src${sep}__tests__${sep}pi-rpc-protocol.test.ts`, + `bot${sep}src${sep}__tests__${sep}schema-guard-contract-check.test.ts`, +]); + +const bannedLinePatterns = [ + /\bMINIME_SCHEMA_PATH(?:_ENV)?\b/, + /\bPI_GUARD_WORKSPACE_ROOT(?:_ENV)?\b/, + /\bguardian-protect-files\b/, + /\bguardian\.sh\b/, + /\bprotect-files\.sh\b/, + /\breadWriteAllowlistSchema\b/, + /\bwrite-allowlist\b/i, + /\bimmutable core\b/i, + /\b(?:schema|write)\s+guard\b/i, + /\bguard extension\b/i, +]; + +const staleSchemaRequirementPatterns = [ + /\bschema\.md\b.*\b(required|requires|must exist|validity|runtime correctness|package correctness)\b/i, + /\b(required|requires|must exist)\b.*\bschema\.md\b/i, +]; + +const retiredContextPattern = /\b(retired|not required|no longer requires|does not require|without any schema\.md|without schema\.md|must not|should not)\b/i; + +function shouldSkipDir(absPath) { + const base = absPath.split(sep).pop(); + return skippedDirs.has(base); +} + +function isAllowedPath(relPath) { + return allowedPaths.has(relPath) || allowedPathPrefixes.some((prefix) => relPath.startsWith(prefix)); +} + +function isProbablyBinary(relPath) { + return binaryExtensions.has(relPath.slice(relPath.lastIndexOf(".")).toLowerCase()); +} + +function* walk(absDir) { + for (const entry of readdirSync(absDir, { withFileTypes: true })) { + const absPath = join(absDir, entry.name); + if (entry.isDirectory()) { + if (!shouldSkipDir(absPath)) yield* walk(absPath); + continue; + } + if (entry.isFile()) yield absPath; + } +} + +function lineHasBannedContract(line) { + if (bannedLinePatterns.some((pattern) => pattern.test(line))) return true; + if (retiredContextPattern.test(line)) return false; + return staleSchemaRequirementPatterns.some((pattern) => pattern.test(line)); +} + +const findings = []; + +for (const absPath of walk(repoRoot)) { + const relPath = relative(repoRoot, absPath); + if (isAllowedPath(relPath) || isProbablyBinary(relPath)) continue; + if (statSync(absPath).size > 2_000_000) continue; + + const text = readFileSync(absPath, "utf8"); + const lines = text.split(/\r?\n/); + lines.forEach((line, index) => { + if (lineHasBannedContract(line)) { + findings.push(`${relPath}:${index + 1}: ${line.trim()}`); + } + }); +} + +if (findings.length > 0) { + console.error("Found active schema/write-guard contract references:"); + for (const finding of findings) { + console.error(`- ${finding}`); + } + process.exit(1); +} + +console.log("No active schema/write-guard contract references found."); diff --git a/bot/src/__tests__/cli.test.ts b/bot/src/__tests__/cli.test.ts index 9e17a0db..c0c5f8b3 100644 --- a/bot/src/__tests__/cli.test.ts +++ b/bot/src/__tests__/cli.test.ts @@ -52,19 +52,6 @@ crons: deliveryChatId: 111 `, ); - writeFileSync( - join(workspace, "schema.md"), - [ - "# Fixture schema", - "", - "```write-allowlist", - "agent-workspace/", - "*.md", - "schema.md", - "```", - "", - ].join("\n"), - ); return workspace; } @@ -92,12 +79,17 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + describe("minime-bot CLI", () => { it("prints help", () => { const result = runWithCapture(["--help"]); assert.equal(result.code, 0); assert.match(result.stdout, /minime-bot config validate --workspace /); assert.match(result.stdout, /minime-bot workspace validate --workspace /); + assert.match(result.stdout, /Control\/app workspace root/); assert.match(result.stdout, /Defaults to MINIME_WORKSPACE_ROOT, then source repo root or package cwd\./); assert.doesNotMatch(result.stdout, /current repo layout/); assert.equal(result.stderr, ""); @@ -124,11 +116,18 @@ describe("minime-bot CLI", () => { assert.equal(result.code, 0); assert.match(result.stdout, /Workspace valid\./); assert.match(result.stdout, /Effective paths:/); - assert.match(result.stdout, new RegExp(`workspace root: ${workspace.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\(cli\\)`)); - assert.match(result.stdout, /schema path:/); + assert.match(result.stdout, new RegExp(`control workspace root: ${escapeRegExp(workspace)} \\(cli\\)`)); + assert.match(result.stdout, /package root:/); + assert.match(result.stdout, /config path:/); + assert.match(result.stdout, /crons path:/); assert.match(result.stdout, /Pi extension dir:/); + assert.match(result.stdout, /data dir:/); + assert.match(result.stdout, /session store path:/); + assert.match(result.stdout, /log dir:/); + assert.match(result.stdout, /media base dir:/); + assert.match(result.stdout, /runtime dir:/); + assert.match(result.stdout, new RegExp(`Agent workspaces:\\n main: ${escapeRegExp(join(workspace, "agent-workspace"))}`)); assert.match(result.stdout, /Crons: 1/); - assert.match(result.stdout, /Schema allow-list entries: 3/); assert.equal(result.stderr, ""); } finally { rmSync(workspace, { recursive: true, force: true }); @@ -144,22 +143,21 @@ describe("minime-bot CLI", () => { assert.equal(result.code, 0); assert.match(result.stdout, /Workspace valid\./); - assert.match(result.stdout, new RegExp(`workspace root: ${MINIMAL_WORKSPACE_FIXTURE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\(env\\)`)); + assert.match(result.stdout, new RegExp(`control workspace root: ${escapeRegExp(MINIMAL_WORKSPACE_FIXTURE)} \\(env\\)`)); assert.match(result.stdout, /config path: .*minimal-workspace\/config\.yaml \(workspace-default\)/); - assert.match(result.stdout, /schema path: .*minimal-workspace\/schema\.md \(workspace-default\)/); assert.equal(result.stderr, ""); }); it("reports workspace validation hard failures separately from warnings", () => { const workspace = createWorkspace(); try { - rmSync(join(workspace, "schema.md"), { force: true }); + rmSync(join(workspace, "agent-workspace"), { recursive: true, force: true }); const result = runWithCapture(["workspace", "validate", "--workspace", workspace], workspace); assert.equal(result.code, 1); assert.match(result.stdout, /Workspace invalid\./); assert.match(result.stdout, /Hard failures:/); - assert.match(result.stdout, /schema validation failed/); + assert.match(result.stdout, /workspaceCwd does not exist/); assert.match(result.stderr, /Error: Workspace validation failed\./); } finally { rmSync(workspace, { recursive: true, force: true }); diff --git a/bot/src/__tests__/config-defaults.test.ts b/bot/src/__tests__/config-defaults.test.ts index a2bd1613..11632e01 100644 --- a/bot/src/__tests__/config-defaults.test.ts +++ b/bot/src/__tests__/config-defaults.test.ts @@ -1,7 +1,7 @@ import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { writeFileSync, mkdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { validateSessionDefaults, validateAgent, loadConfig } from "../config.js"; import { DEFAULT_MAX_MEDIA_BYTES } from "../media-store.js"; import { MINIME_CONFIG_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; @@ -327,11 +327,12 @@ bindings: assert.strictEqual(config.agents.main.workspaceCwd, join(workspaceRoot, "agent-workspace")); }); - it("uses MINIME_CONFIG_PATH relative to workspace root and keeps SOPS paths relative to that config file", () => { + it("uses MINIME_CONFIG_PATH relative to workspace root and keeps SOPS paths relative to the control workspace", () => { const workspaceRoot = join(TEST_DIR, "workspace-config-override"); const configDir = join(workspaceRoot, "settings"); - const sopsPath = join(configDir, "secrets.sops.yaml"); + const sopsPath = join(workspaceRoot, "config", "secrets.sops.yaml"); mkdirSync(configDir, { recursive: true }); + mkdirSync(dirname(sopsPath), { recursive: true }); writeFileSync(sopsPath, "telegram:\n bot_token: encrypted-placeholder\n"); writeFileSync( join(configDir, "bot.yaml"), @@ -341,7 +342,7 @@ agents: workspaceCwd: /tmp/x model: gpt-5.5 secrets: - sopsFile: secrets.sops.yaml + sopsFile: config/secrets.sops.yaml telegramTokenSopsKey: telegram.bot_token bindings: - chatId: 111 diff --git a/bot/src/__tests__/config-secrets.test.ts b/bot/src/__tests__/config-secrets.test.ts index bfec7a43..44da0f4b 100644 --- a/bot/src/__tests__/config-secrets.test.ts +++ b/bot/src/__tests__/config-secrets.test.ts @@ -3,8 +3,9 @@ import assert from "node:assert"; import { writeFileSync, mkdtempSync, rmSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { loadConfig } from "../config.js"; +import { loadConfig, loadTelegramToken } from "../config.js"; import type { ExecFileSyncLike } from "../secrets.js"; +import { MINIME_CONFIG_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; describe("config secret resolution: SOPS and env sources", () => { let tmpDir: string; @@ -19,6 +20,8 @@ describe("config secret resolution: SOPS and env sources", () => { rmSync(tmpDir, { recursive: true, force: true }); delete process.env.TEST_TELEGRAM_TOKEN_ENV; delete process.env.TEST_DISCORD_TOKEN_ENV; + delete process.env[MINIME_CONFIG_PATH_ENV]; + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; }); const minimalAgentsYaml = ` @@ -36,7 +39,7 @@ agents: return file; } - it("prefers telegramTokenSopsKey over env and resolves relative secrets.sopsFile from config dir", () => { + it("prefers telegramTokenSopsKey over env and resolves direct configPath SOPS fallback from the config dir", () => { const sopsFile = writeSopsPlaceholder(); process.env.TEST_TELEGRAM_TOKEN_ENV = "tg-token-from-env"; const calls: Array<{ file: string; args: readonly string[] }> = []; @@ -70,6 +73,46 @@ bindings: ]); }); + it("loadTelegramToken resolves relative SOPS paths against the control workspace when config path is overridden", () => { + const controlWorkspace = join(tmpDir, "control-workspace"); + const configDir = join(controlWorkspace, "settings"); + const sopsDir = join(controlWorkspace, "config"); + const sopsFile = join(sopsDir, "secrets.sops.yaml"); + mkdirSync(configDir, { recursive: true }); + mkdirSync(sopsDir, { recursive: true }); + writeFileSync(sopsFile, "telegram:\n bot_token: encrypted-placeholder\n"); + writeFileSync( + join(configDir, "bot.yaml"), + ` +agents: + main: + workspaceCwd: /tmp/foo + model: gpt-5.5 +secrets: + sopsFile: config/secrets.sops.yaml +telegramTokenSopsKey: telegram.bot_token +`, + ); + process.env[MINIME_WORKSPACE_ROOT_ENV] = controlWorkspace; + process.env[MINIME_CONFIG_PATH_ENV] = "settings/bot.yaml"; + const calls: Array<{ file: string; args: readonly string[] }> = []; + + const token = loadTelegramToken(undefined, { + secretExecFileSync: (file, args) => { + calls.push({ file, args }); + return "tg-token-from-control-sops\n"; + }, + }); + + assert.strictEqual(token, "tg-token-from-control-sops"); + assert.deepStrictEqual(calls[0].args, [ + "-d", + "--extract", + '["telegram"]["bot_token"]', + sopsFile, + ]); + }); + it("falls back to telegramTokenEnv when configured SOPS lookup fails", () => { writeSopsPlaceholder(); process.env.TEST_TELEGRAM_TOKEN_ENV = "tg-token-from-env"; diff --git a/bot/src/__tests__/cron-runner-pi.test.ts b/bot/src/__tests__/cron-runner-pi.test.ts index dd29d0d0..b0aa6299 100644 --- a/bot/src/__tests__/cron-runner-pi.test.ts +++ b/bot/src/__tests__/cron-runner-pi.test.ts @@ -3,7 +3,6 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, - realpathSync, rmSync, writeFileSync, } from "node:fs"; @@ -17,12 +16,13 @@ import { runPi, type PiRunDeps } from "../cron-runner.js"; import { assemblePiContext } from "../pi-context-assembler.js"; import { buildPiSpawnEnv, - PI_CRON_WRAPPER_RELPATHS, - PI_EXTENSIONS_DISABLED_ENV, - PI_GUARD_WORKSPACE_ROOT_ENV, } from "../pi-rpc-protocol.js"; import type { AgentConfig, CronJob } from "../types.js"; -import { MINIME_SCHEMA_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; +import { + MINIME_CONFIG_PATH_ENV, + MINIME_CRONS_PATH_ENV, + MINIME_WORKSPACE_ROOT_ENV, +} from "../workspace-contract.js"; interface SpawnCapture { command: string; @@ -31,7 +31,6 @@ interface SpawnCapture { } const fixtures: string[] = []; -const GUARD_EXTENSION_ARGS = ["--extension", "/fake/guardian-protect-files.ts"]; after(() => { for (const dir of fixtures) { @@ -99,7 +98,6 @@ function makeDeps( buildAgentConfig: (_cron, cwd) => makeAgent(cwd), buildEnv: () => ({}), assembleContext: () => null, - resolveExtensionArgs: () => [...GUARD_EXTENSION_ARGS], ...overrides, }; } @@ -126,13 +124,8 @@ describe("cron-runner runPi", () => { it("spawns Pi in print one-shot mode with the required argv and spawn options", () => { const ws = makeWorkspace(); const captures: SpawnCapture[] = []; - let relpathsSeen: readonly string[] | undefined; const deps = makeDeps(captures, { buildAgentConfig: (_cron, cwd) => makeAgent(cwd, { thinking: "high" }), - resolveExtensionArgs: (options) => { - relpathsSeen = options?.relpaths; - return ["--extension", "/fake/guardian-protect-files.ts"]; - }, }); const output = runPi(makeCron({ timeout: 1234 }), ws, deps); @@ -152,12 +145,8 @@ describe("cron-runner runPi", () => { "high", ]); assertCronSystemInstruction(flagValue(capture.args, "--append-system-prompt")); - assert.deepStrictEqual(capture.args.slice(-2), [ - "--extension", - "/fake/guardian-protect-files.ts", - ]); + assert.ok(!capture.args.includes("--extension")); assert.strictEqual(capture.options.input, undefined); - assert.deepStrictEqual([...(relpathsSeen ?? [])], [...PI_CRON_WRAPPER_RELPATHS]); assert.strictEqual(capture.options.cwd, ws); assert.strictEqual(capture.options.timeout, 1234); assert.strictEqual(capture.options.encoding, "utf8"); @@ -209,7 +198,7 @@ describe("cron-runner runPi", () => { } }); - it("passes context artifact args before A1 extension args", () => { + it("passes context artifact args before the fixed cron instruction", () => { const ws = makeWorkspace(); const captures: SpawnCapture[] = []; const deps = makeDeps(captures, { @@ -217,7 +206,6 @@ describe("cron-runner runPi", () => { systemPromptPath: "/tmp/pi-persona.md", appendSystemPromptPath: "/tmp/pi-bundle.md", }), - resolveExtensionArgs: () => [...GUARD_EXTENSION_ARGS], }); runPi(makeCron(), ws, deps); @@ -234,12 +222,11 @@ describe("cron-runner runPi", () => { const appendIdx = args.indexOf("--append-system-prompt"); const noContextIdx = args.indexOf("--no-context-files"); const cronInstructionIdx = args.indexOf("--append-system-prompt", appendIdx + 2); - const extensionIdx = args.indexOf("--extension"); assert.ok(thinkingIdx < systemIdx); assert.ok(systemIdx < appendIdx); assert.ok(appendIdx < noContextIdx); assert.ok(noContextIdx < cronInstructionIdx); - assert.ok(cronInstructionIdx < extensionIdx); + assert.ok(!args.includes("--extension")); }); it("suppresses flat context loading when context assembly throws", () => { @@ -249,7 +236,6 @@ describe("cron-runner runPi", () => { assembleContext: () => { throw new Error("artifact write failed"); }, - resolveExtensionArgs: () => [...GUARD_EXTENSION_ARGS], }); runPi(makeCron(), ws, deps); @@ -295,7 +281,6 @@ describe("cron-runner runPi", () => { const deps = makeDeps(captures, { buildAgentConfig: (_cron, cwd) => makeAgent(cwd, { systemPrompt: "PERSONA_TOKEN" }), assembleContext: assemblePiContext, - resolveExtensionArgs: () => [...GUARD_EXTENSION_ARGS], }); runPi(makeCron(), ws, deps); @@ -308,9 +293,9 @@ describe("cron-runner runPi", () => { assert.ok(args.includes("--no-context-files")); }); - it("builds the guarded env before assembling cron context", () => { + it("validates the agent workspace before assembling cron context", () => { const workspaceRoot = makeWorkspace(); - const externalWorkspace = makeWorkspace(); + const missingWorkspace = join(workspaceRoot, "missing-agent-workspace"); const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; let assembled = false; @@ -326,8 +311,8 @@ describe("cron-runner runPi", () => { }); assert.throws( - () => runPi(makeCron(), externalWorkspace, deps), - /workspaceCwd must be inside the resolved workspace root/, + () => runPi(makeCron(), missingWorkspace, deps), + /workspaceCwd does not exist/, ); assert.strictEqual(assembled, false); assert.strictEqual(captures.length, 0); @@ -352,6 +337,7 @@ describe("cron-runner runPi", () => { const sessionSecretEnv = ["MINIME", "SESSION", "SECRET"].join("_"); const githubTokenEnv = ["GITHUB", "TOKEN"].join("_"); const awsSecretEnv = ["AWS", "SECRET", "ACCESS", "KEY"].join("_"); + const discordTokenEnv = ["DISCORD", "BOT", "TOKEN"].join("_"); const oldOpenAiKey = process.env[openAiKeyEnv]; const oldPiSessionDir = process.env[piSessionDirEnv]; const oldTelegramToken = process.env[telegramTokenEnv]; @@ -359,7 +345,11 @@ describe("cron-runner runPi", () => { const oldSessionSecret = process.env[sessionSecretEnv]; const oldGithubToken = process.env[githubTokenEnv]; const oldAwsSecret = process.env[awsSecretEnv]; + const oldDiscordToken = process.env[discordTokenEnv]; const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + const oldConfigPath = process.env[MINIME_CONFIG_PATH_ENV]; + const oldCronsPath = process.env[MINIME_CRONS_PATH_ENV]; + const fixtureValues = ["cron-telegram-fixture", "cron-discord-fixture", "cron-tavily-fixture"]; try { delete process.env.HOME; @@ -368,14 +358,17 @@ describe("cron-runner runPi", () => { process.env[openAiKeyEnv] = "secret-openai"; process.env[piSessionDirEnv] = "/tmp/pi-sessions"; process.env.CLAUDECODE = "session-marker"; - process.env[telegramTokenEnv] = "fixture"; - process.env[tavilyKeyEnv] = "fixture"; + process.env[telegramTokenEnv] = fixtureValues[0]; + process.env[discordTokenEnv] = fixtureValues[1]; + process.env[tavilyKeyEnv] = fixtureValues[2]; process.env[sessionSecretEnv] = "fixture"; process.env[githubTokenEnv] = "fixture"; process.env[awsSecretEnv] = "fixture"; const ws = makeWorkspace(); process.env[MINIME_WORKSPACE_ROOT_ENV] = ws; + process.env[MINIME_CONFIG_PATH_ENV] = "settings/config.yaml"; + process.env[MINIME_CRONS_PATH_ENV] = join(ws, "settings", "crons.yaml"); const captures: SpawnCapture[] = []; const deps = makeDeps(captures, { buildEnv: buildPiSpawnEnv }); @@ -383,18 +376,26 @@ describe("cron-runner runPi", () => { const env = captures[0].options.env ?? {}; assert.strictEqual(env.HOME, homedir()); + assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], ws); + assert.strictEqual(env[MINIME_CONFIG_PATH_ENV], "settings/config.yaml"); + assert.strictEqual(env[MINIME_CRONS_PATH_ENV], join(ws, "settings", "crons.yaml")); assert.strictEqual(env.CLAUDE_CODE_OAUTH_TOKEN, undefined); assert.strictEqual(env.ANTHROPIC_API_KEY, undefined); assert.strictEqual(env[openAiKeyEnv], undefined); assert.strictEqual(env[piSessionDirEnv], "/tmp/pi-sessions"); assert.strictEqual(env.CLAUDECODE, undefined); assert.strictEqual(env[telegramTokenEnv], undefined); + assert.strictEqual(env[discordTokenEnv], undefined); assert.strictEqual(env[tavilyKeyEnv], undefined); assert.strictEqual(env[sessionSecretEnv], undefined); assert.strictEqual(env[githubTokenEnv], undefined); assert.strictEqual(env[awsSecretEnv], undefined); assert.ok(env.PATH?.includes("/opt/homebrew/bin")); assert.strictEqual(captures[0].options.timeout, 900000); + const serializedChildContract = JSON.stringify({ env, args: captures[0].args }); + for (const value of fixtureValues) { + assert.doesNotMatch(serializedChildContract, new RegExp(value)); + } } finally { if (oldHome === undefined) { delete process.env.HOME; @@ -436,6 +437,11 @@ describe("cron-runner runPi", () => { } else { process.env[tavilyKeyEnv] = oldTavilyKey; } + if (oldDiscordToken === undefined) { + delete process.env[discordTokenEnv]; + } else { + process.env[discordTokenEnv] = oldDiscordToken; + } if (oldSessionSecret === undefined) { delete process.env[sessionSecretEnv]; } else { @@ -456,19 +462,27 @@ describe("cron-runner runPi", () => { } else { process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; } + if (oldConfigPath === undefined) { + delete process.env[MINIME_CONFIG_PATH_ENV]; + } else { + process.env[MINIME_CONFIG_PATH_ENV] = oldConfigPath; + } + if (oldCronsPath === undefined) { + delete process.env[MINIME_CRONS_PATH_ENV]; + } else { + process.env[MINIME_CRONS_PATH_ENV] = oldCronsPath; + } } }); - it("keeps the resolved guard workspace root and schema path in the hardened cron env", () => { + it("keeps only allowed Pi runtime keys in the hardened cron env", () => { const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; - const oldSchema = process.env[MINIME_SCHEMA_PATH_ENV]; const workspace = makeWorkspace(); const agentWorkspace = join(workspace, "agent-workspace"); mkdirSync(agentWorkspace, { recursive: true }); try { process.env[MINIME_WORKSPACE_ROOT_ENV] = workspace; - process.env[MINIME_SCHEMA_PATH_ENV] = "schemas/override.md"; const captures: SpawnCapture[] = []; const deps = makeDeps(captures, { buildEnv: buildPiSpawnEnv, @@ -478,8 +492,7 @@ describe("cron-runner runPi", () => { runPi(makeCron(), agentWorkspace, deps); const env = captures[0].options.env ?? {}; - assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], realpathSync(workspace)); - assert.strictEqual(env[MINIME_SCHEMA_PATH_ENV], join(workspace, "schemas", "override.md")); + assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], workspace); assert.strictEqual(captures[0].options.cwd, agentWorkspace); } finally { if (oldWorkspace === undefined) { @@ -487,62 +500,18 @@ describe("cron-runner runPi", () => { } else { process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; } - if (oldSchema === undefined) { - delete process.env[MINIME_SCHEMA_PATH_ENV]; - } else { - process.env[MINIME_SCHEMA_PATH_ENV] = oldSchema; - } - } - }); - - it("fails closed before spawn when the ambient Pi extension kill-switch would remove A1", () => { - const oldDisabled = process.env[PI_EXTENSIONS_DISABLED_ENV]; - - try { - process.env[PI_EXTENSIONS_DISABLED_ENV] = "1"; - const ws = makeWorkspace(); - const captures: SpawnCapture[] = []; - const deps = makeDeps(captures, { - resolveExtensionArgs: () => [], - }); - - assert.throws( - () => runPi(makeCron(), ws, deps), - /PI_EXTENSIONS_DISABLED=1 cannot disable the required Pi cron guard extension/, - ); - assert.strictEqual(captures.length, 0); - } finally { - if (oldDisabled === undefined) { - delete process.env[PI_EXTENSIONS_DISABLED_ENV]; - } else { - process.env[PI_EXTENSIONS_DISABLED_ENV] = oldDisabled; - } } }); - it("fails closed before spawn when the Pi cron guard resolver returns no extension", () => { - const oldDisabled = process.env[PI_EXTENSIONS_DISABLED_ENV]; + it("allows Pi cron spawns with no explicit first-party wrappers", () => { + const ws = makeWorkspace(); + const captures: SpawnCapture[] = []; + const deps = makeDeps(captures); - try { - delete process.env[PI_EXTENSIONS_DISABLED_ENV]; - const ws = makeWorkspace(); - const captures: SpawnCapture[] = []; - const deps = makeDeps(captures, { - resolveExtensionArgs: () => [], - }); + runPi(makeCron(), ws, deps); - assert.throws( - () => runPi(makeCron(), ws, deps), - /Pi cron extension resolver returned no guard extension/, - ); - assert.strictEqual(captures.length, 0); - } finally { - if (oldDisabled === undefined) { - delete process.env[PI_EXTENSIONS_DISABLED_ENV]; - } else { - process.env[PI_EXTENSIONS_DISABLED_ENV] = oldDisabled; - } - } + assert.strictEqual(captures.length, 1); + assert.ok(!captures[0].args.includes("--extension")); }); it("throws classified Pi errors with bounded private diagnostics so main can use the FAIL path", () => { diff --git a/bot/src/__tests__/guard-parity.test.ts b/bot/src/__tests__/guard-parity.test.ts deleted file mode 100644 index 39e22b6b..00000000 --- a/bot/src/__tests__/guard-parity.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { classifyToolCall, type ClassifyOptions } from "../pi-extensions/guard.js"; - -// --------------------------------------------------------------------------- -// PARITY: the Pi-path classifier (`classifyToolCall` with an injected -// `writeAllowlist`) and the shell hook chain (`protect-files.sh` then -// `guardian.sh`) must reach the SAME allow/deny verdict for every path, driven -// by the SAME schema.md allow-list. This is the single-source-of-truth proof: -// the immutable-core deny-overlay, the three D17 line kinds, the `.claude/` -// split, and deny-by-default all match across both enforcers. -// -// The shell chain is run in the SAME order .claude/settings.json runs it -// (protect-files.sh = immutable deny, THEN guardian.sh = schema allow-check): -// a target is ALLOWED only if BOTH hooks exit 0; BLOCKED if EITHER exits -// non-zero — exactly the deny-overlay > allow > default-deny precedence the -// real session sees. The classifier collapses both halves into one call. -// -// Only write/edit targets are compared — NOT bash redirects: the shell hook path -// deliberately does not parse bash (the D16 tracked v1 known-gap), so a bash -// write would diverge by design. The bash coverage asymmetry is asserted in -// guard.test.ts (Pi side) instead. -// --------------------------------------------------------------------------- - -const REPO_ROOT = resolve(import.meta.dirname, "../../.."); -const PROTECT = join(REPO_ROOT, ".claude/hooks/protect-files.sh"); -const GUARDIAN = join(REPO_ROOT, ".claude/hooks/guardian.sh"); - -// A CLEAN env (no inherited process.env) so no ambient WRITE_GUARD_BYPASS / -// PROTECT_FILES_BYPASS / CRON_NAME leaks in — only PATH (to find jq/git/bash) -// and the workspace root. Mirrors the bash harness's `env -i PATH=... ...`. -const CLEAN_ENV = { PATH: process.env.PATH ?? "" }; - -// The schema.md ```write-allowlist``` block (with #-comments to exercise the -// strip) and its post-strip equivalent fed to the classifier — the SAME list, -// proving both enforcers parse identically. -const WRITE_ALLOWLIST = [ - "memory/", - "docs/", - ".claude/rules/custom/", - ".claude/skills/", - "*.md", - "schema.md", -] as const; - -const SCHEMA_MD = [ - "# Workspace schema (parity fixture)", - "", - "Prose before the block is ignored.", - "", - "```write-allowlist", - "memory/ # narrative + auto memory", - "docs/", - ".claude/rules/custom/", - ".claude/skills/", - "*.md # root-level markdown only", - "schema.md", - "```", - "", -].join("\n"); - -// Fixed path set: every D17 line kind + the .claude/ split + the full -// immutable core, each with its agreed verdict. -const CASES: ReadonlyArray = [ - // D17 directory-prefix - ["memory/notes.md", "ALLOW"], - ["memory", "ALLOW"], // bare dir name matches its prefix line - ["docs/guide.md", "ALLOW"], - // D17 root-only glob - ["top.md", "ALLOW"], - ["sub/top.md", "BLOCK"], // *.md is root-only; nested + sub/ unregistered - // D17 exact root-file - ["schema.md", "ALLOW"], - // .claude/ split — custom allowed, platform/hooks/health-scripts immutable - [".claude/rules/custom/x.md", "ALLOW"], - [".claude/rules/platform/x.md", "BLOCK"], - [".claude/skills/custom/index.ts", "ALLOW"], - [".claude/skills/workspace-health/scripts/x.ts", "BLOCK"], - [".claude/hooks/x.sh", "BLOCK"], - // immutable core — directory prefixes - ["bot/src/x.ts", "BLOCK"], - [".github/workflows/ci.yml", "BLOCK"], - [".githooks/pre-commit", "BLOCK"], - // immutable core — root-only-exact files (blocked even though *.md would allow) - ["README.md", "BLOCK"], - // Case-variant of an immutable file (APFS: README.MD == README.md). DISCRIMINATING: - // `*.md` is allow-listed, so without case-folding in protect-files.sh the claude - // chain would ALLOW this while the Pi path BLOCKs it — the exact divergence the - // case-insensitive deny-overlay fix closes. Locks the "never unlockable" invariant. - ["README.MD", "BLOCK"], - [".gitleaks.toml", "BLOCK"], - [".gitleaksignore", "BLOCK"], - ["config.local.yaml.example", "BLOCK"], - // immutable FILE entry is root-only-exact → docs/README.md is NOT immutable - ["docs/README.md", "ALLOW"], - // deny-by-default - ["unregistered/x.txt", "BLOCK"], -]; - -/** Pi-path verdict via the pure classifier (write tool, injected allow-list). */ -function piVerdict(rel: string, ws: string): "ALLOW" | "BLOCK" { - const opts: ClassifyOptions = { workspaceRoot: ws, writeAllowlist: [...WRITE_ALLOWLIST] }; - return classifyToolCall({ toolName: "write", input: { path: rel } }, opts).block ? "BLOCK" : "ALLOW"; -} - -/** Shell-hook verdict via the full hook chain (protect-files.sh → guardian.sh). */ -function chainVerdict(rel: string, ws: string): "ALLOW" | "BLOCK" { - const input = JSON.stringify({ tool_name: "Write", tool_input: { file_path: join(ws, rel) } }); - const env = { ...CLEAN_ENV, CLAUDE_PROJECT_DIR: ws }; - for (const hook of [PROTECT, GUARDIAN]) { - const r = spawnSync("bash", [hook], { input, env, encoding: "utf8" }); - if (r.status !== 0) { - return "BLOCK"; - } - } - return "ALLOW"; -} - -// Skip gracefully if the shell tooling the chain needs is unavailable. -const haveTools = - spawnSync("bash", ["-c", "command -v jq >/dev/null 2>&1 && command -v git >/dev/null 2>&1"], { - env: CLEAN_ENV, - }).status === 0; - -describe("guard PARITY: classifyToolCall vs protect-files.sh + guardian.sh", { skip: !haveTools }, () => { - // Single throwaway workspace: a schema.md fixture, no git origin, not under - // /.ralphex/worktrees/ → neither hook's bypass fires, the real checks run. - const WS = mkdtempSync(join(tmpdir(), "guard-parity-")); - writeFileSync(join(WS, "schema.md"), SCHEMA_MD); - // No target files are created → guardian.sh never hits its overwrite - // exemption and the classifier's default (nothing exists) matches. - - try { - for (const [rel, expected] of CASES) { - it(`${rel} → ${expected} (both enforcers agree)`, () => { - const pi = piVerdict(rel, WS); - const chain = chainVerdict(rel, WS); - assert.equal(pi, chain, `parity mismatch for "${rel}": Pi=${pi}, claude-chain=${chain}`); - assert.equal(pi, expected, `unexpected verdict for "${rel}"`); - }); - } - } finally { - process.on("exit", () => rmSync(WS, { recursive: true, force: true })); - } -}); diff --git a/bot/src/__tests__/guard.test.ts b/bot/src/__tests__/guard.test.ts deleted file mode 100644 index 5e7483b2..00000000 --- a/bot/src/__tests__/guard.test.ts +++ /dev/null @@ -1,532 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { - classifyToolCall, - extractBashWriteTargets, - isAllowedPath, - isAllowedRootComponent, - isProtectedPath, - PROTECTED_PREFIXES, - type ClassifyOptions, - type ToolCallLike, -} from "../pi-extensions/guard.js"; - -const WS = "/ws"; -const inWs: ClassifyOptions = { workspaceRoot: WS }; - -function block(call: ToolCallLike, opts: ClassifyOptions = inWs): boolean { - return classifyToolCall(call, opts).block; -} - -describe("guard: PROTECTED_PREFIXES (pinned canonical set)", () => { - it("is exactly the 10 upstream-owned immutable-core paths (6 dirs + 4 root files)", () => { - assert.deepStrictEqual( - [...PROTECTED_PREFIXES], - [ - "bot/", - ".claude/hooks/", - ".claude/rules/platform/", - ".claude/skills/workspace-health/scripts/", - ".github/workflows/", - ".githooks/", - ".gitleaks.toml", - ".gitleaksignore", - "README.md", - "config.local.yaml.example", - ], - ); - }); -}); - -describe("guard: isProtectedPath", () => { - it("matches files inside each protected prefix", () => { - assert.equal(isProtectedPath("bot/src/index.ts"), true); - assert.equal(isProtectedPath(".claude/rules/platform/safety.md"), true); - assert.equal(isProtectedPath(".github/workflows/ci.yml"), true); - assert.equal(isProtectedPath(".githooks/pre-commit"), true); - }); - - it("matches the bare protected directory name itself", () => { - assert.equal(isProtectedPath("bot"), true); - assert.equal(isProtectedPath(".githooks"), true); - }); - - it("is case-insensitive (APFS bug fix)", () => { - assert.equal(isProtectedPath("BOT/src/index.ts"), true); - assert.equal(isProtectedPath(".GitHub/Workflows/ci.yml"), true); - }); - - it("does NOT match workspace-local / sibling paths", () => { - assert.equal(isProtectedPath("memory/notes.md"), false); - assert.equal(isProtectedPath(".claude/rules/custom/my-rule.md"), false); - assert.equal(isProtectedPath(".claude/skills/foo/SKILL.md"), false); - assert.equal(isProtectedPath("reference/x.md"), false); - }); - - it("does NOT match prefixes as mere substrings of a longer segment", () => { - assert.equal(isProtectedPath("botanical/x.ts"), false); - assert.equal(isProtectedPath("robot/x.ts"), false); - }); -}); - -describe("guard: isProtectedPath — immutable core (file-vs-dir matching, 10-set)", () => { - it("matches the new directory-prefix entries", () => { - assert.equal(isProtectedPath(".claude/hooks/guardian.sh"), true); - assert.equal(isProtectedPath(".claude/hooks"), true); // bare dir name - assert.equal(isProtectedPath(".claude/skills/workspace-health/scripts/check.sh"), true); - }); - - it("matches root-only FILE entries EXACTLY (no prefix match)", () => { - assert.equal(isProtectedPath("README.md"), true); - assert.equal(isProtectedPath(".gitleaks.toml"), true); - assert.equal(isProtectedPath(".gitleaksignore"), true); - assert.equal(isProtectedPath("config.local.yaml.example"), true); - }); - - it("does NOT match a same-named file in a subdirectory (root-only-exact)", () => { - assert.equal(isProtectedPath("docs/README.md"), false); - assert.equal(isProtectedPath("sub/config.local.yaml.example"), false); - // a file entry never prefix-matches a longer path - assert.equal(isProtectedPath("README.md/evil"), false); - }); - - it("folds case for both dir and file entries (APFS)", () => { - assert.equal(isProtectedPath("readme.md"), true); - assert.equal(isProtectedPath(".GitLeaks.TOML"), true); - assert.equal(isProtectedPath(".CLAUDE/HOOKS/x.sh"), true); - }); - - it("does NOT match .claude/skills/ siblings outside the protected scripts dir", () => { - assert.equal(isProtectedPath(".claude/skills/custom/index.ts"), false); - assert.equal(isProtectedPath(".claude/skills/workspace-health/SKILL.md"), false); - }); -}); - -describe("guard: write/edit into immutable-core file entries", () => { - it("blocks write into the root README.md but allows docs/README.md", () => { - assert.equal(block({ toolName: "write", input: { path: "README.md", content: "" } }), true); - assert.equal(block({ toolName: "write", input: { path: "docs/README.md", content: "" } }), false); - }); - - it("blocks write/edit into .claude/hooks/ and the workspace-health scripts dir", () => { - assert.equal(block({ toolName: "write", input: { path: ".claude/hooks/x.sh", content: "" } }), true); - assert.equal( - block( - { toolName: "edit", input: { path: ".claude/skills/workspace-health/scripts/x.sh", oldText: "a", newText: "b" } }, - ), - true, - ); - }); - - it("blocks write into the root gitleaks config + example files", () => { - assert.equal(block({ toolName: "write", input: { path: ".gitleaks.toml", content: "" } }), true); - assert.equal(block({ toolName: "write", input: { path: "config.local.yaml.example", content: "" } }), true); - }); -}); - -describe("guard: write/edit into protected paths", () => { - it("blocks write into bot/ (relative)", () => { - assert.equal(block({ toolName: "write", input: { path: "bot/src/x.ts", content: "" } }), true); - }); - - it("blocks edit into .claude/rules/platform/", () => { - assert.equal( - block({ toolName: "edit", input: { path: ".claude/rules/platform/safety.md", oldText: "a", newText: "b" } }), - true, - ); - }); - - it("blocks write via an absolute path that lands inside the workspace's bot/", () => { - assert.equal(block({ toolName: "write", input: { path: `${WS}/bot/src/x.ts`, content: "" } }), true); - }); - - it("blocks write via APFS-cased path (BOT/)", () => { - assert.equal(block({ toolName: "write", input: { path: "BOT/src/x.ts", content: "" } }), true); - }); - - it("blocks an absolute path whose workspace-root prefix case-varies (APFS containment fix)", () => { - // On APFS `/WS/bot/x` IS the same file as `/ws/bot/x`. A case-sensitive - // `relative()` would classify it as an escaping outside-the-workspace path - // and (being absolute) allow it — bypassing the protected `bot/` check. - assert.equal(block({ toolName: "write", input: { path: `${WS.toUpperCase()}/bot/src/x.ts`, content: "" } }), true); - assert.equal(block({ toolName: "bash", input: { command: `echo x > ${WS.toUpperCase()}/bot/y.ts` } }), true); - }); - - it("blocks write that normalizes back into bot/ via `..` (traversal bug fix)", () => { - assert.equal(block({ toolName: "write", input: { path: "bot/../bot/src/x.ts", content: "" } }), true); - }); - - it("allows write into a workspace-local, non-protected path", () => { - assert.equal(block({ toolName: "write", input: { path: "memory/notes.md", content: "" } }), false); - assert.equal(block({ toolName: "edit", input: { path: ".claude/rules/custom/r.md", oldText: "a", newText: "b" } }), false); - }); - - it("includes a helpful reason pointing at the upstream rule", () => { - const d = classifyToolCall({ toolName: "write", input: { path: "bot/x.ts", content: "" } }, inWs); - assert.equal(d.block, true); - assert.match(d.reason ?? "", /bot-code-readonly\.md/); - }); -}); - -describe("guard: workspace-structure (traversal escape)", () => { - it("blocks a relative write that escapes the workspace via `..`", () => { - assert.equal(block({ toolName: "write", input: { path: "../outside/x", content: "" } }), true); - assert.equal(block({ toolName: "write", input: { path: "bot/../../etc/x", content: "" } }), true); - }); - - it("the escape reason names a workspace-structure violation", () => { - const d = classifyToolCall({ toolName: "write", input: { path: "../escape", content: "" } }, inWs); - assert.match(d.reason ?? "", /workspace-structure/); - }); - - it("allows an absolute write that simply lives outside the workspace (legacy parity)", () => { - assert.equal(block({ toolName: "write", input: { path: "/tmp/scratch.txt", content: "" } }), false); - }); -}); - -describe("guard: isAllowedRootComponent (orphan allowlist match)", () => { - const list = ["memory", "scripts", ".claude", "*.md", "*.sh"]; - it("matches exact directory names and file globs (case-insensitively)", () => { - assert.equal(isAllowedRootComponent("memory", list), true); - assert.equal(isAllowedRootComponent("Memory", list), true); - assert.equal(isAllowedRootComponent("notes.md", list), true); - assert.equal(isAllowedRootComponent("run.sh", list), true); - assert.equal(isAllowedRootComponent(".claude", list), true); - }); - it("rejects names not in the allowlist", () => { - assert.equal(isAllowedRootComponent("rogue-dir", list), false); - assert.equal(isAllowedRootComponent("evil", list), false); - assert.equal(isAllowedRootComponent("notes.txt", list), false); - }); -}); - -describe("guard: isAllowedPath (schema write-allowlist — three D17 line kinds)", () => { - const list = ["memory/", "reference/", ".claude/rules/custom/", ".claude/skills/", "*.md", "MEMORY.md"]; - - it("directory-prefix matches the bare dir name and anything under it", () => { - assert.equal(isAllowedPath("memory", list), true); - assert.equal(isAllowedPath("memory/notes.md", list), true); - assert.equal(isAllowedPath("memory/sub/deep.txt", list), true); - assert.equal(isAllowedPath(".claude/rules/custom/r.md", list), true); - assert.equal(isAllowedPath(".claude/skills/custom/index.ts", list), true); - }); - - it("directory-prefix does NOT match a longer sibling segment", () => { - assert.equal(isAllowedPath("memorystuff/x", list), false); - assert.equal(isAllowedPath("reference-old/x", list), false); - }); - - it("root-only glob matches ONLY root-level files (no slash in path)", () => { - assert.equal(isAllowedPath("notes.md", list), true); - assert.equal(isAllowedPath("README.md", list), true); // *.md matches at root - assert.equal(isAllowedPath("docs/guide.md", list), false); // not root level - }); - - it("exact-root-file matches that exact relative path only", () => { - assert.equal(isAllowedPath("MEMORY.md", list), true); - assert.equal(isAllowedPath("sub/MEMORY.md", list), false); - }); - - it("is case-insensitive (APFS)", () => { - assert.equal(isAllowedPath("Memory/Notes.MD", list), true); - assert.equal(isAllowedPath("memory.md", list), true); // *.md - }); - - it("an empty allow-list matches nothing (fail-closed substrate)", () => { - assert.equal(isAllowedPath("memory/notes.md", []), false); - assert.equal(isAllowedPath("anything.md", []), false); - }); -}); - -describe("guard: deny-by-default allow-check (schema writeAllowlist model)", () => { - const ALLOW = ["memory/", "reference/", "docs/", ".claude/rules/custom/", ".claude/skills/", "*.md"]; - const schema: ClassifyOptions = { workspaceRoot: WS, writeAllowlist: ALLOW }; - - it("BLOCKS a write whose path matches no allow line, naming schema.md", () => { - assert.equal(block({ toolName: "write", input: { path: "unregistered/x.ts", content: "" } }, schema), true); - const d = classifyToolCall({ toolName: "write", input: { path: "unregistered/x.ts", content: "" } }, schema); - assert.match(d.reason ?? "", /schema\.md/); - assert.match(d.reason ?? "", /unregistered/); - assert.match(d.reason ?? "", /write-allowlist/); - }); - - it("ALLOWS a write whose path matches an allow line", () => { - assert.equal(block({ toolName: "write", input: { path: "memory/x.md", content: "" } }, schema), false); - assert.equal(block({ toolName: "edit", input: { path: ".claude/rules/custom/r.md", oldText: "a", newText: "b" } }, schema), false); - // .claude/ split: custom skills dir is allowed (only the workspace-health - // scripts subdir is immutable), and .claude/rules/custom/ is allowed. - assert.equal(block({ toolName: "write", input: { path: ".claude/skills/custom/index.ts", content: "" } }, schema), false); - assert.equal(block({ toolName: "write", input: { path: ".claude/rules/custom/x.md", content: "" } }, schema), false); - }); - - it("immutable core wins over a matching allow line (precedence)", () => { - // README.md is immutable-core (root-only-exact) yet `*.md` would allow it. - assert.equal(block({ toolName: "write", input: { path: "README.md", content: "" } }, schema), true); - // .claude/skills/ is allowed, but the workspace-health scripts subdir is immutable. - assert.equal( - block({ toolName: "write", input: { path: ".claude/skills/workspace-health/scripts/x.ts", content: "" } }, schema), - true, - ); - // .claude/rules/platform/ is immutable even though .claude/rules/custom/ is allowed. - assert.equal(block({ toolName: "write", input: { path: ".claude/rules/platform/x.md", content: "" } }, schema), true); - }); - - it("immutable FILE entry is root-only-exact — docs/README.md is allowed when docs/ is", () => { - assert.equal(block({ toolName: "write", input: { path: "docs/README.md", content: "" } }, schema), false); - }); - - it("applies to bash write targets too (not just write/edit)", () => { - assert.equal(block({ toolName: "bash", input: { command: "echo x > unregistered/y" } }, schema), true); - assert.equal(block({ toolName: "bash", input: { command: "echo x > memory/y.md" } }, schema), false); - }); - - it("fail-safe: an EMPTY allow-list denies everything non-immutable (fail-closed)", () => { - const empty: ClassifyOptions = { workspaceRoot: WS, writeAllowlist: [] }; - // immutable core still blocks - assert.equal(block({ toolName: "write", input: { path: "bot/x.ts", content: "" } }, empty), true); - // everything else fails closed - assert.equal(block({ toolName: "write", input: { path: "memory/x.md", content: "" } }, empty), true); - const d = classifyToolCall({ toolName: "write", input: { path: "memory/x.md", content: "" } }, empty); - assert.match(d.reason ?? "", /fail-closed/); - assert.match(d.reason ?? "", /schema\.md/); - assert.match(d.reason ?? "", /PI_EXTENSIONS_DISABLED=1/); - }); - - it("undefined writeAllowlist → deny-by-default OFF (legacy behavior preserved)", () => { - // No writeAllowlist injected → non-protected workspace paths are allowed. - assert.equal(block({ toolName: "write", input: { path: "memory/x.md", content: "" } }, inWs), false); - assert.equal(block({ toolName: "write", input: { path: "unregistered/x.ts", content: "" } }, inWs), false); - }); - - it("preserves traversal-escape and outside-workspace handling under the schema model", () => { - // relative escape still blocked - assert.equal(block({ toolName: "write", input: { path: "../escape.md", content: "" } }, schema), true); - // absolute path outside the workspace still allowed (not governed by the allow-list) - assert.equal(block({ toolName: "write", input: { path: "/tmp/scratch.txt", content: "" } }, schema), false); - }); - - it("subagent child: schema allow-check stays anchored at the parent workspace root", () => { - const childSchema: ClassifyOptions = { workspaceRoot: WS, resolveRoot: "/tmp", writeAllowlist: ALLOW }; - // absolute write into an unregistered parent path → blocked - assert.equal(block({ toolName: "write", input: { path: `${WS}/unregistered/x.ts`, content: "" } }, childSchema), true); - // absolute write into an allowed parent path → allowed - assert.equal(block({ toolName: "write", input: { path: `${WS}/memory/x.md`, content: "" } }, childSchema), false); - // genuine relative write under the child's own cwd, outside parent → allowed - assert.equal(block({ toolName: "write", input: { path: "out.txt", content: "" } }, childSchema), false); - }); -}); - -describe("guard: guardian orphan check (workspace-structure rule, guardian.sh parity)", () => { - const ALLOWLIST = ["memory", "scripts", ".claude", "*.md", "*.sh"]; - // Default: nothing exists → every write is a NEW entry. - const orphan: ClassifyOptions = { workspaceRoot: WS, orphanAllowlist: ALLOWLIST, fileExists: () => false }; - - it("blocks a write creating a NEW root-level entry not in the allowlist", () => { - assert.equal(block({ toolName: "write", input: { path: "rogue-dir/evil.sh", content: "" } }, orphan), true); - const d = classifyToolCall({ toolName: "write", input: { path: "rogue-dir/evil.sh", content: "" } }, orphan); - assert.match(d.reason ?? "", /orphan-allowlist/); - assert.match(d.reason ?? "", /rogue-dir/); - }); - - it("allows writes whose root component IS in the allowlist", () => { - assert.equal(block({ toolName: "write", input: { path: "memory/notes.md", content: "" } }, orphan), false); - assert.equal(block({ toolName: "write", input: { path: "scripts/run.sh", content: "" } }, orphan), false); - // NOTES.md (a root *.md that is NOT in the immutable core — README.md now is). - assert.equal(block({ toolName: "write", input: { path: "NOTES.md", content: "" } }, orphan), false); // *.md - }); - - it("allows an OVERWRITE of an existing entry even if its root is not allowlisted", () => { - const overwrite: ClassifyOptions = { workspaceRoot: WS, orphanAllowlist: ALLOWLIST, fileExists: () => true }; - assert.equal(block({ toolName: "write", input: { path: "rogue-dir/evil.sh", content: "" } }, overwrite), false); - }); - - it("applies ONLY to the write tool — edit and bash are out of guardian.sh's scope", () => { - assert.equal(block({ toolName: "edit", input: { path: "rogue-dir/evil.sh", oldText: "a", newText: "b" } }, orphan), false); - assert.equal(block({ toolName: "bash", input: { command: "echo x > rogue-dir/evil.sh" } }, orphan), false); - }); - - it("is DISABLED when no allowlist is injected (security prefixes still apply)", () => { - assert.equal(block({ toolName: "write", input: { path: "rogue-dir/evil.sh", content: "" } }, inWs), false); - }); - - it("protected-prefix and traversal checks still win over the orphan check", () => { - assert.equal(block({ toolName: "write", input: { path: "bot/x.ts", content: "" } }, orphan), true); - assert.equal(block({ toolName: "write", input: { path: "../escape.sh", content: "" } }, orphan), true); - }); -}); - -describe("guard: subagent child cwd (immutable protection root vs relative resolve root)", () => { - // A subagent child is spawned with a caller-controlled cwd; protection MUST stay - // anchored at the PARENT workspace (workspaceRoot) while relative targets resolve - // against the child's real cwd (resolveRoot). Without this, `cwd:"/tmp"` + an - // absolute write back into `/bot/x` would resolve outside `/tmp` and bypass A1. - const childInTmp: ClassifyOptions = { workspaceRoot: WS, resolveRoot: "/tmp" }; - - it("blocks an ABSOLUTE write into the parent's protected bot/ even when the child cwd is elsewhere", () => { - assert.equal(block({ toolName: "write", input: { path: `${WS}/bot/x.ts`, content: "" } }, childInTmp), true); - assert.equal(block({ toolName: "bash", input: { command: `echo x > ${WS}/bot/y.ts` } }, childInTmp), true); - }); - - it("blocks a RELATIVE write that climbs from the child cwd back into the protected tree", () => { - // resolves to /ws/bot/x.ts via `..` from /tmp → caught as a traversal escape. - assert.equal(block({ toolName: "bash", input: { command: "echo x > ../ws/bot/x.ts" } }, childInTmp), true); - }); - - it("allows a genuine relative write under the child's own cwd (no over-block)", () => { - assert.equal(block({ toolName: "write", input: { path: "out.txt", content: "" } }, childInTmp), false); - assert.equal(block({ toolName: "bash", input: { command: "echo x > out.txt" } }, childInTmp), false); - }); - - it("allows an absolute write under the child's own cwd, outside the parent workspace", () => { - assert.equal(block({ toolName: "write", input: { path: "/tmp/out.txt", content: "" } }, childInTmp), false); - }); - - it("orphan check stays anchored at the parent workspace root", () => { - const orphanChild: ClassifyOptions = { - workspaceRoot: WS, - resolveRoot: "/tmp", - orphanAllowlist: ["memory", "*.md"], - fileExists: () => false, - }; - // Absolute write creating a NEW root-level entry in the parent workspace → blocked. - assert.equal(block({ toolName: "write", input: { path: `${WS}/rogue/evil.sh`, content: "" } }, orphanChild), true); - // Absolute write into an allowlisted parent root component → allowed. - assert.equal(block({ toolName: "write", input: { path: `${WS}/memory/n.md`, content: "" } }, orphanChild), false); - }); -}); - -describe("guard: fail-closed", () => { - it("blocks write/edit with a missing or non-string path", () => { - assert.equal(block({ toolName: "write", input: {} }), true); - assert.equal(block({ toolName: "edit", input: { path: 123 as unknown as string } }), true); - assert.equal(block({ toolName: "write", input: undefined }), true); - }); - - it("blocks ANY write when the workspace root is unknown (fail-CLOSED unknown root)", () => { - const noRoot: ClassifyOptions = { workspaceRoot: undefined }; - assert.equal(block({ toolName: "write", input: { path: "bot/x", content: "" } }, noRoot), true); - // Even an otherwise-allowed path is blocked — we cannot verify it without a root. - assert.equal(block({ toolName: "write", input: { path: "memory/x", content: "" } }, noRoot), true); - assert.equal(block({ toolName: "write", input: { path: "bot/x", content: "" } }, { workspaceRoot: " " }), true); - }); -}); - -describe("guard: read-only / non-writing tools pass", () => { - it("allows read/grep/find/ls and custom tools, even on protected paths", () => { - assert.equal(block({ toolName: "read", input: { path: "bot/secret.ts" } }), false); - assert.equal(block({ toolName: "grep", input: { pattern: "x", path: "bot" } }), false); - assert.equal(block({ toolName: "find", input: { pattern: "*.ts" } }), false); - assert.equal(block({ toolName: "ls", input: { path: "bot" } }), false); - assert.equal(block({ toolName: "web_search", input: { query: "bot/" } }), false); - }); -}); - -describe("guard: bash redirect/tee/mv/cp coverage (bash-hook bug fix)", () => { - it("blocks `>` and `>>` redirects into a protected path", () => { - assert.equal(block({ toolName: "bash", input: { command: "echo hi > bot/x.ts" } }), true); - assert.equal(block({ toolName: "bash", input: { command: "echo hi >> .githooks/pre-commit" } }), true); - }); - - it("blocks `tee` into a protected path (incl. through a pipe)", () => { - assert.equal(block({ toolName: "bash", input: { command: "cat a | tee bot/y.ts" } }), true); - }); - - it("blocks `mv` and `cp` into a protected path", () => { - assert.equal(block({ toolName: "bash", input: { command: "mv evil.ts bot/z.ts" } }), true); - assert.equal(block({ toolName: "bash", input: { command: "cp evil.ts bot/z.ts" } }), true); - }); - - it("blocks the `>|` clobber redirect into a protected path", () => { - assert.equal(block({ toolName: "bash", input: { command: "echo hi >| bot/x.ts" } }), true); - }); - - it("blocks `cp -t DIR` (GNU target-directory) into a protected path", () => { - assert.equal(block({ toolName: "bash", input: { command: "cp -t bot evil1.ts evil2.ts" } }), true); - assert.equal(block({ toolName: "bash", input: { command: "cp --target-directory=bot evil.ts" } }), true); - }); - - it("blocks clustered `cp -vt DIR` short-flag forms into a protected path", () => { - assert.equal(block({ toolName: "bash", input: { command: "cp -vt bot a b" } }), true); - assert.equal(block({ toolName: "bash", input: { command: "cp -vtbot a b" } }), true); - }); - - it("blocks `mv` whose SOURCE is protected (mv deletes the source)", () => { - assert.equal(block({ toolName: "bash", input: { command: "mv bot/x.ts /tmp/y.ts" } }), true); - }); - - it("neutralizes the `\\cp` alias-bypass via the lexer", () => { - assert.equal(block({ toolName: "bash", input: { command: "\\cp evil.ts bot/z.ts" } }), true); - }); - - it("classifies the wrapped command through sudo/nohup", () => { - assert.equal(block({ toolName: "bash", input: { command: "sudo tee bot/x.ts" } }), true); - assert.equal(block({ toolName: "bash", input: { command: "nohup cp evil bot/z.ts" } }), true); - }); - - it("sees past wrapper OPTIONS and post-`env` VAR= assignments to the real command", () => { - assert.equal(block({ toolName: "bash", input: { command: "sudo -n tee bot/x.ts" } }), true); - assert.equal(block({ toolName: "bash", input: { command: "env FOO=bar tee bot/x.ts" } }), true); - assert.equal(block({ toolName: "bash", input: { command: "env -i tee bot/x.ts" } }), true); - assert.equal(block({ toolName: "bash", input: { command: "sudo -n nohup cp evil bot/z.ts" } }), true); - }); - - it("allows bash with no write target or writing outside the workspace", () => { - assert.equal(block({ toolName: "bash", input: { command: "echo hello world" } }), false); - assert.equal(block({ toolName: "bash", input: { command: "echo x > /tmp/log.txt" } }), false); - assert.equal(block({ toolName: "bash", input: { command: "grep -r foo bot/" } }), false); - assert.equal(block({ toolName: "bash", input: { command: "cat bot/x.ts" } }), false); - }); -}); - -describe("guard: extractBashWriteTargets", () => { - it("extracts redirect targets", () => { - assert.deepStrictEqual(extractBashWriteTargets("echo hi > bot/x"), ["bot/x"]); - assert.deepStrictEqual(extractBashWriteTargets("echo hi >> a/b"), ["a/b"]); - }); - - it("extracts the `>|` clobber-redirect target", () => { - assert.deepStrictEqual(extractBashWriteTargets("echo hi >| bot/x"), ["bot/x"]); - }); - - it("extracts the `cp -t DIR` target directory in all flag forms", () => { - assert.deepStrictEqual(extractBashWriteTargets("cp -t bot a b"), ["bot"]); - assert.deepStrictEqual(extractBashWriteTargets("cp -tbot a b"), ["bot"]); - assert.deepStrictEqual(extractBashWriteTargets("cp --target-directory=bot a b"), ["bot"]); - assert.deepStrictEqual(extractBashWriteTargets("cp --target-directory bot a b"), ["bot"]); - }); - - it("extracts the target directory from clustered short flags (`-vt`)", () => { - assert.deepStrictEqual(extractBashWriteTargets("cp -vt bot a b"), ["bot"]); - assert.deepStrictEqual(extractBashWriteTargets("cp -vtbot a b"), ["bot"]); - }); - - it("extracts tee file args", () => { - assert.deepStrictEqual(extractBashWriteTargets("cat a | tee out.txt"), ["out.txt"]); - }); - - it("finds the write target past wrapper options and post-`env` assignments", () => { - assert.deepStrictEqual(extractBashWriteTargets("env FOO=bar tee out.txt"), ["out.txt"]); - assert.deepStrictEqual(extractBashWriteTargets("sudo -n tee out.txt"), ["out.txt"]); - assert.deepStrictEqual(extractBashWriteTargets("env -i tee out.txt"), ["out.txt"]); - // A bare command whose own first arg is a flag still resolves correctly. - assert.deepStrictEqual(extractBashWriteTargets("tee -a out.txt"), ["out.txt"]); - }); - - it("treats every mv arg (sources + dest) as a target, but only cp's dest", () => { - assert.deepStrictEqual(extractBashWriteTargets("mv s1 s2 dest"), ["s1", "s2", "dest"]); - assert.deepStrictEqual(extractBashWriteTargets("cp s1 s2 dest"), ["dest"]); - }); - - it("ignores an fd designator before a redirect (the `2` in `2>`)", () => { - assert.deepStrictEqual(extractBashWriteTargets("echo x 2> err.log"), ["err.log"]); - }); - - it("unwraps a quoted redirect target", () => { - assert.deepStrictEqual(extractBashWriteTargets('echo x > "bot/quoted file.ts"'), ["bot/quoted file.ts"]); - }); - - it("returns no targets for a read-only command", () => { - assert.deepStrictEqual(extractBashWriteTargets("grep -r foo bot/"), []); - assert.deepStrictEqual(extractBashWriteTargets("echo hello"), []); - }); -}); diff --git a/bot/src/__tests__/package-install.test.ts b/bot/src/__tests__/package-install.test.ts index 6dd2591b..655fec3e 100644 --- a/bot/src/__tests__/package-install.test.ts +++ b/bot/src/__tests__/package-install.test.ts @@ -5,6 +5,7 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, rmSync, unlinkSync, writeFileSync, @@ -15,6 +16,8 @@ import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BOT_ROOT = resolve(__dirname, "..", ".."); +const EXPECTED_BUNDLED_AGENT_FILES = ["planner.md", "reviewer.md", "scout.md", "worker.md"]; +const EXPECTED_BUNDLED_PROMPT_FILES = ["implement-and-review.md", "implement.md", "scout-and-plan.md"]; interface PackedFile { path: string; @@ -55,7 +58,8 @@ function runNpmPack(args: readonly string[], cwd = BOT_ROOT): PackResult { function createWorkspace(root: string): string { const workspace = join(root, "workspace"); mkdirSync(join(workspace, "agent-workspace"), { recursive: true }); - mkdirSync(join(workspace, "schemas"), { recursive: true }); + mkdirSync(join(workspace, "config"), { recursive: true }); + writeFileSync(join(workspace, "config", "secrets.sops.yaml"), "placeholder: true\n", "utf8"); writeFileSync( join(workspace, "config.yaml"), [ @@ -85,35 +89,22 @@ function createWorkspace(root: string): string { "", ].join("\n"), ); - writeFileSync( - join(workspace, "schema.md"), - [ - "# Default schema", - "", - "```write-allowlist", - "agent-workspace/", - "*.md", - "schema.md", - "```", - "", - ].join("\n"), - ); - writeFileSync( - join(workspace, "schemas", "override.md"), - [ - "# Override schema", - "", - "```write-allowlist", - "agent-workspace/", - "override-only/", - "schema.md", - "```", - "", - ].join("\n"), - ); return workspace; } +function collectSchemaFiles(root: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + found.push(...collectSchemaFiles(path)); + } else if (entry.isFile() && entry.name === "schema.md") { + found.push(path); + } + } + return found; +} + function runInstalledBin(projectDir: string, args: readonly string[], workspace: string): SpawnSyncReturns { return spawnSync(join(projectDir, "node_modules", ".bin", "minime-bot"), args, { cwd: projectDir, @@ -132,22 +123,20 @@ function assertPackFiles(files: readonly string[]): void { "dist/pi-rpc-protocol.js", "dist/workspace-contract.js", "dist/workspace-validator.js", - "dist/pi-extensions/guard.js", "dist/pi-extensions/subagent-args.js", "dist/pi-extensions/tavily.js", "dist/pi-extensions/tavily-secret.js", - "dist/pi-extensions/write-allowlist-schema.js", - "dist/extensions/pi/guardian-protect-files.js", "dist/extensions/pi/web-tools.js", "dist/extensions/pi/subagent/agents.js", "dist/extensions/pi/subagent/index.js", - "dist/extensions/pi/subagent/agents/worker.md", - "dist/extensions/pi/subagent/prompts/implement.md", "scripts/deliver.sh", + ...EXPECTED_BUNDLED_AGENT_FILES.map((file) => `dist/extensions/pi/subagent/agents/${file}`), + ...EXPECTED_BUNDLED_PROMPT_FILES.map((file) => `dist/extensions/pi/subagent/prompts/${file}`), ]) { assert.ok(files.includes(expected), `expected npm pack to include ${expected}`); } + assert.ok(!files.some((file) => file.includes("guardian-protect-files")), "guard extension should not be packed"); assert.ok(!files.some((file) => file.startsWith("src/")), "source TS should not be packed"); assert.ok(!files.some((file) => file.startsWith(".claude/")), "source extension wrappers should not be packed"); assert.ok(!files.some((file) => file.startsWith("test-fixtures/")), "workspace fixtures should not be packed"); @@ -189,6 +178,7 @@ describe("package artifact install", () => { mkdirSync(packDir, { recursive: true }); mkdirSync(projectDir, { recursive: true }); const workspace = createWorkspace(temp); + assert.deepEqual(collectSchemaFiles(workspace), [], "installed workspace fixture must not contain schema.md"); try { const pack = runNpmPack(["--pack-destination", packDir]); @@ -240,7 +230,7 @@ describe("package artifact install", () => { const INSTALLED_ARTIFACT_CHECK = String.raw` import assert from "node:assert/strict"; -import { existsSync, realpathSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import { pathToFileURL } from "node:url"; @@ -253,18 +243,55 @@ const projectDir = process.cwd(); const packageDir = join(projectDir, "node_modules", "minime"); const artifactDir = join(packageDir, "dist", "extensions", "pi"); const agentWorkspace = join(workspace, "agent-workspace"); +const controlTavilySopsFile = join(workspace, "config", "secrets.sops.yaml"); const importFile = (path) => import(pathToFileURL(path).href); const importPackageFile = (relpath) => importFile(join(packageDir, relpath)); +const expectedBundledAgentFiles = ${JSON.stringify(EXPECTED_BUNDLED_AGENT_FILES)}; +const expectedBundledPromptFiles = ${JSON.stringify(EXPECTED_BUNDLED_PROMPT_FILES)}; + +function extensionPathsFromArgs(args) { + const paths = []; + for (let idx = 0; idx < args.length; idx += 2) { + assert.equal(args[idx], "--extension"); + assert.equal(typeof args[idx + 1], "string"); + paths.push(args[idx + 1]); + } + return paths; +} + +function assertNoGuardContract(label, args) { + assert.doesNotMatch(JSON.stringify(args), /guardian-protect-files/, label); +} const piRpc = await importPackageFile("dist/pi-rpc-protocol.js"); -const extensionArgs = piRpc.resolvePiExtensionArgs({ env: {} }); -const extensionPaths = extensionArgs.filter((arg) => arg !== "--extension"); +const parentExtensionArgs = piRpc.resolvePiExtensionArgs({ env: {} }); +const extensionPaths = extensionPathsFromArgs(parentExtensionArgs); assert.deepEqual( extensionPaths.map((path) => relative(artifactDir, path)), - ["guardian-protect-files.js", "web-tools.js", "subagent/index.js"], + ["web-tools.js", "subagent/index.js"], +); +assertNoGuardContract("parent Pi extension args must not load the retired guard", parentExtensionArgs); + +const subagentChildExtensionArgs = piRpc.resolvePiExtensionArgs({ + env: {}, + relpaths: piRpc.PI_SUBAGENT_CHILD_WRAPPER_RELPATHS, +}); +assert.deepEqual( + extensionPathsFromArgs(subagentChildExtensionArgs).map((path) => relative(artifactDir, path)), + ["web-tools.js"], ); +assertNoGuardContract("subagent child extension args must not load the retired guard", subagentChildExtensionArgs); + +const cronExtensionArgs = piRpc.resolvePiExtensionArgs({ + env: {}, + relpaths: piRpc.PI_CRON_WRAPPER_RELPATHS, +}); +assert.deepEqual(cronExtensionArgs, []); +assertNoGuardContract("cron Pi extension args must not load the retired guard", cronExtensionArgs); for (const extensionPath of extensionPaths) { + assert.ok(extensionPath.startsWith(artifactDir + "/"), extensionPath); + assert.equal(extensionPath.startsWith(sourceBotRoot), false); const mod = await importFile(extensionPath); assert.equal(typeof mod.default, "function", extensionPath); } @@ -276,70 +303,24 @@ const loadedConfig = configMod.loadConfig(join(workspace, "config.yaml"), { }); assert.equal(loadedConfig.agents.main.workspaceCwd, agentWorkspace); const childEnv = piRpc.buildPiSpawnEnv(loadedConfig.agents.main); -assert.equal(childEnv.PI_GUARD_WORKSPACE_ROOT, realpathSync(workspace)); +assert.equal(childEnv.MINIME_WORKSPACE_ROOT, workspace); +assert.equal(childEnv.TELEGRAM_BOT_TOKEN, undefined); +assert.equal(childEnv.DISCORD_BOT_TOKEN, undefined); +assert.equal(childEnv.TAVILY_API_KEY, undefined); const workspaceContract = await importPackageFile("dist/workspace-contract.js"); const validator = await importPackageFile("dist/workspace-validator.js"); -const schema = await importPackageFile("dist/pi-extensions/write-allowlist-schema.js"); const defaultContract = workspaceContract.resolveWorkspaceContract({ workspace, cwd: projectDir, env: {}, }); -const defaultResult = validator.validateWorkspaceContract(defaultContract, { env: {} }); +const defaultResult = validator.validateWorkspaceContract(defaultContract); assert.equal(validator.workspaceValidationErrors(defaultResult).length, 0); -assert.equal(defaultResult.schema.entries.includes("override-only/"), false); - -const overrideSchemaPath = join(workspace, "schemas", "override.md"); -const overrideEnv = { [workspaceContract.MINIME_SCHEMA_PATH_ENV]: overrideSchemaPath }; -const overrideContract = workspaceContract.resolveWorkspaceContract({ - workspace, - cwd: projectDir, - env: overrideEnv, -}); -const overrideResult = validator.validateWorkspaceContract(overrideContract, { env: overrideEnv }); -assert.equal(validator.workspaceValidationErrors(overrideResult).length, 0); -assert.deepEqual( - overrideResult.schema.entries, - schema.readWriteAllowlistEntriesForGuard(overrideContract.paths.schemaPath), -); -assert.equal(overrideResult.schema.entries.includes("override-only/"), true); - -const guardian = await importFile(join(artifactDir, "guardian-protect-files.js")); -let toolCallHandler; -const guardianPi = { - on(event, handler) { - if (event === "tool_call") { - toolCallHandler = handler; - } - }, -}; -guardian.default(guardianPi); -assert.equal(typeof toolCallHandler, "function"); - -delete process.env.MINIME_SCHEMA_PATH; -process.env.PI_GUARD_WORKSPACE_ROOT = workspace; -const defaultVerdict = await toolCallHandler( - { toolName: "write", input: { path: "note.txt" } }, - { cwd: agentWorkspace, hasUI: false, ui: { notify() {} } }, -); -assert.equal(defaultVerdict, undefined); - -const protectedVerdict = await toolCallHandler( - { toolName: "write", input: { path: join(workspace, "README.md") } }, - { cwd: agentWorkspace, hasUI: false, ui: { notify() {} } }, -); -assert.equal(protectedVerdict.block, true); - -process.env.MINIME_SCHEMA_PATH = overrideSchemaPath; -const overrideVerdict = await toolCallHandler( - { toolName: "write", input: { path: join(workspace, "override-only", "file.txt") } }, - { cwd: agentWorkspace, hasUI: false, ui: { notify() {} } }, -); -assert.equal(overrideVerdict, undefined); const registeredTools = []; +const registeredToolDefs = []; const resourceHandlers = []; const fakePi = { on(event, handler) { @@ -349,9 +330,51 @@ const fakePi = { }, registerTool(tool) { registeredTools.push(tool.name); + registeredToolDefs.push(tool); }, }; +const fakeBinDir = join(projectDir, "fake-bin"); +const sopsArgvFile = join(projectDir, "sops-argv.txt"); +mkdirSync(fakeBinDir, { recursive: true }); +writeFileSync( + join(fakeBinDir, "sops"), + [ + "#!/bin/bash", + "printf '%s\\n' \"$@\" > \"$SOPS_ARGV_FILE\"", + "printf 'tvly-installed-wrapper-key\\n'", + "", + ].join("\n"), + "utf8", +); +chmodSync(join(fakeBinDir, "sops"), 0o755); +process.env.PATH = fakeBinDir + ":" + process.env.PATH; +process.env.SOPS_ARGV_FILE = sopsArgvFile; + +const callerControlledCwd = join(projectDir, "caller-controlled-subagent-cwd"); +mkdirSync(callerControlledCwd, { recursive: true }); +process.chdir(callerControlledCwd); +const subagentChildEnv = piRpc.buildPiSubagentChildSpawnEnv(); +assert.equal(process.cwd(), callerControlledCwd); +assert.equal(subagentChildEnv.MINIME_WORKSPACE_ROOT, workspace); +assert.equal(subagentChildEnv.TELEGRAM_BOT_TOKEN, undefined); +assert.equal(subagentChildEnv.DISCORD_BOT_TOKEN, undefined); +assert.equal(subagentChildEnv.TAVILY_API_KEY, undefined); + +process.chdir(agentWorkspace); + +const fetchCalls = []; +const oldFetch = globalThis.fetch; +globalThis.fetch = async (url, init) => { + fetchCalls.push({ url: String(url), init }); + return { + ok: true, + status: 200, + json: async () => ({ results: [] }), + text: async () => "{\"results\":[]}", + }; +}; + const webTools = await importFile(join(artifactDir, "web-tools.js")); webTools.default(fakePi); const subagent = await importFile(join(artifactDir, "subagent", "index.js")); @@ -361,8 +384,30 @@ assert.deepEqual( ["subagent", "web_fetch", "web_search"], ); +try { + const searchTool = registeredToolDefs.find((tool) => tool.name === "web_search"); + assert.ok(searchTool, "web_search should be registered"); + const searchResult = await searchTool.execute("call-1", { query: "installed wrapper" }); + assert.equal(searchResult.details.ok, true); + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0].init.headers.Authorization, "Bearer tvly-installed-wrapper-key"); + assert.deepEqual(readFileSync(sopsArgvFile, "utf8").trim().split("\n"), [ + "-d", + "--extract", + "[\"tavily\"][\"api_key\"]", + controlTavilySopsFile, + ]); +} finally { + globalThis.fetch = oldFetch; +} + const agentsMod = await importFile(join(artifactDir, "subagent", "agents.js")); const discovery = agentsMod.discoverAgents(workspace, "project"); +const bundledAgentNames = discovery.agents + .filter((agent) => agent.source === "bundled") + .map((agent) => agent.name) + .sort(); +assert.deepEqual(bundledAgentNames, expectedBundledAgentFiles.map((file) => file.slice(0, -".md".length)).sort()); const bundledWorker = discovery.agents.find((agent) => agent.name === "worker" && agent.source === "bundled"); assert.ok(bundledWorker, "expected bundled worker agent from installed artifact"); assert.ok(bundledWorker.filePath.startsWith(join(artifactDir, "subagent", "agents"))); @@ -371,6 +416,18 @@ assert.equal(bundledWorker.filePath.startsWith(sourceBotRoot), false); assert.equal(resourceHandlers.length, 1); const resources = resourceHandlers[0](); assert.deepEqual(resources.promptPaths, [join(artifactDir, "subagent", "prompts")]); -assert.ok(existsSync(join(resources.promptPaths[0], "implement.md"))); -assert.ok(existsSync(join(artifactDir, "subagent", "agents", "worker.md"))); +assert.deepEqual( + readdirSync(join(artifactDir, "subagent", "agents")).filter((file) => file.endsWith(".md")).sort(), + expectedBundledAgentFiles, +); +assert.deepEqual( + readdirSync(resources.promptPaths[0]).filter((file) => file.endsWith(".md")).sort(), + expectedBundledPromptFiles, +); +for (const file of expectedBundledAgentFiles) { + assert.ok(existsSync(join(artifactDir, "subagent", "agents", file)), file); +} +for (const file of expectedBundledPromptFiles) { + assert.ok(existsSync(join(resources.promptPaths[0], file)), file); +} `; diff --git a/bot/src/__tests__/pi-rpc-protocol.test.ts b/bot/src/__tests__/pi-rpc-protocol.test.ts index 4de58626..035a7764 100644 --- a/bot/src/__tests__/pi-rpc-protocol.test.ts +++ b/bot/src/__tests__/pi-rpc-protocol.test.ts @@ -11,7 +11,6 @@ import { mkdirSync, mkdtempSync, readFileSync, - realpathSync, rmSync, symlinkSync, writeFileSync, @@ -22,7 +21,6 @@ import { PI_CRON_WRAPPER_RELPATHS, PI_EXTENSION_ARTIFACT_WRAPPER_RELPATHS, PI_EXTENSION_WRAPPER_RELPATHS, - PI_GUARD_WORKSPACE_ROOT_ENV, PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS, PI_SUBAGENT_CHILD_WRAPPER_RELPATHS, buildGetStateCommand, @@ -37,6 +35,7 @@ import { piExtensionRelpathForDir, readPiStream, resolvePiExtensionArgs, + resolveValidatedPiAgentWorkspaceCwd, sendPiGetState, sendPiPrompt, sendPiSteer, @@ -44,7 +43,11 @@ import { type PiExtensionResolveOptions, } from "../pi-rpc-protocol.js"; import type { AgentConfig, StreamLine } from "../types.js"; -import { MINIME_SCHEMA_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; +import { + MINIME_CONFIG_PATH_ENV, + MINIME_CRONS_PATH_ENV, + MINIME_WORKSPACE_ROOT_ENV, +} from "../workspace-contract.js"; const testAgent: AgentConfig = { id: "main", @@ -349,8 +352,7 @@ describe("buildPiSpawnArgs context assembly (provider: pi)", () => { assert.ok(noContextIdx < firstExtension, "context args must precede --extension args"); assert.ok(firstExtension < session, "--extension args must precede --session"); assert.strictEqual(args[session + 1], "pi-sess-resume"); - // All three extension wrappers still resolve alongside the new context args. - assert.strictEqual(args.filter((a) => a === "--extension").length, 3); + assert.strictEqual(args.filter((a) => a === "--extension").length, 2); }); it("degrades to no context args for an empty pi workspace", () => { @@ -412,60 +414,71 @@ describe("Pi extension loading (--extension)", () => { it("resolves a repeatable --extension arg (abs path) for each wrapper, in load order", () => { assert.deepStrictEqual(resolvePiExtensionArgs(presentAll), [ - "--extension", wrapperAbs("guardian-protect-files.ts"), "--extension", wrapperAbs("web-tools.ts"), "--extension", wrapperAbs("subagent/index.ts"), ]); }); - it("defaults the wrapper list to A1 guard, A2 web-tools, A3 subagent", () => { + it("defaults the wrapper list to web-tools and subagent", () => { assert.deepStrictEqual( [...PI_EXTENSION_WRAPPER_RELPATHS], - ["guardian-protect-files.ts", "web-tools.ts", "subagent/index.ts"], + ["web-tools.ts", "subagent/index.ts"], ); assert.deepStrictEqual( [...PI_EXTENSION_ARTIFACT_WRAPPER_RELPATHS], - ["guardian-protect-files.js", "web-tools.js", "subagent/index.js"], + ["web-tools.js", "subagent/index.js"], ); }); - it("the subagent-child wrapper subset is A1 guard + A2 web-tools, without A3 subagent recursion", () => { - assert.deepStrictEqual([...PI_SUBAGENT_CHILD_WRAPPER_RELPATHS], [ - "guardian-protect-files.ts", - "web-tools.ts", + it("post-retirement extension contract excludes guardian in parent, cron, and subagent child args", () => { + const parentArgs = resolvePiExtensionArgs(presentAll); + const subagentChildArgs = resolvePiExtensionArgs({ + ...presentAll, + relpaths: PI_SUBAGENT_CHILD_WRAPPER_RELPATHS, + }); + const cronArgs = resolvePiExtensionArgs({ + ...presentAll, + relpaths: PI_CRON_WRAPPER_RELPATHS, + }); + + assert.deepStrictEqual(parentArgs, [ + "--extension", wrapperAbs("web-tools.ts"), + "--extension", wrapperAbs("subagent/index.ts"), ]); - assert.deepStrictEqual([...PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS], [ - "guardian-protect-files.js", - "web-tools.js", + assert.deepStrictEqual(subagentChildArgs, [ + "--extension", wrapperAbs("web-tools.ts"), ]); + assert.deepStrictEqual(cronArgs, []); + assert.doesNotMatch( + JSON.stringify({ parentArgs, subagentChildArgs, cronArgs }), + /guardian-protect-files/, + ); }); - it("the Pi cron wrapper subset is A1 guard only", () => { - assert.deepStrictEqual([...PI_CRON_WRAPPER_RELPATHS], ["guardian-protect-files.ts"]); + it("the subagent-child wrapper subset is web-tools only, without subagent recursion", () => { + assert.deepStrictEqual([...PI_SUBAGENT_CHILD_WRAPPER_RELPATHS], ["web-tools.ts"]); + assert.deepStrictEqual([...PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS], ["web-tools.js"]); }); - it("resolves only the requested relpaths subset (subagent child loads guard + web-tools only)", () => { + it("the Pi cron wrapper subset is empty", () => { + assert.deepStrictEqual([...PI_CRON_WRAPPER_RELPATHS], []); + }); + + it("resolves only the requested relpaths subset (subagent child loads web-tools only)", () => { const args = resolvePiExtensionArgs({ ...presentAll, relpaths: PI_SUBAGENT_CHILD_WRAPPER_RELPATHS }); assert.deepStrictEqual(args, [ - "--extension", wrapperAbs("guardian-protect-files.ts"), "--extension", wrapperAbs("web-tools.ts"), ]); }); - it("resolves only the requested relpaths subset (Pi cron loads guard only)", () => { + it("resolves only the requested relpaths subset (Pi cron loads no wrappers)", () => { const args = resolvePiExtensionArgs({ ...presentAll, relpaths: PI_CRON_WRAPPER_RELPATHS }); - assert.deepStrictEqual(args, [ - "--extension", wrapperAbs("guardian-protect-files.ts"), - ]); + assert.deepStrictEqual(args, []); }); it("maps source wrapper relpaths to built JS relpaths for package artifact dirs", () => { const artifactDir = resolve("/tmp/project/node_modules/minime/dist/extensions/pi"); - assert.equal( - piExtensionRelpathForDir(artifactDir, "guardian-protect-files.ts"), - "guardian-protect-files.js", - ); assert.equal( piExtensionRelpathForDir(artifactDir, "subagent/index.ts"), "subagent/index.js", @@ -486,7 +499,6 @@ describe("Pi extension loading (--extension)", () => { }); assert.deepStrictEqual(args, [ - "--extension", resolve(artifactDir, "guardian-protect-files.js"), "--extension", resolve(artifactDir, "web-tools.js"), "--extension", resolve(artifactDir, "subagent/index.js"), ]); @@ -502,19 +514,6 @@ describe("Pi extension loading (--extension)", () => { assert.deepStrictEqual(args, []); }); - it("the subset still fails CLOSED when the A1 guard wrapper is missing on disk", () => { - assert.throws( - () => - resolvePiExtensionArgs({ - extensionsDir: FAKE_DIR, - env: {}, - exists: () => false, - relpaths: PI_SUBAGENT_CHILD_WRAPPER_RELPATHS, - }), - /guardian-protect-files\.ts[\s\S]*Refusing to spawn an unguarded/, - ); - }); - it("the subset still fails CLOSED when the A2 web-tools wrapper is missing on disk", () => { const presentWithoutWeb = (p: string): boolean => !p.includes("web-tools.ts"); assert.throws( @@ -525,7 +524,7 @@ describe("Pi extension loading (--extension)", () => { exists: presentWithoutWeb, relpaths: PI_SUBAGENT_CHILD_WRAPPER_RELPATHS, }), - /web-tools\.ts[\s\S]*Refusing to spawn an unguarded/, + /web-tools\.ts[\s\S]*Refusing to spawn without the expected first-party extensions/, ); }); @@ -533,9 +532,7 @@ describe("Pi extension loading (--extension)", () => { const args = buildPiSpawnArgs(testAgent, undefined, presentAll); assert.ok(args.includes("--no-extensions"), "ambient Pi extension discovery is always suppressed"); - // The three wrappers are present, one --extension flag each. - assert.strictEqual(args.filter((a) => a === "--extension").length, 3); - assert.ok(args.includes(wrapperAbs("guardian-protect-files.ts"))); + assert.strictEqual(args.filter((a) => a === "--extension").length, 2); assert.ok(args.includes(wrapperAbs("web-tools.ts"))); assert.ok(args.includes(wrapperAbs("subagent/index.ts"))); // Base args remain intact and precede the first --extension. @@ -571,7 +568,7 @@ describe("Pi extension loading (--extension)", () => { it("fails CLOSED (throws loudly) when a configured wrapper is missing on disk", () => { assert.throws( () => resolvePiExtensionArgs({ extensionsDir: FAKE_DIR, env: {}, exists: () => false }), - /Pi extension wrapper not found[\s\S]*Refusing to spawn an unguarded/, + /Pi extension wrapper not found[\s\S]*Refusing to spawn without the expected first-party extensions/, ); }); @@ -595,27 +592,27 @@ describe("Pi extension loading (--extension)", () => { assert.strictEqual(args[session + 1], "pi-sess-resume"); }); - // End-to-end smoke (real disk, no mocks): the resolver's fail-CLOSED contract + // End-to-end smoke (real disk, no mocks): the resolver's missing-wrapper contract // means resolvePiExtensionArgs() with NO overrides — real default dir - // (bot/.claude/extensions) + real fs.existsSync — only returns without throwing - // if all three A1-A3 wrapper files actually exist where a live Pi spawn expects + // (bot/.claude/extensions) + real fs.existsSync — returns without throwing + // only if all expected wrapper files exist where a live Pi spawn expects // them. This is the acceptance smoke that the mocked tests above cannot give: // it would catch a wrapper that was renamed, moved, or never copied. - it("smoke: a real Pi spawn resolves all three on-disk wrappers (A1 guard, A2 web-tools, A3 subagent)", () => { + it("smoke: a real Pi spawn resolves the expected on-disk wrappers", () => { // Override only the env (drop any ambient kill-switch) so the default dir + // default existsSync run against the real repo layout. const args = resolvePiExtensionArgs({ env: {} }); const flags = args.filter((a) => a === "--extension"); - assert.strictEqual(flags.length, 3, "expected one --extension per wrapper"); + assert.strictEqual(flags.length, 2, "expected one --extension per wrapper"); const paths = args.filter((a) => a !== "--extension"); - assert.strictEqual(paths.length, 3); + assert.strictEqual(paths.length, 2); for (const p of paths) { assert.ok(p.startsWith("/"), `wrapper path must be absolute: ${p}`); assert.ok(existsSync(p), `resolved wrapper must exist on disk: ${p}`); } - // Paths resolve to the canonical A1-A3 entrypoints, in load order. + // Paths resolve to the canonical entrypoints, in load order. assert.deepStrictEqual( paths.map((p) => p.split("/.claude/extensions/")[1]), [...PI_EXTENSION_WRAPPER_RELPATHS], @@ -664,8 +661,10 @@ describe("buildPiSpawnEnv", () => { "TELEGRAM_BOT_TOKEN", "MINIME_SESSION_SECRET", "MINIME_SCHEMA_PATH", - "MINIME_WORKSPACE_ROOT", "PI_GUARD_WORKSPACE_ROOT", + MINIME_CONFIG_PATH_ENV, + MINIME_CRONS_PATH_ENV, + MINIME_WORKSPACE_ROOT_ENV, ]; const oldValues = new Map(envKeys.map((key) => [key, process.env[key]])); @@ -689,10 +688,13 @@ describe("buildPiSpawnEnv", () => { process.env.TAVILY_API_KEY = "fixture"; process.env.TELEGRAM_BOT_TOKEN = "fixture"; process.env.MINIME_SESSION_SECRET = "fixture"; + process.env.MINIME_SCHEMA_PATH = "/tmp/schema.md"; + process.env.PI_GUARD_WORKSPACE_ROOT = "/tmp/guard-root"; process.env[MINIME_WORKSPACE_ROOT_ENV] = "/tmp"; - process.env[PI_GUARD_WORKSPACE_ROOT_ENV] = "/wrong-root"; + delete process.env[MINIME_CONFIG_PATH_ENV]; + delete process.env[MINIME_CRONS_PATH_ENV]; - const env = buildPiSpawnEnv(testAgent); + const env = buildPiSpawnEnv(); assert.strictEqual(env.CLAUDE_CODE_OAUTH_TOKEN, undefined); assert.strictEqual(env.ANTHROPIC_API_KEY, undefined); @@ -703,9 +705,11 @@ describe("buildPiSpawnEnv", () => { assert.strictEqual(env.TAVILY_API_KEY, undefined); assert.strictEqual(env.TELEGRAM_BOT_TOKEN, undefined); assert.strictEqual(env.MINIME_SESSION_SECRET, undefined); - assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], undefined); - assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], realpathSync("/tmp")); - assert.strictEqual(env[MINIME_SCHEMA_PATH_ENV], "/tmp/schema.md"); + assert.strictEqual(env.MINIME_SCHEMA_PATH, undefined); + assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, undefined); + assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], "/tmp"); + assert.strictEqual(env[MINIME_CONFIG_PATH_ENV], undefined); + assert.strictEqual(env[MINIME_CRONS_PATH_ENV], undefined); assert.strictEqual(env.ANTHROPIC_OAUTH_TOKEN, undefined); assert.strictEqual(env.AWS_ACCESS_KEY_ID, undefined); assert.strictEqual(env.AWS_SECRET_ACCESS_KEY, undefined); @@ -729,17 +733,53 @@ describe("buildPiSpawnEnv", () => { }); it("includes /opt/homebrew/bin in PATH", () => { - const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)); + const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv()); assert.ok(env.PATH?.includes("/opt/homebrew/bin")); }); + it("passes the non-secret control workspace contract to Pi children", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-spawn-env-control-contract-")); + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + const oldConfig = process.env[MINIME_CONFIG_PATH_ENV]; + const oldCrons = process.env[MINIME_CRONS_PATH_ENV]; + + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspaceRoot; + process.env[MINIME_CONFIG_PATH_ENV] = "settings/bot.yaml"; + process.env[MINIME_CRONS_PATH_ENV] = join(workspaceRoot, "ops", "crons.yaml"); + + const env = buildPiSpawnEnv(); + + assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], workspaceRoot); + assert.strictEqual(env[MINIME_CONFIG_PATH_ENV], "settings/bot.yaml"); + assert.strictEqual(env[MINIME_CRONS_PATH_ENV], join(workspaceRoot, "ops", "crons.yaml")); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + if (oldConfig === undefined) { + delete process.env[MINIME_CONFIG_PATH_ENV]; + } else { + process.env[MINIME_CONFIG_PATH_ENV] = oldConfig; + } + if (oldCrons === undefined) { + delete process.env[MINIME_CRONS_PATH_ENV]; + } else { + process.env[MINIME_CRONS_PATH_ENV] = oldCrons; + } + rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + it("does not double-prepend /opt/homebrew/bin when already present", () => { const oldPath = process.env.PATH; try { process.env.PATH = "/opt/homebrew/bin:/usr/bin"; - const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)); + const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv()); assert.strictEqual(env.PATH, "/opt/homebrew/bin:/usr/bin"); } finally { @@ -756,10 +796,10 @@ describe("buildPiSpawnEnv", () => { try { process.env.PATH = ""; - assert.strictEqual(withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)).PATH, "/opt/homebrew/bin"); + assert.strictEqual(withWorkspaceRoot("/tmp", () => buildPiSpawnEnv()).PATH, "/opt/homebrew/bin"); process.env.PATH = ":/usr/bin::/bin:"; - assert.strictEqual(withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)).PATH, "/opt/homebrew/bin:/usr/bin:/bin"); + assert.strictEqual(withWorkspaceRoot("/tmp", () => buildPiSpawnEnv()).PATH, "/opt/homebrew/bin:/usr/bin:/bin"); } finally { if (oldPath === undefined) { delete process.env.PATH; @@ -774,7 +814,7 @@ describe("buildPiSpawnEnv", () => { try { process.env.CLAUDECODE = "1"; - const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv(testAgent)); + const env = withWorkspaceRoot("/tmp", () => buildPiSpawnEnv()); assert.strictEqual(env.CLAUDECODE, undefined); } finally { @@ -786,160 +826,53 @@ describe("buildPiSpawnEnv", () => { } }); - it("fails closed when the agent workspace is outside the resolved workspace root", () => { - assert.throws( - () => withWorkspaceRoot("/tmp/minime-workspace", () => buildPiSpawnEnv(testAgent)), - /workspaceCwd must be inside the resolved workspace root/, - ); - }); - - it("fails closed when the agent workspace symlink resolves outside the workspace root", () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-spawn-env-root-")); - const externalWorkspace = mkdtempSync(join(tmpdir(), "pi-spawn-env-external-")); - const symlinkWorkspace = join(workspaceRoot, "agent-workspace"); - symlinkSync(externalWorkspace, symlinkWorkspace, "dir"); + it("allows an absolute agent workspace outside the control workspace root", () => { + const controlWorkspace = mkdtempSync(join(tmpdir(), "pi-spawn-env-control-")); try { - assert.throws( - () => withWorkspaceRoot(workspaceRoot, () => buildPiSpawnEnv({ ...testAgent, workspaceCwd: symlinkWorkspace })), - /workspaceCwd must be inside the resolved workspace root/, - ); - } finally { - rmSync(workspaceRoot, { recursive: true, force: true }); - rmSync(externalWorkspace, { recursive: true, force: true }); - } - }); - - it("replaces a stray PI_GUARD_WORKSPACE_ROOT with the resolved workspace root", () => { - const oldRoot = process.env.PI_GUARD_WORKSPACE_ROOT; - const oldWorkspace = process.env.MINIME_WORKSPACE_ROOT; - - try { - process.env[MINIME_WORKSPACE_ROOT_ENV] = "/tmp"; - process.env.PI_GUARD_WORKSPACE_ROOT = "/somewhere/else"; - const env = buildPiSpawnEnv(testAgent); + const resolved = withWorkspaceRoot(controlWorkspace, () => resolveValidatedPiAgentWorkspaceCwd(testAgent)); - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync("/tmp")); + assert.strictEqual(resolved, testAgent.workspaceCwd); } finally { - if (oldRoot === undefined) { - delete process.env.PI_GUARD_WORKSPACE_ROOT; - } else { - process.env.PI_GUARD_WORKSPACE_ROOT = oldRoot; - } - if (oldWorkspace === undefined) { - delete process.env.MINIME_WORKSPACE_ROOT; - } else { - process.env.MINIME_WORKSPACE_ROOT = oldWorkspace; - } + rmSync(controlWorkspace, { recursive: true, force: true }); } }); - it("canonicalizes PI_GUARD_WORKSPACE_ROOT to the workspace realpath", () => { + it("allows a symlinked agent workspace that resolves outside the control workspace root", () => { const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-spawn-env-root-")); - const parentDir = mkdtempSync(join(tmpdir(), "pi-spawn-env-link-parent-")); - const workspaceLink = join(parentDir, "workspace-link"); - symlinkSync(workspaceRoot, workspaceLink, "dir"); - const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; - - try { - mkdirSync(join(workspaceRoot, "agent-workspace"), { recursive: true }); - process.env[MINIME_WORKSPACE_ROOT_ENV] = workspaceLink; - const env = buildPiSpawnEnv({ ...testAgent, workspaceCwd: "./agent-workspace" }); - - assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], realpathSync(workspaceRoot)); - } finally { - if (oldWorkspace === undefined) { - delete process.env[MINIME_WORKSPACE_ROOT_ENV]; - } else { - process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; - } - rmSync(workspaceRoot, { recursive: true, force: true }); - rmSync(parentDir, { recursive: true, force: true }); - } - }); - - it("canonicalizes subagent child guard roots to realpaths", () => { - const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-subagent-env-root-")); - const parentDir = mkdtempSync(join(tmpdir(), "pi-subagent-env-link-parent-")); - const workspaceLink = join(parentDir, "workspace-link"); - symlinkSync(workspaceRoot, workspaceLink, "dir"); + const externalWorkspace = mkdtempSync(join(tmpdir(), "pi-spawn-env-external-")); + const symlinkWorkspace = join(workspaceRoot, "agent-workspace"); + symlinkSync(externalWorkspace, symlinkWorkspace, "dir"); try { - const env = buildPiSubagentChildSpawnEnv(workspaceLink); + const resolved = withWorkspaceRoot( + workspaceRoot, + () => resolveValidatedPiAgentWorkspaceCwd({ ...testAgent, workspaceCwd: symlinkWorkspace }), + ); - assert.strictEqual(env[PI_GUARD_WORKSPACE_ROOT_ENV], realpathSync(workspaceRoot)); + assert.strictEqual(resolved, symlinkWorkspace); } finally { rmSync(workspaceRoot, { recursive: true, force: true }); - rmSync(parentDir, { recursive: true, force: true }); - } - }); - - it("propagates an absolute MINIME_SCHEMA_PATH override to Pi guard processes", () => { - const oldWorkspace = process.env.MINIME_WORKSPACE_ROOT; - const oldSchema = process.env.MINIME_SCHEMA_PATH; - - try { - process.env.MINIME_WORKSPACE_ROOT = "/tmp"; - process.env.MINIME_SCHEMA_PATH = "/tmp/override-schema.md"; - const env = buildPiSpawnEnv(testAgent); - - assert.strictEqual(env.MINIME_SCHEMA_PATH, "/tmp/override-schema.md"); - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync("/tmp")); - } finally { - if (oldWorkspace === undefined) { - delete process.env.MINIME_WORKSPACE_ROOT; - } else { - process.env.MINIME_WORKSPACE_ROOT = oldWorkspace; - } - if (oldSchema === undefined) { - delete process.env.MINIME_SCHEMA_PATH; - } else { - process.env.MINIME_SCHEMA_PATH = oldSchema; - } + rmSync(externalWorkspace, { recursive: true, force: true }); } }); - it("resolves a relative MINIME_SCHEMA_PATH override before passing it to Pi guard processes", () => { - const oldWorkspace = process.env.MINIME_WORKSPACE_ROOT; - const oldSchema = process.env.MINIME_SCHEMA_PATH; - - try { - process.env.MINIME_WORKSPACE_ROOT = "/tmp"; - process.env.MINIME_SCHEMA_PATH = "schemas/override.md"; - const env = buildPiSpawnEnv(testAgent); - - assert.strictEqual(env.MINIME_SCHEMA_PATH, "/tmp/schemas/override.md"); - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync("/tmp")); - } finally { - if (oldWorkspace === undefined) { - delete process.env.MINIME_WORKSPACE_ROOT; - } else { - process.env.MINIME_WORKSPACE_ROOT = oldWorkspace; - } - if (oldSchema === undefined) { - delete process.env.MINIME_SCHEMA_PATH; - } else { - process.env.MINIME_SCHEMA_PATH = oldSchema; - } - } - }); }); describe("spawnPiRpcSession workspace validation", () => { - it("validates containment before assembling context artifacts", () => { + it("validates missing workspaceCwd before assembling context artifacts", () => { const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-spawn-root-")); - const externalWorkspace = mkdtempSync(join(tmpdir(), "pi-spawn-external-")); + const missingWorkspace = join(workspaceRoot, "missing-agent-workspace"); const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; - writeFileSync(join(externalWorkspace, "CLAUDE.md"), "# External\n\nBODY", "utf8"); try { process.env[MINIME_WORKSPACE_ROOT_ENV] = workspaceRoot; assert.throws( - () => spawnPiRpcSession({ ...testAgent, id: "external", workspaceCwd: externalWorkspace }), - /workspaceCwd must be inside the resolved workspace root/, + () => spawnPiRpcSession({ ...testAgent, id: "missing", workspaceCwd: missingWorkspace }), + /workspaceCwd does not exist/, ); - assert.ok(!existsSync(join(externalWorkspace, ".tmp")), "context artifacts must not be written before validation"); + assert.ok(!existsSync(join(missingWorkspace, ".tmp")), "context artifacts must not be written before validation"); } finally { if (oldWorkspace === undefined) { delete process.env[MINIME_WORKSPACE_ROOT_ENV]; @@ -947,13 +880,12 @@ describe("spawnPiRpcSession workspace validation", () => { process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; } rmSync(workspaceRoot, { recursive: true, force: true }); - rmSync(externalWorkspace, { recursive: true, force: true }); } }); }); describe("buildPiSubagentChildSpawnEnv", () => { - it("uses the same credential-scrubbed env as Pi spawns and pins the guard root", () => { + it("uses the same credential-scrubbed env as Pi spawns", () => { const envKeys = [ "ANTHROPIC_API_KEY", "GITHUB_TOKEN", @@ -961,10 +893,12 @@ describe("buildPiSubagentChildSpawnEnv", () => { "OPENAI_API_KEY", "PATH", "PI_CODING_AGENT_SESSION_DIR", - "PI_GUARD_WORKSPACE_ROOT", "SSH_AUTH_SOCK", "TAVILY_API_KEY", "TELEGRAM_BOT_TOKEN", + MINIME_CONFIG_PATH_ENV, + MINIME_CRONS_PATH_ENV, + MINIME_WORKSPACE_ROOT_ENV, ]; const oldValues = new Map(envKeys.map((key) => [key, process.env[key]])); @@ -975,28 +909,67 @@ describe("buildPiSubagentChildSpawnEnv", () => { process.env.OPENAI_API_KEY = "fixture"; process.env.PATH = "/usr/bin"; process.env.PI_CODING_AGENT_SESSION_DIR = "/tmp/pi-sessions"; - process.env.PI_GUARD_WORKSPACE_ROOT = "/wrong/root"; process.env.SSH_AUTH_SOCK = "/tmp/ssh-agent.sock"; process.env.TAVILY_API_KEY = "fixture"; process.env.TELEGRAM_BOT_TOKEN = "fixture"; - const guardRoot = mkdtempSync(join(tmpdir(), "pi-subagent-guard-root-")); - - try { - const env = buildPiSubagentChildSpawnEnv(guardRoot); - - assert.strictEqual(env.PI_GUARD_WORKSPACE_ROOT, realpathSync(guardRoot)); - assert.strictEqual(env.PI_CODING_AGENT_SESSION_DIR, "/tmp/pi-sessions"); - assert.strictEqual(env.LC_CTYPE, "UTF-8"); - assert.strictEqual(env.PATH, "/opt/homebrew/bin:/usr/bin"); - assert.strictEqual(env.ANTHROPIC_API_KEY, undefined); - assert.strictEqual(env.GITHUB_TOKEN, undefined); - assert.strictEqual(env.OPENAI_API_KEY, undefined); - assert.strictEqual(env.SSH_AUTH_SOCK, undefined); - assert.strictEqual(env.TAVILY_API_KEY, undefined); - assert.strictEqual(env.TELEGRAM_BOT_TOKEN, undefined); - } finally { - rmSync(guardRoot, { recursive: true, force: true }); + process.env[MINIME_WORKSPACE_ROOT_ENV] = "/tmp"; + delete process.env[MINIME_CONFIG_PATH_ENV]; + delete process.env[MINIME_CRONS_PATH_ENV]; + + const env = buildPiSubagentChildSpawnEnv(); + + assert.strictEqual(env.PI_CODING_AGENT_SESSION_DIR, "/tmp/pi-sessions"); + assert.strictEqual(env.LC_CTYPE, "UTF-8"); + assert.strictEqual(env.PATH, "/opt/homebrew/bin:/usr/bin"); + assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], "/tmp"); + assert.strictEqual(env[MINIME_CONFIG_PATH_ENV], undefined); + assert.strictEqual(env[MINIME_CRONS_PATH_ENV], undefined); + assert.strictEqual(env.ANTHROPIC_API_KEY, undefined); + assert.strictEqual(env.GITHUB_TOKEN, undefined); + assert.strictEqual(env.OPENAI_API_KEY, undefined); + assert.strictEqual(env.SSH_AUTH_SOCK, undefined); + assert.strictEqual(env.TAVILY_API_KEY, undefined); + assert.strictEqual(env.TELEGRAM_BOT_TOKEN, undefined); + } finally { + for (const key of envKeys) { + const oldValue = oldValues.get(key); + if (oldValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = oldValue; + } } + } + }); + + it("propagates explicit control config and crons path overrides", () => { + const workspaceRoot = mkdtempSync(join(tmpdir(), "pi-subagent-child-env-control-contract-")); + const envKeys = [ + "DISCORD_BOT_TOKEN", + "TAVILY_API_KEY", + "TELEGRAM_BOT_TOKEN", + MINIME_CONFIG_PATH_ENV, + MINIME_CRONS_PATH_ENV, + MINIME_WORKSPACE_ROOT_ENV, + ]; + const oldValues = new Map(envKeys.map((key) => [key, process.env[key]])); + + try { + process.env.DISCORD_BOT_TOKEN = "fixture"; + process.env.TAVILY_API_KEY = "fixture"; + process.env.TELEGRAM_BOT_TOKEN = "fixture"; + process.env[MINIME_WORKSPACE_ROOT_ENV] = workspaceRoot; + process.env[MINIME_CONFIG_PATH_ENV] = "settings/bot.yaml"; + process.env[MINIME_CRONS_PATH_ENV] = join(workspaceRoot, "ops", "crons.yaml"); + + const env = buildPiSubagentChildSpawnEnv(); + + assert.strictEqual(env[MINIME_WORKSPACE_ROOT_ENV], workspaceRoot); + assert.strictEqual(env[MINIME_CONFIG_PATH_ENV], "settings/bot.yaml"); + assert.strictEqual(env[MINIME_CRONS_PATH_ENV], join(workspaceRoot, "ops", "crons.yaml")); + assert.strictEqual(env.DISCORD_BOT_TOKEN, undefined); + assert.strictEqual(env.TAVILY_API_KEY, undefined); + assert.strictEqual(env.TELEGRAM_BOT_TOKEN, undefined); } finally { for (const key of envKeys) { const oldValue = oldValues.get(key); @@ -1006,6 +979,7 @@ describe("buildPiSubagentChildSpawnEnv", () => { process.env[key] = oldValue; } } + rmSync(workspaceRoot, { recursive: true, force: true }); } }); }); diff --git a/bot/src/__tests__/pi-rpc-spawn-workspaces.test.ts b/bot/src/__tests__/pi-rpc-spawn-workspaces.test.ts new file mode 100644 index 00000000..81c914b8 --- /dev/null +++ b/bot/src/__tests__/pi-rpc-spawn-workspaces.test.ts @@ -0,0 +1,193 @@ +import { describe, it, after, afterEach, mock } from "node:test"; +import assert from "node:assert/strict"; +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { loadConfig } from "../config.js"; +import { MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; +import type { ExecFileSyncLike } from "../secrets.js"; + +interface SpawnCapture { + command: string; + args: string[]; + options: SpawnOptions; +} + +const fixtures: string[] = []; +const spawnCaptures: SpawnCapture[] = []; + +mock.module("node:child_process", { + namedExports: { + spawn(command: string, args: readonly string[], options: SpawnOptions): ChildProcess { + spawnCaptures.push({ command, args: [...args], options }); + return createMockChild(); + }, + }, +}); + +const { + PI_EXTENSIONS_DISABLED_ENV, + spawnPiRpcSession, +} = await import("../pi-rpc-protocol.js"); + +after(() => { + for (const fixture of fixtures) { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +afterEach(() => { + spawnCaptures.length = 0; +}); + +function createMockChild(): ChildProcess { + const child = new EventEmitter() as ChildProcess; + Object.assign(child, { + stdout: new Readable({ read() {} }), + stderr: new Readable({ read() {} }), + stdin: new Writable({ write(_chunk, _enc, callback) { callback(); } }), + pid: 12345, + exitCode: null, + signalCode: null, + killed: false, + kill() { + (child as unknown as { killed: boolean }).killed = true; + return true; + }, + }); + return child; +} + +function flagValue(args: readonly string[], flag: string): string { + const index = args.indexOf(flag); + assert.notEqual(index, -1, `missing ${flag}`); + return args[index + 1]; +} + +describe("Pi spawn workspace contract", () => { + it("uses per-agent sibling workspace cwd/context while reading config secrets from the control workspace", () => { + const root = mkdtempSync(join(tmpdir(), "minime-pi-spawn-workspaces-")); + fixtures.push(root); + const controlWorkspace = join(root, "control-workspace"); + const mainWorkspace = join(root, "agent-workspace-main"); + const reviewerWorkspace = join(root, "agent-workspace-reviewer"); + const controlSecretsFile = join(controlWorkspace, "config", "secrets.sops.yaml"); + mkdirSync(dirname(controlSecretsFile), { recursive: true }); + mkdirSync(mainWorkspace, { recursive: true }); + mkdirSync(reviewerWorkspace, { recursive: true }); + writeFileSync(controlSecretsFile, "telegram:\n bot_token: ENC[AES256_GCM,data:test]\n", "utf8"); + writeFileSync(join(mainWorkspace, "CLAUDE.md"), "# Main\n\nMAIN_CONTEXT_TOKEN", "utf8"); + writeFileSync(join(reviewerWorkspace, "CLAUDE.md"), "# Reviewer\n\nREVIEWER_CONTEXT_TOKEN", "utf8"); + writeFileSync( + join(controlWorkspace, "config.yaml"), + [ + "secrets:", + " sopsFile: config/secrets.sops.yaml", + "telegramTokenSopsKey: telegram.bot_token", + "agents:", + " main:", + ` workspaceCwd: ${mainWorkspace}`, + " model: gpt-5.5", + " reviewer:", + ` workspaceCwd: ${reviewerWorkspace}`, + " model: gpt-5.5", + "bindings:", + " - chatId: 111", + " agentId: main", + " kind: dm", + " - chatId: 222", + " agentId: reviewer", + " kind: dm", + "", + ].join("\n"), + "utf8", + ); + + const secretReads: string[] = []; + const fakeSops: ExecFileSyncLike = (_file, args) => { + secretReads.push(args[args.length - 1]); + return "resolved-telegram-token\n"; + }; + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + const oldDisabled = process.env[PI_EXTENSIONS_DISABLED_ENV]; + const secretEnvKeys = [ + ["TELEGRAM", "BOT", "TOKEN"].join("_"), + ["DISCORD", "BOT", "TOKEN"].join("_"), + ["TAVILY", "API", "KEY"].join("_"), + ] as const; + const oldSecretValues = new Map(secretEnvKeys.map((key) => [key, process.env[key]])); + const fixtureValues = [ + "parent-telegram-fixture", + "parent-discord-fixture", + "parent-tavily-fixture", + "resolved-telegram-token", + ]; + + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = controlWorkspace; + process.env[PI_EXTENSIONS_DISABLED_ENV] = "1"; + process.env[secretEnvKeys[0]] = fixtureValues[0]; + process.env[secretEnvKeys[1]] = fixtureValues[1]; + process.env[secretEnvKeys[2]] = fixtureValues[2]; + const config = loadConfig(join(controlWorkspace, "config.yaml"), { + workspaceRoot: controlWorkspace, + secretExecFileSync: fakeSops, + }); + + assert.equal(config.agents.main.workspaceCwd, mainWorkspace); + assert.equal(config.agents.reviewer.workspaceCwd, reviewerWorkspace); + assert.deepEqual(secretReads, [controlSecretsFile]); + + spawnPiRpcSession(config.agents.main); + spawnPiRpcSession(config.agents.reviewer); + + assert.equal(spawnCaptures.length, 2); + assert.equal(spawnCaptures[0].command, "pi"); + assert.equal(spawnCaptures[0].options.cwd, mainWorkspace); + assert.equal(spawnCaptures[1].options.cwd, reviewerWorkspace); + for (const capture of spawnCaptures) { + const env = capture.options.env as NodeJS.ProcessEnv; + assert.equal(env[MINIME_WORKSPACE_ROOT_ENV], controlWorkspace); + const serializedChildContract = JSON.stringify({ env, args: capture.args }); + for (const value of fixtureValues) { + assert.doesNotMatch(serializedChildContract, new RegExp(value)); + } + } + + const mainBundle = readFileSync(flagValue(spawnCaptures[0].args, "--append-system-prompt"), "utf8"); + const reviewerBundle = readFileSync(flagValue(spawnCaptures[1].args, "--append-system-prompt"), "utf8"); + assert.match(mainBundle, /MAIN_CONTEXT_TOKEN/); + assert.doesNotMatch(mainBundle, /REVIEWER_CONTEXT_TOKEN/); + assert.match(reviewerBundle, /REVIEWER_CONTEXT_TOKEN/); + assert.doesNotMatch(reviewerBundle, /MAIN_CONTEXT_TOKEN/); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + if (oldDisabled === undefined) { + delete process.env[PI_EXTENSIONS_DISABLED_ENV]; + } else { + process.env[PI_EXTENSIONS_DISABLED_ENV] = oldDisabled; + } + for (const key of secretEnvKeys) { + const oldValue = oldSecretValues.get(key); + if (oldValue === undefined) { + delete process.env[key]; + } else { + process.env[key] = oldValue; + } + } + } + }); +}); diff --git a/bot/src/__tests__/safety-hooks.test.ts b/bot/src/__tests__/safety-hooks.test.ts deleted file mode 100644 index 3ccba28a..00000000 --- a/bot/src/__tests__/safety-hooks.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { describe, it, beforeEach, afterEach } from "node:test"; -import assert from "node:assert/strict"; -import { execSync, type ExecSyncOptions } from "node:child_process"; -import { mkdirSync, writeFileSync, rmSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { tmpdir } from "node:os"; - -// Paths to hook scripts (relative to repo root) -const REPO_ROOT = resolve(import.meta.dirname, "../../.."); -const PROTECT_FILES = join(REPO_ROOT, ".claude/hooks/protect-files.sh"); -const GUARDIAN = join(REPO_ROOT, ".claude/hooks/guardian.sh"); - -// Temp workspace for guardian tests -const TMP_WORKSPACE = join(tmpdir(), "guardian-test-workspace"); - -function runHook( - hookPath: string, - input: object, - env: Record = {}, -): { exitCode: number; stderr: string } { - return runHookRaw(hookPath, JSON.stringify(input), env); -} - -function runHookRaw( - hookPath: string, - rawInput: string, - env: Record = {}, -): { exitCode: number; stderr: string } { - const opts: ExecSyncOptions = { - input: rawInput, - env: { ...process.env, ...env }, - encoding: "utf-8" as const, - stdio: ["pipe", "pipe", "pipe"], - }; - try { - execSync(`bash "${hookPath}"`, opts); - return { exitCode: 0, stderr: "" }; - } catch (e: unknown) { - const err = e as { status: number; stderr: string }; - return { exitCode: err.status, stderr: err.stderr || "" }; - } -} - -// ------------------------------------------------------------------- -// protect-files.sh -// ------------------------------------------------------------------- - -describe("protect-files.sh", () => { - const PROTECT_ENV = { CLAUDE_PROJECT_DIR: "/workspace" }; - - it("allows when CRON_NAME is not set", () => { - const result = runHook(PROTECT_FILES, { - tool_name: "Edit", - tool_input: { file_path: "/workspace/.claude/skills/foo/SKILL.md" }, - }, PROTECT_ENV); - assert.equal(result.exitCode, 0); - }); - - it("allows non-skill files even with CRON_NAME set", () => { - const result = runHook( - PROTECT_FILES, - { - tool_name: "Write", - tool_input: { file_path: "/workspace/memory/notes.md" }, - }, - { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, - ); - assert.equal(result.exitCode, 0); - }); - - it("blocks cron from writing to .claude/skills/", () => { - const result = runHook( - PROTECT_FILES, - { - tool_name: "Write", - tool_input: { - file_path: "/workspace/.claude/skills/workspace-health/SKILL.md", - }, - }, - { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("Blocked")); - assert.ok(result.stderr.includes("nightly-consolidation")); - }); - - it("blocks cron from editing .claude/skills/", () => { - const result = runHook( - PROTECT_FILES, - { - tool_name: "Edit", - tool_input: { - file_path: "/workspace/.claude/skills/workspace-health/SKILL.md", - }, - }, - { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("Blocked")); - assert.ok(result.stderr.includes("nightly-consolidation")); - }); - - it("allows when file_path is empty", () => { - const result = runHook(PROTECT_FILES, { - tool_name: "Write", - tool_input: {}, - }, PROTECT_ENV); - assert.equal(result.exitCode, 0); - }); - - it("blocks on malformed JSON input (fail-closed)", () => { - const result = runHookRaw(PROTECT_FILES, "{", PROTECT_ENV); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("failed to parse")); - }); - - it("blocks cron even with /./ in path (path normalization)", () => { - const result = runHook( - PROTECT_FILES, - { - tool_name: "Write", - tool_input: { - file_path: - "/workspace/.claude/./skills/workspace-health/SKILL.md", - }, - }, - { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("Blocked")); - }); - - it("blocks cron with .. path bypass attempt", () => { - const result = runHook( - PROTECT_FILES, - { - tool_name: "Write", - tool_input: { - file_path: - "/workspace/.claude/notskills/../skills/workspace-health/SKILL.md", - }, - }, - { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("Blocked")); - }); - - it("blocks cron with relative path (no leading slash)", () => { - const result = runHook( - PROTECT_FILES, - { - tool_name: "Write", - tool_input: { - file_path: ".claude/skills/workspace-health/SKILL.md", - }, - }, - { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("Blocked")); - }); - - it("blocks cron with // path bypass attempt", () => { - const result = runHook( - PROTECT_FILES, - { - tool_name: "Write", - tool_input: { - file_path: - "/workspace/.claude//skills/workspace-health/SKILL.md", - }, - }, - { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("Blocked")); - }); -}); - -// ------------------------------------------------------------------- -// guardian.sh -// ------------------------------------------------------------------- - -describe("guardian.sh", () => { - beforeEach(() => { - rmSync(TMP_WORKSPACE, { recursive: true, force: true }); - mkdirSync(TMP_WORKSPACE, { recursive: true }); - - // Schema-driven write allow-list — the model guardian.sh now enforces (the - // ```write-allowlist``` fenced block in schema.md, replacing the legacy - // orphan-allowlist.txt root-component model). A comment line exercises the - // hook's #-comment stripping. - writeFileSync( - join(TMP_WORKSPACE, "schema.md"), - [ - "# Test schema", - "", - "```write-allowlist", - "memory/ # narrative + auto memory", - "reference/", - "*.md", - ".claude/", - "```", - ].join("\n"), - ); - - // Create an existing file for overwrite tests - mkdirSync(join(TMP_WORKSPACE, "bot/src"), { recursive: true }); - writeFileSync(join(TMP_WORKSPACE, "bot/src/existing.ts"), "export {};"); - }); - - afterEach(() => { - rmSync(TMP_WORKSPACE, { recursive: true, force: true }); - }); - - it("allows Edit tool unconditionally", () => { - const result = runHook( - GUARDIAN, - { - tool_name: "Edit", - tool_input: { file_path: join(TMP_WORKSPACE, "anything/file.ts") }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 0); - }); - - it("allows overwrite of existing file", () => { - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { - file_path: join(TMP_WORKSPACE, "bot/src/existing.ts"), - }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 0); - }); - - it("allows new file in listed root location", () => { - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { file_path: join(TMP_WORKSPACE, "memory/notes.md") }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 0); - }); - - it("allows new file matching glob pattern", () => { - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { file_path: join(TMP_WORKSPACE, "CHANGELOG.md") }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 0); - }); - - it("blocks new file in unlisted root location", () => { - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { - file_path: join(TMP_WORKSPACE, "rogue-dir/evil.sh"), - }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("BLOCKED")); - assert.ok(result.stderr.includes("rogue-dir")); - }); - - it("blocks path traversal attempts", () => { - // Use string concatenation to preserve ".." (join() would resolve it) - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { - file_path: TMP_WORKSPACE + "/memory/../evil/file.txt", - }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("traversal")); - }); - - it("blocks traversal even when resolved target exists", () => { - // bot/src/existing.ts exists, but the path uses ".." — must still block - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { - file_path: TMP_WORKSPACE + "/bot/../bot/src/existing.ts", - }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("traversal")); - }); - - it("allows files outside workspace", () => { - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { file_path: "/tmp/some-other-place/file.txt" }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 0); - }); - - it("blocks Write without file_path", () => { - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: {}, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("Write tool called without file_path")); - }); - - it("blocks when CLAUDE_PROJECT_DIR is not set", () => { - // Explicitly set CLAUDE_PROJECT_DIR to empty to ensure it's unset, - // regardless of what the test runner's process.env contains. - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { file_path: "/workspace/some/file.txt" }, - }, - { CLAUDE_PROJECT_DIR: "" }, - ); - // Should block because CLAUDE_PROJECT_DIR is empty - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("CLAUDE_PROJECT_DIR not set")); - }); - - it("blocks (fail-closed) when schema.md is missing", () => { - // Remove the schema.md allow-list from workspace root — the new fail-safe: - // security never relaxes, the allow-check fails CLOSED with an actionable msg. - rmSync(join(TMP_WORKSPACE, "schema.md")); - const result = runHook( - GUARDIAN, - { - tool_name: "Write", - tool_input: { file_path: join(TMP_WORKSPACE, "memory/test.md") }, - }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("fail-closed")); - assert.ok(result.stderr.includes("schema.md")); - }); - - it("blocks when tool_name cannot be parsed", () => { - const result = runHook( - GUARDIAN, - { tool_input: { file_path: "/some/path" } }, - { CLAUDE_PROJECT_DIR: TMP_WORKSPACE }, - ); - assert.equal(result.exitCode, 2); - assert.ok(result.stderr.includes("could not parse tool_name")); - }); -}); diff --git a/bot/src/__tests__/schema-guard-contract-check.test.ts b/bot/src/__tests__/schema-guard-contract-check.test.ts new file mode 100644 index 00000000..7513936c --- /dev/null +++ b/bot/src/__tests__/schema-guard-contract-check.test.ts @@ -0,0 +1,69 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BOT_ROOT = resolve(__dirname, "..", ".."); +const CHECK_SCRIPT = join(BOT_ROOT, "scripts", "check-no-active-schema-guard-contract.mjs"); +const fixtures: string[] = []; + +after(() => { + for (const fixture of fixtures) { + rmSync(fixture, { recursive: true, force: true }); + } +}); + +function makeFixture(): string { + const root = mkdtempSync(join(tmpdir(), "schema-guard-contract-check-")); + fixtures.push(root); + return root; +} + +function writeFixture(root: string, relpath: string, content: string): void { + const path = join(root, relpath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content, "utf8"); +} + +function runCheck(root: string) { + return spawnSync(process.execPath, [CHECK_SCRIPT, root], { + cwd: BOT_ROOT, + encoding: "utf8", + }); +} + +describe("check-no-active-schema-guard-contract", () => { + it("fails active prose that still claims cron guard-extension loading", () => { + const root = makeFixture(); + writeFixture(root, "crons.yaml", "# LLM crons use only the explicit A1 guard extension.\n"); + + const result = runCheck(root); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, /A1 guard extension/); + }); + + it("fails active source comments that still claim A1 write guard behavior", () => { + const root = makeFixture(); + writeFixture(root, join("bot", "src", "pi-extensions", "subagent-args.ts"), "// Loads the A1 write guard.\n"); + + const result = runCheck(root); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, /A1 write guard/); + }); + + it("allows retired-context prose and explicitly historical plan paths", () => { + const root = makeFixture(); + writeFixture(root, "README.md", "schema.md is retired and no longer required for runtime correctness.\n"); + writeFixture(root, join("docs", "plans", "historical.md"), "guardian-protect-files was removed here.\n"); + + const result = runCheck(root); + + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + }); +}); diff --git a/bot/src/__tests__/subagent.test.ts b/bot/src/__tests__/subagent.test.ts index 3392d708..e689a8e2 100644 --- a/bot/src/__tests__/subagent.test.ts +++ b/bot/src/__tests__/subagent.test.ts @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseFrontmatter } from "@earendil-works/pi-coding-agent"; +import { buildPiSubagentChildSpawnEnv } from "../pi-rpc-protocol.js"; import { accumulateAssistantUsage, buildSubagentSpawnArgs, @@ -23,7 +24,9 @@ import { type SubagentMessage, type SubagentReadableLike, type SubagentSpawn, + type SubagentSpawnOptions, } from "../pi-extensions/subagent-args.js"; +import { MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BOT_DIR = resolve(__dirname, "..", ".."); @@ -107,7 +110,6 @@ describe("subagent: buildSubagentSpawnArgs", () => { it("injects the child's --extension args before the task", () => { const extensionArgs = [ - "--extension", "/abs/guardian-protect-files.ts", "--extension", "/abs/web-tools.ts", ]; const args = buildSubagentSpawnArgs({}, "delegate", { extensionArgs }); @@ -145,9 +147,92 @@ describe("subagent: wrapper spawn environment", () => { it("uses the scrubbed subagent-child env helper instead of copying process.env", () => { const wrapper = readFileSync(resolve(BOT_DIR, ".claude", "extensions", "subagent", "index.ts"), "utf8"); - assert.match(wrapper, /buildPiSubagentChildSpawnEnv\(guardWorkspaceRoot\)/); + assert.match(wrapper, /buildPiSubagentChildSpawnEnv\(\)/); assert.doesNotMatch(wrapper, /env:\s*\{\s*\.{3}process\.env/); }); + + it("keeps caller-selected child cwd separate from the control workspace env", async () => { + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; + const controlWorkspace = "/control/workspace"; + const callerCwd = "/caller/selected/subagent-cwd"; + + try { + process.env[MINIME_WORKSPACE_ROOT_ENV] = controlWorkspace; + const env = buildPiSubagentChildSpawnEnv(); + const { child, calls, spawn } = setupRunner(); + const promise = runSubagentChild({ + spawn, + command: "pi", + args: ["-p", "delegate"], + cwd: callerCwd, + env, + agentName: "worker", + }); + child.emitClose(0); + await promise; + + assert.equal(env[MINIME_WORKSPACE_ROOT_ENV], controlWorkspace); + assert.deepEqual(calls, [ + { + command: "pi", + args: ["-p", "delegate"], + options: { + cwd: callerCwd, + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }, + }, + ]); + } finally { + if (oldWorkspace === undefined) { + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + } else { + process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; + } + } + }); + + it("does not inject ambient bot or Tavily secrets into subagent child argv", () => { + const telegramTokenEnv = ["TELEGRAM", "BOT", "TOKEN"].join("_"); + const discordTokenEnv = ["DISCORD", "BOT", "TOKEN"].join("_"); + const tavilyKeyEnv = ["TAVILY", "API", "KEY"].join("_"); + const oldTelegram = process.env[telegramTokenEnv]; + const oldDiscord = process.env[discordTokenEnv]; + const oldTavily = process.env[tavilyKeyEnv]; + const fixtureValues = ["subagent-telegram-fixture", "subagent-discord-fixture", "subagent-tavily-fixture"]; + + try { + process.env[telegramTokenEnv] = fixtureValues[0]; + process.env[discordTokenEnv] = fixtureValues[1]; + process.env[tavilyKeyEnv] = fixtureValues[2]; + + const args = buildSubagentSpawnArgs({}, "delegate safely", { + extensionArgs: ["--extension", "/abs/web-tools.ts"], + }); + const serializedArgs = JSON.stringify(args); + + for (const value of fixtureValues) { + assert.doesNotMatch(serializedArgs, new RegExp(value)); + } + } finally { + if (oldTelegram === undefined) { + delete process.env[telegramTokenEnv]; + } else { + process.env[telegramTokenEnv] = oldTelegram; + } + if (oldDiscord === undefined) { + delete process.env[discordTokenEnv]; + } else { + process.env[discordTokenEnv] = oldDiscord; + } + if (oldTavily === undefined) { + delete process.env[tavilyKeyEnv]; + } else { + process.env[tavilyKeyEnv] = oldTavily; + } + } + }); }); describe("subagent: parseSubagentEventLine", () => { @@ -274,7 +359,7 @@ class FakeChild implements SubagentChildLike { interface SpawnRecord { command: string; args: string[]; - cwd?: string; + options: SubagentSpawnOptions; } function setupRunner() { @@ -282,7 +367,7 @@ function setupRunner() { const calls: SpawnRecord[] = []; const warns: SubagentChildErrorWarn[] = []; const spawn: SubagentSpawn = (command, args, options) => { - calls.push({ command, args, cwd: options.cwd }); + calls.push({ command, args, options }); return child; }; return { child, calls, warns, spawn }; @@ -310,7 +395,18 @@ describe("subagent: runSubagentChild (mock spawn)", () => { child.emitClose(0); const result = await promise; - assert.deepEqual(calls, [{ command: "pi", args: ["--mode", "json", "-p"], cwd: "/work" }]); + assert.deepEqual(calls, [ + { + command: "pi", + args: ["--mode", "json", "-p"], + options: { + cwd: "/work", + env: undefined, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }, + }, + ]); assert.equal(result.exitCode, 0); assert.equal(result.aborted, false); assert.equal(getFinalOutput(result.messages), "done"); diff --git a/bot/src/__tests__/tavily.test.ts b/bot/src/__tests__/tavily.test.ts index 142ec013..cde1c138 100644 --- a/bot/src/__tests__/tavily.test.ts +++ b/bot/src/__tests__/tavily.test.ts @@ -1,6 +1,6 @@ import { describe, it, mock } from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -25,11 +25,13 @@ import { } from "../pi-extensions/tavily.js"; import { readTavilyApiKeyFromSops, + tavilyControlWorkspaceRoot, tavilySopsFilePath, TAVILY_SOPS_FILE_RELPATH, TAVILY_SOPS_KEY, } from "../pi-extensions/tavily-secret.js"; import type { ExecFileSyncLike } from "../secrets.js"; +import { MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; const KEY = "tvly-test-key"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -561,14 +563,14 @@ describe("tavily: executeWebFetch", () => { }); describe("tavily: SOPS API key lookup", () => { - it("resolves config/secrets.sops.yaml relative to the Pi session cwd", () => { + it("resolves config/secrets.sops.yaml relative to the control workspace", () => { assert.equal(tavilySopsFilePath("/workspace"), "/workspace/config/secrets.sops.yaml"); }); - it("reads tavily.api_key from the workspace SOPS file", () => { - const tmpDir = mkdtempSync(join(tmpdir(), "tavily-secret-test-")); - mkdirSync(join(tmpDir, "config")); - writeFileSync(join(tmpDir, TAVILY_SOPS_FILE_RELPATH), "placeholder: true\n", "utf8"); + it("reads tavily.api_key from the control-workspace SOPS file", () => { + const controlWorkspace = mkdtempSync(join(tmpdir(), "tavily-secret-control-test-")); + mkdirSync(join(controlWorkspace, "config")); + writeFileSync(join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH), "placeholder: true\n", "utf8"); const calls: Array<{ file: string; args: readonly string[] }> = []; const execFileSync: ExecFileSyncLike = (file, args) => { calls.push({ file, args }); @@ -577,22 +579,92 @@ describe("tavily: SOPS API key lookup", () => { try { const value = readTavilyApiKeyFromSops({ - cwd: tmpDir, + controlWorkspaceRoot: controlWorkspace, execFileSync, }); assert.equal(value, "tvly-from-sops"); assert.deepEqual(calls, [{ file: "sops", - args: ["-d", "--extract", '["tavily"]["api_key"]', join(tmpDir, TAVILY_SOPS_FILE_RELPATH)], + args: ["-d", "--extract", '["tavily"]["api_key"]', join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH)], }]); } finally { - rmSync(tmpDir, { recursive: true, force: true }); + rmSync(controlWorkspace, { recursive: true, force: true }); } }); - it("returns undefined when the SOPS file is missing without invoking sops", () => { - const tmpDir = mkdtempSync(join(tmpdir(), "tavily-secret-missing-test-")); + it("uses MINIME_WORKSPACE_ROOT as the control-workspace secret contract", () => { + const controlWorkspace = mkdtempSync(join(tmpdir(), "tavily-secret-env-control-")); + const agentWorkspace = mkdtempSync(join(tmpdir(), "tavily-secret-env-agent-")); + mkdirSync(join(controlWorkspace, "config")); + writeFileSync(join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH), "placeholder: true\n", "utf8"); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const execFileSync: ExecFileSyncLike = (file, args) => { + calls.push({ file, args }); + return "tvly-from-control-env\n"; + }; + const oldCwd = process.cwd(); + + try { + process.chdir(agentWorkspace); + const value = readTavilyApiKeyFromSops({ + env: { [MINIME_WORKSPACE_ROOT_ENV]: controlWorkspace }, + execFileSync, + }); + + assert.equal(value, "tvly-from-control-env"); + assert.equal(tavilyControlWorkspaceRoot({ [MINIME_WORKSPACE_ROOT_ENV]: controlWorkspace }), controlWorkspace); + assert.deepEqual(calls, [{ + file: "sops", + args: ["-d", "--extract", '["tavily"]["api_key"]', join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH)], + }]); + } finally { + process.chdir(oldCwd); + rmSync(controlWorkspace, { recursive: true, force: true }); + rmSync(agentWorkspace, { recursive: true, force: true }); + } + }); + + it("uses the same control-workspace Tavily secret reference from different agent workspaces", () => { + const controlWorkspace = mkdtempSync(join(tmpdir(), "tavily-secret-shared-control-")); + const agentOne = mkdtempSync(join(tmpdir(), "tavily-secret-agent-one-")); + const agentTwo = mkdtempSync(join(tmpdir(), "tavily-secret-agent-two-")); + mkdirSync(join(controlWorkspace, "config")); + writeFileSync(join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH), "placeholder: true\n", "utf8"); + const calls: Array<{ file: string; args: readonly string[] }> = []; + const execFileSync: ExecFileSyncLike = (file, args) => { + calls.push({ file, args }); + return "tvly-shared-control\n"; + }; + const oldCwd = process.cwd(); + + try { + process.chdir(agentOne); + assert.equal( + readTavilyApiKeyFromSops({ env: { [MINIME_WORKSPACE_ROOT_ENV]: controlWorkspace }, execFileSync }), + "tvly-shared-control", + ); + + process.chdir(agentTwo); + assert.equal( + readTavilyApiKeyFromSops({ env: { [MINIME_WORKSPACE_ROOT_ENV]: controlWorkspace }, execFileSync }), + "tvly-shared-control", + ); + + assert.deepEqual( + calls.map((call) => call.args[3]), + [join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH), join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH)], + ); + } finally { + process.chdir(oldCwd); + rmSync(controlWorkspace, { recursive: true, force: true }); + rmSync(agentOne, { recursive: true, force: true }); + rmSync(agentTwo, { recursive: true, force: true }); + } + }); + + it("returns undefined when the control SOPS file is missing without invoking sops", () => { + const controlWorkspace = mkdtempSync(join(tmpdir(), "tavily-secret-missing-test-")); const calls: Array<{ file: string; args: readonly string[] }> = []; const execFileSync: ExecFileSyncLike = (file, args) => { calls.push({ file, args }); @@ -600,10 +672,38 @@ describe("tavily: SOPS API key lookup", () => { }; try { - assert.equal(readTavilyApiKeyFromSops({ cwd: tmpDir, execFileSync }), undefined); + assert.equal(readTavilyApiKeyFromSops({ controlWorkspaceRoot: controlWorkspace, execFileSync }), undefined); assert.equal(calls.length, 0); } finally { - rmSync(tmpDir, { recursive: true, force: true }); + rmSync(controlWorkspace, { recursive: true, force: true }); + } + }); + + it("returns undefined without invoking sops when MINIME_WORKSPACE_ROOT is absent", () => { + const calls: Array<{ file: string; args: readonly string[] }> = []; + const execFileSync: ExecFileSyncLike = (file, args) => { + calls.push({ file, args }); + return "should-not-run\n"; + }; + + assert.equal(readTavilyApiKeyFromSops({ env: {}, execFileSync }), undefined); + assert.equal(tavilyControlWorkspaceRoot({}), undefined); + assert.equal(calls.length, 0); + }); + + it("does not expose SOPS secret values through lookup failures", () => { + const controlWorkspace = mkdtempSync(join(tmpdir(), "tavily-secret-private-value-")); + mkdirSync(join(controlWorkspace, "config")); + writeFileSync(join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH), "placeholder: true\n", "utf8"); + const secretValue = "tvly-private-value"; + const execFileSync: ExecFileSyncLike = () => { + throw new Error(secretValue); + }; + + try { + assert.equal(readTavilyApiKeyFromSops({ controlWorkspaceRoot: controlWorkspace, execFileSync }), undefined); + } finally { + rmSync(controlWorkspace, { recursive: true, force: true }); } }); @@ -698,11 +798,13 @@ describe("web-tools Pi wrapper", () => { const tmpDir = mkdtempSync(join(tmpdir(), "web-tools-wrapper-missing-")); const oldCwd = process.cwd(); const oldWarn = console.warn; + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; const warnings: string[] = []; const registered: RegisteredTool[] = []; try { process.chdir(tmpDir); + delete process.env[MINIME_WORKSPACE_ROOT_ENV]; console.warn = (message?: unknown) => { warnings.push(String(message)); }; @@ -718,28 +820,45 @@ describe("web-tools Pi wrapper", () => { assert.match(result.content[0].text, /SOPS key tavily\.api_key in config\/secrets\.sops\.yaml/); } finally { console.warn = oldWarn; + if (oldWorkspace === undefined) delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + else process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; process.chdir(oldCwd); rmSync(tmpDir, { recursive: true, force: true }); } }); - it("reads the wrapper API key through SOPS and passes it to Tavily requests", async () => { + it("reads the wrapper API key from the control workspace while cwd is an arbitrary child workspace", async () => { const tmpDir = mkdtempSync(join(tmpdir(), "web-tools-wrapper-sops-")); + const controlWorkspace = join(tmpDir, "control-workspace"); + const childWorkspace = join(tmpDir, "subagent-child-workspace"); const binDir = join(tmpDir, "bin"); + const sopsLog = join(tmpDir, "sops-argv.txt"); const oldCwd = process.cwd(); const oldPath = process.env.PATH; + const oldWorkspace = process.env[MINIME_WORKSPACE_ROOT_ENV]; const oldFetch = globalThis.fetch; const registered: RegisteredTool[] = []; const fetchCalls: FetchCall[] = []; try { - mkdirSync(join(tmpDir, "config"), { recursive: true }); + mkdirSync(join(controlWorkspace, "config"), { recursive: true }); + mkdirSync(childWorkspace, { recursive: true }); mkdirSync(binDir, { recursive: true }); - writeFileSync(join(tmpDir, TAVILY_SOPS_FILE_RELPATH), "placeholder: true\n", "utf8"); - writeFileSync(join(binDir, "sops"), "#!/bin/bash\nprintf 'tvly-wrapper-key\\n'\n", "utf8"); + writeFileSync(join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH), "placeholder: true\n", "utf8"); + writeFileSync( + join(binDir, "sops"), + [ + "#!/bin/bash", + `printf '%s\\n' "$@" > ${JSON.stringify(sopsLog)}`, + "printf 'tvly-wrapper-key\\n'", + "", + ].join("\n"), + "utf8", + ); chmodSync(join(binDir, "sops"), 0o755); process.env.PATH = `${binDir}:${oldPath ?? ""}`; - process.chdir(tmpDir); + process.env[MINIME_WORKSPACE_ROOT_ENV] = controlWorkspace; + process.chdir(childWorkspace); globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { fetchCalls.push({ url: String(url), init }); return jsonResponse({ results: [] }); @@ -752,10 +871,18 @@ describe("web-tools Pi wrapper", () => { assert.equal(result.details.ok, true); assert.equal(fetchCalls.length, 1); assert.equal(fetchCalls[0].init?.headers && (fetchCalls[0].init.headers as Record)["Authorization"], "Bearer tvly-wrapper-key"); + assert.deepEqual(readFileSync(sopsLog, "utf8").trim().split("\n"), [ + "-d", + "--extract", + '["tavily"]["api_key"]', + join(controlWorkspace, TAVILY_SOPS_FILE_RELPATH), + ]); } finally { globalThis.fetch = oldFetch; if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; + if (oldWorkspace === undefined) delete process.env[MINIME_WORKSPACE_ROOT_ENV]; + else process.env[MINIME_WORKSPACE_ROOT_ENV] = oldWorkspace; process.chdir(oldCwd); rmSync(tmpDir, { recursive: true, force: true }); } diff --git a/bot/src/__tests__/workspace-contract.test.ts b/bot/src/__tests__/workspace-contract.test.ts index 0b913ba5..977de060 100644 --- a/bot/src/__tests__/workspace-contract.test.ts +++ b/bot/src/__tests__/workspace-contract.test.ts @@ -7,7 +7,6 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { MINIME_CONFIG_PATH_ENV, MINIME_CRONS_PATH_ENV, - MINIME_SCHEMA_PATH_ENV, MINIME_WORKSPACE_ROOT_ENV, resolveWorkspaceContract, workspaceContractDiagnostics, @@ -37,10 +36,10 @@ describe("workspace contract resolver", () => { assertAbsolutePaths(contract.paths); assert.strictEqual(contract.paths.packageRoot, BOT_ROOT); assert.strictEqual(contract.paths.botRoot, BOT_ROOT); + assert.strictEqual(contract.paths.controlWorkspaceRoot, REPO_ROOT); assert.strictEqual(contract.paths.workspaceRoot, REPO_ROOT); assert.strictEqual(contract.paths.configPath, resolve(REPO_ROOT, "config.yaml")); assert.strictEqual(contract.paths.cronsPath, resolve(REPO_ROOT, "crons.yaml")); - assert.strictEqual(contract.paths.schemaPath, resolve(REPO_ROOT, "schema.md")); assert.strictEqual(contract.paths.piExtensionDir, resolve(BOT_ROOT, ".claude", "extensions")); assert.strictEqual(contract.paths.dataDir, resolve(REPO_ROOT, "data")); assert.strictEqual(contract.paths.sessionStorePath, resolve(BOT_ROOT, "data", "sessions.json")); @@ -97,7 +96,9 @@ describe("workspace contract resolver", () => { }); assert.strictEqual(contract.paths.workspaceRoot, resolve(cwd, cliWorkspace)); + assert.strictEqual(contract.paths.controlWorkspaceRoot, resolve(cwd, cliWorkspace)); assert.strictEqual(contract.effectivePaths.workspaceRoot.source, "cli"); + assert.strictEqual(contract.effectivePaths.controlWorkspaceRoot.source, "cli"); assert.strictEqual(contract.paths.configPath, resolve(cwd, cliWorkspace, "config.yaml")); }); @@ -110,16 +111,16 @@ describe("workspace contract resolver", () => { }); assertAbsolutePaths(contract.paths); + assert.strictEqual(contract.paths.controlWorkspaceRoot, workspaceRoot); assert.strictEqual(contract.paths.workspaceRoot, workspaceRoot); assert.strictEqual(contract.effectivePaths.workspaceRoot.source, "env"); assert.strictEqual(contract.paths.configPath, join(workspaceRoot, "config.yaml")); assert.strictEqual(contract.paths.cronsPath, join(workspaceRoot, "crons.yaml")); - assert.strictEqual(contract.paths.schemaPath, join(workspaceRoot, "schema.md")); assert.strictEqual(contract.paths.dataDir, join(workspaceRoot, "data")); assert.strictEqual(contract.paths.runtimeDir, join(workspaceRoot, ".tmp")); }); - it("uses explicit config, crons, and schema path overrides", () => { + it("uses explicit config and crons path overrides", () => { const workspaceRoot = mkdtempSync(join(tmpdir(), "minime-contract-overrides-")); const absoluteCronsPath = join(workspaceRoot, "absolute", "crons.yaml"); const contract = resolveWorkspaceContract({ @@ -128,7 +129,6 @@ describe("workspace contract resolver", () => { [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, [MINIME_CONFIG_PATH_ENV]: "custom/config.yaml", [MINIME_CRONS_PATH_ENV]: absoluteCronsPath, - [MINIME_SCHEMA_PATH_ENV]: "schemas/write-allowlist.md", LOG_DIR: "/tmp/minime-logs", MINIME_TEST_MEDIA_BASE: "/tmp/minime-media", }, @@ -138,12 +138,10 @@ describe("workspace contract resolver", () => { assert.strictEqual(contract.paths.configPath, join(workspaceRoot, "custom", "config.yaml")); assert.strictEqual(contract.paths.cronsPath, absoluteCronsPath); - assert.strictEqual(contract.paths.schemaPath, join(workspaceRoot, "schemas", "write-allowlist.md")); assert.strictEqual(contract.paths.logDir, "/tmp/minime-logs"); assert.strictEqual(contract.paths.mediaBaseDir, "/tmp/minime-media/6789"); assert.strictEqual(contract.effectivePaths.configPath.source, "env"); assert.strictEqual(contract.effectivePaths.cronsPath.source, "env"); - assert.strictEqual(contract.effectivePaths.schemaPath.source, "env"); }); it("does not guess a package install parent directory as the workspace", () => { @@ -171,7 +169,6 @@ describe("workspace contract resolver", () => { env: { [MINIME_WORKSPACE_ROOT_ENV]: workspaceRoot, [MINIME_CONFIG_PATH_ENV]: "missing-config.yaml", - [MINIME_SCHEMA_PATH_ENV]: "missing-schema.md", TELEGRAM_TOKEN: "do-not-print-me", SOPS_AGE_KEY: "do-not-print-me-either", }, @@ -181,7 +178,6 @@ describe("workspace contract resolver", () => { const serialized = JSON.stringify({ diagnostics, warnings: contract.warnings }); assert.strictEqual(diagnostics.configPath.path, join(workspaceRoot, "missing-config.yaml")); - assert.strictEqual(diagnostics.schemaPath.path, join(workspaceRoot, "missing-schema.md")); assert.doesNotMatch(serialized, /do-not-print-me/); }); }); diff --git a/bot/src/__tests__/workspace-validator.test.ts b/bot/src/__tests__/workspace-validator.test.ts index 8c128aa0..12e316fa 100644 --- a/bot/src/__tests__/workspace-validator.test.ts +++ b/bot/src/__tests__/workspace-validator.test.ts @@ -1,19 +1,15 @@ import { describe, it, after } from "node:test"; import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { classifyToolCall } from "../pi-extensions/guard.js"; import { - readWriteAllowlistEntriesForGuard, - resolveWriteAllowlistSchemaPath, -} from "../pi-extensions/write-allowlist-schema.js"; -import { validateWorkspaceContract, workspaceValidationErrors } from "../workspace-validator.js"; -import { - MINIME_SCHEMA_PATH_ENV, - resolveWorkspaceContract, -} from "../workspace-contract.js"; + validateWorkspaceContract, + workspaceValidationErrors, + workspaceValidationWarnings, +} from "../workspace-validator.js"; +import { resolveWorkspaceContract } from "../workspace-contract.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BOT_ROOT = resolve(__dirname, "..", ".."); @@ -28,7 +24,6 @@ after(() => { }); function createWorkspace(options: { - schema?: string | null; extraFiles?: Record; workspaceCwd?: string; } = {}): string { @@ -63,10 +58,6 @@ function createWorkspace(options: { ].join("\n"), ); - if (options.schema !== null) { - writeFileSync(join(workspace, "schema.md"), options.schema ?? validSchema(["agent-workspace/", "*.md", "schema.md"])); - } - for (const [rel, content] of Object.entries(options.extraFiles ?? {})) { const path = join(workspace, rel); mkdirSync(dirname(path), { recursive: true }); @@ -76,31 +67,80 @@ function createWorkspace(options: { return workspace; } -function validSchema(entries: readonly string[]): string { - return [ - "# Workspace schema", - "", - "```write-allowlist", - ...entries, - "```", - "", - ].join("\n"); -} - function validate(workspace: string, env: NodeJS.ProcessEnv = {}) { const contract = resolveWorkspaceContract({ workspace, cwd: workspace, env }); - return validateWorkspaceContract(contract, { env }); + return validateWorkspaceContract(contract); +} + +function errorMessages(result: ReturnType): string { + return workspaceValidationErrors(result).map((item) => item.message).join("\n"); } -function guardBlocks(workspaceRoot: string, schemaPath: string, relPath: string): boolean { - const entries = readWriteAllowlistEntriesForGuard(schemaPath); - return classifyToolCall( - { toolName: "write", input: { path: relPath } }, - { workspaceRoot, writeAllowlist: entries }, - ).block; +function warningMessages(result: ReturnType): string { + return workspaceValidationWarnings(result).map((item) => item.message).join("\n"); } -describe("workspace validator schema parity with the live guard parser", () => { +function createSiblingWorkspaceFixture(): { + root: string; + controlWorkspace: string; + agentMain: string; + agentReviewer: string; +} { + const root = mkdtempSync(join(tmpdir(), "minime-validator-sibling-layout-")); + fixtures.push(root); + const controlWorkspace = join(root, "control-workspace"); + const agentMain = join(root, "agent-workspace-main"); + const agentReviewer = join(root, "agent-workspace-reviewer"); + mkdirSync(controlWorkspace, { recursive: true }); + mkdirSync(agentMain, { recursive: true }); + mkdirSync(agentReviewer, { recursive: true }); + writeFileSync( + join(controlWorkspace, "config.yaml"), + [ + "agents:", + " main:", + ` workspaceCwd: ${agentMain}`, + " model: gpt-5.5", + " reviewer:", + ` workspaceCwd: ${agentReviewer}`, + " model: gpt-5.5", + "telegramTokenEnv: MINIME_FIXTURE_TELEGRAM_TOKEN", + "bindings:", + " - chatId: 111", + " agentId: main", + " kind: dm", + " - chatId: 222", + " agentId: reviewer", + " kind: dm", + "discord:", + " tokenEnv: MINIME_FIXTURE_DISCORD_TOKEN", + " bindings:", + " - guildId: \"guild-1\"", + " agentId: main", + " kind: channel", + " channels:", + " - channelId: \"review-channel\"", + " agentId: reviewer", + " label: Review", + "", + ].join("\n"), + ); + writeFileSync( + join(controlWorkspace, "crons.yaml"), + [ + "crons:", + " - name: smoke", + " schedule: \"0 9 * * *\"", + " prompt: smoke", + " agentId: main", + " deliveryChatId: 111", + "", + ].join("\n"), + ); + return { root, controlWorkspace, agentMain, agentReviewer }; +} + +describe("workspace validator", () => { it("validates the tracked fixture from a package-installed-like layout", () => { const projectDir = mkdtempSync(join(tmpdir(), "minime-validator-installed-")); fixtures.push(projectDir); @@ -115,39 +155,114 @@ describe("workspace validator schema parity with the live guard parser", () => { env: {}, }); - const result = validateWorkspaceContract(contract, { env: {} }); + const result = validateWorkspaceContract(contract); assert.deepStrictEqual(workspaceValidationErrors(result), []); assert.strictEqual(result.contract.effectivePaths.workspaceRoot.source, "cli"); assert.strictEqual(result.contract.paths.piExtensionDir, artifactExtensionDir); - assert.deepStrictEqual(result.schema?.entries, ["agent-workspace/", "*.md", "schema.md"]); assert.strictEqual(result.crons?.length, 1); }); - it("valid schema: validator entries match guard entries and guard verdicts", () => { + it("does not require schema.md in the control or agent workspace", () => { const workspace = createWorkspace(); const result = validate(workspace); - const guardEntries = readWriteAllowlistEntriesForGuard(result.contract.paths.schemaPath); - assert.deepStrictEqual(result.schema?.entries, guardEntries); assert.deepStrictEqual(workspaceValidationErrors(result), []); - assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "agent-workspace/note.md"), false); - assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "unregistered/file.txt"), true); + assert.equal(existsSync(join(workspace, "schema.md")), false); + assert.equal(existsSync(join(workspace, "agent-workspace", "schema.md")), false); }); - it("rejects agent workspaceCwd outside the resolved workspace root", () => { + it("hard-fails invalid control workspace config", () => { + const workspace = createWorkspace(); + writeFileSync( + join(workspace, "config.yaml"), + [ + "agents: []", + "telegramTokenEnv: MINIME_FIXTURE_TELEGRAM_TOKEN", + "bindings: []", + "", + ].join("\n"), + ); + + const result = validate(workspace); + + assert.match(errorMessages(result), /config does not parse with secret resolution disabled/); + }); + + it("hard-fails invalid control workspace crons", () => { + const workspace = createWorkspace(); + writeFileSync(join(workspace, "crons.yaml"), "crons: not-an-array\n"); + + const result = validate(workspace); + + assert.match(errorMessages(result), /crons file does not parse: crons\.yaml missing 'crons' array/); + }); + + it("warns instead of hard-failing when agent context files are missing", () => { + const workspace = createWorkspace(); + const result = validate(workspace); + const warnings = warningMessages(result); + + assert.deepStrictEqual(workspaceValidationErrors(result), []); + assert.match(warnings, /agent "main" context file is not present: .*CLAUDE\.md/); + assert.match(warnings, /agent "main" context file is not present: .*MEMORY\.md/); + assert.match(warnings, /agent "main" rules dir is not present: .*\.claude\/rules\/platform/); + assert.match(warnings, /agent "main" rules dir is not present: .*\.claude\/rules\/custom/); + }); + + it("validates configured SOPS references without invoking sops", () => { + const workspace = createWorkspace(); + const fakeBin = mkdtempSync(join(tmpdir(), "minime-validator-fake-sops-")); + fixtures.push(fakeBin); + const marker = join(fakeBin, "sops-was-called"); + mkdirSync(join(workspace, "config"), { recursive: true }); + writeFileSync(join(workspace, "config", "secrets.sops.yaml"), "placeholder: true\n", "utf8"); + writeFileSync( + join(workspace, "config.yaml"), + [ + "agents:", + " main:", + " workspaceCwd: ./agent-workspace", + " model: gpt-5.5", + "secrets:", + " sopsFile: config/secrets.sops.yaml", + "telegramTokenSopsKey: telegram.bot_token", + "bindings:", + " - chatId: 111", + " agentId: main", + " kind: dm", + "", + ].join("\n"), + ); + writeFileSync( + join(fakeBin, "sops"), + [ + "#!/bin/bash", + `touch ${JSON.stringify(marker)}`, + "printf 'should-not-resolve\\n'", + "", + ].join("\n"), + "utf8", + ); + chmodSync(join(fakeBin, "sops"), 0o755); + + const result = validate(workspace, { PATH: `${fakeBin}:${process.env.PATH ?? ""}` }); + + assert.deepStrictEqual(workspaceValidationErrors(result), []); + assert.equal(existsSync(marker), false); + }); + + it("accepts an absolute agent workspaceCwd outside the control workspace root", () => { const externalWorkspace = mkdtempSync(join(tmpdir(), "minime-validator-external-agent-")); fixtures.push(externalWorkspace); const workspace = createWorkspace({ workspaceCwd: externalWorkspace }); const result = validate(workspace); - assert.match( - workspaceValidationErrors(result).map((item) => item.message).join("\n"), - /workspaceCwd must be inside the resolved workspace root/, - ); + assert.deepStrictEqual(workspaceValidationErrors(result), []); + assert.equal(result.config?.agents.main.workspaceCwd, externalWorkspace); }); - it("rejects symlinked agent workspaceCwd that resolves outside the workspace root", () => { + it("accepts a symlinked agent workspaceCwd that resolves outside the control workspace root", () => { const externalWorkspace = mkdtempSync(join(tmpdir(), "minime-validator-external-agent-")); fixtures.push(externalWorkspace); const workspace = createWorkspace(); @@ -157,68 +272,56 @@ describe("workspace validator schema parity with the live guard parser", () => { const result = validate(workspace); - assert.match( - workspaceValidationErrors(result).map((item) => item.message).join("\n"), - /workspaceCwd must be inside the resolved workspace root/, - ); + assert.deepStrictEqual(workspaceValidationErrors(result), []); }); - it("missing schema: validator fails hard and the guard parser fails closed", () => { - const workspace = createWorkspace({ schema: null }); - const result = validate(workspace); + it("accepts sibling control and agent workspace roots with multiple agents", () => { + const { controlWorkspace, agentMain, agentReviewer } = createSiblingWorkspaceFixture(); + const result = validate(controlWorkspace); - assert.match(workspaceValidationErrors(result).map((item) => item.message).join("\n"), /schema file does not exist/); - assert.deepStrictEqual(result.schema?.entries, []); - assert.deepStrictEqual(readWriteAllowlistEntriesForGuard(result.contract.paths.schemaPath), []); - assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "agent-workspace/note.md"), true); + assert.deepStrictEqual(workspaceValidationErrors(result), []); + assert.equal(result.contract.paths.controlWorkspaceRoot, controlWorkspace); + assert.equal(result.config?.agents.main.workspaceCwd, agentMain); + assert.equal(result.config?.agents.reviewer.workspaceCwd, agentReviewer); }); - it("empty schema block: validator fails hard and guard parser fails closed", () => { - const workspace = createWorkspace({ schema: validSchema(["# only a comment"]) }); - const result = validate(workspace); + it("keeps one bot binding model routed to multiple sibling agent workspaces", () => { + const { controlWorkspace, agentMain, agentReviewer } = createSiblingWorkspaceFixture(); + const result = validate(controlWorkspace); - assert.match(workspaceValidationErrors(result).map((item) => item.message).join("\n"), /empty/); - assert.deepStrictEqual(result.schema?.entries, []); - assert.deepStrictEqual(readWriteAllowlistEntriesForGuard(result.contract.paths.schemaPath), []); - assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "agent-workspace/note.md"), true); + assert.deepStrictEqual(workspaceValidationErrors(result), []); + assert.equal(result.config?.bindings[0]?.agentId, "main"); + assert.equal(result.config?.agents[result.config.bindings[0].agentId]?.workspaceCwd, agentMain); + assert.equal(result.config?.bindings[1]?.agentId, "reviewer"); + assert.equal(result.config?.agents[result.config.bindings[1].agentId]?.workspaceCwd, agentReviewer); + const discordBinding = result.config?.discord?.bindings[0]; + assert.equal(discordBinding?.agentId, "main"); + assert.equal(result.config?.agents[discordBinding?.agentId ?? ""]?.workspaceCwd, agentMain); + assert.equal(discordBinding?.channels?.[0]?.agentId, "reviewer"); + assert.equal(result.config?.agents[discordBinding?.channels?.[0]?.agentId ?? ""]?.workspaceCwd, agentReviewer); }); - it("malformed schema block: validator fails hard and guard parser fails closed", () => { - const workspace = createWorkspace({ - schema: [ - "# Workspace schema", - "", - "```write-allowlist", - "agent-workspace/", - "", - ].join("\n"), - }); + it("rejects a missing configured agent workspaceCwd", () => { + const workspace = createWorkspace({ workspaceCwd: "./missing-agent-workspace" }); const result = validate(workspace); - assert.match(workspaceValidationErrors(result).map((item) => item.message).join("\n"), /closing fence/); - assert.deepStrictEqual(result.schema?.entries, []); - assert.deepStrictEqual(readWriteAllowlistEntriesForGuard(result.contract.paths.schemaPath), []); - assert.equal(guardBlocks(workspace, result.contract.paths.schemaPath, "agent-workspace/note.md"), true); + assert.match( + errorMessages(result), + /agent "main" workspaceCwd does not exist/, + ); }); - it("schema override: validator and guard use the same MINIME_SCHEMA_PATH", () => { + it("rejects a configured agent workspaceCwd that is not a directory", () => { const workspace = createWorkspace({ - schema: validSchema(["default-only/"]), - extraFiles: { - "schemas/override.md": validSchema(["agent-workspace/", "schema.md"]), - }, + workspaceCwd: "./not-a-directory", + extraFiles: { "not-a-directory": "not a directory" }, }); - const env = { [MINIME_SCHEMA_PATH_ENV]: "schemas/override.md" }; - const result = validate(workspace, env); - const guardSchemaPath = resolveWriteAllowlistSchemaPath(workspace, env); + const result = validate(workspace); - assert.equal(result.contract.paths.schemaPath, guardSchemaPath); - assert.deepStrictEqual(workspaceValidationErrors(result), []); - assert.deepStrictEqual( - result.schema?.entries, - readWriteAllowlistEntriesForGuard(guardSchemaPath), + assert.match( + errorMessages(result), + /agent "main" workspaceCwd is not a directory/, ); - assert.equal(guardBlocks(workspace, guardSchemaPath, "agent-workspace/note.md"), false); - assert.equal(guardBlocks(workspace, guardSchemaPath, "default-only/file.txt"), true); }); + }); diff --git a/bot/src/cli.ts b/bot/src/cli.ts index 12b6f5cc..cd6c2efc 100644 --- a/bot/src/cli.ts +++ b/bot/src/cli.ts @@ -50,7 +50,7 @@ const HELP_TEXT = `Usage: minime-bot workspace validate --workspace Options: - --workspace Workspace root. Defaults to MINIME_WORKSPACE_ROOT, then source repo root or package cwd. + --workspace Control/app workspace root. Defaults to MINIME_WORKSPACE_ROOT, then source repo root or package cwd. -h, --help Show this help text. `; @@ -103,11 +103,16 @@ function resolveForCli(parsed: ParsedArgs, options: CliRunOptions): ResolvedWork function formatEffectivePaths(contract: ResolvedWorkspaceContract): string[] { const diagnostics = workspaceContractDiagnostics(contract); return [ - ` workspace root: ${diagnostics.workspaceRoot.path} (${diagnostics.workspaceRoot.source})`, + ` control workspace root: ${diagnostics.controlWorkspaceRoot.path} (${diagnostics.controlWorkspaceRoot.source})`, ` config path: ${diagnostics.configPath.path} (${diagnostics.configPath.source})`, ` crons path: ${diagnostics.cronsPath.path} (${diagnostics.cronsPath.source})`, - ` schema path: ${diagnostics.schemaPath.path} (${diagnostics.schemaPath.source})`, + ` package root: ${diagnostics.packageRoot.path} (${diagnostics.packageRoot.source})`, ` Pi extension dir: ${diagnostics.piExtensionDir.path} (${diagnostics.piExtensionDir.source})`, + ` data dir: ${diagnostics.dataDir.path} (${diagnostics.dataDir.source})`, + ` session store path: ${diagnostics.sessionStorePath.path} (${diagnostics.sessionStorePath.source})`, + ` log dir: ${diagnostics.logDir.path} (${diagnostics.logDir.source})`, + ` media base dir: ${diagnostics.mediaBaseDir.path} (${diagnostics.mediaBaseDir.source})`, + ` runtime dir: ${diagnostics.runtimeDir.path} (${diagnostics.runtimeDir.source})`, ]; } @@ -138,11 +143,12 @@ function writeWorkspaceValidationReport( } if (result.config) { writeLine(stdout, `Agents: ${Object.keys(result.config.agents).join(", ")}`); + writeLine(stdout, "Agent workspaces:"); + for (const [agentId, agent] of Object.entries(result.config.agents)) { + writeLine(stdout, ` ${agentId}: ${agent.workspaceCwd}`); + } } writeLine(stdout, `Crons: ${result.crons === undefined ? "not present" : result.crons.length}`); - if (result.schema) { - writeLine(stdout, `Schema allow-list entries: ${result.schema.entries.length}`); - } if (errors.length > 0) { writeLine(stdout, "Hard failures:"); for (const error of errors) { @@ -160,9 +166,8 @@ function writeWorkspaceValidationReport( function runWorkspaceValidate( contract: ResolvedWorkspaceContract, stdout: WriteFn, - env: NodeJS.ProcessEnv, ): void { - const result = validateWorkspaceContract(contract, { env }); + const result = validateWorkspaceContract(contract); writeWorkspaceValidationReport(result, stdout); if (workspaceValidationErrors(result).length > 0) { throw new WorkspaceValidationError(); @@ -199,7 +204,7 @@ export function runCli(argv: readonly string[] = process.argv.slice(2), options: return 0; } if (scope === "workspace" && action === "validate") { - runWorkspaceValidate(contract, stdout, options.env ?? process.env); + runWorkspaceValidate(contract, stdout); return 0; } } catch (err) { diff --git a/bot/src/config.ts b/bot/src/config.ts index abd6cb81..5ac55edd 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -400,14 +400,14 @@ function optionalConfigString(value: unknown, fieldName: string): string | undef return trimmed === "" ? undefined : trimmed; } -function resolveConfiguredSopsFile(raw: RawConfig, configPath?: string): string | undefined { +function resolveConfiguredSopsFile(raw: RawConfig, controlWorkspaceRoot: string): string | undefined { if (raw.secrets === undefined) return undefined; if (typeof raw.secrets !== "object" || raw.secrets === null || Array.isArray(raw.secrets)) { throw new Error("secrets must be an object"); } const sopsFile = optionalConfigString(raw.secrets.sopsFile, "secrets.sopsFile"); if (!sopsFile) return undefined; - return resolve(dirname(resolveConfigPath(configPath)), sopsFile); + return resolve(controlWorkspaceRoot, sopsFile); } function validateConfiguredSopsSource( @@ -432,7 +432,8 @@ function findLegacyConfigKey(raw: object, keyPattern: RegExp): string | undefine export function loadTelegramToken(configPath?: string, options: LoadConfigOptions = {}): string { const raw: RawConfig = loadRawMergedConfig(configPath) as RawConfig; - const sopsFile = resolveConfiguredSopsFile(raw, configPath); + const workspaceRoot = resolveConfigWorkspaceRoot(configPath, options.workspaceRoot); + const sopsFile = resolveConfiguredSopsFile(raw, workspaceRoot); const legacyTelegramKey = findLegacyConfigKey(raw, LEGACY_TELEGRAM_SERVICE_KEY_RE); if (legacyTelegramKey) { throw new Error( @@ -464,7 +465,7 @@ export function loadConfig(configPath?: string, options: LoadConfigOptions = {}) if (!raw || typeof raw !== "object") { throw new Error("Config file is empty or invalid"); } - const sopsFile = resolveConfiguredSopsFile(raw, configPath); + const sopsFile = resolveConfiguredSopsFile(raw, workspaceRoot); // Validate top-level defaults for migration clarity. Agents no longer inherit // defaultModel now that Pi is the only runtime. diff --git a/bot/src/cron-runner.ts b/bot/src/cron-runner.ts index a08b704d..aed4c58e 100644 --- a/bot/src/cron-runner.ts +++ b/bot/src/cron-runner.ts @@ -24,14 +24,11 @@ import type { CronJob, AgentConfig } from "./types.js"; import { shouldSuppressNoReply } from "./no-reply.js"; import { buildPiSpawnEnv, - PI_CRON_WRAPPER_RELPATHS, - resolvePiExtensionArgs, - PI_EXTENSIONS_DISABLED_ENV, - PI_GUARD_WORKSPACE_ROOT_ENV, + resolveValidatedPiAgentWorkspaceCwd, shouldIncludePiChildEnvKey, } from "./pi-rpc-protocol.js"; import { assemblePiContext } from "./pi-context-assembler.js"; -import { MINIME_SCHEMA_PATH_ENV, resolveWorkspaceContract } from "./workspace-contract.js"; +import { resolveWorkspaceContract } from "./workspace-contract.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const BOT_DIR = resolve(__dirname, ".."); @@ -591,9 +588,8 @@ type PiSpawnSync = ( export interface PiRunDeps { spawnSync: PiSpawnSync; buildAgentConfig: (cron: CronJob, workspaceCwd: string, agentData?: CronAgentData) => AgentConfig; - buildEnv: (agent: AgentConfig) => Record; + buildEnv: () => Record; assembleContext: typeof assemblePiContext; - resolveExtensionArgs: typeof resolvePiExtensionArgs; } function buildPiCronAgentConfigForRun(cron: CronJob, workspaceCwd: string, agentData?: CronAgentData): AgentConfig { @@ -606,29 +602,12 @@ const defaultPiDeps: PiRunDeps = { buildAgentConfig: buildPiCronAgentConfigForRun, buildEnv: buildPiSpawnEnv, assembleContext: assemblePiContext, - resolveExtensionArgs: resolvePiExtensionArgs, }; -function resolvePiCronExtensionArgs(resolveExtensionArgs: typeof resolvePiExtensionArgs): string[] { - if (process.env[PI_EXTENSIONS_DISABLED_ENV] === "1") { - throw new Error(`${PI_EXTENSIONS_DISABLED_ENV}=1 cannot disable the required Pi cron guard extension; unset it before running Pi crons`); - } - - const extensionArgs = resolveExtensionArgs({ relpaths: PI_CRON_WRAPPER_RELPATHS }); - if (extensionArgs.length === 0) { - throw new Error("Pi cron extension resolver returned no guard extension; refusing to spawn an unguarded Pi cron"); - } - return extensionArgs; -} - function hardenPiCronEnv(rawEnv: Record): Record { const env: Record = {}; for (const [key, value] of Object.entries(rawEnv)) { - if ( - shouldIncludePiChildEnvKey(key) || - key === MINIME_SCHEMA_PATH_ENV || - key === PI_GUARD_WORKSPACE_ROOT_ENV - ) { + if (shouldIncludePiChildEnvKey(key)) { env[key] = value; } } @@ -651,9 +630,10 @@ function runPi( } const agent = deps.buildAgentConfig(cron, workspaceCwd, agentData); + const validatedWorkspaceCwd = resolveValidatedPiAgentWorkspaceCwd(agent); const thinking = isCronPiThinking(agent.thinking) ? agent.thinking : "medium"; const systemInstruction = buildCronSystemInstruction(); - const env = hardenPiCronEnv(deps.buildEnv(agent)); + const env = hardenPiCronEnv(deps.buildEnv()); // Pi authenticates via ~/.pi/agent/auth.json, not legacy OAuth credentials. env.HOME ||= homedir(); const args: string[] = [ @@ -682,10 +662,8 @@ function runPi( } args.push("--append-system-prompt", systemInstruction); - args.push(...resolvePiCronExtensionArgs(deps.resolveExtensionArgs)); - const result = deps.spawnSync(PI_BIN, args, { - cwd: workspaceCwd, + cwd: validatedWorkspaceCwd, timeout: cron.timeout ?? DEFAULT_TIMEOUT_MS, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, diff --git a/bot/src/pi-extensions/README.md b/bot/src/pi-extensions/README.md index 95ceb40e..978891c4 100644 --- a/bot/src/pi-extensions/README.md +++ b/bot/src/pi-extensions/README.md @@ -2,8 +2,8 @@ Pure, testable helpers for Pi extension wrappers. -Most helpers here back the live Pi RPC extensions (A1 guard, A2 web-tools, A3 -subagent) loaded into every `pi --mode rpc` spawn. `codex-usage.ts` is different: +Most helpers here back the live Pi RPC extensions (web-tools and subagent) +loaded into every `pi --mode rpc` spawn. `codex-usage.ts` is different: it is used only by the out-of-band Codex quota sampler via `bot/.claude/extensions/codex-usage.ts`, and must not be added to the normal `PI_EXTENSION_WRAPPER_RELPATHS` list. See @@ -14,8 +14,8 @@ it is used only by the out-of-band Codex quota sampler via There are TWO kinds of files in this feature, deliberately split: 1. **Pure helpers — `bot/src/pi-extensions/*.ts`** (this directory). - All real logic lives here: path classification (A1), Tavily request/parse - (A2), subagent spawn-arg/result helpers (A3), and the sampler-only Codex + All real logic lives here: Tavily request/parse, subagent spawn-arg/result + helpers, and the sampler-only Codex quota cache/export helper. These files are: - **Type-checked by `tsc --noEmit`** (the `npm run lint` command) because the bot `tsconfig.json` `include` is `["src/**/*.ts"]`, which matches this path. @@ -24,8 +24,8 @@ There are TWO kinds of files in this feature, deliberately split: Proven in Task 0 by a throwaway stub helper (`_smoke.ts`) + a sibling test (`__tests__/pi-extensions-smoke.test.ts`); both were removed in Task 4 once - the real A1-A3 helpers (`guard.ts`, `tavily.ts`, `subagent-args.ts`) made the - coverage self-evident. + the real helpers (`tavily.ts`, `subagent-args.ts`) made the coverage + self-evident. 2. **Thin wrappers — `bot/.claude/extensions/.ts`** (or `/index.ts` for A3). Each is a minimal `export default function (pi) { ... }` that wires a diff --git a/bot/src/pi-extensions/guard.ts b/bot/src/pi-extensions/guard.ts deleted file mode 100644 index 1ddc56ac..00000000 --- a/bot/src/pi-extensions/guard.ts +++ /dev/null @@ -1,767 +0,0 @@ -/** - * A1 — guardian + protect-files write guard (pure, testable core). - * - * Ports the workspace's file-write protection (the `protect-files.sh` + - * `guardian.sh` PreToolUse hooks, which only run in the original hook path) into a - * provider-agnostic classifier so a Pi (`openai-codex`) session is guarded the - * same way. The thin Pi wrapper at - * `bot/.claude/extensions/guardian-protect-files.ts` calls {@link classifyToolCall} - * from a `tool_call` handler and returns `{ block, reason }` to Pi. - * - * Single source of truth: {@link PROTECTED_PREFIXES} pins the upstream-owned - * paths that `protect-files.sh` / `bot-code-readonly.md` encode — the full - * 10-path IMMUTABLE CORE (6 directory prefixes + 4 root-only files). This is the - * deny-overlay of the schema-enforced write guard: it is checked FIRST and ALWAYS - * blocks, even when the workspace allow-list would match. A pinned test - * (`guard.test.ts`) locks the set so drift is caught. - * - * This re-implementation deliberately FIXES four bugs in the bash hooks: - * 1. traversal — the hook resolves `..` with a fragile sed loop; here - * `node:path` canonicalizes `.`/`..`/`//` so `bot/../../etc/x` and - * `bot/../bot/x` both classify correctly. - * 2. APFS case — macOS APFS is case-insensitive, but the hook's `case` - * matching is case-sensitive, so `BOT/x` slips through. Matching here is - * case-insensitive. - * 3. bash-redirect coverage — the hooks only inspect `tool_input.file_path` - * (write/edit) and never parse bash, so `echo x > bot/y`, `tee`, `mv`, `cp` - * into a protected path are unguarded. {@link extractBashWriteTargets} - * parses the command for write targets. - * 4. fail-open — when the project root is unknown the hook strips a leading `/` - * and relies on literal patterns, silently bypassing the prefix match. Here - * an unknown root FAILS CLOSED (blocks). - */ - -import { basename, isAbsolute, normalize, relative, resolve } from "node:path"; - -/** - * Upstream-owned paths (relative to the workspace root) a guarded session may NOT - * write into — the IMMUTABLE CORE / deny-overlay of the schema-enforced write - * guard. Two entry kinds, distinguished by the trailing slash: - * - Directory prefix (trailing slash, e.g. `bot/`): matches the bare directory - * name itself OR anything under it. - * - Root-only file (no slash, e.g. `README.md`): matches that EXACT root-level - * path only — `README.md` blocks the root file but NOT `docs/README.md`. - * Matching is case-insensitive (APFS). - * - * This is the full 10-path set `protect-files.sh` / `bot-code-readonly.md` encode - * (no longer a narrowed 4). PINNED by `guard.test.ts`. To change: edit the - * upstream rule (`bot-code-readonly.md` / `protect-files.sh`) and this list - * together — they are the doc and its enforcement. - */ -export const PROTECTED_PREFIXES = [ - "bot/", - ".claude/hooks/", - ".claude/rules/platform/", - ".claude/skills/workspace-health/scripts/", - ".github/workflows/", - ".githooks/", - ".gitleaks.toml", - ".gitleaksignore", - "README.md", - "config.local.yaml.example", -] as const; - -/** Built-in Pi tools that write a single file at `input.path`. */ -const WRITE_FILE_TOOLS = new Set(["write", "edit"]); - -/** - * Command wrappers skipped when locating the real command word in a bash - * segment, so `sudo tee bot/x` / `nohup cp evil bot/x` are still classified by - * the wrapped command. (`\cp` is neutralized earlier — the lexer strips the - * leading backslash.) - */ -const WRAPPER_CMDS = new Set([ - "sudo", - "command", - "nohup", - "time", - "env", - "builtin", - "exec", - "xargs", -]); - -export interface ToolCallLike { - /** Pi tool name: `write`, `edit`, `bash`, `read`, `grep`, … or a custom name. */ - toolName: string; - /** Raw tool input object (`{path,...}` for write/edit, `{command}` for bash). */ - input: Record | undefined; -} - -export interface ClassifyOptions { - /** - * Absolute workspace root the protected-prefix check is anchored at — the - * IMMUTABLE top of the protected tree. For a top-level Pi session this is the - * `ctx.cwd`; for a subagent child spawned with a caller-supplied `cwd` it is the - * PARENT workspace root (so the child cannot move the guard root). See - * {@link resolveRoot}. - */ - workspaceRoot: string | undefined; - /** - * Working dir RELATIVE targets resolve against (the process's real `ctx.cwd`). - * Defaults to {@link workspaceRoot} when absent, collapsing to the original - * single-root behavior. It diverges from `workspaceRoot` ONLY for a subagent - * child whose `cwd` was overridden: protection stays pinned to the parent - * workspace while a genuine relative write resolves where it actually lands, so - * an absolute write back into a protected dir is still caught and a legitimate - * relative write under the child's own cwd is not over-blocked. - */ - resolveRoot?: string; - /** - * Guardian orphan-allowlist patterns — the merged `orphan-allowlist.txt` + - * `orphan-allowlist.local.txt` root-level names/globs. When provided, a - * `write` that CREATES a new root-level entry whose first path segment matches - * NONE of these is blocked (the "workspace-structure rule" of criterion 2, - * ported from `guardian.sh`). `undefined` → the orphan check is disabled (the - * pure classifier never reads the filesystem itself; the wrapper injects this). - */ - orphanAllowlist?: readonly string[]; - /** - * Schema-driven write allow-list — the lines of `schema.md`'s - * ```` ```write-allowlist ```` fenced block (comments/blanks already stripped - * by the wrapper). When PRESENT (defined, even if empty), the guard switches to - * DENY-BY-DEFAULT: after the immutable-core deny, a write/edit/bash target is - * ALLOWED only if its workspace-relative path matches an allow line; otherwise - * it is BLOCKED with an actionable message naming `schema.md`. An EMPTY array - * means the `schema.md` block is missing/empty/unparseable → the allow-check - * fails CLOSED (the immutable core still blocks; everything else is denied). - * `undefined` → deny-by-default is OFF and the legacy {@link orphanAllowlist} - * model (if injected) is in force instead. The wrapper injects exactly ONE - * model per session — never both. See {@link isAllowedPath}. - */ - writeAllowlist?: readonly string[]; - /** - * Existence probe for overwrite detection (the wrapper passes `fs.existsSync`). - * An EXISTING target is an overwrite, never a new root-level entry, so it is - * exempt from the orphan check (guardian.sh parity). Default: nothing exists. - */ - fileExists?: (absPath: string) => boolean; -} - -export interface GuardDecision { - block: boolean; - reason?: string; -} - -function toPosix(p: string): string { - return p.replace(/\\/g, "/"); -} - -/** - * Is `relPath` (a workspace-root-relative POSIX path) covered by the immutable - * {@link PROTECTED_PREFIXES}? Two entry kinds: - * - trailing-slash directory entry → prefix match (also matches the bare - * directory name, e.g. `bot` as well as `bot/x`). - * - no-slash file entry → ROOT-ONLY EXACT match (`README.md` blocks the root - * file but NOT `docs/README.md`; a file entry never prefix-matches). - * Case-insensitive (APFS bug fix). - */ -export function isProtectedPath(relPath: string): boolean { - const lc = toPosix(relPath).replace(/^\.\//, "").toLowerCase(); - return PROTECTED_PREFIXES.some((entry) => { - const e = entry.toLowerCase(); - if (e.endsWith("/")) { - const base = e.slice(0, -1); // strip trailing slash → bare dir name - return lc === base || lc.startsWith(e); - } - // No-slash file entry: ROOT-ONLY EXACT (no prefix match). - return lc === e; - }); -} - -/** Compile a simple shell glob (`*`, `?`, literals) to an anchored RegExp. */ -function globToRegExp(pattern: string): RegExp { - const escaped = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex metachars (NOT * or ?) - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - return new RegExp(`^${escaped}$`); -} - -/** - * Does a write's root path component match the guardian orphan allowlist? - * Mirrors `guardian.sh`'s exact-or-glob `case` match; case-insensitive for APFS - * parity with the rest of this module (the allowlist patterns are lowercase). - */ -export function isAllowedRootComponent(rootComponent: string, allowlist: readonly string[]): boolean { - const rc = rootComponent.toLowerCase(); - return allowlist.some((raw) => { - const pat = raw.trim().toLowerCase(); - if (!pat) { - return false; - } - if (!pat.includes("*") && !pat.includes("?")) { - return rc === pat; - } - return globToRegExp(pat).test(rc); - }); -} - -/** - * Does `relPath` (a workspace-root-relative POSIX path) match an entry in the - * schema-driven write allow-list? Implements the three D17 line kinds, all - * case-insensitively (APFS parity with the rest of this module): - * - **Directory prefix** (trailing slash, e.g. `memory/`): matches the bare - * directory name itself OR anything under it (`memory` and `memory/x.md`). - * - **Root-only glob** (a bare glob with `*`/`?`, e.g. `*.md`): matches a - * ROOT-LEVEL file only — `relPath` has no `/` AND the glob matches it. - * - **Exact root-file** (no slash, no glob, e.g. `MEMORY.md`): matches that - * exact relative path only. - * Reuses {@link globToRegExp} for the root-only-glob kind. Deliberately does NOT - * reuse {@link isAllowedRootComponent} — that matches the FIRST path component - * only, which is the wrong granularity here (this check is full-relative-path). - */ -export function isAllowedPath(relPath: string, writeAllowlist: readonly string[]): boolean { - const lc = toPosix(relPath).replace(/^\.\//, "").toLowerCase(); - return writeAllowlist.some((raw) => { - const pat = raw.trim().toLowerCase(); - if (!pat) { - return false; - } - if (pat.endsWith("/")) { - // Directory prefix: the bare dir name OR anything under it. - const base = pat.slice(0, -1); - return lc === base || lc.startsWith(pat); - } - if (pat.includes("*") || pat.includes("?")) { - // Root-only glob: a ROOT-LEVEL file only (no `/` in the relative path). - return !lc.includes("/") && globToRegExp(pat).test(lc); - } - // Exact root-file: that exact relative path only (never a prefix match). - return lc === pat; - }); -} - -/** - * Suggest the `schema.md` allow-list line that would unblock `relPath`: the - * first directory component as a directory-prefix line when the path is nested - * (`docs/x.md` → `docs/`), or the exact path for a root-level file (`notes.md` → - * `notes.md`). Used to make the deny-by-default block message actionable. - */ -function suggestAllowLine(relPath: string): string { - const slash = relPath.indexOf("/"); - return slash === -1 ? relPath : relPath.slice(0, slash + 1); -} - -interface CollectedTargets { - tool: string; - /** Raw write-target path strings this tool call would modify. */ - targets: string[]; - /** True for write/edit (a missing path is anomalous → fail closed). */ - needsPath: boolean; -} - -function collectTargets(call: ToolCallLike): CollectedTargets { - const tool = call.toolName; - const input = call.input ?? {}; - - if (WRITE_FILE_TOOLS.has(tool)) { - const p = input.path; - return { tool, targets: typeof p === "string" ? [p] : [], needsPath: true }; - } - - if (tool === "bash") { - const cmd = input.command; - return { - tool, - targets: typeof cmd === "string" ? extractBashWriteTargets(cmd) : [], - needsPath: false, - }; - } - - // read / grep / find / ls / custom tools do not write a file → nothing to guard. - return { tool, targets: [], needsPath: false }; -} - -/** - * Classify one target path (relative or absolute) against the workspace root. - * Returns a block decision for protected prefixes (immutable core), for traversal - * escapes, for schema deny-by-default allow-list misses (when a `writeAllowlist` - * is injected — the new model), and (for `write`, legacy model) for guardian - * orphan-allowlist violations; allows everything else (incl. absolute paths that - * simply live outside the workspace, matching the legacy hook's within-workspace - * scope). Precedence: immutable-core deny > schema allow > default-deny. - * - * `workspaceRoot` anchors the protected-prefix/orphan check (the IMMUTABLE top of - * the protected tree); `resolveRoot` is the cwd RELATIVE targets resolve against. - * They are identical for a top-level session and for a default child — only a - * subagent child with an overridden `cwd` separates them (see {@link ClassifyOptions}). - */ -function classifyTargetPath( - rawTarget: string, - workspaceRoot: string, - resolveRoot: string, - tool: string, - opts: ClassifyOptions, -): GuardDecision { - const raw = rawTarget.trim(); - if (!raw) { - return { block: false }; - } - - // node:path.resolve/normalize canonicalize `.`, `..`, `//` — the traversal - // bug fix. A RELATIVE target resolves against the real working dir - // (`resolveRoot`), so its absolute location is exactly where the OS would write - // it; an absolute target is normalized as-is (so `/bot/../bot/x` collapses - // back into `bot/`). - const abs = normalize(isAbsolute(raw) ? raw : resolve(resolveRoot, raw)); - // Containment is decided CASE-INSENSITIVELY (APFS). Folding BOTH sides before - // `relative()` is essential: without it, an absolute target that case-varies - // the workspace-root prefix (e.g. `/users/...` for the real `/Users/...`) - // yields a `..`-escape relative path and is wrongly allowed below — even - // though APFS resolves it to the SAME protected file. `isProtectedPath` folds - // too, so the folded rel flows through consistently. - // - relProtect: position vs the IMMUTABLE protection root (prefix + orphan). - // - relResolve: position vs the real working dir (relative-traversal escape). - // For a single root the two are identical → original behavior, byte for byte. - const relProtect = toPosix(relative(normalize(workspaceRoot).toLowerCase(), abs.toLowerCase())); - const relResolve = toPosix(relative(normalize(resolveRoot).toLowerCase(), abs.toLowerCase())); - - // Target IS the workspace root directory — cannot write a file there. - if (relProtect === "") { - return { block: false }; - } - - // A RELATIVE target that climbs above its OWN working dir is a - // workspace-structure violation (traversal escape). An absolute target that - // merely lives outside the workspace (e.g. /tmp/log) is allowed. - const climbs = relResolve === ".." || relResolve.startsWith("../"); - if (climbs && !isAbsolute(raw)) { - return { - block: true, - reason: - `Blocked: ${tool} target "${rawTarget}" escapes the workspace via path ` + - `traversal (workspace-structure violation).`, - }; - } - - // Target lives OUTSIDE the protected workspace tree (an absolute path - // elsewhere, or a relative path that resolved under a different working dir) — - // nothing upstream-owned to protect there. - const outsideWorkspace = relProtect === ".." || relProtect.startsWith("../"); - if (outsideWorkspace) { - return { block: false }; - } - - if (isProtectedPath(relProtect)) { - return { - block: true, - reason: - `Blocked: ${tool} into upstream-owned path "${relProtect}" — these files ` + - `come from upstream (see .claude/rules/platform/bot-code-readonly.md). ` + - `Change it via a PR in the public repo, then merge upstream.`, - }; - } - - // Schema-enforced DENY-BY-DEFAULT allow-check. This runs AFTER the immutable - // core (deny-overlay > allow > default-deny) and is the NEW model, gated on a - // provided `writeAllowlist`. When present, a write/edit/bash target is allowed - // ONLY if its workspace-relative path matches an allow line (the three D17 - // kinds in `isAllowedPath`); otherwise it is BLOCKED. An EMPTY allow-list - // (missing/empty/unparseable `schema.md` block) denies everything non-immutable - // — the fail-CLOSED path (security never relaxes; never silently allow-all). - // `undefined` → this model is OFF and the legacy orphan check below applies - // instead (the wrapper injects exactly ONE model per session, never both). - if (opts.writeAllowlist !== undefined) { - if (isAllowedPath(relProtect, opts.writeAllowlist)) { - return { block: false }; - } - const reason = - opts.writeAllowlist.length === 0 - ? `Blocked (deny-by-default, fail-closed): ${tool} target "${relProtect}" — the ` + - `workspace write allow-list is empty or unreadable (schema.md is missing, or its ` + - "```write-allowlist``` block is empty/unparseable). Add the block to schema.md and " + - `register this path, notify the workspace owner, then retry. To bypass for one ` + - `session set PI_EXTENSIONS_DISABLED=1.` - : `Blocked (deny-by-default): ${tool} target "${relProtect}" is not in the workspace ` + - "write allow-list. Add a line to schema.md's ```write-allowlist``` block (e.g. " + - `"${suggestAllowLine(relProtect)}"), notify the workspace owner, then retry. To ` + - `bypass for one session set PI_EXTENSIONS_DISABLED=1.`; - return { block: true, reason }; - } - - // Guardian orphan check — the "workspace-structure rule" of criterion 2, - // ported from `guardian.sh`: a `write` that CREATES a NEW root-level entry - // whose first path segment is not in the orphan allowlist is blocked. Scope - // matches guardian.sh exactly: the `write` tool only (Edit targets existing - // content; bash redirects are out of guardian.sh's scope), and only when the - // target does not already exist (an overwrite is not a new entry). Disabled - // when no allowlist is injected (`isProtectedPath` already covers the - // security-critical prefixes regardless). - if (tool === "write" && opts.orphanAllowlist) { - const rootComponent = relProtect.split("/")[0]; - const exists = opts.fileExists?.(abs) ?? false; - if (!exists && !isAllowedRootComponent(rootComponent, opts.orphanAllowlist)) { - return { - block: true, - reason: - `Blocked: ${tool} would create a new root-level entry "${rootComponent}" ` + - `not in the workspace orphan-allowlist (orphan-allowlist.txt / ` + - `orphan-allowlist.local.txt). Add a pattern there, or write under an ` + - `existing allowed directory.`, - }; - } - } - - return { block: false }; -} - -/** - * Decide whether a Pi `tool_call` should be blocked. - * - * - write/edit with no resolvable path → fail closed (defense-in-depth). - * - read-only / non-writing tools → allow. - * - unknown workspace root → fail CLOSED (cannot verify → block). - * - any target hitting a protected prefix or escaping the workspace → block. - */ -export function classifyToolCall(call: ToolCallLike, opts: ClassifyOptions): GuardDecision { - const { tool, targets, needsPath } = collectTargets(call); - - if (needsPath && targets.length === 0) { - return { - block: true, - reason: `Blocked: ${tool} called without a resolvable file path (fail-closed).`, - }; - } - - if (targets.length === 0) { - return { block: false }; - } - - const root = opts.workspaceRoot?.trim(); - if (!root) { - return { - block: true, - reason: - `Blocked: workspace root unknown — cannot verify ${tool} target(s) ` + - `against protected paths (fail-closed).`, - }; - } - - // Relative targets resolve against the real working dir; absent, that IS the - // (immutable) protection root — collapsing to the original single-root guard. - const resolveRoot = opts.resolveRoot?.trim() || root; - - for (const raw of targets) { - const decision = classifyTargetPath(raw, root, resolveRoot, tool, opts); - if (decision.block) { - return decision; - } - } - - return { block: false }; -} - -// --- bash command parsing (best-effort, conservative) --------------------- - -interface Tok { - type: "word" | "op"; - value: string; -} - -/** - * Tokenize a shell command into words + operators, honoring single/double - * quotes and backslash escapes. Quote/escape handling neutralizes the `\cp` - * alias-bypass and unwraps quoted redirect targets (`> "bot/x"`). Best-effort: - * command substitution and process substitution are not interpreted. - */ -function lexShell(command: string): Tok[] { - const toks: Tok[] = []; - let word = ""; - let hasWord = false; - const n = command.length; - - const flush = (): void => { - if (hasWord) { - toks.push({ type: "word", value: word }); - word = ""; - hasWord = false; - } - }; - - let i = 0; - while (i < n) { - const c = command[i]; - - if (c === "'") { - const end = command.indexOf("'", i + 1); - hasWord = true; - if (end === -1) { - word += command.slice(i + 1); - break; - } - word += command.slice(i + 1, end); - i = end + 1; - continue; - } - - if (c === '"') { - let j = i + 1; - hasWord = true; - while (j < n) { - if (command[j] === "\\" && j + 1 < n) { - word += command[j + 1]; - j += 2; - continue; - } - if (command[j] === '"') { - break; - } - word += command[j]; - j++; - } - i = j + 1; - continue; - } - - if (c === "\\") { - if (i + 1 < n) { - word += command[i + 1]; - hasWord = true; - i += 2; - continue; - } - i++; - continue; - } - - if (c === " " || c === "\t") { - flush(); - i++; - continue; - } - - if (c === "\n" || c === "\r") { - flush(); - toks.push({ type: "op", value: "\n" }); - i++; - continue; - } - - if (c === ">") { - flush(); - if (command[i + 1] === ">") { - toks.push({ type: "op", value: ">>" }); - i += 2; - } else if (command[i + 1] === "|") { - // `>|` is the clobber redirect (force-overwrite under `noclobber`) — - // semantically a write redirect, so treat it the same as `>`. - toks.push({ type: "op", value: ">" }); - i += 2; - } else { - toks.push({ type: "op", value: ">" }); - i++; - } - continue; - } - - if (c === "<") { - flush(); - toks.push({ type: "op", value: "<" }); - i++; - continue; - } - - if (c === "|") { - flush(); - if (command[i + 1] === "|") { - toks.push({ type: "op", value: "||" }); - i += 2; - } else { - toks.push({ type: "op", value: "|" }); - i++; - } - continue; - } - - if (c === ";") { - flush(); - toks.push({ type: "op", value: ";" }); - i++; - continue; - } - - if (c === "&") { - flush(); - if (command[i + 1] === "&") { - toks.push({ type: "op", value: "&&" }); - i += 2; - } else { - toks.push({ type: "op", value: "&" }); - i++; - } - continue; - } - - word += c; - hasWord = true; - i++; - } - flush(); - return toks; -} - -const SEGMENT_SEPARATORS = new Set(["|", "||", "&&", ";", "&", "\n"]); - -/** Extract the write-target paths from a single pipeline segment's tokens. */ -function analyzeSegment(toks: Tok[]): string[] { - const targets: string[] = []; - const isRedirectTarget: boolean[] = new Array(toks.length).fill(false); - const isFdDesignator: boolean[] = new Array(toks.length).fill(false); - - for (let k = 0; k < toks.length; k++) { - const t = toks[k]; - if (t.type !== "op") { - continue; - } - if (t.value === ">" || t.value === ">>" || t.value === "<") { - // A word immediately preceding a redirect op is an fd designator (the `2` - // in `2> err`), not a positional arg. - if (toks[k - 1]?.type === "word") { - isFdDesignator[k - 1] = true; - } - // The word after a WRITE redirect is a write target. - if ((t.value === ">" || t.value === ">>") && toks[k + 1]?.type === "word") { - isRedirectTarget[k + 1] = true; - targets.push(toks[k + 1].value); - } - } - } - - const positional: string[] = []; - for (let k = 0; k < toks.length; k++) { - if (toks[k].type === "word" && !isRedirectTarget[k] && !isFdDesignator[k]) { - positional.push(toks[k].value); - } - } - - // Strip the command prefix to find the REAL command word. The prefix is any - // interleaving of: leading `VAR=value` env assignments (also `env`'s own - // VAR=val args), command wrappers (sudo/env/nohup/…), and the OPTIONS those - // wrappers take (`sudo -n`, `env -i`, `time -p`). Skipping only wrapper NAMES - // (the old two-loop form) left `env FOO=bar tee x` parsed as command `FOO=bar` - // and `sudo -n tee x` as command `-n`, missing the `tee` write target. - let ci = 0; - for (;;) { - if (ci >= positional.length) { - break; - } - const w = positional[ci]; - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) { - ci++; // VAR=value assignment (leading, or an arg to a preceding `env`) - continue; - } - if (WRAPPER_CMDS.has(basename(w))) { - ci++; // a command wrapper (sudo / env / nohup / time / …) - continue; - } - // A leading-dash word is a wrapper OPTION only once a wrapper/assignment has - // already been consumed (ci > 0). At ci === 0 the real command sits there and - // never starts with `-`, so a bare `tee -a x` keeps `tee` as the command. - if (ci > 0 && w.startsWith("-")) { - ci++; - continue; - } - break; // first non-assignment / non-wrapper / non-option word = the command - } - - const cmd = ci < positional.length ? basename(positional[ci]) : ""; - const rawArgs = positional.slice(ci + 1); - const argWords = rawArgs.filter((v) => !v.startsWith("-")); - - if (cmd === "tee") { - targets.push(...argWords); // tee writes every file argument - } else if (cmd === "mv") { - targets.push(...argWords); // mv: sources are deleted + dest created - } else if (cmd === "cp") { - // GNU `cp -t DIR` / `--target-directory=DIR` writes the sources INTO DIR, so - // the real destination is DIR — not the last positional arg the POSIX form - // uses. Honor the flag first so `cp -t bot a b` is caught. - const targetDir = extractTargetDirFlag(rawArgs); - if (targetDir !== undefined) { - targets.push(targetDir); - } else if (argWords.length > 0) { - targets.push(argWords[argWords.length - 1]); // cp dest = last arg - } - } - - return targets; -} - -/** - * Extract the GNU coreutils target directory from a `cp` arg list: - * `-t DIR`, `-tDIR`, `--target-directory[=DIR]`, and clustered short forms where - * `t` is bundled with other flags (`-vt DIR`, `-vtDIR`). In a short-flag cluster - * the `t` option consumes the rest of the cluster as its argument, or the next - * separate arg when it is the last letter — so `-vt bot a b` writes into `bot`, - * not `b`. Returns undefined when the flag is absent (POSIX `cp src dest` form). - */ -function extractTargetDirFlag(args: string[]): string | undefined { - for (let a = 0; a < args.length; a++) { - const w = args[a]; - - // Long form: --target-directory or --target-directory=DIR. - if (w === "--target-directory") { - const next = args[a + 1]; - if (next !== undefined && !next.startsWith("-")) { - return next; - } - continue; - } - if (w.startsWith("--target-directory=")) { - return w.slice("--target-directory=".length); - } - - // Short form: a single-dash cluster containing `t` (cp's only lowercase-`t` - // option is --target-directory; `-T` is a different option and is ignored by - // the case-sensitive search). `t` consumes the remainder of the cluster, or - // the next separate non-flag arg when it is the cluster's last letter. - if (w.startsWith("-") && !w.startsWith("--") && w.length > 1) { - const ti = w.indexOf("t"); - if (ti >= 1) { - const rest = w.slice(ti + 1); - if (rest.length > 0) { - return rest; - } - const next = args[a + 1]; - if (next !== undefined && !next.startsWith("-")) { - return next; - } - } - } - } - return undefined; -} - -/** - * Best-effort, conservative extraction of paths a bash command would write: - * `>` / `>>` redirects, `tee` file args, `mv` (sources + dest), `cp` (dest). - * Returns deduped, non-empty target strings. Defense-in-depth on top of the - * solid write/edit guarantee — not a full shell parser. - * - * Bash-redirect asymmetry (D16 — by design for v1): guardian.sh inspects only - * `tool_input.file_path` (the Write/Edit target) and never parses bash. The Pi - * guard runs the same deny-by-default allow-check over these extracted targets, - * so redirects are covered in the active runtime. - */ -export function extractBashWriteTargets(command: string): string[] { - const toks = lexShell(command); - const targets: string[] = []; - let segment: Tok[] = []; - - for (const t of toks) { - if (t.type === "op" && SEGMENT_SEPARATORS.has(t.value)) { - if (segment.length > 0) { - targets.push(...analyzeSegment(segment)); - } - segment = []; - } else { - segment.push(t); - } - } - if (segment.length > 0) { - targets.push(...analyzeSegment(segment)); - } - - return [...new Set(targets.map((s) => s.trim()).filter(Boolean))]; -} diff --git a/bot/src/pi-extensions/subagent-args.ts b/bot/src/pi-extensions/subagent-args.ts index 44c8dad1..fdaabfe4 100644 --- a/bot/src/pi-extensions/subagent-args.ts +++ b/bot/src/pi-extensions/subagent-args.ts @@ -55,13 +55,11 @@ export interface BuildSubagentSpawnArgsOptions { */ systemPromptPath?: string; /** - * Pre-resolved `--extension ` args to load into the child (e.g. the - * A1 write guard, so a delegated task cannot bypass the guard a parent session - * runs under). Appended verbatim BEFORE the positional task. Empty/absent → - * the child loads no explicit first-party extensions (e.g. when the kill-switch - * is set). The child still passes `--no-extensions` to block Pi's ambient - * discovery. The caller resolves these (`resolvePiExtensionArgs`) so this module - * stays pure/testable. + * Pre-resolved `--extension ` args to load into the child. Appended + * verbatim BEFORE the positional task. Empty/absent means the child loads no + * explicit first-party extensions. The child still passes `--no-extensions` to + * block Pi's ambient discovery. The caller resolves these (`resolvePiExtensionArgs`) + * so this module stays pure/testable. */ extensionArgs?: string[]; } @@ -294,10 +292,17 @@ export interface SubagentChildLike { } /** Injected spawn (real: `node:child_process.spawn`; tests: a fake). */ +export interface SubagentSpawnOptions { + cwd?: string; + env?: Record; + shell: false; + stdio: ["ignore", "pipe", "pipe"]; +} + export type SubagentSpawn = ( command: string, args: string[], - options: { cwd?: string }, + options: SubagentSpawnOptions, ) => SubagentChildLike; export interface SubagentRunResult extends SubagentResultLike { @@ -316,6 +321,8 @@ export interface RunSubagentChildDeps { args: string[]; /** Working directory for the child. */ cwd?: string; + /** Allowlisted child environment. */ + env?: Record; /** Abort signal — when aborted, SIGTERM then (after a grace) SIGKILL. */ signal?: AbortSignal; /** Streaming hook fired after each parsed message (drives onUpdate). */ @@ -405,7 +412,12 @@ export function runSubagentChild(deps: RunSubagentChildDeps): Promise { buffer += chunk.toString(); diff --git a/bot/src/pi-extensions/tavily-secret.ts b/bot/src/pi-extensions/tavily-secret.ts index f4c20ffd..424e037a 100644 --- a/bot/src/pi-extensions/tavily-secret.ts +++ b/bot/src/pi-extensions/tavily-secret.ts @@ -1,23 +1,33 @@ import { resolve } from "node:path"; import { readSopsSecret, type ExecFileSyncLike } from "../secrets.js"; +import { MINIME_WORKSPACE_ROOT_ENV } from "../workspace-contract.js"; import { TAVILY_SOPS_FILE_RELPATH, TAVILY_SOPS_KEY } from "./tavily-constants.js"; export { TAVILY_SOPS_FILE_RELPATH, TAVILY_SOPS_KEY } from "./tavily-constants.js"; export interface ReadTavilyApiKeyOptions { - cwd?: string; + controlWorkspaceRoot?: string; + env?: NodeJS.ProcessEnv; execFileSync?: ExecFileSyncLike; } -export function tavilySopsFilePath(cwd: string = process.cwd()): string { - return resolve(cwd, TAVILY_SOPS_FILE_RELPATH); +export function tavilyControlWorkspaceRoot(env: NodeJS.ProcessEnv = process.env): string | undefined { + const root = env[MINIME_WORKSPACE_ROOT_ENV]?.trim(); + return root ? resolve(root) : undefined; } -/** Read the Tavily key from the workspace-private SOPS file; never throws. */ +export function tavilySopsFilePath(controlWorkspaceRoot: string): string { + return resolve(controlWorkspaceRoot, TAVILY_SOPS_FILE_RELPATH); +} + +/** Read the Tavily key from the control-workspace SOPS file; never throws. */ export function readTavilyApiKeyFromSops(opts: ReadTavilyApiKeyOptions = {}): string | undefined { + const controlWorkspaceRoot = opts.controlWorkspaceRoot ?? tavilyControlWorkspaceRoot(opts.env); + if (!controlWorkspaceRoot) return undefined; + try { return readSopsSecret({ - file: tavilySopsFilePath(opts.cwd), + file: tavilySopsFilePath(controlWorkspaceRoot), key: TAVILY_SOPS_KEY, execFileSync: opts.execFileSync, }); diff --git a/bot/src/pi-extensions/tavily.ts b/bot/src/pi-extensions/tavily.ts index 3558897e..01b4e6a5 100644 --- a/bot/src/pi-extensions/tavily.ts +++ b/bot/src/pi-extensions/tavily.ts @@ -596,7 +596,7 @@ function errResult(text: string): WebToolResult { function missingKeyText(tool: "web_search" | "web_fetch"): string { return `${tool} is unavailable: Tavily API key not configured (SOPS key ` + `${TAVILY_SOPS_KEY} in ${TAVILY_SOPS_FILE_RELPATH}). Add the private ` + - "Tavily-only workspace SOPS file and restart the bot."; + "control-workspace Tavily SOPS file and restart the bot."; } /** diff --git a/bot/src/pi-extensions/write-allowlist-schema.ts b/bot/src/pi-extensions/write-allowlist-schema.ts deleted file mode 100644 index 24d31dab..00000000 --- a/bot/src/pi-extensions/write-allowlist-schema.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { readFileSync } from "node:fs"; -import { isAbsolute, normalize, resolve } from "node:path"; -import { MINIME_SCHEMA_PATH_ENV } from "../workspace-contract.js"; - -export const WRITE_ALLOWLIST_FENCE = "```write-allowlist"; - -export type WriteAllowlistSchemaIssueKind = - | "missing-file" - | "unreadable-file" - | "missing-block" - | "malformed-block" - | "empty-block"; - -export interface WriteAllowlistSchemaIssue { - kind: WriteAllowlistSchemaIssueKind; - message: string; -} - -export interface WriteAllowlistSchemaResult { - schemaPath: string; - entries: string[]; - issue?: WriteAllowlistSchemaIssue; -} - -export type ReadSchemaFile = (schemaPath: string) => string; - -function defaultReadSchemaFile(schemaPath: string): string { - return readFileSync(schemaPath, "utf8"); -} - -function issue( - schemaPath: string, - kind: WriteAllowlistSchemaIssueKind, - message: string, -): WriteAllowlistSchemaResult { - return { - schemaPath, - entries: [], - issue: { kind, message }, - }; -} - -export function resolveWriteAllowlistSchemaPath( - workspaceRoot: string, - env: NodeJS.ProcessEnv = process.env, -): string { - const override = env[MINIME_SCHEMA_PATH_ENV]?.trim(); - if (override) { - return normalize(isAbsolute(override) ? override : resolve(workspaceRoot, override)); - } - return normalize(resolve(workspaceRoot, "schema.md")); -} - -export function parseWriteAllowlistSchemaContent( - content: string, - schemaPath = "schema.md", -): WriteAllowlistSchemaResult { - const entries: string[] = []; - let inBlock = false; - let foundBlock = false; - let closedBlock = false; - - for (const rawLine of content.split("\n")) { - if (!inBlock) { - if (rawLine === WRITE_ALLOWLIST_FENCE) { - inBlock = true; - foundBlock = true; - } - continue; - } - - if (rawLine.startsWith("```")) { - closedBlock = true; - break; - } - - const line = rawLine.replace(/#.*$/, "").trim(); - if (line) { - entries.push(line); - } - } - - if (!foundBlock) { - return issue( - schemaPath, - "missing-block", - `schema does not contain an exact ${WRITE_ALLOWLIST_FENCE} fenced block`, - ); - } - - if (!closedBlock) { - return issue( - schemaPath, - "malformed-block", - "schema write-allowlist block is missing a closing fence", - ); - } - - if (entries.length === 0) { - return issue( - schemaPath, - "empty-block", - "schema write-allowlist block is empty after comments and blank lines are removed", - ); - } - - return { schemaPath, entries }; -} - -export function readWriteAllowlistSchema( - schemaPath: string, - readSchemaFile: ReadSchemaFile = defaultReadSchemaFile, -): WriteAllowlistSchemaResult { - let content: string; - try { - content = readSchemaFile(schemaPath); - } catch (err) { - const code = typeof err === "object" && err !== null && "code" in err - ? String((err as NodeJS.ErrnoException).code) - : ""; - if (code === "ENOENT") { - return issue(schemaPath, "missing-file", `schema file does not exist: ${schemaPath}`); - } - return issue(schemaPath, "unreadable-file", `schema file cannot be read: ${schemaPath}`); - } - - return parseWriteAllowlistSchemaContent(content, schemaPath); -} - -export function readWriteAllowlistEntriesForGuard( - schemaPath: string, - readSchemaFile?: ReadSchemaFile, -): string[] { - const result = readWriteAllowlistSchema(schemaPath, readSchemaFile); - return result.issue ? [] : result.entries; -} diff --git a/bot/src/pi-rpc-protocol.ts b/bot/src/pi-rpc-protocol.ts index f8d6b570..66024741 100644 --- a/bot/src/pi-rpc-protocol.ts +++ b/bot/src/pi-rpc-protocol.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import { StringDecoder } from "node:string_decoder"; -import { existsSync, realpathSync, statSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { normalize, resolve } from "node:path"; import type { AgentConfig, @@ -13,8 +13,9 @@ import type { import { log } from "./logger.js"; import { assemblePiContext } from "./pi-context-assembler.js"; import { - MINIME_SCHEMA_PATH_ENV, - realPathIsInsideOrEqual, + MINIME_CONFIG_PATH_ENV, + MINIME_CRONS_PATH_ENV, + MINIME_WORKSPACE_ROOT_ENV, resolveAgentWorkspaceCwd, resolveWorkspaceContract, type ResolvedWorkspaceContract, @@ -26,43 +27,36 @@ const DEFAULT_PI_MODEL = "openai-codex/gpt-5.5"; /** * Wrapper entrypoints loaded into EVERY Pi spawn, in load order: - * A1 guardian+protect-files write guard, - * A2 web-tools (Tavily web_search/web_fetch), - * A3 subagent (isolated `pi -p` child spawn). - * Paths are relative to {@link DEFAULT_PI_EXTENSIONS_DIR}. A3 is a multi-file + * web-tools (Tavily web_search/web_fetch), + * subagent (isolated `pi -p` child spawn). + * Paths are relative to {@link DEFAULT_PI_EXTENSIONS_DIR}. subagent is a multi-file * DIRECTORY whose entrypoint is `index.ts`. */ export const PI_EXTENSION_WRAPPER_RELPATHS = [ - "guardian-protect-files.ts", "web-tools.ts", "subagent/index.ts", ] as const; export const PI_EXTENSION_ARTIFACT_WRAPPER_RELPATHS = [ - "guardian-protect-files.js", "web-tools.js", "subagent/index.js", ] as const; /** * Wrappers a subagent CHILD `pi` spawn must load. The subagent tool spawns an - * isolated `pi -p` child to run a delegated task; without the A1 write guard a - * parent could delegate a protected write (e.g. into `bot/`) to a child and - * bypass A1 entirely. Children load the guard plus A2 web tools so delegated - * research can use web_search/web_fetch, but they do NOT load A3 subagent: the - * recursive spawn tool stays disabled in child sessions. Honors the same - * kill-switch + fail-closed resolution as the parent (via - * {@link resolvePiExtensionArgs}). + * isolated `pi -p` child to run a delegated task. Children load web tools so + * delegated research can use web_search/web_fetch, but they do NOT load the + * subagent wrapper: recursive spawning stays disabled in child sessions. */ -export const PI_SUBAGENT_CHILD_WRAPPER_RELPATHS = ["guardian-protect-files.ts", "web-tools.ts"] as const; +export const PI_SUBAGENT_CHILD_WRAPPER_RELPATHS = ["web-tools.ts"] as const; /** - * Wrappers a Pi print-mode cron must load. Crons require only the A1 write - * guard; they intentionally do not get A2 web tools or A3 subagent parity. + * Wrappers a Pi print-mode cron must load. Crons intentionally do not get + * interactive web-tools or subagent parity. */ -export const PI_CRON_WRAPPER_RELPATHS = ["guardian-protect-files.ts"] as const; +export const PI_CRON_WRAPPER_RELPATHS = [] as const; -export const PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS = ["guardian-protect-files.js", "web-tools.js"] as const; +export const PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS = ["web-tools.js"] as const; /** * Kill-switch env var: set to exactly `"1"` to spawn Pi with no explicit @@ -71,20 +65,6 @@ export const PI_SUBAGENT_CHILD_ARTIFACT_WRAPPER_RELPATHS = ["guardian-protect-fi */ export const PI_EXTENSIONS_DISABLED_ENV = "PI_EXTENSIONS_DISABLED"; -/** - * Env var carrying the IMMUTABLE parent workspace root to a subagent CHILD's A1 - * guard. A child is spawned with a caller-controlled `cwd` (the subagent tool's - * `cwd` param), so its guard must NOT anchor the protected-prefix check on its own - * `ctx.cwd`: a parent could delegate `cwd:"/tmp"` + an absolute write back into a - * protected dir (`/bot/x`), which resolves OUTSIDE `/tmp` and would be allowed - * — bypassing A1 entirely. The subagent spawn sets this to the parent workspace - * root; the guard wrapper prefers it over `ctx.cwd` for protection while still - * resolving RELATIVE paths against the child's real cwd. Top-level parent spawns - * also set this from the workspace contract because an agent's cwd may be a child - * directory inside the workspace. - */ -export const PI_GUARD_WORKSPACE_ROOT_ENV = "PI_GUARD_WORKSPACE_ROOT"; - export interface PiExtensionResolveOptions { /** Override the wrapper base dir (default: resolved workspace/package contract). */ extensionsDir?: string; @@ -93,10 +73,10 @@ export interface PiExtensionResolveOptions { /** Override the existence check (default: `fs.existsSync`). */ exists?: (path: string) => boolean; /** - * Which wrapper relpaths to resolve (default: the full A1-A3 + * Which wrapper relpaths to resolve (default: the full interactive * {@link PI_EXTENSION_WRAPPER_RELPATHS}). A subagent child passes - * {@link PI_SUBAGENT_CHILD_WRAPPER_RELPATHS} to load only A1 guard + A2 web - * tools, leaving recursive A3 subagent spawning disabled. + * {@link PI_SUBAGENT_CHILD_WRAPPER_RELPATHS} to load web tools while leaving + * recursive subagent spawning disabled. */ relpaths?: readonly string[]; } @@ -108,6 +88,9 @@ const PI_CHILD_ENV_KEY_ALLOWLIST = new Set([ "HOME", "LANG", "LOGNAME", + MINIME_CONFIG_PATH_ENV, + MINIME_CRONS_PATH_ENV, + MINIME_WORKSPACE_ROOT_ENV, "NO_COLOR", "PATH", "PI_CODING_AGENT_DIR", @@ -153,10 +136,9 @@ export function shouldIncludePiChildEnvKey(key: string): boolean { * first-party wrappers; callers still pass `--no-extensions` to keep ambient * discovery disabled. * - * FAIL-CLOSED: a configured wrapper missing on disk THROWS loudly instead of - * silently dropping it — A1 is the write guard, so a silent skip would spawn an - * UNGUARDED Pi session able to edit upstream-owned paths. The thrown message - * names the missing path and points at the kill-switch as the deliberate bypass. + * A configured wrapper missing on disk throws loudly instead of silently + * dropping part of the first-party extension contract. The thrown message names + * the missing path and points at the kill-switch as the deliberate bypass. */ export function resolvePiExtensionArgs(options?: PiExtensionResolveOptions): string[] { const env = options?.env ?? process.env; @@ -173,8 +155,8 @@ export function resolvePiExtensionArgs(options?: PiExtensionResolveOptions): str const abs = resolve(baseDir, piExtensionRelpathForDir(baseDir, rel)); if (!fileExists(abs)) { throw new Error( - `Pi extension wrapper not found: ${abs}. Refusing to spawn an unguarded ` + - `Pi session. Restore the wrapper, or set ${PI_EXTENSIONS_DISABLED_ENV}=1 ` + + `Pi extension wrapper not found: ${abs}. Refusing to spawn without the ` + + `expected first-party extensions. Restore the wrapper, or set ${PI_EXTENSIONS_DISABLED_ENV}=1 ` + `to spawn without explicit first-party extensions.`, ); } @@ -305,20 +287,13 @@ function validateAgentWorkspaceCwd( if (!existsSync(agentWorkspace)) { throw new Error( `Agent "${agent.id}" workspaceCwd does not exist: ${agentWorkspace}. ` + - `Set MINIME_WORKSPACE_ROOT/--workspace to the owning workspace or create the agent workspace under it.`, + `Create the configured agent workspace or update agents.${agent.id}.workspaceCwd.`, ); } if (!statSync(agentWorkspace).isDirectory()) { throw new Error( `Agent "${agent.id}" workspaceCwd is not a directory: ${agentWorkspace}. ` + - `Set MINIME_WORKSPACE_ROOT/--workspace to the owning workspace or move the agent workspace under it.`, - ); - } - if (!realPathIsInsideOrEqual(contract.paths.workspaceRoot, agentWorkspace)) { - throw new Error( - `Agent "${agent.id}" workspaceCwd must be inside the resolved workspace root for Pi guard enforcement: ` + - `workspaceCwd=${agentWorkspace} workspaceRoot=${contract.paths.workspaceRoot}. ` + - `Set MINIME_WORKSPACE_ROOT/--workspace to the owning workspace or move the agent workspace under it.`, + `Point agents.${agent.id}.workspaceCwd at a directory.`, ); } return agentWorkspace; @@ -375,8 +350,8 @@ export function buildPiSpawnArgs( } // Keep `--no-extensions` on every spawn to suppress Pi's ambient extension - // discovery; load A1-A3 only as explicit repeatable `--extension ` - // args. The kill-switch and the fail-closed missing-wrapper check live in + // discovery; load first-party wrappers only as explicit repeatable + // `--extension ` args. The kill-switch and missing-wrapper check live in // resolvePiExtensionArgs. args.push(...resolvePiExtensionArgs(extensionOptions)); @@ -391,24 +366,15 @@ export function buildPiSpawnArgs( return args; } -export function buildPiSpawnEnv(agent: AgentConfig): Record { - const contract = resolveWorkspaceContract(); - validateAgentWorkspaceCwd(agent, contract); - - const env = buildAllowedPiChildEnv(); - env[PI_GUARD_WORKSPACE_ROOT_ENV] = realpathSync(contract.paths.workspaceRoot); - env[MINIME_SCHEMA_PATH_ENV] = contract.paths.schemaPath; - return env; +export function buildPiSpawnEnv(): Record { + return buildAllowedPiChildEnv(resolveWorkspaceContract()); } -export function buildPiSubagentChildSpawnEnv(guardWorkspaceRoot: string): Record { - return { - ...buildAllowedPiChildEnv(), - [PI_GUARD_WORKSPACE_ROOT_ENV]: realpathSync(guardWorkspaceRoot), - }; +export function buildPiSubagentChildSpawnEnv(): Record { + return buildAllowedPiChildEnv(resolveWorkspaceContract()); } -function buildAllowedPiChildEnv(): Record { +function buildAllowedPiChildEnv(contract: ResolvedWorkspaceContract): Record { const env: Record = {}; for (const [key, val] of Object.entries(process.env)) { if (val !== undefined && shouldIncludePiChildEnvKey(key)) { @@ -429,10 +395,32 @@ function buildAllowedPiChildEnv(): Record { pathParts.unshift("/opt/homebrew/bin"); } env.PATH = pathParts.join(":"); + env[MINIME_WORKSPACE_ROOT_ENV] = contract.paths.controlWorkspaceRoot; + copyExplicitControlPathEnv(env, contract, MINIME_CONFIG_PATH_ENV, "configPath"); + copyExplicitControlPathEnv(env, contract, MINIME_CRONS_PATH_ENV, "cronsPath"); return env; } +function copyExplicitControlPathEnv( + env: Record, + contract: ResolvedWorkspaceContract, + envKey: typeof MINIME_CONFIG_PATH_ENV | typeof MINIME_CRONS_PATH_ENV, + pathName: "configPath" | "cronsPath", +): void { + if (contract.effectivePaths[pathName].source !== "env") { + delete env[envKey]; + return; + } + + const value = process.env[envKey]?.trim(); + if (value) { + env[envKey] = value; + } else { + delete env[envKey]; + } +} + /** * Startup diagnostics stashed on a Pi child by `spawnPiRpcSession` so the spawn * caller can classify a startup failure WITHOUT re-piping stderr. `piStartupStderr()` @@ -450,7 +438,7 @@ const PI_STARTUP_STDERR_CAP = 64 * 1024; export function spawnPiRpcSession(agent: AgentConfig, resumeSessionId?: string): ChildProcess { const workspaceCwd = resolveValidatedPiAgentWorkspaceCwd(agent); const spawnAgent = { ...agent, workspaceCwd }; - const env = buildPiSpawnEnv(spawnAgent); + const env = buildPiSpawnEnv(); const child = spawn(PI_BIN, buildPiSpawnArgs(spawnAgent, resumeSessionId), { env, cwd: workspaceCwd, diff --git a/bot/src/types.ts b/bot/src/types.ts index 479d37bb..8da73165 100644 --- a/bot/src/types.ts +++ b/bot/src/types.ts @@ -6,6 +6,7 @@ export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "x export interface AgentConfig { id: string; + /** Resolved agent workspace root used as the Pi session cwd and context source. */ workspaceCwd: string; model: string; systemPrompt?: string; diff --git a/bot/src/workspace-contract.ts b/bot/src/workspace-contract.ts index 3369d033..171bcb25 100644 --- a/bot/src/workspace-contract.ts +++ b/bot/src/workspace-contract.ts @@ -1,12 +1,10 @@ -import { realpathSync } from "node:fs"; import { homedir } from "node:os"; -import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, normalize, resolve } from "node:path"; import { fileURLToPath } from "node:url"; export const MINIME_WORKSPACE_ROOT_ENV = "MINIME_WORKSPACE_ROOT"; export const MINIME_CONFIG_PATH_ENV = "MINIME_CONFIG_PATH"; export const MINIME_CRONS_PATH_ENV = "MINIME_CRONS_PATH"; -export const MINIME_SCHEMA_PATH_ENV = "MINIME_SCHEMA_PATH"; export type WorkspacePathSource = | "cli" @@ -23,12 +21,15 @@ export interface WorkspacePathDiagnostic { } export interface WorkspaceContractPaths { + /** Installed/source package root that owns runtime code and bundled extensions. */ packageRoot: string; botRoot: string; + /** Control/app workspace root selected by --workspace or MINIME_WORKSPACE_ROOT. */ + controlWorkspaceRoot: string; + /** Backwards-compatible alias for controlWorkspaceRoot. */ workspaceRoot: string; configPath: string; cronsPath: string; - schemaPath: string; piExtensionDir: string; dataDir: string; sessionStorePath: string; @@ -99,7 +100,7 @@ function inferPiExtensionDir(packageRoot: string, moduleUrl: string): WorkspaceP }; } -function workspaceRootFromOptions( +function controlWorkspaceRootFromOptions( options: Required> & { packageRoot: string; workspace?: string; @@ -125,7 +126,7 @@ function workspaceRootFromOptions( return { diagnostic: { path: options.cwd, source: "cwd-fallback" }, warnings: [ - `No workspace root was supplied; using cwd (${options.cwd}). Pass --workspace or ` + + `No control workspace root was supplied; using cwd (${options.cwd}). Pass --workspace or ` + `${MINIME_WORKSPACE_ROOT_ENV} when running from a package install.`, ], }; @@ -143,15 +144,15 @@ function workspaceRootFromOptions( function pathOverrideOrWorkspaceDefault( env: NodeJS.ProcessEnv, envKey: string, - workspaceRoot: string, + controlWorkspaceRoot: string, defaultFileName: string, ): WorkspacePathDiagnostic { const override = optionalEnvPath(env, envKey); if (override) { - return { path: absolutePath(override, workspaceRoot), source: "env" }; + return { path: absolutePath(override, controlWorkspaceRoot), source: "env" }; } return { - path: normalize(resolve(workspaceRoot, defaultFileName)), + path: normalize(resolve(controlWorkspaceRoot, defaultFileName)), source: "workspace-default", }; } @@ -179,7 +180,7 @@ export function resolveWorkspaceContract( path: botRoot, source: "package-default", }; - const workspaceRootResult = workspaceRootFromOptions({ + const workspaceRootResult = controlWorkspaceRootFromOptions({ cwd, env, packageRoot, @@ -198,12 +199,6 @@ export function resolveWorkspaceContract( workspaceRootDiag.path, "crons.yaml", ); - const schemaPathDiag = pathOverrideOrWorkspaceDefault( - env, - MINIME_SCHEMA_PATH_ENV, - workspaceRootDiag.path, - "schema.md", - ); const piExtensionDirDiag = inferPiExtensionDir(packageRoot, moduleUrl); const dataDirDiag: WorkspacePathDiagnostic = { path: normalize(resolve(workspaceRootDiag.path, "data")), @@ -240,10 +235,10 @@ export function resolveWorkspaceContract( const effectivePaths: WorkspaceContractEffectivePaths = { packageRoot: packageRootDiag, botRoot: botRootDiag, + controlWorkspaceRoot: workspaceRootDiag, workspaceRoot: workspaceRootDiag, configPath: configPathDiag, cronsPath: cronsPathDiag, - schemaPath: schemaPathDiag, piExtensionDir: piExtensionDirDiag, dataDir: dataDirDiag, sessionStorePath: sessionStorePathDiag, @@ -267,19 +262,6 @@ export function workspaceContractDiagnostics( return contract.effectivePaths; } -export function resolveAgentWorkspaceCwd(workspaceRoot: string, workspaceCwd: string): string { - return normalize(isAbsolute(workspaceCwd) ? workspaceCwd : resolve(workspaceRoot, workspaceCwd)); -} - -export function pathIsInsideOrEqual(parent: string, candidate: string): boolean { - const rel = relative(normalize(parent), normalize(candidate)); - return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); -} - -export function realPathIsInsideOrEqual(parent: string, candidate: string): boolean { - try { - return pathIsInsideOrEqual(realpathSync.native(parent), realpathSync.native(candidate)); - } catch { - return false; - } +export function resolveAgentWorkspaceCwd(controlWorkspaceRoot: string, workspaceCwd: string): string { + return normalize(isAbsolute(workspaceCwd) ? workspaceCwd : resolve(controlWorkspaceRoot, workspaceCwd)); } diff --git a/bot/src/workspace-validator.ts b/bot/src/workspace-validator.ts index ae6c9fbb..64c694cf 100644 --- a/bot/src/workspace-validator.ts +++ b/bot/src/workspace-validator.ts @@ -1,16 +1,9 @@ import { existsSync, statSync } from "node:fs"; -import { normalize, resolve } from "node:path"; +import { join } from "node:path"; import { loadConfig } from "./config.js"; import { loadMergedCrons } from "./cron-runner.js"; -import { PI_EXTENSIONS_DISABLED_ENV } from "./pi-rpc-protocol.js"; -import { - readWriteAllowlistSchema, - resolveWriteAllowlistSchemaPath, - type WriteAllowlistSchemaResult, -} from "./pi-extensions/write-allowlist-schema.js"; import type { BotConfig } from "./types.js"; import { - realPathIsInsideOrEqual, resolveAgentWorkspaceCwd, type ResolvedWorkspaceContract, } from "./workspace-contract.js"; @@ -26,15 +19,9 @@ export interface WorkspaceValidationResult { contract: ResolvedWorkspaceContract; config?: BotConfig; crons?: Array>; - schema?: WriteAllowlistSchemaResult; issues: WorkspaceValidationIssue[]; } -export interface ValidateWorkspaceOptions { - env?: NodeJS.ProcessEnv; - guardEnforcementEnabled?: boolean; -} - function issue( issues: WorkspaceValidationIssue[], severity: WorkspaceValidationSeverity, @@ -59,6 +46,26 @@ function existsAsFile(path: string): boolean { return safeStat(path)?.isFile() === true; } +function warnIfMissingAgentContext( + issues: WorkspaceValidationIssue[], + agentId: string, + agentWorkspace: string, +): void { + for (const fileName of ["CLAUDE.md", "MEMORY.md"] as const) { + const path = join(agentWorkspace, fileName); + if (!existsAsFile(path)) { + issue(issues, "warning", `agent "${agentId}" context file is not present: ${path}`); + } + } + + for (const relDir of [join(".claude", "rules", "platform"), join(".claude", "rules", "custom")] as const) { + const path = join(agentWorkspace, relDir); + if (!existsAsDirectory(path)) { + issue(issues, "warning", `agent "${agentId}" rules dir is not present: ${path}`); + } + } +} + function describePathKind(path: string): string { if (!existsSync(path)) { return "does not exist"; @@ -76,10 +83,6 @@ function describePathKind(path: string): string { return "is not a regular file"; } -function schemaGuardEnabled(options: ValidateWorkspaceOptions, env: NodeJS.ProcessEnv): boolean { - return options.guardEnforcementEnabled ?? env[PI_EXTENSIONS_DISABLED_ENV] !== "1"; -} - export function workspaceValidationErrors( result: WorkspaceValidationResult, ): WorkspaceValidationIssue[] { @@ -94,17 +97,15 @@ export function workspaceValidationWarnings( export function validateWorkspaceContract( contract: ResolvedWorkspaceContract, - options: ValidateWorkspaceOptions = {}, ): WorkspaceValidationResult { - const env = options.env ?? process.env; const issues: WorkspaceValidationIssue[] = []; let config: BotConfig | undefined; let crons: Array> | undefined; if (!existsSync(contract.paths.workspaceRoot)) { - issue(issues, "error", `workspace root does not exist: ${contract.paths.workspaceRoot}`); + issue(issues, "error", `control workspace root does not exist: ${contract.paths.workspaceRoot}`); } else if (!existsAsDirectory(contract.paths.workspaceRoot)) { - issue(issues, "error", `workspace root is not a directory: ${contract.paths.workspaceRoot}`); + issue(issues, "error", `control workspace root is not a directory: ${contract.paths.workspaceRoot}`); } if (!existsAsFile(contract.paths.configPath)) { @@ -132,34 +133,6 @@ export function validateWorkspaceContract( } } - const defaultSchemaPath = normalize(resolve(contract.paths.workspaceRoot, "schema.md")); - if (contract.effectivePaths.schemaPath.source !== "env" && contract.paths.schemaPath !== defaultSchemaPath) { - issue( - issues, - "error", - `schema path must default to workspace root schema.md when no override is set: ${contract.paths.schemaPath}`, - ); - } - - const guardSchemaPath = resolveWriteAllowlistSchemaPath(contract.paths.workspaceRoot, env); - if (guardSchemaPath !== contract.paths.schemaPath) { - issue( - issues, - "error", - `live guard schema path does not match validator schema path: guard=${guardSchemaPath} validator=${contract.paths.schemaPath}`, - ); - } - - let schema: WriteAllowlistSchemaResult | undefined; - if (schemaGuardEnabled(options, env)) { - schema = readWriteAllowlistSchema(contract.paths.schemaPath); - if (schema.issue) { - issue(issues, "error", `schema validation failed: ${schema.issue.message}`); - } - } else { - issue(issues, "warning", `Pi guard enforcement is disabled by ${PI_EXTENSIONS_DISABLED_ENV}=1; schema allow-list was not enforced`); - } - if (config) { for (const [agentId, agent] of Object.entries(config.agents)) { const agentWorkspace = resolveAgentWorkspaceCwd(contract.paths.workspaceRoot, agent.workspaceCwd); @@ -167,13 +140,8 @@ export function validateWorkspaceContract( issue(issues, "error", `agent "${agentId}" workspaceCwd does not exist: ${agentWorkspace}`); } else if (!existsAsDirectory(agentWorkspace)) { issue(issues, "error", `agent "${agentId}" workspaceCwd is not a directory: ${agentWorkspace}`); - } else if (!realPathIsInsideOrEqual(contract.paths.workspaceRoot, agentWorkspace)) { - issue( - issues, - "error", - `agent "${agentId}" workspaceCwd must be inside the resolved workspace root for Pi guard enforcement: ` + - `workspaceCwd=${agentWorkspace} workspaceRoot=${contract.paths.workspaceRoot}`, - ); + } else { + warnIfMissingAgentContext(issues, agentId, agentWorkspace); } } } @@ -188,5 +156,5 @@ export function validateWorkspaceContract( issue(issues, "warning", warning); } - return { contract, config, crons, schema, issues }; + return { contract, config, crons, issues }; } diff --git a/bot/test-fixtures/minimal-workspace/schema.md b/bot/test-fixtures/minimal-workspace/schema.md deleted file mode 100644 index 10129325..00000000 --- a/bot/test-fixtures/minimal-workspace/schema.md +++ /dev/null @@ -1,7 +0,0 @@ -# Minimal Workspace Schema - -```write-allowlist -agent-workspace/ -*.md -schema.md -``` diff --git a/config.local.yaml.example b/config.local.yaml.example index 71a26393..32914c58 100644 --- a/config.local.yaml.example +++ b/config.local.yaml.example @@ -20,7 +20,7 @@ agents: main: - workspaceCwd: /absolute/path/to/agent/workspace # must be inside the resolved workspace root (~ not expanded) + workspaceCwd: /absolute/path/to/agent/workspace # relative paths resolve under the control workspace; absolute paths may live outside it (~ not expanded) model: gpt-5.5 # provider: pi # optional compatibility field; omit or set to "pi" # thinking: xhigh # optional Pi/Codex thinking level: off, minimal, low, medium, high, xhigh diff --git a/crons.yaml b/crons.yaml index 6add9270..1014ae7b 100644 --- a/crons.yaml +++ b/crons.yaml @@ -20,7 +20,7 @@ # # Script-mode crons execute via /bin/bash, capture stdout, and deliver like LLM crons. # They skip LLM setup entirely. Empty output skips delivery. -# LLM crons always use Pi print mode with model openai-codex/gpt-5.5 and only the explicit A1 guard extension. +# LLM crons always use Pi print mode with model openai-codex/gpt-5.5 and no explicit first-party extensions. crons: # Weekly workspace health check (uses /workspace-health skill) diff --git a/docs/plans/2026-06-06-private-production-guard-retirement-cleanup.md b/docs/plans/2026-06-06-private-production-guard-retirement-cleanup.md new file mode 100644 index 00000000..28de1cf6 --- /dev/null +++ b/docs/plans/2026-06-06-private-production-guard-retirement-cleanup.md @@ -0,0 +1,20 @@ +# Private production guard retirement cleanup + +This note tracks private production work required before deploying the guard-retired package from the issue #148 continuation. It is an operator checklist only; it does not authorize deployment by itself. + +Do not paste, print, decrypt, or commit secret values while completing this checklist. Validate only file paths, key presence where needed, hook wiring, import references, and operator approval state. + +## Required private cleanup + +- [ ] Review private settings and remove obsolete schema/write-guard hooks or extension references before restart. +- [ ] Remove or explicitly retire private `guardian.sh` / `protect-files.sh` hook wiring and prose if still present. +- [ ] Remove obsolete `@schema.md` imports from private agent workspace context files if any remain. +- [ ] Decide whether inert private `schema.md` files should be archived, left as historical notes, or removed through a recoverable cleanup path. +- [ ] Confirm the deploy wrapper is pointed at the guard-retired package version intended for production. +- [ ] Record explicit operator sign-off before restarting production services with the guard-retired package. + +## Non-secret verification + +- [ ] Confirm Telegram, Discord, and Tavily secret references still resolve from the control workspace without printing plaintext values. +- [ ] Confirm production agent workspace paths are configured as intended, including any absolute paths outside the control workspace. +- [ ] Confirm no private Claude-path prose still claims schema/write-guard or immutable-core enforcement is active after retirement. diff --git a/docs/plans/completed/2026-06-06-issue-148-control-agent-contract-cleanup.md b/docs/plans/completed/2026-06-06-issue-148-control-agent-contract-cleanup.md new file mode 100644 index 00000000..fe518e5c --- /dev/null +++ b/docs/plans/completed/2026-06-06-issue-148-control-agent-contract-cleanup.md @@ -0,0 +1,295 @@ +# Plan: Issue #148 continuation — control/agent workspace split, global Tavily, retire schema guard + +Public repo location: +`docs/plans/2026-06-06-issue-148-control-agent-contract-cleanup.md` + +GitHub issue: #148 / PR #151 follow-up continuation +Target repo after move: `fitz123/claude-code-bot` / future `fitz123/minime-bot` + +## Goal + +Finish the #148 package/workspace-contract direction with the corrected final architecture: + +- `--workspace` points to the **control/app workspace**: config, crons, global secrets, bindings, deploy/runtime state. +- Individual sessions run in **agent workspaces** selected by `agents.*.workspaceCwd`. +- Absolute `agents.*.workspaceCwd` values may be outside the control workspace; the final target topology is sibling roots such as `` and ``. +- Relative `agents.*.workspaceCwd` values remain backwards-compatible and resolve relative to the control workspace. +- Telegram, Discord, and Tavily secrets are **global control-workspace secrets**, not per-agent secrets. +- `schema.md`, `MINIME_SCHEMA_PATH`, Pi `guardian-protect-files`, write-allowlist parsing, and schema validation are **retired from the bot package contract** instead of becoming per-agent infrastructure. +- Bot runtime and Pi/harness extensions remain compatible across source checkout, built `dist`, and package-installed modes. + +## Prerequisites and preflight gates + +Do not move or launch this plan until all are true: + +1. PR #151 is no longer moving unexpectedly: Ralphex completed, branch status is clean, and the PR has been reviewed/settled enough that the continuation can be based on its final diff. +2. Reconcile this draft against the final PR #151 file names and symbols. Use the actual merged/current branch diff, not the earlier draft assumptions. +3. Public repo preflight from ``: + ```bash + git status --short + git rev-parse --short HEAD + rg -n 'file:/tmp|/tmp/.*\.tgz|/tmp/.*minime|/tmp/.*claude-code-bot' bot/package.json bot/package-lock.json || true + ``` + Required result: clean tree; no machine-local package dependency paths. `/tmp` in test scripts is okay, but no package dependency may point to `/tmp`. +4. Governance is aligned: ADR-081 is recorded and supersedes ADR-073 plus ADR-080 schema/write-guard clauses. + +Task 0 reconciliation against final PR #151 branch: + +- Base branch verified: `issue-148-package-cli-workspace-contract` / `origin/issue-148-package-cli-workspace-contract` at `5894383`. +- Continuation branch merged the final PR #151 tip at `d1a9582`; package branch later cherry-picked the same post-merge fix, so the continuation branch differs only by this plan before Task 0 edits. +- Preflight result at `d1a9582`: clean tree, no machine-local `/tmp` package dependency paths in `bot/package.json` or `bot/package-lock.json`. +- Final #151 fixture names still use `bot/test-fixtures/minimal-workspace`; this continuation may add/rename control-workspace-specific fixtures in later implementation tasks. +- Final #151 active symbols still include `MINIME_SCHEMA_PATH_ENV`, `PI_GUARD_WORKSPACE_ROOT_ENV`, `PI_SUBAGENT_CHILD_WRAPPER_RELPATHS`, `PI_CRON_WRAPPER_RELPATHS`, `guardian-protect-files`, and `realPathIsInsideOrEqual`; Tasks 1-7 own removal or replacement of those code/test references. +- Public Claude-path guard artifacts remain physically present at launch. Ownership decision: Task 2 removes or rewrites active public hook/settings/guidance for the package-contract retirement, and Task 8 records private-production cleanup before deployment; until then current guidance must mark these artifacts legacy/deferred rather than package-runtime contract. + +## Governance impact + +ADR-081 is the active decision for this continuation: + +- schema/write-guard is retired from the final bot package contract; +- `--workspace` is the control/app workspace; +- `agents.*.workspaceCwd` may be outside the control workspace when absolute; +- Telegram, Discord, and Tavily are global control-workspace secrets; +- private production deploy of guard retirement requires explicit operator sign-off and cleanup of obsolete schema/guard hooks/imports. + +Implementation must update any current operator guidance that still contradicts ADR-081, including public `CLAUDE.md` lines saying agent workspaces must stay inside the workspace root. + +## Non-goals + +- Do not edit private production workspace files in this public-repo run. +- Do not create the new `fitz123/minime-bot` repo in this run. +- Do not migrate launchd production services in this run. +- Do not remove unrelated CLI/package groundwork from #151/#148. +- Do not redesign broad tool permissions or sandboxing beyond removing schema/write-guard from the package contract. +- Do not print, decrypt, or log secret values. + +## Architecture decisions + +### Control workspace vs agent workspace + +Use exact terminology in code/docs: + +| Term | Meaning | Examples | +|---|---|---| +| control workspace | app/control root passed to `minime-bot --workspace`; owns config/crons/secrets/bindings/runtime state | `` eventually; current compatibility may still be `` | +| agent workspace | per-agent cwd/context root from `agents.*.workspaceCwd`; owns CLAUDE/MEMORY/rules/context/project files; absolute values may be outside the control workspace | ``, `.../coder`, `.../yulia` | +| package root | installed/source bot package root; owns runtime code and first-party Pi extensions | `node_modules/minime-bot`, source checkout `bot/` | + +One bot daemon can serve many agent workspaces. Binding flow remains: + +```text +incoming Telegram/Discord event +→ binding selects agentId +→ config resolves agents[agentId].workspaceCwd +→ Pi child cwd = resolved agent.workspaceCwd +→ context/rules/memory are read from the agent workspace +→ app config/secrets/runtime state stay in the control workspace +``` + +Path rules: + +- `--workspace` / `MINIME_WORKSPACE_ROOT` resolves the control workspace. +- `MINIME_CONFIG_PATH` / `MINIME_CRONS_PATH` remain control-workspace config overrides. +- Relative `agents.*.workspaceCwd` values resolve against the control workspace. +- Absolute `agents.*.workspaceCwd` values are allowed outside the control workspace after existence/directory validation. +- Remove the current containment hard-fail that requires agent workspace realpaths to be inside the control workspace; that check existed only for Pi guard anchoring and becomes invalid once the guard is retired. + +### Pi child non-secret control contract + +Pi children run with `cwd = resolved agent workspace`, but package-installed extensions may need control config/secrets. The bot must pass a non-secret control contract to Pi children: + +- include `MINIME_WORKSPACE_ROOT=` in the allowlisted Pi child env; +- include `MINIME_CONFIG_PATH` / `MINIME_CRONS_PATH` only when explicitly configured, preserving their existing semantics; +- apply the same non-secret control contract to parent Pi RPC children, Pi cron children, and Pi **subagent child** spawns (the subagent child has caller-controlled `cwd` and also loads web tools); +- remove `MINIME_SCHEMA_PATH` and `PI_GUARD_WORKSPACE_ROOT` from child env after guard retirement; +- never pass plaintext Telegram/Discord/Tavily secret values in env, argv, stdout, stderr, or logs. + +### Secrets + +Global/control-workspace secrets: + +- Telegram bot token; +- Discord bot token; +- Tavily API key. + +Tavily must not resolve from `process.cwd()` / agent workspace. The web-tools extension must use the Pi child control contract above. Preserve the current Tavily secret shape by default: dedicated `config/secrets.sops.yaml` under the control workspace plus key `tavily.api_key`; change only the resolution root from agent `process.cwd()` to `MINIME_WORKSPACE_ROOT`. If the implementation intentionally unifies Tavily onto config `secrets.sopsFile`, that is a separate migration and must add a no-decrypt key-presence gate proving the configured production SOPS file contains `tavily.api_key` before cutover. + +### Schema/guard retirement + +Do not create a replacement per-agent schema system. + +Remove from active package contract: + +- `MINIME_SCHEMA_PATH`; +- `PI_GUARD_WORKSPACE_ROOT` if it is only used by the retired guard; +- workspace validator schema checks; +- Pi `guardian-protect-files` default extension loading; +- Pi cron hard-requirement that refuses to run without guard extension; +- subagent child wrapper lists that load only `guardian-protect-files`; +- write-allowlist parser requirements/parity tests; +- public-repo Claude-path guard hooks/settings/guidance if still active (`guardian.sh`, `protect-files.sh`, `.claude/settings.json` PreToolUse wiring, `bot-code-readonly.md`, CLAUDE/README/operator prose that claims schema/write-guard/immutable-core enforcement); +- docs implying `schema.md` is required for package/runtime correctness. + +Public package guard retirement can land before private production cutover, but it must not be deployed to production as a silent safety downgrade. Private production deploy must be paired with explicit operator sign-off and a private cleanup plan for obsolete schema/guard hooks/imports. + +Safety baseline after retirement: + +- git history/PR review/Ralphex/Copilot; +- explicit approval for destructive operations; +- task artifacts are preserved; +- secrets are never printed/decrypted to stdout; +- package-installed tests catch runtime compatibility issues. + +## Tasks + +### Task 0: Reconcile with final PR #151 and governance + +- [x] Confirm PR #151 branch is clean and final enough to base this continuation on. +- [x] Re-run the preflight checks from this plan. +- [x] Update this plan's file/symbol names against the final PR #151 diff. +- [x] Verify ADR-081 is present and active in `reference/governance/decisions.md` before moving the plan into the public repo. +- [x] Update public `CLAUDE.md` / current operator guidance that still says agent workspaces must stay inside the control workspace. +- [x] Decide public-repo Claude-path guard ownership before launch: remove/update active public hook wiring/guidance in this run, or explicitly mark it legacy/deferred and include it in the private cleanup artifact. + +### Task 1: Encode control-vs-agent workspace semantics + +- [x] Add/update central types or resolver docs to distinguish `controlWorkspaceRoot`, `agentWorkspaceRoot` / `workspaceCwd`, and `packageRoot`. +- [x] Ensure CLI help and validator output use these names consistently. +- [x] Ensure `--workspace` is documented as control/app workspace, not agent workspace. +- [x] Preserve backwards compatibility for relative agent workspace paths by resolving them against the control workspace. +- [x] Remove/relax the `realPathIsInsideOrEqual(controlWorkspaceRoot, agentWorkspace)` hard-fail from Pi spawn validation and workspace validation. +- [x] Keep hard failures for missing/non-directory configured agent workspaces. +- [x] Add tests with two agents pointing at different `workspaceCwd` values proving session spawn cwd/context remains per-agent while config/secrets are read from control workspace. +- [x] Add a regression fixture where the control workspace and two agent workspaces are sibling directories; `workspace validate` and Pi spawn env/cwd tests must pass. + +### Task 2: Remove schema/write-guard from package contract + +- [x] Remove `MINIME_SCHEMA_PATH` from resolver constants, CLI docs, validator output, env allowlists, tests, README, and active docs introduced by #151/#148. +- [x] Remove `schemaPath` from active workspace contract paths unless a final code path still needs it for explicitly legacy-only diagnostics. +- [x] Remove schema validation from `workspace validate`. +- [x] Remove validator-vs-guard parser parity requirements/tests from active test suites. +- [x] Remove/default-disable the Pi `guardian-protect-files` extension from default first-party extension loading. +- [x] Remove or rewrite Pi cron guard hard-requirements that currently throw when guard extension args are empty/disabled. +- [x] Remove or rewrite subagent child wrapper constants/lists that currently load only `guardian-protect-files`. +- [x] Remove `PI_GUARD_WORKSPACE_ROOT` and protected-prefix guard comments/tests unless retained only in explicitly legacy code. +- [x] Remove/update public-repo Claude-path guard artifacts if still current: `.claude/hooks/guardian.sh`, `.claude/hooks/protect-files.sh`, `.claude/settings.json` PreToolUse wiring, and operator prose in `CLAUDE.md`, README, and `.claude/rules/**` that says schema/write-guard/immutable-core is enforced. +- [x] If any Claude-path guard artifact is intentionally deferred to private cleanup, document that explicitly and ensure the deterministic removal gate treats it as legacy/deferred rather than current guidance. +- [x] Ensure package-installed Pi extension tests no longer expect guard extension loading. +- [x] Leave historical docs/plans alone unless they are current operator guidance. + +### Task 3: Pass non-secret control contract to Pi children + +- [x] Add `MINIME_WORKSPACE_ROOT` to the Pi child env allowlist and set it to the resolved control workspace root for every Pi RPC, Pi cron, and Pi subagent child spawn. +- [x] Preserve configured `MINIME_CONFIG_PATH` / `MINIME_CRONS_PATH` propagation only as non-secret path references. +- [x] Remove `MINIME_SCHEMA_PATH` and `PI_GUARD_WORKSPACE_ROOT` propagation. +- [x] Add tests proving parent/cron/subagent child env contains control `MINIME_WORKSPACE_ROOT` while child `cwd` is the agent workspace or caller-selected subagent cwd. +- [x] Add tests proving absolute sibling agent workspace paths do not alter control config/secrets paths. +- [x] Add negative tests proving no plaintext Telegram/Discord/Tavily secret is present in child env or argv. + +### Task 4: Make Tavily a global control-workspace secret + +- [x] Replace Tavily `process.cwd()` / agent-workspace SOPS resolution with control-workspace contract resolution through the named child env contract in Task 3. +- [x] Preserve the current Tavily secret contract by default: `config/secrets.sops.yaml` under the control workspace plus key `tavily.api_key`. Do not silently reroute Tavily onto config `secrets.sopsFile` unless the plan also adds an explicit no-decrypt production key-presence gate for that configured file. +- [x] Do not pass Tavily plaintext in env/argv. +- [x] Add tests proving an agent workspace without `config/secrets.sops.yaml` can still use Tavily via the control workspace SOPS pointer. +- [x] Add tests proving two different agent workspaces use the same control-workspace Tavily secret reference. +- [x] Add installed-wrapper tests with `process.cwd()` set to an agent workspace lacking config/secrets; fake SOPS must be invoked with the control-workspace Tavily relpath/key. +- [x] Add subagent-child tests proving `buildPiSubagentChildSpawnEnv` (or final equivalent) propagates control `MINIME_WORKSPACE_ROOT` while child `cwd` is caller-controlled. +- [x] Add tests proving a subagent child's loaded `web-tools` wrapper resolves Tavily from the control workspace even when `cwd` is an arbitrary caller directory without config/secrets files. +- [x] Add negative tests with fake secret resolver/exec proving validators do not invoke SOPS and web-tools secret resolution never prints values. + +### Task 5: Keep extension/runtime package compatibility after guard removal + +- [x] Ensure source checkout, built `dist`, and package-installed modes still load the remaining first-party Pi extensions. +- [x] Ensure subagent extension non-code resources (`agents/*.md`, `prompts/*.md`) are still packaged and discovered. +- [x] Ensure extension helpers do not depend on private workspace cwd or source-only paths. +- [x] Update package `files`/build scripts if removing guard changes artifact lists. +- [x] Add/keep install-fixture tests for `node_modules/.bin/minime-bot --help`, validator, web-tools wrapper loading, and subagent resource discovery. +- [x] Add an exact extension-list assertion for the post-retirement contract: no `guardian-protect-files` in parent, cron, or subagent child extension args; expected web/subagent wrappers still present where intended. +- [x] Add an exact subagent-child env assertion: web-enabled delegated children get the control `MINIME_WORKSPACE_ROOT` even when their `cwd` is caller-controlled. + +### Task 6: Update validator contract + +- [x] Validator hard-fails on invalid control workspace config/crons and missing/non-directory configured agent `workspaceCwd` directories. +- [x] Validator no longer requires `schema.md` in control or agent workspaces. +- [x] Validator accepts absolute agent workspace paths outside the control workspace. +- [x] Validator prints effective paths for: + - control workspace root; + - config path; + - crons path; + - package root; + - extension dir; + - runtime data/log dirs; + - every configured agent workspace. +- [x] Validator treats missing agent context files (`CLAUDE.md`, `MEMORY.md`, rules dirs) as warnings unless current runtime requires them as hard failures. +- [x] Validator remains no-decrypt by default. + +### Task 7: Update docs, tests, and deterministic removal gate + +- [x] Update README/package docs to describe control workspace vs agent workspaces. +- [x] Update current docs that say `schema.md`/write-allowlist is required for runtime/package validity. +- [x] Update tests that assert `MINIME_SCHEMA_PATH` or schema validation exists. +- [x] Add regression tests for the one-bot-many-workspaces binding model. +- [x] Add regression tests that package-installed mode works without any `schema.md` files in fixtures. +- [x] Replace any non-blocking `rg ... || true` removal check with a deterministic failing check that allows only explicitly listed historical/legacy paths. +- [x] Scope the removal check to active symbols/prose (`MINIME_SCHEMA_PATH`, `MINIME_SCHEMA_PATH_ENV`, `PI_GUARD_WORKSPACE_ROOT`, `guardian-protect-files`, `guardian.sh`, `protect-files.sh`, `readWriteAllowlistSchema`, active `write-allowlist` parser usage, `immutable core`, and current prose claiming schema/write-guard enforcement), not a broad unqualified `schema.md` substring. + +### Task 8: Private-production follow-up artifact only + +Public code must not edit private production files in this run, but the run must leave an operator artifact for private deployment: + +- [x] Create/update a task note listing private cleanup required before deploying the guard-retired package (`docs/plans/2026-06-06-private-production-guard-retirement-cleanup.md`): + - remove obsolete schema/guard hooks or extension references from private settings; + - remove or explicitly retire private `guardian.sh` / `protect-files.sh` hook wiring and prose if still present; + - remove obsolete `@schema.md` imports if any agent workspaces have them; + - decide whether to archive or delete inert `schema.md` files; + - confirm deploy wrapper/operator sign-off before production restart. +- [x] The artifact must not contain secrets or decrypted SOPS values. + +## Validation commands + +Run from `bot/` or the post-#151 equivalent package root: + +```bash +npm test +npm run typecheck +npm run build +npm run workspace:validate -- --workspace ./test-fixtures/minimal-workspace +node dist/cli.js --help +node dist/cli.js config validate --workspace ./test-fixtures/minimal-workspace +node dist/cli.js workspace validate --workspace ./test-fixtures/minimal-workspace +npm pack --dry-run +``` + +Package-installed fixture must also run: + +```bash +node_modules/.bin/minime-bot --help +node_modules/.bin/minime-bot config validate --workspace +node_modules/.bin/minime-bot workspace validate --workspace +``` + +After Task 7 adds it, the deterministic removal gate must run and fail on active references outside an explicit legacy allowlist. Draft shape: + +```bash +node scripts/check-no-active-schema-guard-contract.mjs +``` + +The script should scan active code/docs/tests and fail non-zero for unallowlisted references to removed active symbols/prose, including stale Claude-path guard guidance. Historical completed plans may be allowlisted; current README/CLAUDE/operator guidance must not be. + +## Acceptance criteria + +- ADR-081 is active and public/operator guidance no longer contradicts it. +- `MINIME_SCHEMA_PATH` is gone from active package contract. +- `workspace validate` passes without `schema.md` in control or agent workspace fixtures. +- `workspace validate` accepts absolute sibling agent workspace paths outside the control workspace. +- Pi guard extension is not loaded by default, by cron Pi runs, or by subagent child Pi runs. +- Pi child env includes non-secret `MINIME_WORKSPACE_ROOT` control root while child cwd remains `agents.*.workspaceCwd`. +- Telegram/Discord/Tavily secrets are resolved as global control-workspace secret references. +- Tavily installed-wrapper tests prove control Tavily relpath/key resolution from an agent cwd lacking secrets files. +- Subagent child env/tests prove `MINIME_WORKSPACE_ROOT` is propagated to caller-controlled child cwd and web-tools still resolves Tavily from the control workspace. +- Current public/operator guidance no longer advertises Claude-path schema/write-guard/immutable-core enforcement unless explicitly marked legacy/deferred. +- Source checkout, built `dist`, and package-installed modes all pass compatibility tests. +- Deterministic removal gate has no unallowlisted active schema/guard references. +- No secret values are printed in tests, validator output, logs, or review artifacts. +- Current #151/#148 package CLI/workspace-contract functionality remains intact except for deliberate schema/guard removal and the deliberate containment-relaxation for agent workspaces. diff --git a/reference/governance/decisions.md b/reference/governance/decisions.md new file mode 100644 index 00000000..88f21824 --- /dev/null +++ b/reference/governance/decisions.md @@ -0,0 +1,13 @@ +# Architectural Decision Records + +Track architectural decisions so future sessions know what was decided and why. + +## Decisions + +### ADR-081: Control Workspace, Agent Workspaces, And Guard Retirement + +- Status: accepted +- Date: 2026-06-06 +- Context: Issue #148 / PR #151 introduced the package CLI workspace contract, but the final runtime topology needs to separate the bot's control workspace from per-agent workspaces and remove schema/write-guard assumptions from the package contract. +- Decision: `--workspace` and `MINIME_WORKSPACE_ROOT` identify the control/app workspace that owns config, crons, bindings, runtime state, and global secret references. `agents.*.workspaceCwd` identifies each agent workspace; relative values resolve under the control workspace for compatibility, and absolute values may point outside the control workspace after existence/directory validation. Telegram, Discord, and Tavily secrets are global control-workspace secret references. `schema.md`, `MINIME_SCHEMA_PATH`, `PI_GUARD_WORKSPACE_ROOT`, Pi `guardian-protect-files`, write-allowlist parsing, and schema validation are retired from the bot package contract. +- Consequences: ADR-081 supersedes ADR-073 and ADR-080 clauses that made schema/write-guard infrastructure part of runtime/package correctness. Current public guidance must not describe agent workspaces as required to stay inside the control workspace except as explicitly legacy/deferred behavior. Private production deployment of guard-retired code requires explicit operator sign-off and cleanup of obsolete schema/guard hooks, imports, and prose before restart.