Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 82 additions & 7 deletions .claude/hooks/protect-files.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions .claude/rules/platform/anti-amnesia.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions .claude/rules/platform/bot-code-readonly.md
Comment thread
fitz123 marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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/`.
9 changes: 9 additions & 0 deletions .claude/rules/platform/contradictions.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions .claude/rules/platform/runtime-context.md
Original file line number Diff line number Diff line change
@@ -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/-<workspace-path-dashed>/*.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
40 changes: 40 additions & 0 deletions .claude/rules/platform/sops-no-decrypt-stdout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# SOPS: Never decrypt to stdout in agent transcripts

`sops -d <file>` 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 <encrypted-env-file>` 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 <file> | cut -d= -f1` (keys only)
- `sops -d <file> | wc -l` (count only)
- `sops -d <file> | grep -c '=' ` (count of entries)
- `sops -d <file> >/dev/null && echo ok` (round-trip check)
- **FORBIDDEN:** raw decrypt to terminal
- `sops -d <file>` (alone)
- `sops -d <file> | head`
- `sops -d <file> 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 <file>` (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.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
@USER.md
@IDENTITY.md
@MEMORY.md
@reference/governance/decisions.md

## Critical Rules

Expand Down
22 changes: 12 additions & 10 deletions bot/src/__tests__/safety-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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);
});
Expand All @@ -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"));
Expand All @@ -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"));
Expand All @@ -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"));
});
Expand All @@ -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"));
Expand All @@ -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"));
Expand All @@ -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"));
Expand All @@ -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"));
Expand Down
Loading