capt-hook 6.5.0__tar.gz → 7.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {capt_hook-6.5.0 → capt_hook-7.0.0}/PKG-INFO +2 -4
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/__init__.py +10 -3
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/app.py +1 -1
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/cli.py +6 -1
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/conditions.py +8 -2
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/context.py +3 -2
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/dispatch.py +15 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/events.py +51 -10
- capt_hook-7.0.0/captain_hook/packs/fixes/capt-hook.toml +4 -0
- capt_hook-7.0.0/captain_hook/packs/fixes/teammate_permissions.py +66 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/go/testing.py +6 -6
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/python/testing.py +8 -6
- capt_hook-7.0.0/captain_hook/packs/steering/__init__.py +1 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/steering/steering.py +9 -1
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/__init__.py +1 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/commands.py +2 -1
- capt_hook-7.0.0/captain_hook/primitives/permissions.py +162 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/prompt.py +15 -2
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/capt-hook-api.md +34 -12
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/pattern-catalog.md +7 -5
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/testing-hooks.md +6 -1
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/bootstrapping-hooks/SKILL.md +2 -2
- capt_hook-7.0.0/captain_hook/testing/__init__.py +11 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/testing/helpers.py +25 -6
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/testing/types.py +28 -5
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/types.py +51 -8
- capt_hook-7.0.0/captain_hook/util/automode.py +53 -0
- capt_hook-7.0.0/captain_hook/util/proc.py +59 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/pyproject.toml +8 -4
- capt_hook-6.5.0/captain_hook/command.py +0 -382
- capt_hook-6.5.0/captain_hook/testing/__init__.py +0 -1
- {capt_hook-6.5.0 → capt_hook-7.0.0}/LICENSE +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/README.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/.claude-plugin/plugin.json +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/__main__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/ast_grep.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/classifiers/__init__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/classifiers/conductor.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/classifiers/droid.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/classifiers/native.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/contexts.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/decisions.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/durable.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/fields.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/file.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/loader.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/log.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/__init__.py +0 -0
- {capt_hook-6.5.0/captain_hook/packs/general → capt_hook-7.0.0/captain_hook/packs/fixes}/__init__.py +0 -0
- {capt_hook-6.5.0/captain_hook/packs/go → capt_hook-7.0.0/captain_hook/packs/general}/__init__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/_lib.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/capt-hook.toml +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/commands.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/detours.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/docs.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/models.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/plans.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/prompts.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/review.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/tasks.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/general/tombstones.py +0 -0
- {capt_hook-6.5.0/captain_hook/packs/python → capt_hook-7.0.0/captain_hook/packs/go}/__init__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/go/capt-hook.toml +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/go/toolchain.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/manager.py +0 -0
- {capt_hook-6.5.0/captain_hook/packs/steering → capt_hook-7.0.0/captain_hook/packs/python}/__init__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/python/capt-hook.toml +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/python/style.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/python/toolchain.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/packs/steering/capt-hook.toml +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/lint.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/llm.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/nudge.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/rewrite.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/primitives/workflow.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/py.typed +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/__init__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/cli.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/dashboard.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/fix.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/formats.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/judge.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/pipeline.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/repo.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/scan.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/settings.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/store.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/review/sync.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/session.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/settings.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/signals/__init__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/signals/nlp.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/SKILL.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/authoring-hooks/references/pitfalls.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/SKILL.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/references/pr-workflow.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/scanning-sessions/references/review-cli.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/SKILL.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/llm-rule-patterns.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/matcher-reference.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/skills/translating-styleguides/references/tier-rubric.md +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/state.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/__init__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/ast_grep.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/matchers.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/scope.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/style/types.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/tasks.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/templates/example_hook.py.tmpl +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/testing/session_cache.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/__init__.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/fs.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/http.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/model_cache.py +0 -0
- {capt_hook-6.5.0 → capt_hook-7.0.0}/captain_hook/util/text.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capt-hook
|
|
3
|
-
Version:
|
|
3
|
+
Version: 7.0.0
|
|
4
4
|
Summary: Stop repeating yourself to Claude.
|
|
5
5
|
Keywords: claude,claude-code,hooks,llm,agents,guardrails,cli
|
|
6
6
|
Author: Yasyf Mohamedali
|
|
@@ -16,11 +16,9 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
16
16
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
17
|
Classifier: Topic :: Software Development :: Testing
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: cc-transcript>=
|
|
19
|
+
Requires-Dist: cc-transcript>=8,<9
|
|
20
20
|
Requires-Dist: pydantic>=2.0
|
|
21
21
|
Requires-Dist: pydantic-settings>=2.0
|
|
22
|
-
Requires-Dist: tree-sitter>=0.24
|
|
23
|
-
Requires-Dist: tree-sitter-bash>=0.23
|
|
24
22
|
Requires-Dist: ast-grep-py>=0.39,<1
|
|
25
23
|
Requires-Dist: funcy>=2.0
|
|
26
24
|
Requires-Dist: spacy>=3.7
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from cc_transcript.command import Command, CommandLine, Redirect
|
|
3
4
|
from cc_transcript.tools import (
|
|
4
5
|
BashCall,
|
|
5
6
|
EditCall,
|
|
@@ -22,7 +23,6 @@ from cc_transcript.tools import (
|
|
|
22
23
|
from captain_hook import file, style, util
|
|
23
24
|
from captain_hook.app import hook, on
|
|
24
25
|
from captain_hook.ast_grep import COMMENT_TYPES
|
|
25
|
-
from captain_hook.command import CommandLine, ParsedCommand, Redirect
|
|
26
26
|
from captain_hook.conditions import workflow_opt_values, workflow_script_source
|
|
27
27
|
from captain_hook.context import HookContext
|
|
28
28
|
from captain_hook.contexts import AfterEdit, BeforeEdit, Introduced, PromptContext, apply_contexts
|
|
@@ -30,6 +30,7 @@ from captain_hook.durable import DurableSlot, DurableState, DurableStore
|
|
|
30
30
|
from captain_hook.events import (
|
|
31
31
|
BaseHookEvent,
|
|
32
32
|
NotificationEvent,
|
|
33
|
+
PermissionRequestEvent,
|
|
33
34
|
PostToolUseEvent,
|
|
34
35
|
PostToolUseFailureEvent,
|
|
35
36
|
PreCompactEvent,
|
|
@@ -40,6 +41,7 @@ from captain_hook.events import (
|
|
|
40
41
|
SubagentStartEvent,
|
|
41
42
|
SubagentStopEvent,
|
|
42
43
|
ToolHookEvent,
|
|
44
|
+
ToolRewriteEvent,
|
|
43
45
|
UserPromptSubmitEvent,
|
|
44
46
|
)
|
|
45
47
|
from captain_hook.fields import Deque
|
|
@@ -48,8 +50,12 @@ from captain_hook.primitives import (
|
|
|
48
50
|
GateVerdict,
|
|
49
51
|
NudgeVerdict,
|
|
50
52
|
PromptCheckVerdict,
|
|
53
|
+
SafetyVerdict,
|
|
54
|
+
approve,
|
|
51
55
|
block_command,
|
|
56
|
+
deny,
|
|
52
57
|
gate,
|
|
58
|
+
llm_approve,
|
|
53
59
|
llm_evaluate,
|
|
54
60
|
llm_gate,
|
|
55
61
|
llm_nudge,
|
|
@@ -73,18 +79,18 @@ from captain_hook.settings import HooksSettings, build_settings
|
|
|
73
79
|
from captain_hook.signals.nlp import Clause, NlpSignal, Phrase, has_nominal_subject, is_past_predicate
|
|
74
80
|
from captain_hook.state import HookState, PrimitiveState, WorkflowState, workflow_state
|
|
75
81
|
from captain_hook.tasks import Task, Tasks
|
|
76
|
-
from captain_hook.testing import Allow, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
|
|
82
|
+
from captain_hook.testing import Allow, Ask, Block, FileFixture, InlineTests, Input, Rewrite, TranscriptFixture, Warn
|
|
77
83
|
from captain_hook.types import (
|
|
78
84
|
Action,
|
|
79
85
|
Agent,
|
|
80
86
|
And,
|
|
81
|
-
Command,
|
|
82
87
|
Content,
|
|
83
88
|
CustomCommandLineCondition,
|
|
84
89
|
CustomCondition,
|
|
85
90
|
CustomInputTypeCondition,
|
|
86
91
|
Event,
|
|
87
92
|
FilePath,
|
|
93
|
+
FromSubagent,
|
|
88
94
|
HookResponse,
|
|
89
95
|
HookResult,
|
|
90
96
|
InPlanMode,
|
|
@@ -96,6 +102,7 @@ from captain_hook.types import (
|
|
|
96
102
|
Runs,
|
|
97
103
|
Signal,
|
|
98
104
|
Signals,
|
|
105
|
+
SkipPermissions,
|
|
99
106
|
SourceEdits,
|
|
100
107
|
TCondition,
|
|
101
108
|
TestFile,
|
|
@@ -40,7 +40,7 @@ HookHandler = Callable[["BaseHookEvent"], "HookResult | None"]
|
|
|
40
40
|
VALID_CONDITION_TYPES = tuple(t for t in get_args(TCondition) if t is not CustomCondition)
|
|
41
41
|
VALID_CONDITION_NAMES = ", ".join(t.__name__ for t in VALID_CONDITION_TYPES) + ", or a CustomCondition"
|
|
42
42
|
|
|
43
|
-
_TOOL_EVENTS = Event.PreToolUse | Event.PostToolUse | Event.PostToolUseFailure
|
|
43
|
+
_TOOL_EVENTS = Event.PreToolUse | Event.PostToolUse | Event.PostToolUseFailure | Event.PermissionRequest
|
|
44
44
|
|
|
45
45
|
# Conditions that read the current event's tool input can only match on a tool event; a
|
|
46
46
|
# condition absent from this map (transcript-history conditions, InPlanMode, Waiting,
|
|
@@ -259,7 +259,12 @@ def settings_drift(root: Path) -> set[str]:
|
|
|
259
259
|
def warn_settings_drift(
|
|
260
260
|
output: dict[str, Any] | None, event: Event, root: Path | None, session_dir: Path | None, *, async_: bool
|
|
261
261
|
) -> dict[str, Any] | None:
|
|
262
|
-
if
|
|
262
|
+
if (
|
|
263
|
+
async_
|
|
264
|
+
or root is None
|
|
265
|
+
or session_dir is None
|
|
266
|
+
or event in (Event.Stop | Event.SubagentStop | Event.PermissionRequest)
|
|
267
|
+
):
|
|
263
268
|
return output
|
|
264
269
|
marker = session_dir / ".drift_surfaced"
|
|
265
270
|
if marker.exists():
|
|
@@ -16,6 +16,7 @@ from captain_hook.types import (
|
|
|
16
16
|
Content,
|
|
17
17
|
CustomCondition,
|
|
18
18
|
FilePath,
|
|
19
|
+
FromSubagent,
|
|
19
20
|
InPlanMode,
|
|
20
21
|
Not,
|
|
21
22
|
Or,
|
|
@@ -23,6 +24,7 @@ from captain_hook.types import (
|
|
|
23
24
|
RanCommand,
|
|
24
25
|
ReadFile,
|
|
25
26
|
Runs,
|
|
27
|
+
SkipPermissions,
|
|
26
28
|
SourceEdits,
|
|
27
29
|
TCondition,
|
|
28
30
|
TestFile,
|
|
@@ -228,8 +230,8 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
|
228
230
|
return has_read_glob(evt.ctx.transcript, *patterns, subagents=subagents)
|
|
229
231
|
case TouchedFile(patterns, subagents):
|
|
230
232
|
return evt.ctx.transcript.has_edit_to(*patterns, subagents=subagents)
|
|
231
|
-
case RanCommand(
|
|
232
|
-
return evt.ctx.transcript.has_command(
|
|
233
|
+
case RanCommand(argv, subagents):
|
|
234
|
+
return evt.ctx.transcript.has_command(*argv, subagents=subagents)
|
|
233
235
|
case Runs(argv):
|
|
234
236
|
return bool(argv and (cl := evt.command_line) and any(cmd.argv[: len(argv)] == argv for cmd in cl.commands))
|
|
235
237
|
case InPlanMode():
|
|
@@ -239,6 +241,10 @@ def check_condition(c: TCondition, evt: BaseHookEvent) -> bool:
|
|
|
239
241
|
)
|
|
240
242
|
case Waiting():
|
|
241
243
|
return is_waiting(evt)
|
|
244
|
+
case FromSubagent():
|
|
245
|
+
return evt.is_subagent
|
|
246
|
+
case SkipPermissions():
|
|
247
|
+
return evt.skip_permissions
|
|
242
248
|
case And(conditions):
|
|
243
249
|
return all(check_condition(sub, evt) for sub in conditions)
|
|
244
250
|
case Not(condition):
|
|
@@ -7,7 +7,8 @@ from functools import cached_property
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import TYPE_CHECKING, Any, Literal, overload
|
|
9
9
|
|
|
10
|
-
from cc_transcript.activity import SessionActivity
|
|
10
|
+
from cc_transcript.activity import SessionActivity
|
|
11
|
+
from cc_transcript.filterspec import event_meta
|
|
11
12
|
from cc_transcript.ids import SessionId
|
|
12
13
|
from cc_transcript.models import AssistantEvent, ToolUseBlock
|
|
13
14
|
from cc_transcript.parser import parse_events_from_bytes
|
|
@@ -84,7 +85,7 @@ def lift_session(events: Sequence[TranscriptEvent], *, path: Path | None = None)
|
|
|
84
85
|
events=events,
|
|
85
86
|
)
|
|
86
87
|
session_id = next(
|
|
87
|
-
(meta.session_id for event in events if (meta :=
|
|
88
|
+
(meta.session_id for event in events if (meta := event_meta(event)) is not None),
|
|
88
89
|
SessionId(path.stem if path else "unknown"),
|
|
89
90
|
)
|
|
90
91
|
return Session.from_activity(
|
|
@@ -67,10 +67,25 @@ def execute_hook(
|
|
|
67
67
|
return result
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def format_permission_decision(result: HookResult) -> dict[str, Any] | None:
|
|
71
|
+
match result.action:
|
|
72
|
+
case Action.allow:
|
|
73
|
+
decision: dict[str, Any] = {"behavior": "allow"}
|
|
74
|
+
case Action.rewrite:
|
|
75
|
+
decision = {"behavior": "allow", "updatedInput": result.updated_input}
|
|
76
|
+
case Action.block:
|
|
77
|
+
decision = {"behavior": "deny"} | ({"message": result.message} if result.message else {})
|
|
78
|
+
case Action.warn:
|
|
79
|
+
return None
|
|
80
|
+
return {"hookSpecificOutput": {"hookEventName": Event.PermissionRequest.name, "decision": decision}}
|
|
81
|
+
|
|
82
|
+
|
|
70
83
|
def format_output(event: Event, result: HookResult) -> dict[str, Any] | None:
|
|
71
84
|
"""Render a ``HookResult`` as the JSON envelope Claude Code expects on stdout for *event*."""
|
|
72
85
|
if event in (Event.Stop | Event.SubagentStop):
|
|
73
86
|
return {"decision": "block", "reason": result.message} if result.action is not Action.allow else None
|
|
87
|
+
if event is Event.PermissionRequest:
|
|
88
|
+
return format_permission_decision(result)
|
|
74
89
|
|
|
75
90
|
match result.action:
|
|
76
91
|
case Action.block:
|
|
@@ -25,10 +25,10 @@ from captain_hook.types import Event
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
from collections.abc import Callable
|
|
27
27
|
|
|
28
|
+
from cc_transcript.command import CommandLine as CommandLineType
|
|
28
29
|
from cc_transcript.ids import ToolDigest
|
|
29
30
|
from cc_transcript.tools import ToolCall
|
|
30
31
|
|
|
31
|
-
from captain_hook.command import CommandLine as CommandLineType
|
|
32
32
|
from captain_hook.context import HookContext
|
|
33
33
|
from captain_hook.tasks import Tasks
|
|
34
34
|
from captain_hook.types import HookResult
|
|
@@ -81,6 +81,18 @@ class BaseHookEvent:
|
|
|
81
81
|
|
|
82
82
|
return T.for_session(self.session_id)
|
|
83
83
|
|
|
84
|
+
@cached_property
|
|
85
|
+
def skip_permissions(self) -> bool:
|
|
86
|
+
"""Whether the session's ``claude`` process was launched with permission bypass available.
|
|
87
|
+
|
|
88
|
+
Walks the process tree to the nearest ``claude`` ancestor and reports whether that
|
|
89
|
+
process was started with ``--dangerously-skip-permissions`` or
|
|
90
|
+
``--allow-dangerously-skip-permissions``.
|
|
91
|
+
"""
|
|
92
|
+
from captain_hook.util.proc import claude_skip_permissions
|
|
93
|
+
|
|
94
|
+
return claude_skip_permissions()
|
|
95
|
+
|
|
84
96
|
@property
|
|
85
97
|
def user_prompt(self) -> str | None:
|
|
86
98
|
return self._raw.get("prompt")
|
|
@@ -125,7 +137,7 @@ class BaseHookEvent:
|
|
|
125
137
|
def command(self) -> str | None:
|
|
126
138
|
return self.input.command if isinstance(self.input, BashCall) else None
|
|
127
139
|
|
|
128
|
-
@
|
|
140
|
+
@property
|
|
129
141
|
def command_line(self) -> CommandLineType | None:
|
|
130
142
|
return None
|
|
131
143
|
|
|
@@ -213,11 +225,11 @@ class ToolHookEvent(BaseHookEvent):
|
|
|
213
225
|
def _tool_input(self) -> dict[str, Any]:
|
|
214
226
|
return self._raw.get("tool_input", {})
|
|
215
227
|
|
|
216
|
-
@
|
|
228
|
+
@property
|
|
217
229
|
def command_line(self) -> CommandLineType | None:
|
|
218
|
-
from
|
|
230
|
+
from cc_transcript.command import parse_command_line
|
|
219
231
|
|
|
220
|
-
return
|
|
232
|
+
return parse_command_line(cmd) if (cmd := self.command) else None
|
|
221
233
|
|
|
222
234
|
@property
|
|
223
235
|
def content(self) -> str | None:
|
|
@@ -267,9 +279,9 @@ class ToolHookEvent(BaseHookEvent):
|
|
|
267
279
|
return call.agent_type if isinstance(call := self.input, TaskCall) else None
|
|
268
280
|
|
|
269
281
|
def command_matches(self, *patterns: str) -> bool:
|
|
270
|
-
if not (cl := self.command_line):
|
|
282
|
+
if not (cl := self.command_line) or (cmd := cl.primary) is None:
|
|
271
283
|
return False
|
|
272
|
-
return any(
|
|
284
|
+
return any(cmd.matches(p) for p in patterns)
|
|
273
285
|
|
|
274
286
|
def file_matches(self, *globs: str) -> bool:
|
|
275
287
|
return bool(self.file) and self.file.matches(*globs)
|
|
@@ -279,10 +291,12 @@ class ToolHookEvent(BaseHookEvent):
|
|
|
279
291
|
|
|
280
292
|
|
|
281
293
|
@dataclass
|
|
282
|
-
class
|
|
283
|
-
"""
|
|
294
|
+
class ToolRewriteEvent(ToolHookEvent):
|
|
295
|
+
"""Tool event whose input can be rewritten before it runs.
|
|
284
296
|
|
|
285
|
-
|
|
297
|
+
Base for the events where Claude Code accepts an ``updatedInput`` decision
|
|
298
|
+
(``PreToolUse`` and ``PermissionRequest``), adding the ``rewrite*`` helpers.
|
|
299
|
+
"""
|
|
286
300
|
|
|
287
301
|
def rewrite(self, updated_input: dict[str, Any], *, note: str | None = None) -> HookResult:
|
|
288
302
|
"""Allow the tool but replace its input with ``updated_input`` (Claude Code's ``updatedInput``).
|
|
@@ -322,6 +336,33 @@ class PreToolUseEvent(ToolHookEvent):
|
|
|
322
336
|
return self.rewrite(updated, note=note) if updated != base else None
|
|
323
337
|
|
|
324
338
|
|
|
339
|
+
@dataclass
|
|
340
|
+
class PreToolUseEvent(ToolRewriteEvent):
|
|
341
|
+
"""Fires before a tool is executed. Return a block result to prevent execution."""
|
|
342
|
+
|
|
343
|
+
event_name: ClassVar[Event] = Event.PreToolUse
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@dataclass
|
|
347
|
+
class PermissionRequestEvent(ToolRewriteEvent):
|
|
348
|
+
"""Fires when a permission dialog would be shown.
|
|
349
|
+
|
|
350
|
+
``allow``/``block``/``rewrite`` answer the dialog (block maps to a deny with the
|
|
351
|
+
message shown to the user); ``None`` and ``warn`` fall through, so the dialog
|
|
352
|
+
shows normally.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
event_name: ClassVar[Event] = Event.PermissionRequest
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def permission_suggestions(self) -> list[dict[str, Any]]:
|
|
359
|
+
return self._raw.get("permission_suggestions", [])
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def agent_type(self) -> str | None:
|
|
363
|
+
return self._raw.get("agent_type")
|
|
364
|
+
|
|
365
|
+
|
|
325
366
|
@dataclass
|
|
326
367
|
class PostToolUseEvent(ToolHookEvent):
|
|
327
368
|
"""Fires after a tool completes successfully, with access to the tool response."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Workaround for https://github.com/anthropics/claude-code/issues/73176: in-process
|
|
2
|
+
# teammates don't inherit the leader's --dangerously-skip-permissions consent, so their
|
|
3
|
+
# Bash calls (heredocs, redirects) pop permission dialogs in the lead UI. Auto-approve
|
|
4
|
+
# native teammate Bash only — MCP tools named mcp__<srv>__Bash suffix-match Tool("Bash")
|
|
5
|
+
# and are vetoed — and only when the process tree shows the user launched with the flag;
|
|
6
|
+
# the denylist is a courtesy speed bump (raw-text, over-prompting by design), not a
|
|
7
|
+
# security boundary — the session is already bypass-consented.
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from captain_hook import (
|
|
11
|
+
Allow,
|
|
12
|
+
Ask,
|
|
13
|
+
BaseHookEvent,
|
|
14
|
+
CustomCondition,
|
|
15
|
+
FromSubagent,
|
|
16
|
+
Input,
|
|
17
|
+
SkipPermissions,
|
|
18
|
+
Tool,
|
|
19
|
+
ToolInput,
|
|
20
|
+
approve,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class McpTool(CustomCondition):
|
|
25
|
+
"""Matches MCP-server tools (``mcp__<server>__<tool>``), which Tool() suffix-matching also accepts."""
|
|
26
|
+
|
|
27
|
+
def check(self, evt: BaseHookEvent) -> bool:
|
|
28
|
+
return bool(evt.tool_name) and evt.tool_name.startswith("mcp__")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
approve(
|
|
32
|
+
"teammate bash under skip-permissions",
|
|
33
|
+
only_if=[Tool("Bash"), ToolInput("command", r"[\s\S]"), FromSubagent(), SkipPermissions()],
|
|
34
|
+
skip_if=[
|
|
35
|
+
McpTool(),
|
|
36
|
+
ToolInput(
|
|
37
|
+
"command",
|
|
38
|
+
r"\b(rm|dd|shred|truncate|sudo|mkfs[.\w]*)\b"
|
|
39
|
+
r"|\bgit\s+(-[Cc]\s+\S+\s+|--?\S+\s+)*(reset|clean|restore)\b"
|
|
40
|
+
r"|\bgit\s+(-[Cc]\s+\S+\s+|--?\S+\s+)*push\b[^\n]*(\s--?force(-with-lease)?\b|\s--delete\b)"
|
|
41
|
+
r"|\b(curl|wget)\b[^|\n]*\|\s*((\S*/)?env\s+)?(\S*/)?(ba|z|da)?sh\b",
|
|
42
|
+
),
|
|
43
|
+
],
|
|
44
|
+
tests={
|
|
45
|
+
Input(command="python3 - <<'EOF'\nprint(1)\nEOF", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
|
|
46
|
+
Input(command="echo 'x = 1' > /tmp/conf.py", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
|
|
47
|
+
Input(command="git status", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
|
|
48
|
+
Input(command="git -C . log", agent_id="tm1", skip_permissions=True): Allow(explicit=True),
|
|
49
|
+
Input(command="rm -rf build", agent_id="tm1", skip_permissions=True): Ask(),
|
|
50
|
+
Input(command="sudo systemsetup -setremotelogin on", agent_id="tm1", skip_permissions=True): Ask(),
|
|
51
|
+
Input(command="git reset --hard HEAD~1", agent_id="tm1", skip_permissions=True): Ask(),
|
|
52
|
+
Input(command="git -C . reset --hard", agent_id="tm1", skip_permissions=True): Ask(),
|
|
53
|
+
Input(command="git --no-pager clean -fd", agent_id="tm1", skip_permissions=True): Ask(),
|
|
54
|
+
Input(command="curl https://get.x.sh | sh", agent_id="tm1", skip_permissions=True): Ask(),
|
|
55
|
+
Input(command="curl https://get.x.sh | /usr/bin/env bash", agent_id="tm1", skip_permissions=True): Ask(),
|
|
56
|
+
Input(command="git push --force origin main", agent_id="tm1", skip_permissions=True): Ask(),
|
|
57
|
+
Input(command="git -C repo push --force origin main", agent_id="tm1", skip_permissions=True): Ask(),
|
|
58
|
+
Input(tool="mcp__srv__Bash", tool_input={"cmd": "x"}, agent_id="tm1", skip_permissions=True): Ask(),
|
|
59
|
+
Input(
|
|
60
|
+
tool="mcp__srv__Bash", tool_input={"command": "echo hi"}, agent_id="tm1", skip_permissions=True
|
|
61
|
+
): Ask(), # MCP Bash, benign command — never auto-approved
|
|
62
|
+
Input(tool="mcp__ops__Bash", tool_input={"command": "rm -rf /"}, agent_id="tm1", skip_permissions=True): Ask(),
|
|
63
|
+
Input(command="python3 - <<'EOF'\nprint(1)\nEOF", skip_permissions=True): Ask(), # main thread
|
|
64
|
+
Input(command="python3 - <<'EOF'\nprint(1)\nEOF", agent_id="tm1", skip_permissions=False): Ask(), # no consent
|
|
65
|
+
},
|
|
66
|
+
)
|
|
@@ -4,7 +4,6 @@ from captain_hook import (
|
|
|
4
4
|
Allow,
|
|
5
5
|
BaseHookEvent,
|
|
6
6
|
Block,
|
|
7
|
-
Command,
|
|
8
7
|
CustomCondition,
|
|
9
8
|
Event,
|
|
10
9
|
FilePath,
|
|
@@ -15,6 +14,7 @@ from captain_hook import (
|
|
|
15
14
|
gate,
|
|
16
15
|
nudge,
|
|
17
16
|
)
|
|
17
|
+
from captain_hook.types import Command as CommandCondition
|
|
18
18
|
|
|
19
19
|
nudge(
|
|
20
20
|
"""
|
|
@@ -50,14 +50,14 @@ class CommitsGo(CustomCondition):
|
|
|
50
50
|
"""Matches when the git command explicitly names a Go path."""
|
|
51
51
|
|
|
52
52
|
def check(self, evt: BaseHookEvent) -> bool:
|
|
53
|
-
return bool(cl := evt.command_line) and ".go" in str(
|
|
53
|
+
return bool(cl := evt.command_line) and (cmd := cl.primary) is not None and ".go" in str(cmd)
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
gate(
|
|
57
57
|
"No `go test` execution found. Run tests before committing Go changes.",
|
|
58
|
-
only_if=[Tool("Bash"),
|
|
58
|
+
only_if=[Tool("Bash"), CommandCondition(r"git\s+commit"), CommitsGo()],
|
|
59
59
|
skip_if=[
|
|
60
|
-
RanCommand(
|
|
60
|
+
RanCommand("go", "test"),
|
|
61
61
|
UserSaid("commit", "just commit"),
|
|
62
62
|
AllEditsUnder("docs/", ".claude/", ".github/"),
|
|
63
63
|
],
|
|
@@ -72,9 +72,9 @@ gate(
|
|
|
72
72
|
nudge(
|
|
73
73
|
"No `go test` execution detected in this session. If you changed Go files, run tests "
|
|
74
74
|
"before committing. If this is a docs/config-only change, proceed.",
|
|
75
|
-
only_if=[Tool("Bash"),
|
|
75
|
+
only_if=[Tool("Bash"), CommandCondition(r"git\s+commit")],
|
|
76
76
|
skip_if=[
|
|
77
|
-
RanCommand(
|
|
77
|
+
RanCommand("go", "test"),
|
|
78
78
|
UserSaid("commit", "just commit"),
|
|
79
79
|
AllEditsUnder("docs/", ".claude/", ".github/"),
|
|
80
80
|
CommitsGo(),
|
|
@@ -4,7 +4,6 @@ from captain_hook import (
|
|
|
4
4
|
Allow,
|
|
5
5
|
BaseHookEvent,
|
|
6
6
|
Block,
|
|
7
|
-
Command,
|
|
8
7
|
CustomCondition,
|
|
9
8
|
Event,
|
|
10
9
|
Input,
|
|
@@ -15,6 +14,7 @@ from captain_hook import (
|
|
|
15
14
|
gate,
|
|
16
15
|
nudge,
|
|
17
16
|
)
|
|
17
|
+
from captain_hook.types import Command as CommandCondition
|
|
18
18
|
|
|
19
19
|
nudge(
|
|
20
20
|
"""
|
|
@@ -50,14 +50,15 @@ class CommitsPython(CustomCondition):
|
|
|
50
50
|
"""Matches when the git command explicitly names a Python path."""
|
|
51
51
|
|
|
52
52
|
def check(self, evt: BaseHookEvent) -> bool:
|
|
53
|
-
return bool(cl := evt.command_line) and ".py" in str(
|
|
53
|
+
return bool(cl := evt.command_line) and (cmd := cl.primary) is not None and ".py" in str(cmd)
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
gate(
|
|
57
57
|
"No `uv run pytest` execution found. Run tests before committing Python changes.",
|
|
58
|
-
only_if=[Tool("Bash"),
|
|
58
|
+
only_if=[Tool("Bash"), CommandCondition(r"git\s+commit"), CommitsPython()],
|
|
59
59
|
skip_if=[
|
|
60
|
-
RanCommand(
|
|
60
|
+
RanCommand("uv", "run", "pytest"),
|
|
61
|
+
RanCommand("pytest"),
|
|
61
62
|
UserSaid("commit", "just commit"),
|
|
62
63
|
AllEditsUnder("docs/", ".claude/", ".github/"),
|
|
63
64
|
],
|
|
@@ -72,9 +73,10 @@ gate(
|
|
|
72
73
|
nudge(
|
|
73
74
|
"No `uv run pytest` execution detected in this session. If you changed Python files, run "
|
|
74
75
|
"tests before committing. If this is a docs/config-only change, proceed.",
|
|
75
|
-
only_if=[Tool("Bash"),
|
|
76
|
+
only_if=[Tool("Bash"), CommandCondition(r"git\s+commit")],
|
|
76
77
|
skip_if=[
|
|
77
|
-
RanCommand(
|
|
78
|
+
RanCommand("uv", "run", "pytest"),
|
|
79
|
+
RanCommand("pytest"),
|
|
78
80
|
UserSaid("commit", "just commit"),
|
|
79
81
|
AllEditsUnder("docs/", ".claude/", ".github/"),
|
|
80
82
|
CommitsPython(),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
@@ -180,7 +180,15 @@ nudge(
|
|
|
180
180
|
"overriding `property`, minor override mismatches, descriptor protocol). Only fix type "
|
|
181
181
|
"issues that indicate actual bugs. Don't check git history to see if you introduced "
|
|
182
182
|
"them — move on.",
|
|
183
|
-
skip_if=[
|
|
183
|
+
skip_if=[
|
|
184
|
+
RanCommand("uv", "run", "ty", "check"),
|
|
185
|
+
RanCommand("uvx", "ty", "check"),
|
|
186
|
+
RanCommand("prek", "run", "ty"),
|
|
187
|
+
RanCommand("prek", "run", "--all-files"),
|
|
188
|
+
RanCommand("uvx", "prek", "run", "ty"),
|
|
189
|
+
RanCommand("uvx", "prek", "run", "--all-files"),
|
|
190
|
+
RanCommand("uvx", "pyright"),
|
|
191
|
+
],
|
|
184
192
|
signals=TRIVIAL_TYPE_SIGNALS,
|
|
185
193
|
tests={
|
|
186
194
|
Input(
|
|
@@ -12,4 +12,5 @@ from captain_hook.primitives.llm import (
|
|
|
12
12
|
prompt_check,
|
|
13
13
|
)
|
|
14
14
|
from captain_hook.primitives.nudge import gate, nudge
|
|
15
|
+
from captain_hook.primitives.permissions import SafetyVerdict, approve, deny, llm_approve
|
|
15
16
|
from captain_hook.primitives.rewrite import rewrite_code, set_tool_input
|
|
@@ -138,7 +138,8 @@ def rewrite_command(
|
|
|
138
138
|
def structural_handler(evt: PreToolUseEvent) -> HookResponse:
|
|
139
139
|
if not (cl := evt.command_line):
|
|
140
140
|
return None
|
|
141
|
-
|
|
141
|
+
new = ast_grep.rewrite(cl.raw, "bash", pattern, replace)
|
|
142
|
+
return evt.rewrite_command(new, note=note) if new != cl.raw else None
|
|
142
143
|
|
|
143
144
|
on(Event.PreToolUse, only_if=[Tool("Bash"), *only_if], skip_if=skip_if, tests=tests)(structural_handler)
|
|
144
145
|
return
|