Merge branch 'login-rewrite-guard'

This commit is contained in:
naudachu
2026-05-30 16:27:39 +05:00
3 changed files with 188 additions and 78 deletions
+126 -40
View File
@@ -1,47 +1,133 @@
#!/usr/bin/env bash #!/usr/bin/env python3
# """
# tea-guard — PreToolUse hook for Bash commands. tea-guard — PreToolUse(Bash) hook for the `tea` plugin.
#
# 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
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). Claude must write: tea ... --login "$GITEA_LOGIN" ...
if command -v jq >/dev/null 2>&1; then The guard rewrites: tea ... --login <operator-pinned-login> ...
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
# Not a `tea` command → not our concern. The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call
if ! printf '%s' "$cmd" | grep -Eq '(^|[;&|(]|[[:space:]])tea([[:space:]]|$)'; then time — from the FILE, not the environment — so a freshly pinned login works in
exit 0 the same session with no restart.
fi
# Whitelist: login enumeration + meta. These do not act under any identity and Rules:
# are needed by /tea:login itself (which runs while $GITEA_LOGIN may be unset). - not a `tea` command ............................. allow (passthrough)
if printf '%s' "$cmd" | grep -Eq 'tea[[:space:]]+(logins[[:space:]]+(list|ls)|--version|-v|--help|help)([[:space:]]|$)'; then - tea logins list/ls, tea --version/--help ........ allow (no identity used)
exit 0 - no --login / -l ................................. BLOCK
fi - --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. Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
if ! printf '%s' "$cmd" | grep -Eq '(--login|[[:space:]]-l)([[:space:]=])'; then rewrite; exit 2 + stderr to block.
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 import sys, os, re, json, shlex
fi
# Require $GITEA_LOGIN to be set in the environment. PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
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
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 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 # /tea:login — pin the project Gitea login
Goal: select exactly one `tea` login for this project and persist it to Goal: have the **operator** select exactly one `tea` login for this project and
`.claude/settings.local.json` under `env.GITEA_LOGIN`, so every later `tea` persist it to `.claude/settings.local.json` under `env.GITEA_LOGIN`. The
call can pass `--login "$GITEA_LOGIN"`. The `tea-guard` hook blocks every `tea-guard` hook reads this file at call time and rewrites every
Gitea-touching `tea` command until this is done — this command is the `--login "$GITEA_LOGIN"` to the pinned value, so the choice takes effect
remediation it points to. **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 ## 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` `tea logins list -o json`
2. **No logins:** stop and ask the user to run `tea logins add` themselves 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. 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. 3. **One login:** propose it; confirm before writing.
4. **Several logins:** use `AskUserQuestion` to let the user pick. Show each 4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and
login's `name`, `user`, and `url` so the choice is unambiguous. `url` so the operator's choice is unambiguous. Never decide for them.
5. Merge the chosen name into `.claude/settings.local.json` under `env` 5. Merge the chosen name into `.claude/settings.local.json` under `env`
do not clobber other keys: (do not clobber other keys):
```json ```json
{ "env": { "GITEA_LOGIN": "<chosen-name>" } } { "env": { "GITEA_LOGIN": "<chosen-name>" } }
``` ```
6. Tell the user: the updated `$GITEA_LOGIN` is only exported into Bash after a 6. Done — it is live. The guard resolves the pin from the file on the next
**session restart**. Until they restart, pass the literal name explicitly: `tea` call; no restart needed. Tell the operator which login is now pinned.
`tea --login <chosen-name> ...`.
## Hard rules (identity safety) ## Identity-safety rules
- NEVER run commands that mutate logins or global login state: - NEVER run commands that mutate logins or global login state:
`tea logins add/edit/delete/default`, `tea logout`. Read-only `tea logins add/edit/delete/default`, `tea logout`. Read-only
`tea logins list` is the only allowed login command. `tea logins list` is the only allowed login command.
- If a `tea` call fails with a permission/scope error, report it to the user. - If a `tea` call fails with a permission/scope error, report it. Do NOT try to
Do NOT try to fix it by switching to, or editing, a different login. fix it by switching to, or editing, a different login.
- If you ever see `no gitea login detected, falling back to login '...'`, - If you ever see `no gitea login detected, falling back to login '...'`, treat
treat it as a hard failure: stop, do not act on the result, surface it. it as a hard failure: stop, do not act on the result, surface it.
+29 -16
View File
@@ -1,6 +1,6 @@
--- ---
name: use 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 # /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 Use these docs to look up commands, flags, filters, and output fields before
running `tea` via Bash. 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"` Every `tea` invocation that touches Gitea MUST carry the login as the **literal
(or `-l "$GITEA_LOGIN"`). This is enforced by the **`tea-guard`** PreToolUse placeholder** `--login "$GITEA_LOGIN"` (or `-l "$GITEA_LOGIN"`). Do **not**
hook — a `tea` command without `--login` is blocked before it runs. If substitute an actual login name yourself.
`$GITEA_LOGIN` is unset, run **`/tea:login`** to pin one.
Why: without `--login`, `tea` silently falls back to the machine's default The **`tea-guard`** PreToolUse hook enforces this and resolves it:
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` - no `--login` → blocked.
under `env.GITEA_LOGIN`. Only `tea logins list` and `tea --version/--help` are - `--login "$GITEA_LOGIN"` → the hook reads the operator's pinned login from
exempt from the guard. `.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 ## How to use
@@ -28,12 +37,14 @@ exempt from the guard.
releases, times, repos, branches, actions, webhooks, comments, releases, times, repos, branches, actions, webhooks, comments,
notifications, etc. notifications, etc.
2. Find the matching command in the index below. 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`. `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 `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 `--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 ## Index
@@ -91,12 +102,14 @@ request payload to `$PWD/tmp/` first, then POST via `tea api`.
| Create release | `POST repos/{owner}/{repo}/releases` | | Create release | `POST repos/{owner}/{repo}/releases` |
Short single-line bodies (e.g. `tea comment 42 "lgtm" --login "$GITEA_LOGIN"`) 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 ## Tips
- Pass `-o json` for structured output when parsing programmatically. - Pass `-o json` for structured output when parsing programmatically.
- Use `--fields, -f` to narrow columns. - Use `--fields, -f` to narrow columns.
- Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30). - Pagination: `--page, -p <n>` and `--limit, --lm <n>` (defaults 1 / 30).
- If a `tea` command is blocked by `tea-guard`, you forgot `--login` or - If a `tea` command is blocked by `tea-guard`: either you forgot
`$GITEA_LOGIN` is unset — add the flag or run `/tea:login`. `--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`).