hook-shipyard 0.1.0__py3-none-any.whl

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,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())
hook_shipyard/cli.py ADDED
@@ -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
+ )
hook_shipyard/guard.py ADDED
@@ -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())
@@ -0,0 +1,197 @@
1
+ """Manage owner-scoped hooks without importing a monitoring implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from re import fullmatch
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .merge import hooks_of, legacy_records, remove
10
+ from .migrations import suffixes
11
+ from .receipts import read, write
12
+ from .storage import load, locked, save
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+ from pathlib import Path
17
+
18
+ from .models import HookSpec, JsonObject, JsonValue, Provider
19
+
20
+
21
+ class HookManager:
22
+ """Own native registrations for one independently named consumer."""
23
+
24
+ def __init__(
25
+ self,
26
+ *,
27
+ provider: Provider,
28
+ root: Path,
29
+ owner: str,
30
+ legacy_flag: str | None = None,
31
+ backup_suffix: str = ".hook-shipyard-bak",
32
+ ) -> None:
33
+ """Select an explicit host, configuration root and consumer.
34
+
35
+ Args:
36
+ provider: Native configuration adapter.
37
+ root: User home or repository root selected by the caller.
38
+ owner: Stable consumer identifier, such as district or formatter.
39
+ legacy_flag: Explicit legacy two-argument launcher migration.
40
+ backup_suffix: Consumer's existing backup filename suffix.
41
+
42
+ Raises:
43
+ ValueError: When the owner is empty or unsafe.
44
+
45
+ """
46
+ if (
47
+ fullmatch(pattern=r"[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}", string=owner)
48
+ is None
49
+ ):
50
+ msg: str = "Hook owner must be a short stable identifier"
51
+ raise ValueError(msg)
52
+ self._provider: Provider = provider
53
+ self._owner: str = owner
54
+ self._legacy_flag: str | None = legacy_flag
55
+ self._backup_suffix: str = backup_suffix
56
+ self.path: Path = root / provider.relative
57
+ self._receipt: Path = self.path.with_name(
58
+ name=self.path.name + ".hook-shipyard-owners",
59
+ )
60
+ self._legacy_receipts: tuple[Path, ...] = tuple(
61
+ self.path.with_name(name=self.path.name + suffix)
62
+ for suffix in suffixes(kind="receipts")
63
+ )
64
+
65
+ def install(self, *, specs: Sequence[HookSpec]) -> Path:
66
+ """Create or replace this owner's registrations idempotently.
67
+
68
+ Args:
69
+ specs: Complete desired registrations for this consumer.
70
+
71
+ Returns:
72
+ Native configuration path.
73
+
74
+ """
75
+ records: list[JsonValue] = [
76
+ {"event": spec.event, "entry": self._provider.render(spec=spec)}
77
+ for spec in specs
78
+ ]
79
+ self._update(records=records)
80
+ return self.path
81
+
82
+ def uninstall(self) -> None:
83
+ """Remove only this owner's recorded commands, preserving others."""
84
+ if (
85
+ self.path.exists()
86
+ or self._receipt.exists()
87
+ or any(path.exists() for path in self._legacy_receipts)
88
+ ):
89
+ self._update(records=[])
90
+
91
+ def inspect(self) -> JsonObject:
92
+ """Read native configuration and ownership without executing hooks.
93
+
94
+ Returns:
95
+ Provider, path, owner and current recorded registrations.
96
+
97
+ """
98
+ return {
99
+ "provider": self._provider.name,
100
+ "path": str(object=self.path),
101
+ "owner": self._owner,
102
+ "registrations": self._owners().get(self._owner, []),
103
+ "hooks": hooks_of(document=load(path=self.path)),
104
+ }
105
+
106
+ def _update(self, *, records: list[JsonValue]) -> None:
107
+ """Commit receipt intent before changing executable configuration.
108
+
109
+ Args:
110
+ records: Desired owned event/entry pairs.
111
+
112
+ Raises:
113
+ ValueError: When a provider version or registration conflicts.
114
+ TypeError: When the receipt is malformed.
115
+
116
+ """
117
+ with locked(path=self.path):
118
+ self._migrate()
119
+ document: JsonObject = load(path=self.path)
120
+ if self._provider.versioned and document.get("version", 1) != 1:
121
+ msg: str = "Unsupported native hook configuration version"
122
+ raise ValueError(msg)
123
+ owners: JsonObject = read(path=self._receipt)
124
+ previous: JsonValue = owners.get(self._owner, [])
125
+ if not isinstance(previous, list):
126
+ msg = "Hook ownership receipt must contain registration arrays"
127
+ raise TypeError(msg)
128
+ existing: JsonObject = hooks_of(document=document)
129
+ if self._owner not in owners and self._legacy_flag is not None:
130
+ previous = legacy_records(
131
+ hooks=existing,
132
+ flag=self._legacy_flag,
133
+ )
134
+ hooks: JsonObject = remove(hooks=existing, records=previous)
135
+ for record in records:
136
+ if isinstance(record, dict) and isinstance(
137
+ event := record.get("event"),
138
+ str,
139
+ ):
140
+ entries: JsonValue = hooks.setdefault(event, [])
141
+ if isinstance(entries, list):
142
+ if record["entry"] in entries:
143
+ msg = (
144
+ "Identical hook owned by another registration"
145
+ )
146
+ raise ValueError(msg)
147
+ entries.append(deepcopy(x=record["entry"]))
148
+ if hooks or self._provider.versioned:
149
+ document["hooks"] = hooks
150
+ else:
151
+ _ = document.pop("hooks", None)
152
+ if self._provider.versioned:
153
+ document["version"] = 1
154
+ owners[self._owner] = previous + [
155
+ record for record in records if record not in previous
156
+ ]
157
+ write(path=self._receipt, owners=owners)
158
+
159
+ save(
160
+ path=self.path,
161
+ data=document,
162
+ backup_suffix=self._backup_suffix,
163
+ )
164
+ owners[self._owner] = records
165
+ write(path=self._receipt, owners=owners)
166
+
167
+ def _owners(self) -> JsonObject:
168
+ """Inspect either ledger without changing user configuration.
169
+
170
+ Returns:
171
+ Existing ownership records.
172
+
173
+ """
174
+ if self._receipt.exists():
175
+ return read(path=self._receipt)
176
+ for path in self._legacy_receipts:
177
+ if path.exists():
178
+ return read(path=path)
179
+ return {}
180
+
181
+ def _migrate(self) -> None:
182
+ """Rename a validated old ledger while both lock names are held.
183
+
184
+ Raises:
185
+ ValueError: When both ledgers exist; no ownership is discarded.
186
+
187
+ """
188
+ for legacy in self._legacy_receipts:
189
+ if not legacy.exists():
190
+ continue
191
+ if self._receipt.exists():
192
+ msg: str = (
193
+ "Two ownership ledgers exist; reconcile before editing"
194
+ )
195
+ raise ValueError(msg)
196
+ _ = read(path=legacy)
197
+ _ = legacy.replace(target=self._receipt)