ozm 2026.4.25.4__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.
- ozm-2026.4.25.4/PKG-INFO +13 -0
- ozm-2026.4.25.4/pyproject.toml +18 -0
- ozm-2026.4.25.4/src/ozm/__init__.py +0 -0
- ozm-2026.4.25.4/src/ozm/approve.py +197 -0
- ozm-2026.4.25.4/src/ozm/cli.py +21 -0
- ozm-2026.4.25.4/src/ozm/cmd.py +63 -0
- ozm-2026.4.25.4/src/ozm/config.py +56 -0
- ozm-2026.4.25.4/src/ozm/git.py +100 -0
- ozm-2026.4.25.4/src/ozm/install.py +154 -0
- ozm-2026.4.25.4/src/ozm/run.py +164 -0
ozm-2026.4.25.4/PKG-INFO
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ozm
|
|
3
|
+
Version: 2026.4.25.4
|
|
4
|
+
Summary: Content-aware script execution gate and git rule enforcer
|
|
5
|
+
Author: Kamyar
|
|
6
|
+
Author-email: claude@kamy.me
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Requires-Dist: click (>=8.0)
|
|
13
|
+
Requires-Dist: pyyaml (>=6.0)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "ozm"
|
|
3
|
+
version = "2026.4.25.4"
|
|
4
|
+
description = "Content-aware script execution gate and git rule enforcer"
|
|
5
|
+
authors = ["Kamyar <claude@kamy.me>"]
|
|
6
|
+
packages = [{include = "ozm", from = "src"}]
|
|
7
|
+
|
|
8
|
+
[tool.poetry.scripts]
|
|
9
|
+
ozm = "ozm.cli:cli"
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = ">=3.12"
|
|
13
|
+
click = ">=8.0"
|
|
14
|
+
pyyaml = ">=6.0"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["poetry-core"]
|
|
18
|
+
build-backend = "poetry.core.masonry.api"
|
|
File without changes
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OS-native approval dialog for script review."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import NamedTuple
|
|
8
|
+
|
|
9
|
+
ELECTRON_EDITORS = {"code", "cursor", "codium", "code-insiders"}
|
|
10
|
+
|
|
11
|
+
ELECTRON_PROCESS_NAMES = {
|
|
12
|
+
"code": "Code",
|
|
13
|
+
"code-insiders": "Code - Insiders",
|
|
14
|
+
"cursor": "Cursor",
|
|
15
|
+
"codium": "VSCodium",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ReviewSession(NamedTuple):
|
|
20
|
+
proc: subprocess.Popen
|
|
21
|
+
editor: str | None = None
|
|
22
|
+
filename: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApprovalResult(NamedTuple):
|
|
26
|
+
approved: bool | None
|
|
27
|
+
feedback: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extract_feedback(stdout: str) -> str | None:
|
|
31
|
+
for part in stdout.strip().split(", "):
|
|
32
|
+
if part.startswith("text returned:"):
|
|
33
|
+
text = part[len("text returned:"):]
|
|
34
|
+
return text if text.strip() else None
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def request_approval(script: str, label: str) -> ApprovalResult:
|
|
39
|
+
"""Ask the user to review and approve a script via OS-native UI."""
|
|
40
|
+
if platform.system() == "Darwin":
|
|
41
|
+
return _approve_macos(script, label)
|
|
42
|
+
return ApprovalResult(approved=None)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _count_lines(path: str) -> int:
|
|
46
|
+
with open(path) as f:
|
|
47
|
+
return sum(1 for _ in f)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_electron_editor(editor: str) -> bool:
|
|
51
|
+
return os.path.basename(editor) in ELECTRON_EDITORS
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _open_for_review(path: str) -> ReviewSession:
|
|
55
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
|
|
56
|
+
|
|
57
|
+
if editor and _is_electron_editor(editor):
|
|
58
|
+
basename = os.path.basename(editor)
|
|
59
|
+
proc = subprocess.Popen(
|
|
60
|
+
[editor, "--new-window", path],
|
|
61
|
+
stdout=subprocess.DEVNULL,
|
|
62
|
+
stderr=subprocess.DEVNULL,
|
|
63
|
+
)
|
|
64
|
+
return ReviewSession(proc=proc, editor=basename, filename=os.path.basename(path))
|
|
65
|
+
|
|
66
|
+
if editor:
|
|
67
|
+
proc = subprocess.Popen(
|
|
68
|
+
[editor, path],
|
|
69
|
+
stdout=subprocess.DEVNULL,
|
|
70
|
+
stderr=subprocess.DEVNULL,
|
|
71
|
+
)
|
|
72
|
+
return ReviewSession(proc=proc)
|
|
73
|
+
|
|
74
|
+
proc = subprocess.Popen(
|
|
75
|
+
["qlmanage", "-p", path],
|
|
76
|
+
stdout=subprocess.DEVNULL,
|
|
77
|
+
stderr=subprocess.DEVNULL,
|
|
78
|
+
)
|
|
79
|
+
return ReviewSession(proc=proc)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _close_review(session: ReviewSession) -> None:
|
|
83
|
+
if session.editor and session.filename:
|
|
84
|
+
process_name = ELECTRON_PROCESS_NAMES.get(session.editor)
|
|
85
|
+
if process_name:
|
|
86
|
+
_close_electron_window(process_name, session.filename)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
session.proc.terminate()
|
|
90
|
+
try:
|
|
91
|
+
session.proc.wait(timeout=3)
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
session.proc.kill()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _close_electron_window(process_name: str, filename: str) -> None:
|
|
97
|
+
safe_name = filename.replace("\\", "\\\\").replace('"', '\\"')
|
|
98
|
+
script = (
|
|
99
|
+
f'tell application "System Events"\n'
|
|
100
|
+
f' tell process "{process_name}"\n'
|
|
101
|
+
f' set w to (first window whose name contains "{safe_name}")\n'
|
|
102
|
+
f' click (first button of w whose subrole is "AXCloseButton")\n'
|
|
103
|
+
f' delay 0.5\n'
|
|
104
|
+
f' if (count of windows) is 0 then\n'
|
|
105
|
+
f' keystroke "q" using command down\n'
|
|
106
|
+
f' end if\n'
|
|
107
|
+
f' end tell\n'
|
|
108
|
+
f'end tell'
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=5)
|
|
112
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def request_cmd_approval(command: str) -> ApprovalResult:
|
|
117
|
+
"""Ask the user to approve an arbitrary command via OS-native dialog."""
|
|
118
|
+
if platform.system() == "Darwin":
|
|
119
|
+
return _approve_cmd_macos(command)
|
|
120
|
+
return ApprovalResult(approved=None)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_dialog_result(result: subprocess.CompletedProcess) -> ApprovalResult:
|
|
124
|
+
if result.returncode == 0:
|
|
125
|
+
stdout = result.stdout
|
|
126
|
+
feedback = _extract_feedback(stdout)
|
|
127
|
+
if "button returned:Allow" in stdout:
|
|
128
|
+
return ApprovalResult(approved=True)
|
|
129
|
+
if "button returned:Deny" in stdout:
|
|
130
|
+
return ApprovalResult(approved=False, feedback=feedback)
|
|
131
|
+
return ApprovalResult(approved=False)
|
|
132
|
+
|
|
133
|
+
if "user canceled" in result.stderr.lower():
|
|
134
|
+
return ApprovalResult(approved=False)
|
|
135
|
+
|
|
136
|
+
return ApprovalResult(approved=None)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _approve_cmd_macos(command: str) -> ApprovalResult:
|
|
140
|
+
safe_cmd = command.replace("\\", "\\\\").replace('"', '\\"')
|
|
141
|
+
dialog_text = (
|
|
142
|
+
f"Command:\\n\\n{safe_cmd}\\n\\n"
|
|
143
|
+
f"Allow execution?"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
result = subprocess.run(
|
|
148
|
+
[
|
|
149
|
+
"osascript",
|
|
150
|
+
"-e",
|
|
151
|
+
f'display dialog "{dialog_text}" '
|
|
152
|
+
f'default answer "" '
|
|
153
|
+
f'buttons {{"Deny", "Allow"}} default button "Deny" '
|
|
154
|
+
f'with title "ozm" with icon caution',
|
|
155
|
+
],
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
timeout=300,
|
|
159
|
+
)
|
|
160
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
161
|
+
return ApprovalResult(approved=None)
|
|
162
|
+
|
|
163
|
+
return _parse_dialog_result(result)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _approve_macos(script: str, label: str) -> ApprovalResult:
|
|
167
|
+
line_count = _count_lines(script)
|
|
168
|
+
session = _open_for_review(script)
|
|
169
|
+
|
|
170
|
+
safe_path = script.replace("\\", "\\\\").replace('"', '\\"')
|
|
171
|
+
dialog_text = (
|
|
172
|
+
f"[{label}] {safe_path}\\n\\n"
|
|
173
|
+
f"{line_count} lines\\n\\n"
|
|
174
|
+
f"The file has been opened for review.\\n"
|
|
175
|
+
f"Allow execution?"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
result = subprocess.run(
|
|
180
|
+
[
|
|
181
|
+
"osascript",
|
|
182
|
+
"-e",
|
|
183
|
+
f'display dialog "{dialog_text}" '
|
|
184
|
+
f'default answer "" '
|
|
185
|
+
f'buttons {{"Deny", "Allow"}} default button "Deny" '
|
|
186
|
+
f'with title "ozm" with icon caution',
|
|
187
|
+
],
|
|
188
|
+
capture_output=True,
|
|
189
|
+
text=True,
|
|
190
|
+
timeout=300,
|
|
191
|
+
)
|
|
192
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
193
|
+
_close_review(session)
|
|
194
|
+
return ApprovalResult(approved=None)
|
|
195
|
+
|
|
196
|
+
_close_review(session)
|
|
197
|
+
return _parse_dialog_result(result)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from ozm.run import reset_cmd, run_cmd, status_cmd
|
|
6
|
+
from ozm.git import git_cmd
|
|
7
|
+
from ozm.install import install_cmd
|
|
8
|
+
from ozm.cmd import cmd_cmd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def cli():
|
|
13
|
+
"""Content-aware script execution gate and git rule enforcer."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
cli.add_command(run_cmd, "run")
|
|
17
|
+
cli.add_command(status_cmd, "status")
|
|
18
|
+
cli.add_command(reset_cmd, "reset")
|
|
19
|
+
cli.add_command(git_cmd, "git")
|
|
20
|
+
cli.add_command(install_cmd, "install")
|
|
21
|
+
cli.add_command(cmd_cmd, "cmd")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Arbitrary command pass-through with approval dialog."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from ozm.approve import request_cmd_approval
|
|
11
|
+
from ozm.config import is_command_allowed, project_key
|
|
12
|
+
from ozm.run import load_hashes, save_hashes
|
|
13
|
+
|
|
14
|
+
CMD_PREFIX = "cmd:"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _cmd_hash(command: str) -> str:
|
|
18
|
+
return hashlib.sha256(command.encode()).hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.command(
|
|
22
|
+
"cmd",
|
|
23
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
24
|
+
)
|
|
25
|
+
@click.argument("command_and_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
|
26
|
+
def cmd_cmd(command_and_args: tuple[str, ...]) -> None:
|
|
27
|
+
"""Run an arbitrary command after approval."""
|
|
28
|
+
if not command_and_args:
|
|
29
|
+
raise click.ClickException("Nothing to run.")
|
|
30
|
+
|
|
31
|
+
command = " ".join(command_and_args)
|
|
32
|
+
|
|
33
|
+
if is_command_allowed(command):
|
|
34
|
+
result = subprocess.run(command, shell=True)
|
|
35
|
+
sys.exit(result.returncode)
|
|
36
|
+
|
|
37
|
+
key = project_key(CMD_PREFIX + command)
|
|
38
|
+
current_hash = _cmd_hash(command)
|
|
39
|
+
hashes = load_hashes()
|
|
40
|
+
|
|
41
|
+
if hashes.get(key) == current_hash:
|
|
42
|
+
result = subprocess.run(command, shell=True)
|
|
43
|
+
sys.exit(result.returncode)
|
|
44
|
+
|
|
45
|
+
approval = request_cmd_approval(command)
|
|
46
|
+
|
|
47
|
+
if approval.approved is True:
|
|
48
|
+
hashes[key] = current_hash
|
|
49
|
+
save_hashes(hashes)
|
|
50
|
+
click.echo("ozm: approved cmd")
|
|
51
|
+
result = subprocess.run(command, shell=True)
|
|
52
|
+
sys.exit(result.returncode)
|
|
53
|
+
|
|
54
|
+
if approval.approved is False:
|
|
55
|
+
if approval.feedback:
|
|
56
|
+
click.echo(f"ozm: denied cmd — {approval.feedback}", err=True)
|
|
57
|
+
else:
|
|
58
|
+
click.echo("ozm: denied cmd", err=True)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
click.echo(f"ozm: {command}")
|
|
62
|
+
click.echo("No approval dialog available. Review the command above.")
|
|
63
|
+
sys.exit(1)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Per-project configuration via .ozm.yaml."""
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CONFIG_FILE = ".ozm.yaml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def find_project_root() -> str:
|
|
14
|
+
"""Walk up from cwd to find directory containing .ozm.yaml or .git, else cwd."""
|
|
15
|
+
d = os.getcwd()
|
|
16
|
+
while True:
|
|
17
|
+
if os.path.exists(os.path.join(d, CONFIG_FILE)) or os.path.exists(
|
|
18
|
+
os.path.join(d, ".git")
|
|
19
|
+
):
|
|
20
|
+
return d
|
|
21
|
+
parent = os.path.dirname(d)
|
|
22
|
+
if parent == d:
|
|
23
|
+
return os.getcwd()
|
|
24
|
+
d = parent
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_project_config() -> dict:
|
|
28
|
+
"""Load .ozm.yaml from the project root. Returns empty dict if missing."""
|
|
29
|
+
root = find_project_root()
|
|
30
|
+
path = os.path.join(root, CONFIG_FILE)
|
|
31
|
+
if not os.path.exists(path):
|
|
32
|
+
return {}
|
|
33
|
+
with open(path) as f:
|
|
34
|
+
data = yaml.safe_load(f)
|
|
35
|
+
return data if isinstance(data, dict) else {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_command_allowed(command: str) -> bool:
|
|
39
|
+
"""Check if a command matches any pattern in the project's allowed_commands."""
|
|
40
|
+
config = load_project_config()
|
|
41
|
+
patterns = config.get("allowed_commands", [])
|
|
42
|
+
if not isinstance(patterns, list):
|
|
43
|
+
return False
|
|
44
|
+
first_word = command.split()[0] if command.strip() else ""
|
|
45
|
+
for pattern in patterns:
|
|
46
|
+
if not isinstance(pattern, str):
|
|
47
|
+
continue
|
|
48
|
+
if fnmatch.fnmatch(command, pattern) or fnmatch.fnmatch(first_word, pattern):
|
|
49
|
+
return True
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def project_key(key: str) -> str:
|
|
54
|
+
"""Prefix a hash key with the project root for project-scoped storage."""
|
|
55
|
+
root = find_project_root()
|
|
56
|
+
return f"{root}:{key}"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Git pass-through with rule enforcement on commit and push."""
|
|
3
|
+
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
MAX_SUBJECT_LENGTH = 72
|
|
10
|
+
MAX_MESSAGE_LENGTH = 500
|
|
11
|
+
PROTECTED_BRANCHES = {"main", "master"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_current_branch() -> str | None:
|
|
15
|
+
result = subprocess.run(
|
|
16
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
)
|
|
20
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_message(args: list[str]) -> str | None:
|
|
24
|
+
for i, arg in enumerate(args):
|
|
25
|
+
if arg in ("-m", "--message") and i + 1 < len(args):
|
|
26
|
+
return args[i + 1]
|
|
27
|
+
if arg.startswith("-m") and len(arg) > 2:
|
|
28
|
+
return arg[2:]
|
|
29
|
+
if arg.startswith("--message="):
|
|
30
|
+
return arg.split("=", 1)[1]
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_message(message: str) -> list[str]:
|
|
35
|
+
errors = []
|
|
36
|
+
lines = message.splitlines()
|
|
37
|
+
subject = lines[0] if lines else ""
|
|
38
|
+
|
|
39
|
+
if len(subject) > MAX_SUBJECT_LENGTH:
|
|
40
|
+
errors.append(
|
|
41
|
+
f"Subject line is {len(subject)} chars (max {MAX_SUBJECT_LENGTH})"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if len(message) > MAX_MESSAGE_LENGTH:
|
|
45
|
+
errors.append(
|
|
46
|
+
f"Total message is {len(message)} chars (max {MAX_MESSAGE_LENGTH})"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return errors
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _check_commit(args: list[str]) -> None:
|
|
53
|
+
message = extract_message(args)
|
|
54
|
+
if message:
|
|
55
|
+
errors = validate_message(message)
|
|
56
|
+
if errors:
|
|
57
|
+
click.echo("ozm: commit blocked:", err=True)
|
|
58
|
+
for e in errors:
|
|
59
|
+
click.echo(f" - {e}", err=True)
|
|
60
|
+
sys.exit(1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_push(args: list[str]) -> None:
|
|
64
|
+
force_flags = {"--force", "-f"}
|
|
65
|
+
if any(a in force_flags for a in args):
|
|
66
|
+
click.echo("ozm: force push is not allowed", err=True)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
branch = get_current_branch()
|
|
70
|
+
if branch in PROTECTED_BRANCHES:
|
|
71
|
+
click.echo(f"ozm: pushing to '{branch}' is not allowed", err=True)
|
|
72
|
+
sys.exit(1)
|
|
73
|
+
|
|
74
|
+
for arg in args:
|
|
75
|
+
if not arg.startswith("-") and arg in PROTECTED_BRANCHES:
|
|
76
|
+
click.echo(f"ozm: pushing to '{arg}' is not allowed", err=True)
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@click.command(
|
|
81
|
+
"git",
|
|
82
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
83
|
+
)
|
|
84
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
85
|
+
def git_cmd(args: tuple[str, ...]) -> None:
|
|
86
|
+
"""Git pass-through. Enforces rules on commit and push."""
|
|
87
|
+
if not args:
|
|
88
|
+
subprocess.run(["git"])
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
subcmd = args[0]
|
|
92
|
+
rest = list(args[1:])
|
|
93
|
+
|
|
94
|
+
if subcmd == "commit":
|
|
95
|
+
_check_commit(rest)
|
|
96
|
+
elif subcmd == "push":
|
|
97
|
+
_check_push(rest)
|
|
98
|
+
|
|
99
|
+
result = subprocess.run(["git", *args])
|
|
100
|
+
sys.exit(result.returncode)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install ozm hooks and agent configuration."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
OZM_DIR = os.path.expanduser("~/.ozm")
|
|
11
|
+
HOOKS_DIR = os.path.join(OZM_DIR, "hooks")
|
|
12
|
+
ENFORCE_HOOK = os.path.join(HOOKS_DIR, "enforce.sh")
|
|
13
|
+
|
|
14
|
+
HOOK_SCRIPT = r'''#!/usr/bin/env python3
|
|
15
|
+
import json, sys, re
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
data = json.load(sys.stdin)
|
|
19
|
+
except Exception:
|
|
20
|
+
sys.exit(0)
|
|
21
|
+
|
|
22
|
+
command = data.get("tool_input", {}).get("command", "")
|
|
23
|
+
if not command:
|
|
24
|
+
sys.exit(0)
|
|
25
|
+
|
|
26
|
+
def deny(reason):
|
|
27
|
+
json.dump({"hookSpecificOutput": {
|
|
28
|
+
"hookEventName": "PreToolUse",
|
|
29
|
+
"permissionDecision": "deny",
|
|
30
|
+
"permissionDecisionReason": reason,
|
|
31
|
+
}}, sys.stdout)
|
|
32
|
+
sys.exit(0)
|
|
33
|
+
|
|
34
|
+
SAFE = {"echo", "printf", "pwd", "date", "true", "false", "test"}
|
|
35
|
+
|
|
36
|
+
stripped = re.sub(r"""(?:"(?:[^"\\]|\\.)*"|'[^']*')""", '""', command)
|
|
37
|
+
parts = re.split(r"\s*(?:&&|\|\||;)\s*", stripped)
|
|
38
|
+
for part in parts:
|
|
39
|
+
part = part.strip()
|
|
40
|
+
if not part:
|
|
41
|
+
continue
|
|
42
|
+
first_word = re.split(r"\s+", part)[0]
|
|
43
|
+
if first_word == "ozm":
|
|
44
|
+
continue
|
|
45
|
+
if first_word in SAFE:
|
|
46
|
+
continue
|
|
47
|
+
if first_word == "git":
|
|
48
|
+
deny("Use 'ozm git <subcommand>' instead of 'git' directly.")
|
|
49
|
+
deny(f"Use 'ozm cmd {part}' instead of running commands directly. For script files use 'ozm run <script>'.")
|
|
50
|
+
'''
|
|
51
|
+
|
|
52
|
+
CLAUDE_MD = """# ozm — script execution gate
|
|
53
|
+
|
|
54
|
+
All script execution and git operations must go through `ozm`.
|
|
55
|
+
|
|
56
|
+
## Rules
|
|
57
|
+
|
|
58
|
+
- **Run scripts:** `ozm run <script> [args...]` — never `python`, `bash`, `./`, or `uv run` directly
|
|
59
|
+
- **Run commands:** `ozm cmd <command> [args...]` — for arbitrary commands (e.g. `ozm cmd uv pip install -e .`)
|
|
60
|
+
- **Commit:** `ozm git commit -m "short message"` — max 72 char subject, max 500 chars total
|
|
61
|
+
- **Push:** `ozm git push` — no force push, no pushing to main/master
|
|
62
|
+
- **Status:** `ozm status` — show tracked scripts
|
|
63
|
+
- **Reset:** `ozm reset <script>` or `ozm reset --all`
|
|
64
|
+
|
|
65
|
+
Keep commit messages short. No heredoc/EOF patterns. Simple `-m "message"` only.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
AGENTS_MD = """# ozm — script execution gate
|
|
69
|
+
|
|
70
|
+
All script execution and git operations must go through `ozm`.
|
|
71
|
+
|
|
72
|
+
## Rules
|
|
73
|
+
|
|
74
|
+
- **Run scripts:** `ozm run <script> [args...]` — never `python`, `bash`, `./`, or `uv run` directly
|
|
75
|
+
- **Run commands:** `ozm cmd <command> [args...]` — for arbitrary commands (e.g. `ozm cmd uv pip install -e .`)
|
|
76
|
+
- **Commit:** `ozm git commit -m "short message"` — max 72 char subject, max 500 chars total
|
|
77
|
+
- **Push:** `ozm git push` — no force push, no pushing to main/master
|
|
78
|
+
|
|
79
|
+
Keep commit messages short. No heredoc/EOF patterns. Simple `-m "message"` only.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
CLAUDE_HOOKS_CONFIG = {
|
|
83
|
+
"hooks": {
|
|
84
|
+
"PreToolUse": [
|
|
85
|
+
{
|
|
86
|
+
"matcher": "Bash",
|
|
87
|
+
"hooks": [
|
|
88
|
+
{
|
|
89
|
+
"type": "command",
|
|
90
|
+
"command": ENFORCE_HOOK,
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write_hook_script() -> None:
|
|
100
|
+
os.makedirs(HOOKS_DIR, exist_ok=True)
|
|
101
|
+
with open(ENFORCE_HOOK, "w") as f:
|
|
102
|
+
f.write(HOOK_SCRIPT)
|
|
103
|
+
st = os.stat(ENFORCE_HOOK)
|
|
104
|
+
os.chmod(ENFORCE_HOOK, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
105
|
+
click.echo(f" hook: {ENFORCE_HOOK}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _write_file(path: str, content: str) -> None:
|
|
109
|
+
with open(path, "w") as f:
|
|
110
|
+
f.write(content)
|
|
111
|
+
click.echo(f" wrote: {path}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _configure_claude_code() -> None:
|
|
115
|
+
claude_dir = os.path.join(os.getcwd(), ".claude")
|
|
116
|
+
os.makedirs(claude_dir, exist_ok=True)
|
|
117
|
+
settings_path = os.path.join(claude_dir, "settings.json")
|
|
118
|
+
|
|
119
|
+
if os.path.exists(settings_path):
|
|
120
|
+
with open(settings_path) as f:
|
|
121
|
+
settings = json.load(f)
|
|
122
|
+
else:
|
|
123
|
+
settings = {}
|
|
124
|
+
|
|
125
|
+
settings.setdefault("hooks", {})
|
|
126
|
+
pre_hooks = settings["hooks"].setdefault("PreToolUse", [])
|
|
127
|
+
|
|
128
|
+
already = any(
|
|
129
|
+
h.get("matcher") == "Bash"
|
|
130
|
+
and any(
|
|
131
|
+
hk.get("command") == ENFORCE_HOOK
|
|
132
|
+
for hk in h.get("hooks", [])
|
|
133
|
+
)
|
|
134
|
+
for h in pre_hooks
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if not already:
|
|
138
|
+
pre_hooks.append(CLAUDE_HOOKS_CONFIG["hooks"]["PreToolUse"][0])
|
|
139
|
+
|
|
140
|
+
with open(settings_path, "w") as f:
|
|
141
|
+
json.dump(settings, f, indent=2)
|
|
142
|
+
f.write("\n")
|
|
143
|
+
click.echo(f" claude: {settings_path}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@click.command("install")
|
|
147
|
+
def install_cmd() -> None:
|
|
148
|
+
"""Install ozm hooks and agent configuration in the current project."""
|
|
149
|
+
click.echo("ozm: installing...")
|
|
150
|
+
_write_hook_script()
|
|
151
|
+
_configure_claude_code()
|
|
152
|
+
_write_file("CLAUDE.md", CLAUDE_MD)
|
|
153
|
+
_write_file("AGENTS.md", AGENTS_MD)
|
|
154
|
+
click.echo("ozm: done")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hash-based script execution gate."""
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from ozm.approve import request_approval
|
|
14
|
+
from ozm.config import project_key
|
|
15
|
+
|
|
16
|
+
OZM_DIR = os.path.expanduser("~/.ozm")
|
|
17
|
+
HASH_FILE = os.path.join(OZM_DIR, "hashes.yaml")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_hash(path: str) -> str:
|
|
21
|
+
with open(path, "rb") as f:
|
|
22
|
+
return hashlib.sha256(f.read()).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resolve_path(path: str) -> str:
|
|
26
|
+
return os.path.abspath(path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_hashes() -> dict[str, str]:
|
|
30
|
+
if os.path.exists(HASH_FILE):
|
|
31
|
+
with open(HASH_FILE) as f:
|
|
32
|
+
data = yaml.safe_load(f)
|
|
33
|
+
return data if data else {}
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_hashes(hashes: dict[str, str]) -> None:
|
|
38
|
+
os.makedirs(OZM_DIR, exist_ok=True)
|
|
39
|
+
with open(HASH_FILE, "w") as f:
|
|
40
|
+
yaml.dump(hashes, f, default_flow_style=False, sort_keys=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def show_file(path: str) -> None:
|
|
44
|
+
with open(path) as f:
|
|
45
|
+
content = f.read()
|
|
46
|
+
lines = content.splitlines()
|
|
47
|
+
width = len(str(len(lines)))
|
|
48
|
+
click.echo(f"\n{'=' * 60}")
|
|
49
|
+
click.echo(f" {path}")
|
|
50
|
+
click.echo(f"{'=' * 60}")
|
|
51
|
+
for i, line in enumerate(lines, 1):
|
|
52
|
+
click.echo(f" {i:>{width}} | {line}")
|
|
53
|
+
click.echo(f"{'=' * 60}\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def ensure_executable(path: str) -> None:
|
|
57
|
+
st = os.stat(path)
|
|
58
|
+
if not st.st_mode & stat.S_IXUSR:
|
|
59
|
+
os.chmod(path, st.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@click.command(
|
|
63
|
+
"run",
|
|
64
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
65
|
+
)
|
|
66
|
+
@click.argument("script")
|
|
67
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
68
|
+
def run_cmd(script: str, args: tuple[str, ...]) -> None:
|
|
69
|
+
"""Run a script after content review (hash-gated)."""
|
|
70
|
+
if not os.path.exists(script):
|
|
71
|
+
raise click.ClickException(f"{script}: not found")
|
|
72
|
+
|
|
73
|
+
abs_path = resolve_path(script)
|
|
74
|
+
key = project_key(abs_path)
|
|
75
|
+
current_hash = compute_hash(script)
|
|
76
|
+
hashes = load_hashes()
|
|
77
|
+
stored_hash = hashes.get(key)
|
|
78
|
+
|
|
79
|
+
if stored_hash == current_hash:
|
|
80
|
+
ensure_executable(script)
|
|
81
|
+
result = subprocess.run([script, *args])
|
|
82
|
+
sys.exit(result.returncode)
|
|
83
|
+
|
|
84
|
+
label = "NEW" if stored_hash is None else "CHANGED"
|
|
85
|
+
|
|
86
|
+
approval = request_approval(script, label)
|
|
87
|
+
|
|
88
|
+
if approval.approved is True:
|
|
89
|
+
hashes[key] = current_hash
|
|
90
|
+
save_hashes(hashes)
|
|
91
|
+
click.echo(f"ozm: approved {script}")
|
|
92
|
+
ensure_executable(script)
|
|
93
|
+
result = subprocess.run([script, *args])
|
|
94
|
+
sys.exit(result.returncode)
|
|
95
|
+
|
|
96
|
+
if approval.approved is False:
|
|
97
|
+
if approval.feedback:
|
|
98
|
+
click.echo(f"ozm: denied {script} — {approval.feedback}", err=True)
|
|
99
|
+
else:
|
|
100
|
+
click.echo(f"ozm: denied {script}", err=True)
|
|
101
|
+
sys.exit(1)
|
|
102
|
+
|
|
103
|
+
click.echo(f"ozm: [{label}] {script}")
|
|
104
|
+
show_file(script)
|
|
105
|
+
|
|
106
|
+
hashes[key] = current_hash
|
|
107
|
+
save_hashes(hashes)
|
|
108
|
+
|
|
109
|
+
click.echo("Review the content above. Run the same command again to execute.")
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@click.command("status")
|
|
114
|
+
def status_cmd() -> None:
|
|
115
|
+
"""Show tracked files and commands with their approval status."""
|
|
116
|
+
from ozm.config import find_project_root
|
|
117
|
+
|
|
118
|
+
root = find_project_root()
|
|
119
|
+
prefix = root + ":"
|
|
120
|
+
hashes = load_hashes()
|
|
121
|
+
entries = {k: v for k, v in hashes.items() if k.startswith(prefix)}
|
|
122
|
+
if not entries:
|
|
123
|
+
click.echo("No tracked entries.")
|
|
124
|
+
return
|
|
125
|
+
for key, stored_hash in sorted(entries.items()):
|
|
126
|
+
display = key[len(prefix):]
|
|
127
|
+
if "cmd:" in display:
|
|
128
|
+
label = "ok"
|
|
129
|
+
elif os.path.exists(display):
|
|
130
|
+
current = compute_hash(display)
|
|
131
|
+
label = "ok" if current == stored_hash else "CHANGED"
|
|
132
|
+
else:
|
|
133
|
+
label = "MISSING"
|
|
134
|
+
click.echo(f" [{label:>7}] {display}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@click.command("reset")
|
|
138
|
+
@click.argument("script", required=False)
|
|
139
|
+
@click.option("--all", "reset_all", is_flag=True, help="Forget all approvals.")
|
|
140
|
+
def reset_cmd(script: str | None, reset_all: bool) -> None:
|
|
141
|
+
"""Forget approval for a script (or all scripts with --all)."""
|
|
142
|
+
from ozm.config import find_project_root
|
|
143
|
+
|
|
144
|
+
root = find_project_root()
|
|
145
|
+
prefix = root + ":"
|
|
146
|
+
|
|
147
|
+
if reset_all:
|
|
148
|
+
hashes = load_hashes()
|
|
149
|
+
hashes = {k: v for k, v in hashes.items() if not k.startswith(prefix)}
|
|
150
|
+
save_hashes(hashes)
|
|
151
|
+
click.echo("All approvals cleared for this project.")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if not script:
|
|
155
|
+
raise click.ClickException("Provide a script name, or use --all.")
|
|
156
|
+
|
|
157
|
+
abs_path = resolve_path(script)
|
|
158
|
+
key = project_key(abs_path)
|
|
159
|
+
hashes = load_hashes()
|
|
160
|
+
if key not in hashes:
|
|
161
|
+
raise click.ClickException(f"{script} is not tracked.")
|
|
162
|
+
del hashes[key]
|
|
163
|
+
save_hashes(hashes)
|
|
164
|
+
click.echo(f"Forgot approval for {script}")
|