From bac45028bf1716ec38edd63b0166c46112bd9f1a Mon Sep 17 00:00:00 2001 From: naudachu Date: Sat, 30 May 2026 16:26:54 +0500 Subject: [PATCH] tea-guard: resolve and rewrite --login instead of env-checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- hooks/tea-guard.sh | 166 ++++++++++++++++++++++++++++++++---------- skills/login/SKILL.md | 55 ++++++++------ skills/use/SKILL.md | 45 ++++++++---- 3 files changed, 188 insertions(+), 78 deletions(-) diff --git a/hooks/tea-guard.sh b/hooks/tea-guard.sh index d04c2f9..c6a1d86 100755 --- a/hooks/tea-guard.sh +++ b/hooks/tea-guard.sh @@ -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 ... -# 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 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|(?= 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() diff --git a/skills/login/SKILL.md b/skills/login/SKILL.md index 47b3259..a208b25 100644 --- a/skills/login/SKILL.md +++ b/skills/login/SKILL.md @@ -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": "" } } ``` -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 ...`. +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. diff --git a/skills/use/SKILL.md b/skills/use/SKILL.md index 25d9a7b..38c727f 100644 --- a/skills/use/SKILL.md +++ b/skills/use/SKILL.md @@ -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 ` 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 ` and `--limit, --lm ` (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`).