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.
- hook_shipyard-0.1.0/PKG-INFO +123 -0
- hook_shipyard-0.1.0/README.md +115 -0
- hook_shipyard-0.1.0/__init__.py +18 -0
- hook_shipyard-0.1.0/__main__.py +6 -0
- hook_shipyard-0.1.0/cli.py +78 -0
- hook_shipyard-0.1.0/events.py +156 -0
- hook_shipyard-0.1.0/guard.py +104 -0
- hook_shipyard-0.1.0/hook_shipyard.egg-info/PKG-INFO +123 -0
- hook_shipyard-0.1.0/hook_shipyard.egg-info/SOURCES.txt +37 -0
- hook_shipyard-0.1.0/hook_shipyard.egg-info/dependency_links.txt +1 -0
- hook_shipyard-0.1.0/hook_shipyard.egg-info/entry_points.txt +2 -0
- hook_shipyard-0.1.0/hook_shipyard.egg-info/top_level.txt +1 -0
- hook_shipyard-0.1.0/manager.py +197 -0
- hook_shipyard-0.1.0/merge.py +156 -0
- hook_shipyard-0.1.0/migrations.py +45 -0
- hook_shipyard-0.1.0/models.py +138 -0
- hook_shipyard-0.1.0/native.py +335 -0
- hook_shipyard-0.1.0/providers.py +174 -0
- hook_shipyard-0.1.0/pyproject.toml +24 -0
- hook_shipyard-0.1.0/receipts.py +59 -0
- hook_shipyard-0.1.0/resources/migrations.json +4 -0
- hook_shipyard-0.1.0/setup.cfg +4 -0
- hook_shipyard-0.1.0/storage.py +129 -0
- hook_shipyard-0.1.0/templates.py +82 -0
|
@@ -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,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())
|