Skip to content

Commit c1b51ef

Browse files
committed
feat(integrations): add PreToolUse hook blocking direct tap/keg CLI use
Adds a Python matcher that denies tap/keg invocations from the Claude Code agent's Bash tool, forcing all KEG access through mcp__tapper__* tools per integrations/content/agent-orient.md. Source lives in a new pkg/integrations/renderdata/ package overlaid onto the canonical content FS at render time, keeping the hook bytes out of cmd/tap and cmd/keg. Allowlist: tap completion, --version, --help. Matcher tokenizes shell metacharacters, strips env/sudo/command/exec/builtin/time wrappers, and recurses one level into bash -c / sh -c / zsh -c. Stdlib Python only; Windows support deferred (matches kp plugin's python3 ${CLAUDE_PLUGIN_ROOT} invocation). Also: add .DS_Store and Python bytecode artifacts to .gitignore.
1 parent 7178c9d commit c1b51ef

12 files changed

Lines changed: 1072 additions & 17 deletions

File tree

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ go.work.sum
3535
.vscode/*
3636
!.vscode/settings.json
3737

38+
# macOS
39+
.DS_Store
40+
41+
# Python (for embedded hook scripts)
42+
__pycache__/
43+
*.pyc
44+
3845
# Taskfile
3946
.task
4047

cmd/render-integrations/main.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import (
1919
// Side-effect import registers ClaudeAdapter in the integrations
2020
// package-level registry. Additional adapters plug in the same way.
2121
_ "github.com/jlrickert/tapper/pkg/integrations/adapters"
22+
23+
// renderdata supplies host-specific canonical-source bytes (Claude
24+
// hooks today) that must NOT ship inside the cmd/tap or cmd/keg
25+
// binaries. Only this command imports it; the overlay below merges
26+
// it onto the markdown-only canonical tree before adapter dispatch.
27+
"github.com/jlrickert/tapper/pkg/integrations/renderdata"
2228
)
2329

2430
func main() {
@@ -61,8 +67,38 @@ func run() error {
6167
if err != nil {
6268
return fmt.Errorf("resolve %s: %w", canonicalDir, err)
6369
}
64-
content := os.DirFS(canonicalAbs)
70+
primary := os.DirFS(canonicalAbs)
71+
72+
// Overlay renderdata.FS onto the on-disk canonical tree. The overlay
73+
// is asymmetric: paths present in the secondary (renderdata) take
74+
// precedence on overlap, paths only in the primary fall through. In
75+
// practice the two name-spaces are disjoint — primary owns
76+
// markdown bodies at the root, secondary owns the "claude/..."
77+
// subtree — so precedence only documents intent.
78+
content := overlayFS{primary: primary, secondary: renderdata.FS}
6579

6680
dst := integrations.NewDirWriter(rt, renderedDir)
6781
return integrations.RenderAll(rt, content, dst, integrations.DefaultAdapters())
6882
}
83+
84+
// overlayFS composes two fs.FS views. Open consults secondary first; if the
85+
// path is absent there it falls through to primary. io/fs does not ship a
86+
// merged-FS helper, so this minimal local type carries the overlay without
87+
// pulling in a third-party dependency.
88+
type overlayFS struct {
89+
primary, secondary fs.FS
90+
}
91+
92+
// Open implements fs.FS. Any error from secondary that is not
93+
// fs.ErrNotExist is surfaced immediately so a malformed embed is not
94+
// silently masked by the primary.
95+
func (o overlayFS) Open(name string) (fs.File, error) {
96+
f, err := o.secondary.Open(name)
97+
if err == nil {
98+
return f, nil
99+
}
100+
if !errors.Is(err, fs.ErrNotExist) {
101+
return nil, err
102+
}
103+
return o.primary.Open(name)
104+
}
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
#!/usr/bin/env python3
2+
# block-tap-cli.py -- PreToolUse Bash matcher for the tapper plugin.
3+
#
4+
# Cross-platform Python 3 port of block-tap-cli.sh. Runs anywhere
5+
# Python 3.8+ is on PATH (macOS, Linux, Windows). Standard library
6+
# only -- no third-party dependencies.
7+
#
8+
# Purpose: blocks direct `tap` and `keg` CLI invocations from the
9+
# agent so that all KEG access funnels through the mcp__tapper__*
10+
# MCP tools. CLI and MCP have full surface parity (see
11+
# integrations/content/tool-inventory.md), so a categorical deny
12+
# with a tiny allowlist (completion, --version, --help) is safe.
13+
#
14+
# Reads the Claude Code PreToolUse JSON event from stdin, extracts
15+
# .tool_input.command, walks each pipeline segment, strips env-var
16+
# prefixes and wrapper commands (sudo/command/exec/builtin/time),
17+
# and checks whether the resulting argv[0] basename is `tap` or
18+
# `keg`. Also follows `bash -c`/`sh -c`/`zsh -c`/`dash -c` one
19+
# level deep.
20+
#
21+
# Exit codes:
22+
# 0 with no stdout -- allow (default)
23+
# 0 with deny JSON -- explicit deny via permissionDecision
24+
# 2 -- hook error (empty/missing/malformed JSON)
25+
#
26+
# Limitations -- this is a guardrail, not a security boundary:
27+
# - eval "$(printf '%s\n' 'tap list')" -- eval-wrapped
28+
# invocations slip through; we do not run a real shell parser.
29+
# - cmd=tap; "$cmd" list -- variable indirection is not resolved.
30+
# - xargs tap -- argv[0] is `xargs`, so the wrapped `tap` is not
31+
# inspected. Same shape for `find ... -exec tap ...`.
32+
# - \tap list -- after the os.path.basename normalization we
33+
# strip leading `\` and `/` characters before basename, so
34+
# `\tap` is treated as `tap` and denied. This matches the
35+
# bash form, where the shell would have already discarded
36+
# the backslash quoting before exec.
37+
# - t""ap list -- shell concatenation (`t""ap` -> `tap`) is not
38+
# reconstructed; the matcher sees `t""ap` and allows.
39+
# - Nested `bash -c "bash -c '...'"` beyond one level -- only
40+
# one level of -c re-parsing is performed.
41+
# - Quoted segment content -- the segment scanner respects
42+
# single and double quotes, so `echo "tap list && true"` is a
43+
# single segment whose argv[0] is `echo`, which allows.
44+
#
45+
# Catching the bypasses above would require a real shell parser;
46+
# the goal here is to stop accidental and casual direct CLI use,
47+
# not adversarial bypass. The MCP surface is the canonical path.
48+
"""PreToolUse hook entry point. Invoked by Claude Code with the
49+
tool-call event JSON on stdin."""
50+
from __future__ import annotations
51+
52+
import json
53+
import os.path
54+
import re
55+
import shlex
56+
import sys
57+
from typing import List, Optional
58+
59+
# argv[1] tokens that gate the allowlist for `tap`/`keg`. These do
60+
# not touch any KEG and are useful for plumbing (shell completion
61+
# install, version probes, help text).
62+
ALLOWLIST = {"completion", "--version", "-v", "--help", "-h"}
63+
64+
# Wrappers we peel off the head of a segment before inspecting
65+
# argv[0]. Only one wrapper level is peeled -- chains like
66+
# `sudo -E env FOO=bar tap ...` are intentionally out of scope.
67+
WRAPPERS = {"sudo", "command", "exec", "builtin", "time"}
68+
69+
# Shells whose `-c "..."` argument we recurse into one level.
70+
SHELLS = {"bash", "sh", "zsh", "dash"}
71+
72+
# Matches a leading `VAR=value` env assignment with no whitespace
73+
# in the value. Multiple assignments separated by whitespace are
74+
# stripped iteratively.
75+
ENV_ASSIGN_RE = re.compile(r"^[A-Z_][A-Za-z0-9_]*=")
76+
77+
# Exact basename match for the deny set. Anchored so substring
78+
# matches (`taproom`, `keg-foo`, `bootstrap`) do not trigger.
79+
DENY_BASENAME_RE = re.compile(r"^(tap|keg)$")
80+
81+
# Deny reason copied from the bash version verbatim; the reviewer
82+
# confirmed the bash payload is byte-correct.
83+
DENY_REASON = (
84+
"Direct tap/keg CLI invocations are blocked for the agent. "
85+
"Use the mcp__tapper__* tools instead. See "
86+
"integrations/content/agent-orient.md (the 'never read or "
87+
"write node files directly' policy). Allowlisted: "
88+
"'tap completion', '--version', '--help'."
89+
)
90+
91+
92+
def deny() -> None:
93+
"""Emit the deny payload to stdout and exit 0.
94+
95+
Claude Code expects exit 0 with a permissionDecision payload on
96+
stdout for explicit denies; non-zero is reserved for
97+
hook-internal errors.
98+
"""
99+
payload = {
100+
"hookSpecificOutput": {
101+
"hookEventName": "PreToolUse",
102+
"permissionDecision": "deny",
103+
"permissionDecisionReason": DENY_REASON,
104+
}
105+
}
106+
# json.dumps with no indent and default separators produces the
107+
# same compact form as `jq -cn` in the bash original.
108+
sys.stdout.write(json.dumps(payload))
109+
sys.exit(0)
110+
111+
112+
def hook_error(msg: str) -> None:
113+
"""Log to stderr and exit 2 so Claude Code surfaces the failure
114+
rather than silently allowing or denying."""
115+
sys.stderr.write("block-tap-cli: " + msg + "\n")
116+
sys.exit(2)
117+
118+
119+
def split_segments(cmd: str) -> List[str]:
120+
"""Split `cmd` on shell metacharacters (;, &&, ||, |, &), but
121+
treat single- and double-quoted spans as opaque so that a
122+
metacharacter inside a quoted string does not start a new
123+
segment.
124+
125+
This is the guardrail trade-off called out in the header: the
126+
bash form did a textual replace (no quote awareness), which
127+
falsely flagged `echo "a; tap list"`. The Python port keeps
128+
the bash form's coarseness for unquoted text but respects
129+
quotes so the false-positive case allows correctly.
130+
"""
131+
segments: List[str] = []
132+
buf: List[str] = []
133+
i = 0
134+
n = len(cmd)
135+
quote: Optional[str] = None # current quote char, or None
136+
while i < n:
137+
ch = cmd[i]
138+
if quote is not None:
139+
# Inside a quoted span: copy until the matching quote.
140+
# Backslash escapes are preserved as-is; we are not
141+
# interpreting the string, only locating its end.
142+
buf.append(ch)
143+
if ch == "\\" and quote == '"' and i + 1 < n:
144+
# In double quotes, `\"` escapes the closing quote.
145+
buf.append(cmd[i + 1])
146+
i += 2
147+
continue
148+
if ch == quote:
149+
quote = None
150+
i += 1
151+
continue
152+
if ch in ('"', "'"):
153+
quote = ch
154+
buf.append(ch)
155+
i += 1
156+
continue
157+
# Two-character operators: && and ||.
158+
if i + 1 < n and (cmd[i : i + 2] in ("&&", "||")):
159+
segments.append("".join(buf))
160+
buf = []
161+
i += 2
162+
continue
163+
if ch in (";", "|", "&"):
164+
segments.append("".join(buf))
165+
buf = []
166+
i += 1
167+
continue
168+
buf.append(ch)
169+
i += 1
170+
segments.append("".join(buf))
171+
return segments
172+
173+
174+
def strip_env_assignments(argv: List[str]) -> List[str]:
175+
"""Drop leading argv tokens that look like `VAR=value` env
176+
assignments. Mirrors the bash regex: name must match
177+
`[A-Z_][A-Za-z0-9_]*`."""
178+
while argv and ENV_ASSIGN_RE.match(argv[0]):
179+
argv = argv[1:]
180+
return argv
181+
182+
183+
def strip_wrapper(argv: List[str]) -> List[str]:
184+
"""Peel a single leading wrapper (sudo/command/exec/builtin/
185+
time) and any env assignments that follow it. Only one wrapper
186+
level is peeled -- matches the bash behavior."""
187+
if argv and argv[0] in WRAPPERS:
188+
argv = argv[1:]
189+
argv = strip_env_assignments(argv)
190+
return argv
191+
192+
193+
def normalize_argv0_basename(argv0: str) -> str:
194+
"""Return the basename of argv[0] after stripping any leading
195+
`\\` or `/` characters that the shell would have discarded
196+
before exec. `\\tap` -> `tap`, `/usr/bin/tap` -> `tap`."""
197+
s = argv0.lstrip("\\/")
198+
return os.path.basename(s)
199+
200+
201+
def check_segment(segment: str, depth: int = 0) -> None:
202+
"""Inspect one pipeline segment. Calls `deny()` (which exits)
203+
if the segment invokes a denied CLI; returns normally
204+
otherwise.
205+
206+
`depth` caps shell-recursion: only one level of `bash -c "..."`
207+
re-parsing is performed, matching the bash original.
208+
"""
209+
text = segment.strip()
210+
if not text:
211+
return
212+
213+
# Tokenize via shlex in POSIX mode. shlex.split raises
214+
# ValueError on unbalanced quotes; treat that as "cannot
215+
# confidently parse" and allow -- consistent with the bash
216+
# form's textual approach.
217+
try:
218+
argv = shlex.split(text, posix=True)
219+
except ValueError:
220+
return
221+
if not argv:
222+
return
223+
224+
argv = strip_env_assignments(argv)
225+
if not argv:
226+
return
227+
argv = strip_wrapper(argv)
228+
if not argv:
229+
return
230+
231+
base = normalize_argv0_basename(argv[0])
232+
233+
# `bash -c "..."` / `sh -c "..."` / `zsh -c "..."` / `dash -c
234+
# "..."`: recurse one level into the quoted command. shlex has
235+
# already stripped the surrounding quotes from argv[2].
236+
if base in SHELLS and len(argv) >= 3 and argv[1] == "-c" and depth < 1:
237+
inner = argv[2]
238+
for inner_seg in split_segments(inner):
239+
check_segment(inner_seg, depth=depth + 1)
240+
return
241+
242+
if not DENY_BASENAME_RE.match(base):
243+
return
244+
245+
# argv[0] is `tap` or `keg`. Consult the allowlist on argv[1].
246+
argv1 = argv[1] if len(argv) >= 2 else ""
247+
if argv1 in ALLOWLIST:
248+
return
249+
deny()
250+
251+
252+
def main() -> None:
253+
raw = sys.stdin.read()
254+
if not raw:
255+
hook_error("empty stdin")
256+
try:
257+
payload = json.loads(raw)
258+
except json.JSONDecodeError as exc:
259+
hook_error("could not parse JSON payload: " + str(exc))
260+
return # unreachable; appeases type checkers
261+
262+
# Missing/non-string fields collapse to empty -- treat that as
263+
# allow (nothing to match against). Mirrors the bash form's
264+
# `// empty` jq fallback.
265+
tool_input = payload.get("tool_input")
266+
if not isinstance(tool_input, dict):
267+
return
268+
cmd = tool_input.get("command")
269+
if not isinstance(cmd, str) or not cmd:
270+
return
271+
272+
for seg in split_segments(cmd):
273+
check_segment(seg)
274+
275+
276+
if __name__ == "__main__":
277+
main()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"hooks": {
3+
"PreToolUse": [
4+
{
5+
"matcher": "Bash",
6+
"hooks": [
7+
{
8+
"type": "command",
9+
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/block-tap-cli.py",
10+
"timeout": 5
11+
}
12+
]
13+
}
14+
]
15+
}
16+
}

0 commit comments

Comments
 (0)