#!/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. WHERE that file is looked for is not decided here: skills/auth/scripts/pin.py holds the search order, and the sync and wiki scripts resolve the pin through the same module. One order, one copy of it. The guard and the scripts disagreeing about a directory is a bug by construction, and was one: in a git worktree `tea` worked and every script said "no login pinned". 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:auth) Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to rewrite; exit 2 + stderr to block. """ import sys, os, re, json, shlex # The identity layer, reached by the plugin's own layout — the one thing a hook # may assume about where it lives. Import failure is not fatal on its own: a # command that is not `tea` still passes through untouched (see main), and only # a command that needs a login is blocked. sys.path.append(os.path.abspath(os.path.join( os.path.dirname(os.path.abspath(__file__)), os.pardir, "skills", "auth", "scripts"))) try: import pin except Exception: pin = None 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 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:auth # 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:auth. This prevents acting under ' 'the wrong identity.' % raw_val) if pin is None: block('cannot import skills/auth/scripts/pin.py, so the pinned login ' 'cannot be resolved. The plugin tree is incomplete; reinstall it.') # The hint is the directory the Bash command will run in; the rest of the # order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the # search) is pin.py's, and is the same order the scripts get. login, src = pin.find_pin(payload.get("cwd")) if not login: block('no login is pinned. Run /tea:auth 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(login) + cmd[m.end(3):] rewrite(tool_input, new_cmd, 'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src)) if __name__ == "__main__": main()