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()
+33 -22
View File
@@ -1,40 +1,51 @@
---
name: login
description: Pin the Gitea login used by the tea CLI in this project. Run when $GITEA_LOGIN is unset, when the tea-guard hook blocks a command demanding a login, or when the user types /tea:login. Enumerates available logins, lets the user pick one, and persists it to .claude/settings.local.json.
description: Pin the Gitea login used by the tea CLI in this project. Run when the tea-guard hook reports no login is pinned, or when the user types /tea:login. Enumerates available logins, makes the OPERATOR pick one, and persists it to .claude/settings.local.json. The pin takes effect immediately — no restart.
---
# /tea:login — pin the project Gitea login
Goal: select exactly one `tea` login for this project and persist it to
`.claude/settings.local.json` under `env.GITEA_LOGIN`, so every later `tea`
call can pass `--login "$GITEA_LOGIN"`. The `tea-guard` hook blocks every
Gitea-touching `tea` command until this is done — this command is the
remediation it points to.
Goal: have the **operator** select exactly one `tea` login for this project and
persist it to `.claude/settings.local.json` under `env.GITEA_LOGIN`. The
`tea-guard` hook reads this file at call time and rewrites every
`--login "$GITEA_LOGIN"` to the pinned value, so the choice takes effect
**immediately, with no session restart**.
## The one hard rule: the operator chooses, never you
Picking the wrong identity is the exact failure this command exists to prevent.
So:
- **ALWAYS** present the choice with `AskUserQuestion` and let the operator
pick — even if memory, context, the repo URL, or a previous session suggests
a "likely" login. Do **not** auto-select from memory or infer it. A wrong
guess writes under the wrong account.
- The only exception: exactly **one** login exists on the machine — then
propose it and still confirm before writing.
## Steps
1. Enumerate logins (allowed by the guard even without `--login`):
1. Enumerate logins (allowed by the guard even with no pin):
`tea logins list -o json`
2. **No logins:** stop and ask the user to run `tea logins add` themselves
it is interactive (prompts for URL/token). Do not run it for them.
3. **One login:** propose pinning it; confirm with the user before writing.
4. **Several logins:** use `AskUserQuestion` to let the user pick. Show each
login's `name`, `user`, and `url` so the choice is unambiguous.
5. Merge the chosen name into `.claude/settings.local.json` under `env`
do not clobber other keys:
2. **No logins:** stop and ask the operator to run `tea logins add` themselves
it is interactive (prompts for URL/token). Do not run it for them.
3. **One login:** propose it; confirm before writing.
4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
`url` so the operator's choice is unambiguous. Never decide for them.
5. Merge the chosen name into `.claude/settings.local.json` under `env`
(do not clobber other keys):
```json
{ "env": { "GITEA_LOGIN": "<chosen-name>" } }
```
6. Tell the user: the updated `$GITEA_LOGIN` is only exported into Bash after a
**session restart**. Until they restart, pass the literal name explicitly:
`tea --login <chosen-name> ...`.
6. Done — it is live. The guard resolves the pin from the file on the next
`tea` call; no restart needed. Tell the operator which login is now pinned.
## Hard rules (identity safety)
## Identity-safety rules
- NEVER run commands that mutate logins or global login state:
`tea logins add/edit/delete/default`, `tea logout`. Read-only
`tea logins list` is the only allowed login command.
- If a `tea` call fails with a permission/scope error, report it to the user.
Do NOT try to fix it by switching to, or editing, a different login.
- If you ever see `no gitea login detected, falling back to login '...'`,
treat it as a hard failure: stop, do not act on the result, surface it.
- If a `tea` call fails with a permission/scope error, report it. Do NOT try to
fix it by switching to, or editing, a different login.
- If you ever see `no gitea login detected, falling back to login '...'`, treat
it as a hard failure: stop, do not act on the result, surface it.
+29 -16
View File
@@ -1,6 +1,6 @@
---
name: use
description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, issues, pulls, releases, actions, or other Gitea entities, to look up the right `tea` command and flags. Every tea call must carry --login "$GITEA_LOGIN" (enforced by the tea-guard hook; set it with /tea:login).
description: Reference docs for the `tea` CLI — Gitea's command-line client. Load when the user asks about Gitea repos, issues, pulls, releases, actions, or other Gitea entities, to look up the right `tea` command and flags. Always write the login as the literal placeholder --login "$GITEA_LOGIN" the tea-guard hook substitutes the operator-pinned login; set it with /tea:login.
---
# /tea:use — tea CLI reference
@@ -9,18 +9,27 @@ Reference material for the `tea` CLI (Gitea's official command-line client).
Use these docs to look up commands, flags, filters, and output fields before
running `tea` via Bash.
## Login is mandatory (enforced)
## Login: always write the placeholder, never a name (enforced)
Every `tea` invocation that touches Gitea MUST include `--login "$GITEA_LOGIN"`
(or `-l "$GITEA_LOGIN"`). This is enforced by the **`tea-guard`** PreToolUse
hook — a `tea` command without `--login` is blocked before it runs. If
`$GITEA_LOGIN` is unset, run **`/tea:login`** to pin one.
Every `tea` invocation that touches Gitea MUST carry the login as the **literal
placeholder** `--login "$GITEA_LOGIN"` (or `-l "$GITEA_LOGIN"`). Do **not**
substitute an actual login name yourself.
Why: without `--login`, `tea` silently falls back to the machine's default
login (possibly the user's personal account) and writes under the wrong
identity. The login value is pinned per-project in `.claude/settings.local.json`
under `env.GITEA_LOGIN`. Only `tea logins list` and `tea --version/--help` are
exempt from the guard.
The **`tea-guard`** PreToolUse hook enforces this and resolves it:
- no `--login` → blocked.
- `--login "$GITEA_LOGIN"` → the hook reads the operator's pinned login from
`.claude/settings.local.json` (`env.GITEA_LOGIN`) **at call time** and
rewrites the command to use that literal before it runs.
- `--login <some-name>` or any other variable → blocked. You may not choose the
login; only the operator does (via `/tea:login`).
- no login pinned → blocked with a pointer to run `/tea:login`.
Why: without an explicit login `tea` silently falls back to the machine's
default (possibly the user's personal account), and a login *you* pick may be
the wrong identity. Pinning is the operator's decision; the hook guarantees it.
The pin takes effect immediately — no restart. Only `tea logins list` and
`tea --version/--help` are exempt from the guard.
## How to use
@@ -28,12 +37,14 @@ exempt from the guard.
releases, times, repos, branches, actions, webhooks, comments,
notifications, etc.
2. Find the matching command in the index below.
3. Run it via Bash with the login, e.g.
3. Run it via Bash with the placeholder login, e.g.
`tea issues list --login "$GITEA_LOGIN" --repo owner/repo --state open`.
(The hook rewrites `"$GITEA_LOGIN"` to the operator-pinned login.)
`tea` auto-detects owner/repo from `$PWD` inside a git repo; otherwise pass
`--repo owner/repo` (or `-r`). Login is **not** auto-detected — it is pinned
per-project (see `/tea:login`). Config lives in `$XDG_CONFIG_HOME/tea`.
per-project by the operator (see `/tea:login`) and injected by the guard.
Config lives in `$XDG_CONFIG_HOME/tea`.
## Index
@@ -91,12 +102,14 @@ request payload to `$PWD/tmp/` first, then POST via `tea api`.
| Create release | `POST repos/{owner}/{repo}/releases` |
Short single-line bodies (e.g. `tea comment 42 "lgtm" --login "$GITEA_LOGIN"`)
are still fine via entity commands.
are still fine via entity commands. Always the placeholder, never a login name.
## Tips
- Pass `-o json` for structured output when parsing programmatically.
- Use `--fields, -f` to narrow columns.
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
- If a `tea` command is blocked by `tea-guard`, you forgot `--login` or
`$GITEA_LOGIN` is unset — add the flag or run `/tea:login`.
- If a `tea` command is blocked by `tea-guard`: either you forgot
`--login "$GITEA_LOGIN"`, you wrote a literal login name instead of the
placeholder (not allowed — let the guard substitute), or no login is pinned
(run `/tea:login`).