diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh index 1a62ba9..85f317d 100755 --- a/.claude/hooks/protect-files.sh +++ b/.claude/hooks/protect-files.sh @@ -1,6 +1,8 @@ #!/bin/bash # protect-files.sh — PreToolUse hook -# Blocks writes to protected skill files (for crons/autonomous agents only). +# Blocks writes to: +# 1) Skill files — cron/autonomous sessions only (interactive can still edit) +# 2) Upstream-owned platform files — ALL sessions (bot-code-readonly enforcement) # Fail-closed: if jq is missing, block rather than bypass if ! command -v jq &>/dev/null; then @@ -38,13 +40,86 @@ while [[ "$FILE_PATH" == *"/.."* ]]; do [[ "$FILE_PATH" == "$_prev" ]] && break done -# Protected: skill files (read-only for crons/autonomous agents) -# Match both absolute (*/…) and relative (.claude/skills/…) paths -if [[ "$FILE_PATH" == */.claude/skills/* ]] || [[ "$FILE_PATH" == .claude/skills/* ]]; then - if [ -n "$CRON_NAME" ]; then - echo "Blocked: cron '$CRON_NAME' cannot modify skill files: $FILE_PATH" >&2 - exit 2 +# 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:-}" +if [ -n "$PROJECT_ROOT" ] && [[ "$FILE_PATH" == "$PROJECT_ROOT"/* ]]; then + REL_PATH="${FILE_PATH#"$PROJECT_ROOT"/}" +else + REL_PATH="${FILE_PATH#/}" +fi + +# --- 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 "$REL_PATH" in + bot/*) match=1 ;; + .claude/hooks/*) match=1 ;; + .claude/rules/platform/*) match=1 ;; + .claude/skills/workspace-health/scripts/*) match=1 ;; + .github/workflows/*) match=1 ;; + .githooks/*) match=1 ;; + .gitleaks.toml) match=1 ;; + .gitleaksignore) match=1 ;; + README.md) match=1 ;; + config.local.yaml.example) match=1 ;; + *) match=0 ;; +esac + +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/anti-amnesia.md b/.claude/rules/platform/anti-amnesia.md new file mode 100644 index 0000000..e201ce4 --- /dev/null +++ b/.claude/rules/platform/anti-amnesia.md @@ -0,0 +1,5 @@ +# Anti-Amnesia + +Before claiming "we did this" or "this was decided" — verify it exists. If you can't find a source, say "I don't remember this, let me check" instead of guessing. Wrong confidence is worse than honest amnesia. + +If you want to remember something — WRITE IT TO A FILE. "Mental notes" don't survive sessions. diff --git a/.claude/rules/platform/bot-code-readonly.md b/.claude/rules/platform/bot-code-readonly.md new file mode 100644 index 0000000..7db1170 --- /dev/null +++ b/.claude/rules/platform/bot-code-readonly.md @@ -0,0 +1,27 @@ +--- +paths: + - bot/** + - .claude/hooks/** + - .claude/rules/platform/** + - .claude/skills/workspace-health/scripts/** + - .github/workflows/** + - .githooks/** + - .gitleaks.toml + - .gitleaksignore + - README.md + - config.local.yaml.example +--- + +# Bot & Platform Files — Read-Only in Workspace + +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. + +Files that **look upstream but are workspace-local** (excluded from the list above) and ARE safe to edit: + +- `CLAUDE.md` — `.gitattributes merge=ours` keeps your local version on merge. +- `config.yaml`, `config.local.yaml` — each workspace has its own bindings/agents. +- `USER.md`, `IDENTITY.md`, `MEMORY.md`, `memory/`, `.claude/rules/custom/`, `.claude/skills/` (outside upstream-tracked names), `reference/`, `docs/`, `scripts/`. diff --git a/.claude/rules/platform/contradictions.md b/.claude/rules/platform/contradictions.md new file mode 100644 index 0000000..eab02e2 --- /dev/null +++ b/.claude/rules/platform/contradictions.md @@ -0,0 +1,9 @@ +# Contradictions Must Be Flagged + +When a request contradicts an existing rule, decision, evidence gate, ADR, or prior agreement — **STOP and flag it to the user before acting.** + +Do not silently resolve contradictions by ignoring one side. Explain: "This contradicts X because Y. How do you want to handle it?" + +The user may forget or miss contradictions too — that's expected. You are the filter. This applies everywhere: plans, tasks, implementation, cron setup, any decision point. + +We don't work with contradictions without an explicit reason. diff --git a/.claude/rules/platform/runtime-context.md b/.claude/rules/platform/runtime-context.md new file mode 100644 index 0000000..e318a0d --- /dev/null +++ b/.claude/rules/platform/runtime-context.md @@ -0,0 +1,34 @@ +# Runtime Context + +You are running as a **Claude Code CLI subprocess**, spawned by the grammY Telegram bot. + +## How you were started + +- The Telegram bot spawned your process via `claude -p` with stream-json protocol +- Messages arrive from Telegram, routed through bot bindings to your session +- Each Telegram chat gets its own Claude Code subprocess with separate conversation context +- The bot runs under a Max subscription (no API keys, fixed monthly cost) — applies to all agents spawned by this bot + +## What this means + +- You are a Claude Code CLI process. You have full Claude Code capabilities (Read, Edit, Write, Bash, Agent, etc.) +- 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 +- Bot tools are available: `bot/scripts/deliver.sh` for Telegram messaging, `launchctl` for cron management + +## Session transcripts + +Claude Code CLI stores session transcripts as JSONL files. Path pattern: +``` +~/.claude/projects/-/*.jsonl +``` +The workspace path is dash-encoded (every `/` becomes `-`). To find your own transcripts: `pwd | sed 's:/:-:g'` then look under `~/.claude/projects/`. + +Use these to search conversation history, find when/where decisions were made, trace WebFetch URLs, etc. + +## Delegation + +- Use Claude Code's native `Agent` tool for sub-tasks (NOT `sessions_spawn`) +- For cron/scheduled tasks: launchd plists in `~/Library/LaunchAgents/` +- For one-off delayed tasks: `at` command or launchd one-shot plist diff --git a/.claude/rules/platform/sops-no-decrypt-stdout.md b/.claude/rules/platform/sops-no-decrypt-stdout.md new file mode 100644 index 0000000..0347f81 --- /dev/null +++ b/.claude/rules/platform/sops-no-decrypt-stdout.md @@ -0,0 +1,40 @@ +# SOPS: Never decrypt to stdout in agent transcripts + +`sops -d ` outputs plaintext secrets to stdout. In a Claude Code Bash tool +call, that stdout lands in the session JSONL transcript and persists on disk. +**Plaintext secrets in transcripts = compromise.** Real incident on file: +a sub-agent ran `sops -d ` without filtering and leaked +API tokens into the agent transcript. + +## Rule + +When verifying sops-encrypted files in Bash: + +- **ALLOWED:** pipe to filter that strips values + - `sops -d | cut -d= -f1` (keys only) + - `sops -d | wc -l` (count only) + - `sops -d | grep -c '=' ` (count of entries) + - `sops -d >/dev/null && echo ok` (round-trip check) +- **FORBIDDEN:** raw decrypt to terminal + - `sops -d ` (alone) + - `sops -d | head` + - `sops -d 2>&1` (without filter) + +## When delegating review to sub-agents + +Review prompts that ask sub-agents to inspect sops files **MUST include**: + +> Do NOT run `sops -d` without piping to a value-stripping filter (`cut -d= -f1`, +> `wc -l`, or `>/dev/null`). Plaintext to stdout leaks into transcripts. + +## Editing secrets + +Use `sops ` (interactive, no stdout) — it spawns `$EDITOR` on a temp +plaintext file, re-encrypts on save. Never `sops -d > tmp; edit tmp; sops -e tmp`. + +## Why this matters + +Transcripts under `~/.claude/projects/-*/` are not gitignored from system +backups (Time Machine, rsync), they're readable by any process running as +the user, and they're a long-lived record. One careless `sops -d` = persistent +disclosure. diff --git a/CLAUDE.md b/CLAUDE.md index 6168606..0a03d00 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ @USER.md @IDENTITY.md @MEMORY.md +@reference/governance/decisions.md ## Critical Rules diff --git a/bot/src/__tests__/safety-hooks.test.ts b/bot/src/__tests__/safety-hooks.test.ts index cd24a14..b62a6c1 100644 --- a/bot/src/__tests__/safety-hooks.test.ts +++ b/bot/src/__tests__/safety-hooks.test.ts @@ -46,11 +46,13 @@ function runHookRaw( // ------------------------------------------------------------------- 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); }); @@ -61,7 +63,7 @@ describe("protect-files.sh", () => { tool_name: "Write", tool_input: { file_path: "/workspace/memory/notes.md" }, }, - { CRON_NAME: "nightly-consolidation" }, + { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, ); assert.equal(result.exitCode, 0); }); @@ -75,7 +77,7 @@ describe("protect-files.sh", () => { file_path: "/workspace/.claude/skills/workspace-health/SKILL.md", }, }, - { CRON_NAME: "nightly-consolidation" }, + { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, ); assert.equal(result.exitCode, 2); assert.ok(result.stderr.includes("Blocked")); @@ -91,7 +93,7 @@ describe("protect-files.sh", () => { file_path: "/workspace/.claude/skills/workspace-health/SKILL.md", }, }, - { CRON_NAME: "nightly-consolidation" }, + { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, ); assert.equal(result.exitCode, 2); assert.ok(result.stderr.includes("Blocked")); @@ -102,12 +104,12 @@ describe("protect-files.sh", () => { 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, "{"); + const result = runHookRaw(PROTECT_FILES, "{", PROTECT_ENV); assert.equal(result.exitCode, 2); assert.ok(result.stderr.includes("failed to parse")); }); @@ -122,7 +124,7 @@ describe("protect-files.sh", () => { "/workspace/.claude/./skills/workspace-health/SKILL.md", }, }, - { CRON_NAME: "nightly-consolidation" }, + { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, ); assert.equal(result.exitCode, 2); assert.ok(result.stderr.includes("Blocked")); @@ -138,7 +140,7 @@ describe("protect-files.sh", () => { "/workspace/.claude/notskills/../skills/workspace-health/SKILL.md", }, }, - { CRON_NAME: "nightly-consolidation" }, + { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, ); assert.equal(result.exitCode, 2); assert.ok(result.stderr.includes("Blocked")); @@ -153,7 +155,7 @@ describe("protect-files.sh", () => { file_path: ".claude/skills/workspace-health/SKILL.md", }, }, - { CRON_NAME: "nightly-consolidation" }, + { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, ); assert.equal(result.exitCode, 2); assert.ok(result.stderr.includes("Blocked")); @@ -169,7 +171,7 @@ describe("protect-files.sh", () => { "/workspace/.claude//skills/workspace-health/SKILL.md", }, }, - { CRON_NAME: "nightly-consolidation" }, + { ...PROTECT_ENV, CRON_NAME: "nightly-consolidation" }, ); assert.equal(result.exitCode, 2); assert.ok(result.stderr.includes("Blocked"));