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.
@@ -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}")