Merge branch 'login-rewrite-guard'
This commit is contained in:
+126
-40
@@ -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
@@ -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
@@ -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`).
|
||||||
|
|||||||
Reference in New Issue
Block a user