Skip to content

Commit 61d7df9

Browse files
authored
Recipe Read Guard Metadata Command Remediation (#4213)
## Summary Fix recipe read guard false positives by centralizing protected-path command classification for hook and server-side command execution. The change permits narrow non-content metadata/staging operations against protected recipe, skill, and agent paths while continuing to deny direct content reads and shell bypasses. The implementation adds shared classification coverage for safe `git add`, metadata-only `git diff`, safe `git status`, and `wc -l` usage, and blocks content-producing Git modes, heredocs, substitutions, variable reuse in command chains, and unsafe chained reads. Hook and MCP `run_cmd` behavior now use the same classifier and focused regression tests cover the allow and deny cases. Closes #4207 ## Implementation Plan Plan file: `/home/talon/projects/generic_automation_mcp/.autoskillit/temp/codex-loop/4207-manual-remediation-plan.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr -->
1 parent b39cc75 commit 61d7df9

6 files changed

Lines changed: 432 additions & 25 deletions

File tree

src/autoskillit/hooks/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
HookDef,
1212
generate_hooks_json,
1313
)
14-
from autoskillit.hooks._command_classification import _INTERPRETER_LINE_RE, _WRITE_APIS_RE
14+
from autoskillit.hooks._command_classification import (
15+
_INTERPRETER_LINE_RE,
16+
_WRITE_APIS_RE,
17+
command_has_blocked_protected_path_read,
18+
)
1519
from autoskillit.hooks.formatters._fmt_primitives import _HOOK_CONFIG_PATH_COMPONENTS
1620
from autoskillit.hooks.guards.branch_protection_guard import BRANCH_PROTECTION_DENY_TRIGGER
1721
from autoskillit.hooks.guards.review_loop_gate import REVIEW_LOOP_DENY_TRIGGER
@@ -30,5 +34,6 @@
3034
"_HOOK_CONFIG_PATH_COMPONENTS",
3135
"_INTERPRETER_LINE_RE",
3236
"_WRITE_APIS_RE",
37+
"command_has_blocked_protected_path_read",
3338
"generate_hooks_json",
3439
]

src/autoskillit/hooks/_command_classification.py

Lines changed: 211 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import re
1212
import shlex
1313
from collections.abc import Sequence
14+
from typing import Protocol
1415

1516
_INTERPRETER_RE = re.compile(
1617
r"(?:^|&&|\|\||;)\s*(?:env\s+)?(?:python3?|perl|ruby|node)\s+"
@@ -74,6 +75,71 @@
7475
re.DOTALL,
7576
)
7677

