tea-guard: resolve and rewrite --login instead of env-checking

The previous guard required $GITEA_LOGIN to be set in the Bash environment,
which (a) only happens after a session restart and (b) let Claude name any
login it liked as long as one was set. Two failures: pinning needed a restart
to take effect, and Claude could pick the wrong identity from memory.

Rewrite the guard (now python3 for JSON in/out) to RESOLVE the login itself:

- Claude must write the literal placeholder --login "$GITEA_LOGIN".
- The hook reads the operator's pin from .claude/settings.local.json
  (env.GITEA_LOGIN) at call time — from the FILE via CLAUDE_PROJECT_DIR/cwd
  walk-up — and rewrites the command to that literal via updatedInput.
- A literal login, another variable, an empty value, or a missing --login are
  all blocked: Claude may not choose the identity, only the operator may.
- No pin -> block with a pointer to /tea:login.

Effect: pinning works in the same session (no restart), and Claude can no
longer act under a login it picked. /tea:login now mandates an explicit
operator choice (AskUserQuestion), never inferring from memory. /tea:use
documents the placeholder-only contract.

Guard unit-tested across 13 rewrite/block/passthrough cases incl. -l,
--login=, ${...}, compound+walkup+pipe. claude plugin validate passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-05-30 16:26:54 +05:00
parent d4aa0a9038
commit bac45028bf
3 changed files with 188 additions and 78 deletions
+126 -40
View File
@@ -1,47 +1,133 @@
#!/usr/bin/env bash
#
# tea-guard — PreToolUse hook for Bash commands.
#
# Enforces, deterministically, the one rule the prose in /tea:use cannot:
# every `tea` invocation that touches Gitea MUST carry --login "$GITEA_LOGIN",
# and $GITEA_LOGIN must be set. Without this, `tea` silently falls back to the
# machine's default login (often the user's personal account) and writes under
# the wrong identity.
#
# Exit codes: 0 = allow, 2 = block (stderr is fed back to Claude as the reason).
#
set -uo pipefail
#!/usr/bin/env python3
"""
tea-guard — PreToolUse(Bash) hook for the `tea` plugin.
input="$(cat)"
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:
# Extract tool_input.command from the hook payload (jq if present, else python3).
if command -v jq >/dev/null 2>&1; then
cmd="$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null || true)"
else
cmd="$(printf '%s' "$input" | /usr/bin/python3 -c 'import sys,json; print(json.load(sys.stdin).get("tool_input",{}).get("command","") or "")' 2>/dev/null || true)"
fi
Claude must write: tea ... --login "$GITEA_LOGIN" ...
The guard rewrites: tea ... --login <operator-pinned-login> ...
# Not a `tea` command → not our concern.
if ! printf '%s' "$cmd" | grep -Eq '(^|[;&|(]|[[:space:]])tea([[:space:]]|$)'; then
exit 0
fi
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.
# Whitelist: login enumeration + meta. These do not act under any identity and
# are needed by /tea:login itself (which runs while $GITEA_LOGIN may be unset).
if printf '%s' "$cmd" | grep -Eq 'tea[[:space:]]+(logins[[:space:]]+(list|ls)|--version|-v|--help|help)([[:space:]]|$)'; then
exit 0
fi
Rules:
- not a `tea` command ............................. allow (passthrough)
- tea logins list/ls, tea --version/--help ........ allow (no identity used)
- no --login / -l ................................. BLOCK
- --login <literal> 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)
# Require an explicit --login / -l on the invocation.
if ! printf '%s' "$cmd" | grep -Eq '(--login|[[:space:]]-l)([[:space:]=])'; then
echo "tea-guard: BLOCKED — every 'tea' command must include --login \"\$GITEA_LOGIN\" (or -l). Run /tea:login to pin the project login, then retry. Only 'tea logins list' and 'tea --version/--help' are exempt." >&2
exit 2
fi
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
rewrite; exit 2 + stderr to block.
"""
import sys, os, re, json, shlex
# Require $GITEA_LOGIN to be set in the environment.
if [ -z "${GITEA_LOGIN:-}" ]; then
echo "tea-guard: BLOCKED — \$GITEA_LOGIN is empty/unset, so --login \"\$GITEA_LOGIN\" would expand to nothing and tea would fall back to the default login. Run /tea:login to pin a login (writes .claude/settings.local.json), then restart the session so it is exported." >&2
exit 2
fi
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
exit 0
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|(?<![\w-])-l)(\s+|=)(\S+)', cmd)
if not m:
block('every `tea` command must include --login "$GITEA_LOGIN" '
'(the guard substitutes the operator-pinned login). '
'Run /tea:login if no login is pinned.')
raw_val = m.group(3)
inner = raw_val
for q in ('"', "'"):
if len(inner) >= 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()