#!/usr/bin/env python3 """ tea-guard — PreToolUse(Bash) hook for the `tea` plugin. Enforces, deterministically, the one rule prose cannot: every `tea` command that touches Gitea runs under the login the OPERATOR pinned — never one Claude chose. It does this by *resolving and rewriting* the command rather than just checking it: Claude must write: tea ... --login "$GITEA_LOGIN" ... The guard rewrites: tea ... --login ... The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call time — from the FILE, not the environment — so a freshly pinned login works in the same session with no restart. Rules: - not a `tea` command ............................. allow (passthrough) - tea logins list/ls, tea --version/--help ........ allow (no identity used) - no --login / -l ................................. BLOCK - --login or --login "$OTHER_VAR" ....... BLOCK (Claude may not pick) - --login "$GITEA_LOGIN", pin found ............... REWRITE to the pin, allow - --login "$GITEA_LOGIN", no pin .................. BLOCK (run /tea:login) Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to rewrite; exit 2 + stderr to block. """ import sys, os, re, json, shlex PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"} def block(msg): sys.stderr.write("tea-guard: BLOCKED — " + msg + "\n") sys.exit(2) def allow_passthrough(): # exit 0 with no stdout → tool runs unchanged sys.exit(0) def rewrite(tool_input, new_cmd, note): updated = dict(tool_input) updated["command"] = new_cmd print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "updatedInput": updated, "additionalContext": note, } })) sys.exit(0) def find_pin(start_dir): """Walk up from start_dir; return (login, path) from the first .claude/settings.local.json that carries a non-empty env.GITEA_LOGIN.""" try: d = os.path.abspath(start_dir or ".") except Exception: return None, None while True: p = os.path.join(d, ".claude", "settings.local.json") if os.path.isfile(p): try: with open(p) as f: data = json.load(f) v = (data.get("env") or {}).get("GITEA_LOGIN") if isinstance(v, str) and v.strip(): return v.strip(), p except Exception: pass parent = os.path.dirname(d) if parent == d: return None, None d = parent def main(): try: payload = json.load(sys.stdin) except Exception: # Can't parse the hook payload — fail open for non-tea safety, but we # can't even read the command, so don't block arbitrary Bash. allow_passthrough() tool_input = payload.get("tool_input") or {} cmd = tool_input.get("command") or "" # Not a `tea` invocation → not our concern. if not re.search(r'(^|[;&|(]|\s)tea(\s|$)', cmd): allow_passthrough() # Whitelist: login enumeration + meta. No identity is used; /tea:login # needs `tea logins list` while no pin exists yet. if re.search(r'tea\s+(logins\s+(list|ls)|--version|-v|--help|help)(\s|$)', cmd): allow_passthrough() # Locate --login / -l and its value (logins never contain spaces). m = re.search(r'(--login|(?= 2 and inner[0] == q and inner[-1] == q: inner = inner[1:-1] break if inner not in PLACEHOLDERS: block('do not name the login yourself (got `%s`). Write exactly ' '--login "$GITEA_LOGIN"; the guard replaces it with the login ' 'the operator pinned via /tea:login. This prevents acting under ' 'the wrong identity.' % raw_val) start = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd() pin, src = find_pin(start) if not pin: block('no login is pinned. Run /tea:login to choose one (writes ' '.claude/settings.local.json env.GITEA_LOGIN). The guard reads ' 'the file at call time, so it takes effect with no restart.') new_cmd = cmd[:m.start(3)] + shlex.quote(pin) + cmd[m.end(3):] rewrite(tool_input, new_cmd, 'tea-guard: resolved --login -> %s (pinned in %s)' % (pin, src)) if __name__ == "__main__": main()