78+
_PROTECTED_PATH_METADATA_GIT_SUBCOMMANDS: frozenset[str] = frozenset({"add", "diff", "status"})
79+
# Command wrappers whose only effect is to invoke the next command with
80+
# adjusted environment/priority. The verb is the token after the wrapper.
81+
# 'xargs' is intentionally excluded: it dispatches a downstream reader and
82+
# adding it would let `xargs cat src/.../foo.yaml` reach the reader check,
83+
# weakening xargs-chain bypass detection (see D1 design decision).
84+
_COMMAND_WRAPPERS: frozenset[str] = frozenset({"command", "nice", "time", "sudo", "nohup"})
85+
# Wrappers that consume a mandatory DURATION as their first non-wrapper token.
86+
_WRAPPERS_WITH_DURATION: frozenset[str] = frozenset({"timeout"})
87+
# Wrappers that take a single short flag as their first non-wrapper token
88+
# (e.g. 'stdbuf -o0', 'stdbuf -i0', 'stdbuf -e0').
89+
_WRAPPERS_WITH_SHORT_FLAG: frozenset[str] = frozenset({"stdbuf"})
90+
_GIT_ADD_CONTENT_FLAGS: frozenset[str] = frozenset(
91+
{
92+
"-p",
93+
"--patch",
94+
"-e",
95+
"--edit",
96+
"-i",
97+
"--interactive",
98+
"--pathspec-from-file",
99+
# Content-staging flags: -A/--all stages all changes (incl. content);
100+
# --force/--no-ignore-removal/--no-all are the no-restriction variants.
101+
# Without these, `git add -A -- src/.../foo.yaml` is classified as
102+
# metadata but actually stages content for indirect read via
103+
# `git diff --staged`.
104+
"-A",
105+
"--all",
106+
"--force",
107+
"--no-ignore-removal",
108+
"--no-all",
109+
}
110+
)
111+
_GIT_STATUS_CONTENT_FLAGS: frozenset[str] = frozenset({"-v", "--verbose"})
112+
_GIT_DIFF_CONTENT_FLAGS: frozenset[str] = frozenset(
113+
{
114+
"-p",
115+
"--patch",
116+
"--patch-with-stat",
117+
"--patch-with-raw",
118+
"--binary",
119+
"--text",
120+
"--word-diff",
121+
"--color-words",
122+
}
123+
)
124+
_GIT_DIFF_METADATA_FLAGS: frozenset[str] = frozenset(
125+
{
126+
"--name-only",
127+
"--name-status",
128+
"--stat",
129+
"--shortstat",
130+
"--numstat",
131+
"--summary",
132+
}
133+
)
134+
_SHELL_SUBSTITUTION_RE = re.compile(r"\$\(|`|[<>]\(")
135+
_SHELL_STATE_VAR_RE = re.compile(r"\$(?:_|[A-Za-z][A-Za-z0-9_]*|\{[^}]+\})")
136+
_PROTECTED_READ_SHELL_OPS: frozenset[str] = frozenset({"&&", "||", ";", "|", "&"})
137+
_WC_FLAG_RE = re.compile(r"-l+|--lines$")
138+
139+
140+
class SearchPattern(Protocol):
141+
def search(self, string: str, /): ...
142+
77143

