hook-shipyard 0.1.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.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: hook-shipyard
3
+ Version: 0.1.0
4
+ Summary: Consumer-neutral native agent hook management.
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+
9
+ # Hook Shipyard
10
+
11
+ Install the standalone distribution and use its canonical import:
12
+
13
+ ```sh
14
+ python -m pip install hook-shipyard
15
+ hook-shipyard --help
16
+ hook-shipyard --version
17
+ ```
18
+
19
+ `hook_shipyard` is a standard-library-only tool for Claude Code, Codex, Cursor,
20
+ Devin CLI and VS Code's Copilot harness. It neither imports nor starts District.
21
+ District is one consumer; its adapters live in `hook/tools/district/`.
22
+ `events.py` normalizes lifecycle payloads. `native.py` manages registration and
23
+ native response formatting for observation integrations. `templates.py` renders
24
+ packaged `.tmd` resources with strict literal substitution.
25
+
26
+ Create a non-monitoring command hook in an explicit disposable project:
27
+
28
+ ```sh
29
+ python -m hook_shipyard install --provider codex --project \
30
+ --root /path/to/project --owner formatter --phase after_tool \
31
+ --command 'python scripts/format.py' --matcher 'Write|Edit' --timeout 10
32
+ python -m hook_shipyard list --provider codex --project \
33
+ --root /path/to/project --owner formatter
34
+ python -m hook_shipyard uninstall --provider codex --project \
35
+ --root /path/to/project --owner formatter
36
+ ```
37
+
38
+ `install` replaces that owner's complete registration set. The Python API accepts
39
+ multiple `HookSpec` objects in one install. Different owners remain independent.
40
+ Use `--event` for native events or `--phase` for the shared lifecycle vocabulary:
41
+ `session_start`, `session_end`, `before_tool`, `after_tool`, `prompt`, `stop`.
42
+ Only supported native equivalents are accepted; payloads, matcher syntax and
43
+ tool names still belong to the destination host. Shell commands are trusted
44
+ operator code, not values interpolated from hook stdin. They run only when the
45
+ host fires the event; installing or listing never executes them.
46
+
47
+ The included non-monitoring policy blocks exact configured tool names:
48
+
49
+ ```sh
50
+ python -m hook_shipyard.guard --provider codex --deny-tool Bash
51
+ ```
52
+
53
+ Register that command at `before_tool` using the same provider. The guard reads
54
+ bounded JSON from stdin, emits that host's native denial for a blocked name, and
55
+ otherwise emits `{}` (no permission decision). It writes no telemetry or logs.
56
+ Its role is a small deterministic policy example, not a sandbox or protection
57
+ against every tool alias. Test the actual native tool names before relying on it.
58
+
59
+ Configuration roots are explicit. Defaults relative to that root are
60
+ `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json`,
61
+ `.config/devin/config.json` (user) or `.devin/config.json` (project), and
62
+ `.copilot/hooks/kairos.json` (user) or `.github/hooks/kairos.json` (project) for
63
+ VS Code Copilot. Custom home environment variables and Windows APPDATA layouts
64
+ can be represented with an explicit `Provider.relative` override in the Python
65
+ API. Native hook discovery/trust still needs verification in the target host.
66
+
67
+ The manager preserves foreign fields and mixed matcher groups, creates one
68
+ private `.hook-shipyard-bak` backup, and records versioned per-owner registrations in a
69
+ private `.hook-shipyard-owners` sidecar. Atomic replacements preserve native file modes.
70
+ Malformed JSON, unsupported versions, symlinks, duplicate ownership and busy
71
+ locks refuse changes. Interrupted transactions retain old/new ownership intent
72
+ so a retry can reconcile them. Cooperating managers serialize using an exclusive
73
+ `.hook-shipyard-lock` file; after a crashed process, confirm it has stopped before
74
+ removing that stale lock. Non-cooperating editor writes need coordination.
75
+ Backups and receipts remain after uninstall for recovery; protect and remove
76
+ them according to the operator's retention policy.
77
+
78
+ `fail_closed=True` is only accepted for Cursor's native `failClosed`. Other
79
+ providers' failure and timeout semantics differ, so that request is rejected.
80
+ Codex end/interrupt deadlines are bounded by its three-second native maximum.
81
+ No installation changes trust hashes, enables host features, approves commands,
82
+ or bypasses the provider's permissions or enterprise settings.
83
+
84
+ Files: `__init__.py` exports the API; `__main__.py` and `cli.py` expose the CLI;
85
+ `models.py` defines registrations and provider capabilities; `providers.py`
86
+ selects schemas and lifecycle names; `manager.py` owns lifecycle; `merge.py`
87
+ preserves foreign commands; `storage.py` owns atomic local I/O; `receipts.py`
88
+ versions ownership; `guard.py` supplies the policy example; `pyproject.toml`
89
+ packages the independent `hook-shipyard` distribution.
90
+
91
+ Provider contracts checked against primary documentation on 2026-11-01:
92
+ [Claude Code](https://code.claude.com/docs/en/hooks),
93
+ [Codex](https://learn.chatgpt.com/docs/hooks),
94
+ [Cursor](https://prod.cursor.com/docs/hooks),
95
+ [Devin](https://docs.devin.ai/cli/extensibility/hooks/overview), and
96
+ [VS Code](https://code.visualstudio.com/docs/agent-customization/hooks).
97
+
98
+ <!-- exactus:inventory:start -->
99
+ ## Directory contents
100
+
101
+ ### Files
102
+
103
+ - [`__init__.py`](__init__.py) — Manage native agent hooks independently of their consumers.
104
+ - [`__main__.py`](__main__.py) — Run the standalone hook management CLI.
105
+ - [`cli.py`](cli.py) — Manage native hooks without running commands or enabling monitoring.
106
+ - [`events.py`](events.py) — Normalize native agent lifecycle payloads for any hook consumer.
107
+ - [`guard.py`](guard.py) — Implement a standalone non-monitoring pre-tool policy for native hosts.
108
+ - [`manager.py`](manager.py) — Manage owner-scoped hooks without importing a monitoring implementation.
109
+ - [`merge.py`](merge.py) — Remove only recorded commands while preserving mixed native groups.
110
+ - [`migrations.py`](migrations.py) — Read explicit legacy sidecar names without guessing ownership.
111
+ - [`models.py`](models.py) — Define consumer-neutral native hook registrations.
112
+ - [`native.py`](native.py) — Manage native lifecycle registration and wire responses for any consumer.
113
+ - [`providers.py`](providers.py) — Keep provider schema differences outside every consuming tool.
114
+ - [`pyproject.toml`](pyproject.toml) — Provide pyproject toml content.
115
+ - [`receipts.py`](receipts.py) — Version private ownership state separately from native host schemas.
116
+ - [`storage.py`](storage.py) — Protect shared JSON configuration with bounded, atomic local writes.
117
+ - [`templates.py`](templates.py) — Render packaged consumer templates without evaluating executable code.
118
+
119
+ ### Subdirectories
120
+
121
+ - [`resources/`](resources/README.md) — No subdirectories.
122
+
123
+ <!-- exactus:inventory:end -->
@@ -0,0 +1,115 @@
1
+ # Hook Shipyard
2
+
3
+ Install the standalone distribution and use its canonical import:
4
+
5
+ ```sh
6
+ python -m pip install hook-shipyard
7
+ hook-shipyard --help
8
+ hook-shipyard --version
9
+ ```
10
+
11
+ `hook_shipyard` is a standard-library-only tool for Claude Code, Codex, Cursor,
12
+ Devin CLI and VS Code's Copilot harness. It neither imports nor starts District.
13
+ District is one consumer; its adapters live in `hook/tools/district/`.
14
+ `events.py` normalizes lifecycle payloads. `native.py` manages registration and
15
+ native response formatting for observation integrations. `templates.py` renders
16
+ packaged `.tmd` resources with strict literal substitution.
17
+
18
+ Create a non-monitoring command hook in an explicit disposable project:
19
+
20
+ ```sh
21
+ python -m hook_shipyard install --provider codex --project \
22
+ --root /path/to/project --owner formatter --phase after_tool \
23
+ --command 'python scripts/format.py' --matcher 'Write|Edit' --timeout 10
24
+ python -m hook_shipyard list --provider codex --project \
25
+ --root /path/to/project --owner formatter
26
+ python -m hook_shipyard uninstall --provider codex --project \
27
+ --root /path/to/project --owner formatter
28
+ ```
29
+
30
+ `install` replaces that owner's complete registration set. The Python API accepts
31
+ multiple `HookSpec` objects in one install. Different owners remain independent.
32
+ Use `--event` for native events or `--phase` for the shared lifecycle vocabulary:
33
+ `session_start`, `session_end`, `before_tool`, `after_tool`, `prompt`, `stop`.
34
+ Only supported native equivalents are accepted; payloads, matcher syntax and
35
+ tool names still belong to the destination host. Shell commands are trusted
36
+ operator code, not values interpolated from hook stdin. They run only when the
37
+ host fires the event; installing or listing never executes them.
38
+
39
+ The included non-monitoring policy blocks exact configured tool names:
40
+
41
+ ```sh
42
+ python -m hook_shipyard.guard --provider codex --deny-tool Bash
43
+ ```
44
+
45
+ Register that command at `before_tool` using the same provider. The guard reads
46
+ bounded JSON from stdin, emits that host's native denial for a blocked name, and
47
+ otherwise emits `{}` (no permission decision). It writes no telemetry or logs.
48
+ Its role is a small deterministic policy example, not a sandbox or protection
49
+ against every tool alias. Test the actual native tool names before relying on it.
50
+
51
+ Configuration roots are explicit. Defaults relative to that root are
52
+ `.claude/settings.json`, `.codex/hooks.json`, `.cursor/hooks.json`,
53
+ `.config/devin/config.json` (user) or `.devin/config.json` (project), and
54
+ `.copilot/hooks/kairos.json` (user) or `.github/hooks/kairos.json` (project) for
55
+ VS Code Copilot. Custom home environment variables and Windows APPDATA layouts
56
+ can be represented with an explicit `Provider.relative` override in the Python
57
+ API. Native hook discovery/trust still needs verification in the target host.
58
+
59
+ The manager preserves foreign fields and mixed matcher groups, creates one
60
+ private `.hook-shipyard-bak` backup, and records versioned per-owner registrations in a
61
+ private `.hook-shipyard-owners` sidecar. Atomic replacements preserve native file modes.
62
+ Malformed JSON, unsupported versions, symlinks, duplicate ownership and busy
63
+ locks refuse changes. Interrupted transactions retain old/new ownership intent
64
+ so a retry can reconcile them. Cooperating managers serialize using an exclusive
65
+ `.hook-shipyard-lock` file; after a crashed process, confirm it has stopped before
66
+ removing that stale lock. Non-cooperating editor writes need coordination.
67
+ Backups and receipts remain after uninstall for recovery; protect and remove
68
+ them according to the operator's retention policy.
69
+
70
+ `fail_closed=True` is only accepted for Cursor's native `failClosed`. Other
71
+ providers' failure and timeout semantics differ, so that request is rejected.
72
+ Codex end/interrupt deadlines are bounded by its three-second native maximum.
73
+ No installation changes trust hashes, enables host features, approves commands,
74
+ or bypasses the provider's permissions or enterprise settings.
75
+
76
+ Files: `__init__.py` exports the API; `__main__.py` and `cli.py` expose the CLI;
77
+ `models.py` defines registrations and provider capabilities; `providers.py`
78
+ selects schemas and lifecycle names; `manager.py` owns lifecycle; `merge.py`
79
+ preserves foreign commands; `storage.py` owns atomic local I/O; `receipts.py`
80
+ versions ownership; `guard.py` supplies the policy example; `pyproject.toml`
81
+ packages the independent `hook-shipyard` distribution.
82
+
83
+ Provider contracts checked against primary documentation on 2026-11-01:
84
+ [Claude Code](https://code.claude.com/docs/en/hooks),
85
+ [Codex](https://learn.chatgpt.com/docs/hooks),
86
+ [Cursor](https://prod.cursor.com/docs/hooks),
87
+ [Devin](https://docs.devin.ai/cli/extensibility/hooks/overview), and
88
+ [VS Code](https://code.visualstudio.com/docs/agent-customization/hooks).
89
+
90
+ <!-- exactus:inventory:start -->
91
+ ## Directory contents
92
+
93
+ ### Files
94
+
95
+ - [`__init__.py`](__init__.py) — Manage native agent hooks independently of their consumers.
96
+ - [`__main__.py`](__main__.py) — Run the standalone hook management CLI.
97
+ - [`cli.py`](cli.py) — Manage native hooks without running commands or enabling monitoring.
98
+ - [`events.py`](events.py) — Normalize native agent lifecycle payloads for any hook consumer.
99
+ - [`guard.py`](guard.py) — Implement a standalone non-monitoring pre-tool policy for native hosts.
100
+ - [`manager.py`](manager.py) — Manage owner-scoped hooks without importing a monitoring implementation.
101
+ - [`merge.py`](merge.py) — Remove only recorded commands while preserving mixed native groups.
102
+ - [`migrations.py`](migrations.py) — Read explicit legacy sidecar names without guessing ownership.
103
+ - [`models.py`](models.py) — Define consumer-neutral native hook registrations.
104
+ - [`native.py`](native.py) — Manage native lifecycle registration and wire responses for any consumer.
105
+ - [`providers.py`](providers.py) — Keep provider schema differences outside every consuming tool.
106
+ - [`pyproject.toml`](pyproject.toml) — Provide pyproject toml content.
107
+ - [`receipts.py`](receipts.py) — Version private ownership state separately from native host schemas.
108
+ - [`storage.py`](storage.py) — Protect shared JSON configuration with bounded, atomic local writes.
109
+ - [`templates.py`](templates.py) — Render packaged consumer templates without evaluating executable code.
110
+
111
+ ### Subdirectories
112
+
113
+ - [`resources/`](resources/README.md) — No subdirectories.
114
+
115
+ <!-- exactus:inventory:end -->
@@ -0,0 +1,18 @@
1
+ """Manage native agent hooks independently of their consumers."""
2
+
3
+ from .manager import HookManager
4
+ from .models import HookSpec, JsonObject, JsonValue, Provider
5
+ from .providers import event_name, provider
6
+
7
+ __version__: str = "0.1.0"
8
+
9
+ __all__: list[str] = [
10
+ "HookManager",
11
+ "HookSpec",
12
+ "JsonObject",
13
+ "JsonValue",
14
+ "Provider",
15
+ "__version__",
16
+ "event_name",
17
+ "provider",
18
+ ]
@@ -0,0 +1,6 @@
1
+ """Run the standalone hook management CLI."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,78 @@
1
+ """Manage native hooks without running commands or enabling monitoring."""
2
+
3
+ from argparse import ArgumentParser, Namespace
4
+ from json import dumps
5
+ from pathlib import Path
6
+ from sys import stdout
7
+
8
+ from . import __version__
9
+ from .manager import HookManager
10
+ from .models import HookSpec
11
+ from .providers import event_name, provider
12
+
13
+
14
+ def main(arguments: list[str] | None = None) -> int:
15
+ """Install, inspect or remove one consumer's native hooks.
16
+
17
+ Args:
18
+ arguments: Optional explicit command-line arguments.
19
+
20
+ Returns:
21
+ Zero after successful configuration; argparse errors return status 2.
22
+
23
+ """
24
+ parser: ArgumentParser = ArgumentParser(
25
+ prog="hook-shipyard",
26
+ description=__doc__,
27
+ )
28
+ parser.add_argument("--version", action="version", version=__version__)
29
+ parser.add_argument("action", choices=("install", "list", "uninstall"))
30
+ parser.add_argument(
31
+ "--provider",
32
+ required=True,
33
+ choices=("claude-code", "codex", "cursor", "devin", "vscode"),
34
+ )
35
+ parser.add_argument("--root", required=True, type=Path)
36
+ parser.add_argument("--owner", required=True)
37
+ parser.add_argument("--project", action="store_true")
38
+ parser.add_argument("--event")
39
+ parser.add_argument("--phase")
40
+ parser.add_argument("--command")
41
+ parser.add_argument("--matcher")
42
+ parser.add_argument("--timeout", type=float)
43
+ parser.add_argument("--fail-closed", action="store_true")
44
+ parsed: Namespace = parser.parse_args(args=arguments)
45
+ if parsed.event and parsed.phase:
46
+ parser.error(message="Choose either --event or --phase")
47
+ try:
48
+ manager: HookManager = HookManager(
49
+ provider=provider(name=parsed.provider, project=parsed.project),
50
+ root=parsed.root,
51
+ owner=parsed.owner,
52
+ )
53
+ if parsed.action == "install":
54
+ if not (parsed.event or parsed.phase) or not parsed.command:
55
+ parser.error(
56
+ message="install requires --event/--phase and --command",
57
+ )
58
+ _ = manager.install(
59
+ specs=[
60
+ HookSpec(
61
+ event=parsed.event
62
+ or event_name(
63
+ host=provider(name=parsed.provider),
64
+ phase=parsed.phase,
65
+ ),
66
+ command=parsed.command,
67
+ matcher=parsed.matcher,
68
+ timeout=parsed.timeout,
69
+ fail_closed=parsed.fail_closed,
70
+ ),
71
+ ],
72
+ )
73
+ elif parsed.action == "uninstall":
74
+ manager.uninstall()
75
+ _ = stdout.write(dumps(obj=manager.inspect(), indent=2) + "\n")
76
+ except (OSError, TypeError, ValueError) as error:
77
+ parser.error(message=str(object=error))
78
+ return 0
@@ -0,0 +1,156 @@
1
+ """Normalize native agent lifecycle payloads for any hook consumer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from hashlib import sha256
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .models import JsonObject, JsonValue
11
+
12
+
13
+ @dataclass(frozen=True, slots=True, kw_only=True)
14
+ class NativeEvent:
15
+ """Carry provider-independent hook input without consumer dependencies.
16
+
17
+ Attributes:
18
+ provider: Native agent identifier.
19
+ name: Original native event name.
20
+ phase: Normalized lifecycle phase.
21
+ session: Native session or conversation identifier.
22
+ workspace: Preferred native workspace.
23
+ tool: Native tool name.
24
+ command: Shell command when supplied.
25
+ command_id: Correlation identifier, including deterministic fallback.
26
+ response: Native tool result, when present.
27
+ description: Original tool input description.
28
+
29
+ """
30
+
31
+ provider: str
32
+ name: str
33
+ phase: str
34
+ session: str
35
+ workspace: str
36
+ tool: str
37
+ command: str
38
+ command_id: str
39
+ response: JsonObject | None
40
+ description: str = ""
41
+
42
+
43
+ @dataclass(frozen=True, slots=True, kw_only=True)
44
+ class NativeReply:
45
+ """Describe a consumer's effects without prescribing native JSON shapes.
46
+
47
+ Attributes:
48
+ context: Optional context to append to the agent's prompt.
49
+ variables: Explicit environment additions supplied by the consumer.
50
+ command: Optional consumer-specific replacement shell command.
51
+
52
+ """
53
+
54
+ context: str = ""
55
+ variables: dict[str, str] = field(default_factory=dict)
56
+ command: str | None = None
57
+
58
+
59
+ def normalize(*, provider: str, payload: JsonObject) -> NativeEvent:
60
+ """Decode native fields into one consumer-neutral lifecycle contract.
61
+
62
+ Args:
63
+ provider: Registered native provider.
64
+ payload: Validated native JSON object.
65
+
66
+ Returns:
67
+ Normalized event, including empty identity for unscoped payloads.
68
+
69
+ """
70
+ name: str = scalar(value=payload.get("hook_event_name"))
71
+ session: str = scalar(value=payload.get("session_id")) or scalar(
72
+ value=payload.get("conversation_id"),
73
+ )
74
+ workspace: str = scalar(value=payload.get("cwd"))
75
+ roots: JsonValue = payload.get("workspace_roots")
76
+ if provider == "cursor" and isinstance(roots, list) and roots:
77
+ workspace = scalar(value=roots[0]) or workspace
78
+ tool_input: JsonValue = payload.get("tool_input")
79
+ inputs: JsonObject = tool_input if isinstance(tool_input, dict) else {}
80
+ command: str = (
81
+ scalar(value=inputs.get("command"))
82
+ or scalar(
83
+ value=inputs.get("cmd"),
84
+ )
85
+ or scalar(value=payload.get("command"))
86
+ )
87
+ command_id: str = scalar(value=payload.get("tool_use_id"))
88
+ if not command_id and command and provider == "codex":
89
+ command_id = (
90
+ "codex-"
91
+ + sha256(
92
+ string=f"{session}\0{command}".encode(),
93
+ ).hexdigest()[:16]
94
+ )
95
+ response: JsonValue = payload.get("tool_response")
96
+ return NativeEvent(
97
+ provider=provider,
98
+ name=name,
99
+ phase=phase(name=name),
100
+ session=session,
101
+ workspace=workspace,
102
+ tool=scalar(value=payload.get("tool_name", payload.get("toolName"))),
103
+ command=command,
104
+ command_id=command_id,
105
+ response=response if isinstance(response, dict) else None,
106
+ description=scalar(value=inputs.get("description")),
107
+ )
108
+
109
+
110
+ def phase(*, name: str) -> str:
111
+ """Map native event spelling without adding consumer behavior.
112
+
113
+ Args:
114
+ name: Native lifecycle event name.
115
+
116
+ Returns:
117
+ Stable semantic phase or the original unknown name.
118
+
119
+ """
120
+ names: dict[str, str] = {
121
+ "SessionStart": "start",
122
+ "sessionStart": "start",
123
+ "SessionEnd": "end",
124
+ "sessionEnd": "end",
125
+ "PreToolUse": "before_tool",
126
+ "preToolUse": "before_tool",
127
+ "beforeShellExecution": "before_tool",
128
+ "PostToolUse": "after_tool",
129
+ "postToolUse": "after_tool",
130
+ "afterShellExecution": "after_tool",
131
+ "PostToolUseFailure": "tool_failed",
132
+ "UserPromptSubmit": "prompt",
133
+ "userPromptSubmitted": "prompt",
134
+ "beforeSubmitPrompt": "prompt",
135
+ "Stop": "stop",
136
+ "stop": "stop",
137
+ "agentStop": "stop",
138
+ "afterAgentThought": "thinking",
139
+ "afterAgentResponse": "response",
140
+ }
141
+ return names.get(name, name)
142
+
143
+
144
+ def scalar(*, value: JsonValue) -> str:
145
+ """Convert native scalar fields without stringifying structured data.
146
+
147
+ Args:
148
+ value: Native JSON field.
149
+
150
+ Returns:
151
+ Scalar text or an empty string.
152
+
153
+ """
154
+ return (
155
+ str(object=value) if isinstance(value, (str, bool, int, float)) else ""
156
+ )
@@ -0,0 +1,104 @@
1
+ """Implement a standalone non-monitoring pre-tool policy for native hosts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import ArgumentParser, Namespace
6
+ from json import dumps, loads
7
+ from sys import stdin, stdout
8
+ from typing import TYPE_CHECKING, Final, cast, final
9
+
10
+ from .providers import provider
11
+
12
+ if TYPE_CHECKING:
13
+ from .models import JsonObject, JsonValue, Provider
14
+
15
+
16
+ @final
17
+ class Constants:
18
+ """Bound policy input independently of any monitoring implementation.
19
+
20
+ Attributes:
21
+ request_bytes: Maximum JSON input bytes.
22
+ reason: Fixed denial text, without reflecting sensitive input.
23
+
24
+ """
25
+
26
+ request_bytes: Final[int] = 1024 * 1024
27
+ reason: Final[str] = "Tool is denied by the configured hook policy"
28
+
29
+
30
+ def check_tool(
31
+ *,
32
+ host: Provider,
33
+ payload: JsonObject,
34
+ blocked: frozenset[str],
35
+ ) -> JsonObject:
36
+ """Emit a native denial for an explicitly blocked tool identifier.
37
+
38
+ Args:
39
+ host: Native provider contract.
40
+ payload: Pre-tool JSON object from that provider.
41
+ blocked: Operator-selected exact tool names.
42
+
43
+ Returns:
44
+ Native denial, or an empty object making no permission decision.
45
+
46
+ Raises:
47
+ ValueError: When the payload lacks a tool identifier.
48
+
49
+ """
50
+ name: JsonValue = payload.get(
51
+ "toolName" if host.name == "vscode" else "tool_name",
52
+ )
53
+ if not isinstance(name, str) or not name:
54
+ msg: str = "Pre-tool input requires a native tool name"
55
+ raise ValueError(msg)
56
+ if name not in blocked:
57
+ return {}
58
+ if host.name == "cursor":
59
+ return {"permission": "deny", "user_message": Constants.reason}
60
+ if host.name == "devin":
61
+ return {"decision": "block", "reason": Constants.reason}
62
+ decision: JsonObject = {
63
+ "permissionDecision": "deny",
64
+ "permissionDecisionReason": Constants.reason,
65
+ }
66
+ if host.name == "vscode":
67
+ return decision
68
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", **decision}}
69
+
70
+
71
+ def main(arguments: list[str] | None = None) -> int:
72
+ """Run a bounded pre-tool policy without logs, monitoring or shell eval.
73
+
74
+ Args:
75
+ arguments: Explicit CLI arguments, or process arguments by default.
76
+
77
+ Returns:
78
+ Zero after a native response; malformed input exits through argparse.
79
+
80
+ """
81
+ parser: ArgumentParser = ArgumentParser(description=__doc__)
82
+ parser.add_argument("--provider", required=True)
83
+ parser.add_argument("--deny-tool", required=True, action="append")
84
+ parsed: Namespace = parser.parse_args(args=arguments)
85
+ try:
86
+ raw: bytes = stdin.buffer.read(Constants.request_bytes + 1)
87
+ if len(raw) > Constants.request_bytes:
88
+ parser.error(message="Hook input exceeded its byte limit")
89
+ payload: JsonValue = cast(typ="JsonValue", val=loads(s=raw))
90
+ if not isinstance(payload, dict):
91
+ parser.error(message="Hook input must be a JSON object")
92
+ result: JsonObject = check_tool(
93
+ host=provider(name=parsed.provider),
94
+ payload=payload,
95
+ blocked=frozenset(parsed.deny_tool),
96
+ )
97
+ _ = stdout.write(dumps(obj=result) + "\n")
98
+ except (ValueError, OSError) as error:
99
+ parser.error(message=str(object=error))
100
+ return 0
101
+
102
+
103
+ if __name__ == "__main__":
104
+ raise SystemExit(main())