Files
naudachu 83f73c5cea refactor: turn the repo into a two-plugin marketplace
tea and tdl were two repositories, each carrying its own
.claude-plugin/marketplace.json — two marketplaces to register for what
is one collection. Fold them into one.

The repo root is now the marketplace and nothing else: a single
.claude-plugin/marketplace.json whose entries point at ./plugins/tea and
./plugins/tdl. A plugin's root is its own directory under plugins/, so
${CLAUDE_PLUGIN_ROOT} still resolves inside it and every path a plugin
uses stays relative to itself — the hooks and the test roots needed no
adjustment beyond the move.

tea's files move with git mv, so its history and blame follow. tdl
arrives as a plain copy; its history stays in claude-skills/threedotslab.

test_payload_root asserted `tmp/` was ignored by REPO/.gitignore. The
rule is that tmp/ is ignored, not which file says so, and git reads every
.gitignore on the way up — so the test now walks up to the repo root the
same way git does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:25:28 +05:00

281 lines
11 KiB
Bash
Executable File

#!/usr/bin/env python3
"""
tea-guard — PreToolUse(Bash) hook for the `tea` plugin.
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:
Claude must write: tea ... --login "$GITEA_LOGIN" ...
The guard rewrites: tea ... --login <operator-pinned-login> ...
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. WHERE that file is looked for is not decided
here: skills/auth/scripts/pin.py holds the search order, and the sync
scripts resolve the pin through the same module. One order, one copy of it. The
guard and the scripts disagreeing about a directory is a bug by construction,
and was one: in a git worktree `tea` worked and every script said "no login
pinned".
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:auth)
"A `tea` command" means the shell would RUN `tea`, not that the string contains
the word. The guard used to ask the second question — a substring search over
the whole command line — and in a repository whose subject *is* the CLI that is
a different question with the same answer far too often: an issue title, a
commit message, `grep -rn " tea " docs/` and `echo tea` were all blocked, with
a message telling the operator to add `--login` to `git commit`. Worse, the
advice was unfollowable: the only way past the guard was to reword the prose.
So the command is tokenized (heredoc bodies dropped, line continuations
folded, backticks and newlines treated as boundaries) and only words in
*command position* count — the first word, and the first word after `;`, `&&`,
`||`, `|`, `&`, `(`, `)`, `{`, `}`, past any VAR=value assignments and prefix
words like `env`/`sudo`/`xargs`. Quoting is what saves the prose: a title or a
`-m` message is one token, and one token is never a command. Compound commands
stay guarded segment by segment, substitutions included, and every `tea` in the
line is checked — not just the first.
If the line cannot be tokenized at all (unbalanced quotes), the old substring
test decides. That direction fails closed: it over-matches, and over-matching
blocks.
Output protocol: exit 0 + JSON {hookSpecificOutput:{updatedInput,...}} to
rewrite; exit 2 + stderr to block.
"""
import sys, os, re, json, shlex
# The identity layer, reached by the plugin's own layout — the one thing a hook
# may assume about where it lives. Import failure is not fatal on its own: a
# command that is not `tea` still passes through untouched (see main), and only
# a command that needs a login is blocked.
sys.path.append(os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)),
os.pardir, "skills", "auth", "scripts")))
try:
import pin
except Exception:
pin = None
PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"}
# Operators after which the next word is a command again.
SEPARATORS = {";", ";;", "&", "&&", "|", "|&", "||", "(", ")", "{", "}"}
# Words that stand in front of a command without being one.
TRANSPARENT = {"env", "command", "exec", "nohup", "time", "sudo", "xargs",
"if", "then", "else", "elif", "while", "until", "do", "!"}
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
REDIRECT = re.compile(r"^\d*[<>]+&?\d*-?$")
HEREDOC = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
# A login flag and its value, in the ORIGINAL text — this is what gets
# rewritten, so it works on the raw string rather than on tokens.
LOGIN_FLAG = re.compile(r"(--login|(?<![\w-])-l)(\s+|=)(\S+)")
# The pre-tokenizer test, kept for the one case tokenizing cannot serve.
LOOKS_LIKE_TEA = re.compile(r"(^|[;&|(]|\s)tea(\s|$)")
NO_LOGIN = ('every `tea` command must include --login "$GITEA_LOGIN" '
'(the guard substitutes the operator-pinned login). '
'Run /tea:auth if no login is pinned.')
def named_login(raw):
return ('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:auth. This prevents acting under '
'the wrong identity.' % raw)
def unquote(value):
for q in ('"', "'"):
if len(value) >= 2 and value[0] == q and value[-1] == q:
return value[1:-1]
return value
def strip_heredocs(cmd):
"""Drop heredoc bodies. They are data the shell feeds to a command, not
commands — and a commit message quoting a raw `tea api` call is exactly the
thing that used to be unwritable."""
lines, kept, i = cmd.split("\n"), [], 0
while i < len(lines):
line = lines[i]
kept.append(line)
i += 1
for m in HEREDOC.finditer(line):
delim, dash = m.group(2), m.group(0).startswith("<<-")
while i < len(lines):
probe = lines[i].strip() if dash else lines[i].rstrip()
i += 1
if probe == delim:
break
return "\n".join(kept)
def shell_words(cmd):
"""Tokens, with operators as tokens of their own and quotes honored.
Backticks and newlines become separators before tokenizing: shlex knows
neither, and both start a command. Inside quotes that substitution is
harmless — the token still spans the quotes, and a token is never a
command."""
text = strip_heredocs(cmd)
text = re.sub(r"\\\n", " ", text)
text = text.replace("`", " ; ").replace("\n", " ; ")
lex = shlex.shlex(text, posix=True, punctuation_chars=True)
lex.whitespace_split = True
return list(lex)
def tea_invocations(words):
"""The argument list of every `tea` the shell would actually run."""
found, current, expect, skip = [], None, True, False
for w in words:
if skip:
skip = False
continue
if REDIRECT.match(w):
skip = True # the target of a redirection is not a command
continue
if w in SEPARATORS:
current, expect = None, True
continue
if expect:
if ASSIGNMENT.match(w) or w in TRANSPARENT:
continue
expect = False
if w.rsplit("/", 1)[-1] == "tea":
current = []
found.append(current)
continue
if current is not None:
current.append(w)
return found
def is_meta(args):
"""Login enumeration and `--version`/`--help`: no identity is used, and
/tea:auth needs `tea logins list` while no pin exists yet."""
if not args:
return False
if args[0] in ("--version", "-v", "--help", "-h", "help"):
return True
return args[0] in ("logins", "login") and len(args) > 1 \
and args[1] in ("list", "ls")
def login_value(args):
"""The login as written, or None if the flag is absent."""
for i, a in enumerate(args):
if a in ("--login", "-l"):
return args[i + 1] if i + 1 < len(args) else ""
if a.startswith("--login=") or a.startswith("-l="):
return a.split("=", 1)[1]
return None
def substitute(cmd, login):
"""Every placeholder login in the line, replaced by the pin. Every one:
a command may run `tea` twice, and half a rewrite leaves the second call
with an unset variable and no login at all."""
def repl(m):
if unquote(m.group(3)) in PLACEHOLDERS:
return m.group(1) + m.group(2) + shlex.quote(login)
return m.group(0)
return LOGIN_FLAG.sub(repl, cmd)
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 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 ""
try:
runs = tea_invocations(shell_words(cmd))
except ValueError:
# Unbalanced quotes: what the shell would run is not knowable here.
# Fall back to the substring test — it over-matches, and over-matching
# blocks rather than lets an unpinned call through.
runs = None
if runs is None:
if not LOOKS_LIKE_TEA.search(cmd):
allow_passthrough()
m = LOGIN_FLAG.search(cmd)
if not m:
block(NO_LOGIN)
if unquote(m.group(3)) not in PLACEHOLDERS:
block(named_login(m.group(3)))
else:
# The word appears but nothing runs it → not our concern. This is the
# branch that lets prose about the CLI be written at all.
if not runs:
allow_passthrough()
for args in runs:
if is_meta(args):
continue
raw = login_value(args)
if raw is None:
block(NO_LOGIN)
if unquote(raw) not in PLACEHOLDERS:
block(named_login(raw))
if all(is_meta(args) for args in runs):
allow_passthrough()
if pin is None:
block('cannot import skills/auth/scripts/pin.py, so the pinned login '
'cannot be resolved. The plugin tree is incomplete; reinstall it.')
# The hint is the directory the Bash command will run in; the rest of the
# order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the
# search) is pin.py's, and is the same order the scripts get.
login, src = pin.find_pin(payload.get("cwd"))
if not login:
block('no login is pinned. Run /tea:auth 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.')
rewrite(tool_input, substitute(cmd, login),
'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src))
if __name__ == "__main__":
main()