78144
def strip_heredoc_bodies(command: str) -> str:
79145
"""Strip heredoc body content, preserving the opening line and terminator.
@@ -195,16 +261,46 @@ def extract_redirect_targets(tokens: list[str], cwd: str = "") -> list[str]:
195261
return targets
196262

197263

198-
def command_verb(segment: list[str]) -> str:
199-
"""Return the command verb from a segment, skipping 'env' prefix."""
264+
def _command_start_index(segment: list[str]) -> int | None:
200265
if not segment:
201-
return ""
266+
return None
202267
start = 0
203-
if segment[0] == "env" and len(segment) > 1:
204-
start = 1
205-
while start < len(segment) and (segment[start].startswith("-") or "=" in segment[start]):
268+
# Iteratively strip 'env', command wrappers, and any wrapper-required
269+
# argument. This handles nested forms like
270+
# 'env FOO=BAR command git diff' and 'command env FOO=BAR git diff'
271+
# consistently — the real command verb is the first token that is not a
272+
# known env/wrapper prefix. 'xargs' is intentionally not a wrapper
273+
# (see _COMMAND_WRAPPERS docstring).
274+
while start < len(segment):
275+
token = segment[start]
276+
if token == "env" and start + 1 < len(segment):
206277
start += 1
207-
return segment[start] if start < len(segment) else ""
278+
while start < len(segment) and (
279+
segment[start].startswith("-") or "=" in segment[start]
280+
):
281+
start += 1
282+
continue
283+
if token in _COMMAND_WRAPPERS:
284+
start += 1
285+
continue
286+
if token in _WRAPPERS_WITH_DURATION and start + 1 < len(segment):
287+
start += 2
288+
continue
289+
if (
290+
token in _WRAPPERS_WITH_SHORT_FLAG
291+
and start + 1 < len(segment)
292+
and segment[start + 1].startswith("-")
293+
):
294+
start += 2
295+
continue
296+
break
297+
return start if start < len(segment) else None
298+
299+
300+
def command_verb(segment: list[str]) -> str:
301+
"""Return the command verb from a segment, skipping 'env' prefix."""
302+
start = _command_start_index(segment)
303+
return segment[start] if start is not None else ""
208304

209305

210306
def is_gh_command(segment: list[str]) -> bool:
@@ -233,12 +329,13 @@ def extract_git_subcommand_and_flags(
233329
then returns (subcommand, remaining_tokens). Returns None if the segment
234330
is not a git command or has no subcommand.
235331
"""
236-
if not segment:
332+
start = _command_start_index(segment)
333+
if start is None:
237334
return None
238-
verb = segment[0]
335+
verb = segment[start]
239336
if verb != "git" and not verb.endswith("/git"):
240337
return None
241-
i = 1
338+
i = start + 1
242339
while i < len(segment):
243340
token = segment[i]
244341
if token in _GIT_GLOBAL_FLAGS_WITH_VALUE:
@@ -257,6 +354,110 @@ def extract_git_subcommand_and_flags(
257354
return None
258355

259356

357+
def _is_allowed_wc_flag(token: str) -> bool:
358+
"""Return True when *token* is a wc flag that does not reveal file contents.
359+
360+
Allows ``-l``, repeated ``-l`` (e.g. ``-ll``), and the long form ``--lines``
361+
only. Any value-bearing variant (``--lines=10``) or compound form
362+
(``-lL``) is rejected because those are not used for metadata-only reads.
363+
"""
364+
return bool(_WC_FLAG_RE.fullmatch(token))
365+
366+
367+
def is_allowed_protected_path_metadata_command(segment: list[str]) -> bool:
368+
"""Return True for protected-path commands that inspect metadata or VCS state.
369+
370+
Protected recipe/skill/agent paths are normally deny-by-default because most
371+
commands that mention them are content reads. These narrow exceptions support
372+
legitimate pipeline work on files already in scope.
373+
"""
374+
verb = command_verb(segment)
375+
if verb == "git" or verb.endswith("/git"):
376+
git_parts = extract_git_subcommand_and_flags(segment)
377+
if git_parts is None:
378+
return False
379+
subcommand, flags = git_parts
380+
if subcommand not in _PROTECTED_PATH_METADATA_GIT_SUBCOMMANDS:
381+
return False
382+
if subcommand == "add":
383+
return not any(
384+
flag in _GIT_ADD_CONTENT_FLAGS or flag.startswith("--pathspec-from-file=")
385+
for flag in flags
386+
)
387+
if subcommand == "status":
388+
return not any(flag in _GIT_STATUS_CONTENT_FLAGS for flag in flags)
389+
if subcommand == "diff":
390+
if any(
391+
flag in _GIT_DIFF_CONTENT_FLAGS
392+
or flag.startswith("-U")
393+
or flag.startswith("--unified")
394+
or flag.startswith("--word-diff")
395+
or flag.startswith("--color-words")
396+
or flag.startswith("--patch-with-stat")
397+
or flag.startswith("--patch-with-raw")
398+
for flag in flags
399+
):
400+
return False
401+
return any(
402+
flag in _GIT_DIFF_METADATA_FLAGS or flag.startswith("--stat=") for flag in flags
403+
)
404+
return False
405+
if verb == "wc" or verb.endswith("/wc"):
406+
start = _command_start_index(segment)
407+
if start is None:
408+
return False
409+
flags = [token for token in segment[start + 1 :] if token.startswith("-")]
410+
return bool(flags) and all(_is_allowed_wc_flag(token) for token in flags)
411+
return False
412+
413+
414+
def _tokenize_protected_read_segments(command: str) -> list[list[str]]:
415+
try:
416+
lexer = shlex.shlex(command.replace("\n", " ; "), posix=True, punctuation_chars=";&|()")
417+
lexer.whitespace_split = True
418+
tokens = list(lexer)
419+
except (ValueError, TypeError):
420+
return []
421+
422+
segments: list[list[str]] = []
423+
current: list[str] = []
424+
for token in tokens:
425+
if token in _PROTECTED_READ_SHELL_OPS:
426+
if current:
427+
segments.append(current)
428+
current = []
429+
else:
430+
current.append(token)
431+
if current:
432+
segments.append(current)
433+
return segments
434+
435+
436+
def command_has_blocked_protected_path_read(
437+
command: str, protected_path_patterns: Sequence[SearchPattern]
438+
) -> bool:
439+
"""Return True when a command reads a protected recipe/skill/agent path."""
440+
if not any(pattern.search(command) for pattern in protected_path_patterns):
441+
return False
442+
443+
if "<<" in command or _SHELL_SUBSTITUTION_RE.search(command):
444+
return True
445+
446+
segments = _tokenize_protected_read_segments(command)
447+
if not segments:
448+
return True
449+
450+
if len(segments) > 1 and _SHELL_STATE_VAR_RE.search(command):
451+
return True
452+
453+
for segment in segments:
454+
segment_text = " ".join(segment)
455+
if any(pattern.search(segment_text) for pattern in protected_path_patterns):
456+
if not is_allowed_protected_path_metadata_command(segment):
457+
return True
458+
return False
459+
460+
260461
def has_interpreter_write(command: str) -> bool:
261462
if not _INTERPRETER_RE.search(command):
262463
return False

src/autoskillit/hooks/guards/recipe_read_guard.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
import os
1414
import re
1515
import sys
16+
from importlib import import_module
17+
from pathlib import Path
18+
19+
_HOOKS_DIR = str(Path(__file__).resolve().parent.parent)
20+
if _HOOKS_DIR not in sys.path:
21+
sys.path.insert(0, _HOOKS_DIR)
22+
command_has_blocked_protected_path_read = import_module(
23+
"_command_classification"
24+
).command_has_blocked_protected_path_read
1625

1726
RECIPE_READ_DENY_TRIGGER: str = "must not read recipe/skill/agent files directly"
1827

@@ -43,13 +52,12 @@ def main() -> None:
4352
if tool == "run_cmd" or tool_name == "Bash":
4453
cmd_key = "command" if tool_name == "Bash" else "cmd"
4554
cmd: str = tool_input.get(cmd_key, "")
46-
for pattern in _CMD_PATH_PATTERNS:
47-
if pattern.search(cmd):
48-
_deny(
49-
f"run_cmd/Bash {RECIPE_READ_DENY_TRIGGER}. "
50-
"Use load_recipe to recall step definitions or the Skill tool "
51-
"for skill instructions."
52-
)
55+
if command_has_blocked_protected_path_read(cmd, _CMD_PATH_PATTERNS):
56+
_deny(
57+
f"run_cmd/Bash {RECIPE_READ_DENY_TRIGGER}. "
58+
"Use load_recipe to recall step definitions or the Skill tool "
59+
"for skill instructions."
60+
)
5361

5462
if tool == "run_python":
5563
callable_name: str = tool_input.get("callable", "")

src/autoskillit/server/_guards.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
is_path_like_token,
2020
session_type,
2121
)
22+
from autoskillit.hooks import command_has_blocked_protected_path_read
2223
from autoskillit.pipeline import gate_error_result, headless_error_result
2324

2425
if TYPE_CHECKING:
@@ -157,13 +158,12 @@ def _check_recipe_read_prohibition(
157158
if os.environ.get("AUTOSKILLIT_HEADLESS") != "1":
158159
return None
159160
if cmd is not None:
160-
for pattern in _RECIPE_READ_CMD_PATTERNS:
161-
if pattern.search(cmd):
162-
return gate_error_result(
163-
f"run_cmd {RECIPE_READ_DENY_TRIGGER}. "
164-
"Use load_recipe to recall step definitions or the Skill tool "
165-
"for skill instructions."
166-
)
161+
if command_has_blocked_protected_path_read(cmd, _RECIPE_READ_CMD_PATTERNS):
162+
return gate_error_result(
163+
f"run_cmd {RECIPE_READ_DENY_TRIGGER}. "
164+
"Use load_recipe to recall step definitions or the Skill tool "
165+
"for skill instructions."
166+
)
167167
if callable_name is not None:
168168
if _RECIPE_READ_CALLABLE_PATTERN.match(callable_name):
169169
return gate_error_result(

0 commit comments

Comments
 (0)