edit-guard 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.
- edit_guard/__init__.py +3 -0
- edit_guard/cli.py +198 -0
- edit_guard/guard.py +162 -0
- edit_guard-0.1.0.dist-info/METADATA +99 -0
- edit_guard-0.1.0.dist-info/RECORD +9 -0
- edit_guard-0.1.0.dist-info/WHEEL +5 -0
- edit_guard-0.1.0.dist-info/entry_points.txt +2 -0
- edit_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
- edit_guard-0.1.0.dist-info/top_level.txt +1 -0
edit_guard/__init__.py
ADDED
edit_guard/cli.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""edit-guard: a PreToolUse hook that blocks stale concurrent edits."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from . import guard
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _hook_input():
|
|
13
|
+
"""Read the hook's stdin JSON defensively."""
|
|
14
|
+
try:
|
|
15
|
+
raw = sys.stdin.read()
|
|
16
|
+
obj = json.loads(raw)
|
|
17
|
+
return obj if isinstance(obj, dict) else {}
|
|
18
|
+
except Exception:
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tool_path(tool_name, tool_input):
|
|
23
|
+
if not isinstance(tool_input, dict):
|
|
24
|
+
return None
|
|
25
|
+
for key in ("file_path", "path", "file", "filename"):
|
|
26
|
+
v = tool_input.get(key)
|
|
27
|
+
if v:
|
|
28
|
+
return os.path.abspath(os.path.expanduser(str(v)))
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_hook(args):
|
|
33
|
+
"""Entry point for the PreToolUse/PostToolUse hooks.
|
|
34
|
+
|
|
35
|
+
Exits 0 (allow) or 2 (block). PostToolUse (--post-write) only records
|
|
36
|
+
the post-write digest and never blocks.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
data = _hook_input()
|
|
40
|
+
tool = data.get("tool_name", "")
|
|
41
|
+
session = str(data.get("session_id", "") or "unknown")
|
|
42
|
+
if tool not in guard.WATCHED:
|
|
43
|
+
return 0
|
|
44
|
+
path = _tool_path(tool, data.get("tool_input"))
|
|
45
|
+
if not path:
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
if getattr(args, "post_write", False):
|
|
49
|
+
# PostToolUse: the write has landed; record the new digest so
|
|
50
|
+
# this session's next PreToolUse check sees its own change.
|
|
51
|
+
if tool in guard.WRITE_TOOLS:
|
|
52
|
+
guard.record(session, tool, path, guard.digest_of(path))
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
if tool in guard.READ_TOOLS or args.observe:
|
|
56
|
+
# Observe-only: record what this session saw, never block.
|
|
57
|
+
guard.record(session, tool, path, guard.digest_of(path))
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
allowed, reason = guard.check(session, path)
|
|
61
|
+
guard.record(session, tool, path, guard.digest_of(path))
|
|
62
|
+
if not allowed:
|
|
63
|
+
sys.stderr.write(reason + "\n")
|
|
64
|
+
return 2
|
|
65
|
+
return 0
|
|
66
|
+
except Exception:
|
|
67
|
+
return 0 # fail open, always
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _settings_path():
|
|
71
|
+
return os.path.join(os.path.expanduser("~"), ".claude", "settings.json")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
HOOK_SNIPPET = {
|
|
75
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
76
|
+
"hooks": [{"type": "command", "command": "edit-guard hook"}],
|
|
77
|
+
}
|
|
78
|
+
POST_SNIPPET = {
|
|
79
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
80
|
+
"hooks": [{"type": "command", "command": "edit-guard hook --post-write"}],
|
|
81
|
+
}
|
|
82
|
+
READ_SNIPPET = {
|
|
83
|
+
"matcher": "Read",
|
|
84
|
+
"hooks": [{"type": "command", "command": "edit-guard hook --observe"}],
|
|
85
|
+
}
|
|
86
|
+
ALL_SNIPPETS = (
|
|
87
|
+
("PreToolUse", HOOK_SNIPPET),
|
|
88
|
+
("PostToolUse", POST_SNIPPET),
|
|
89
|
+
("PreToolUse", READ_SNIPPET),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_install(args):
|
|
94
|
+
"""Merge the hook into ~/.claude/settings.json (with backup)."""
|
|
95
|
+
sp = _settings_path()
|
|
96
|
+
settings = {}
|
|
97
|
+
if os.path.exists(sp):
|
|
98
|
+
try:
|
|
99
|
+
with open(sp, "r", encoding="utf-8") as fh:
|
|
100
|
+
settings = json.load(fh)
|
|
101
|
+
if not isinstance(settings, dict):
|
|
102
|
+
settings = {}
|
|
103
|
+
except Exception as e:
|
|
104
|
+
print("Could not parse %s: %s" % (sp, e), file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
bak = sp + ".bak"
|
|
107
|
+
try:
|
|
108
|
+
shutil.copy2(sp, bak)
|
|
109
|
+
print("Backed up %s -> %s" % (sp, bak))
|
|
110
|
+
except OSError as e:
|
|
111
|
+
print("Backup failed: %s" % e, file=sys.stderr)
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
hooks = settings.get("hooks")
|
|
115
|
+
if not isinstance(hooks, dict):
|
|
116
|
+
hooks = {}
|
|
117
|
+
settings["hooks"] = hooks
|
|
118
|
+
|
|
119
|
+
def present(entries, snippet):
|
|
120
|
+
return any(
|
|
121
|
+
h.get("matcher") == snippet["matcher"]
|
|
122
|
+
and any("edit-guard" in (x.get("command", "") or "")
|
|
123
|
+
for x in h.get("hooks", []))
|
|
124
|
+
for h in entries if isinstance(h, dict))
|
|
125
|
+
|
|
126
|
+
added = 0
|
|
127
|
+
for hook_type, snippet in ALL_SNIPPETS:
|
|
128
|
+
entries = hooks.get(hook_type)
|
|
129
|
+
if not isinstance(entries, list):
|
|
130
|
+
entries = []
|
|
131
|
+
hooks[hook_type] = entries
|
|
132
|
+
if present(entries, snippet):
|
|
133
|
+
continue
|
|
134
|
+
entries.append(snippet)
|
|
135
|
+
added += 1
|
|
136
|
+
|
|
137
|
+
os.makedirs(os.path.dirname(sp), exist_ok=True)
|
|
138
|
+
with open(sp, "w", encoding="utf-8") as fh:
|
|
139
|
+
json.dump(settings, fh, indent=2)
|
|
140
|
+
fh.write("\n")
|
|
141
|
+
print("Installed edit-guard hooks into %s (%d added, %d already present)."
|
|
142
|
+
% (sp, added, len(ALL_SNIPPETS) - added))
|
|
143
|
+
print("Restart Claude Code for the hooks to take effect.")
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_log(args):
|
|
148
|
+
entries = guard._read_log()
|
|
149
|
+
entries.sort(key=lambda e: e.get("ts", 0))
|
|
150
|
+
for e in entries[-args.limit:]:
|
|
151
|
+
import datetime
|
|
152
|
+
ts = datetime.datetime.fromtimestamp(e.get("ts", 0)).strftime("%m-%d %H:%M:%S")
|
|
153
|
+
print("%s %-12s %-10s %s" % (
|
|
154
|
+
ts, str(e.get("session", ""))[:12],
|
|
155
|
+
e.get("tool", ""), e.get("path", "")))
|
|
156
|
+
return 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_status(args):
|
|
160
|
+
print("state dir: %s" % guard.state_dir())
|
|
161
|
+
print("log: %s (%d entries)" % (guard.log_path(), len(guard._read_log())))
|
|
162
|
+
print("settings: %s" % _settings_path())
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def build_parser():
|
|
167
|
+
p = argparse.ArgumentParser(
|
|
168
|
+
prog="edit-guard",
|
|
169
|
+
description="Block stale concurrent edits across agent sessions.")
|
|
170
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
171
|
+
|
|
172
|
+
ph = sub.add_parser("hook", help="Run as a Claude Code PreToolUse hook.")
|
|
173
|
+
ph.add_argument("--observe", action="store_true",
|
|
174
|
+
help="Record only, never block (for Read).")
|
|
175
|
+
ph.add_argument("--post-write", action="store_true",
|
|
176
|
+
help="PostToolUse mode: record the post-write digest, "
|
|
177
|
+
"never block.")
|
|
178
|
+
ph.set_defaults(func=cmd_hook)
|
|
179
|
+
|
|
180
|
+
pi = sub.add_parser("install", help="Install into ~/.claude/settings.json.")
|
|
181
|
+
pi.set_defaults(func=cmd_install)
|
|
182
|
+
|
|
183
|
+
pl = sub.add_parser("log", help="Show recent guarded file events.")
|
|
184
|
+
pl.add_argument("--limit", type=int, default=20)
|
|
185
|
+
pl.set_defaults(func=cmd_log)
|
|
186
|
+
|
|
187
|
+
ps = sub.add_parser("status", help="Show state and config paths.")
|
|
188
|
+
ps.set_defaults(func=cmd_status)
|
|
189
|
+
return p
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def main(argv=None):
|
|
193
|
+
args = build_parser().parse_args(argv)
|
|
194
|
+
return args.func(args)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
sys.exit(main())
|
edit_guard/guard.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Core staleness-detection logic for edit-guard.
|
|
2
|
+
|
|
3
|
+
Model: every Read/Edit/Write observed through the hooks records
|
|
4
|
+
(session_id, tool, path, digest_seen, timestamp) in an append-only log.
|
|
5
|
+
PreToolUse records the digest *before* a write; PostToolUse records the
|
|
6
|
+
digest *after* the write lands. When a session attempts Edit/Write on a
|
|
7
|
+
path, we compare the file's current digest against the digest *that session*
|
|
8
|
+
last saw (usually its own post-write record). If the file changed since
|
|
9
|
+
then AND another session *wrote* to it in between, the edit is stale and
|
|
10
|
+
blocked. Another session's Read never counts as a modification.
|
|
11
|
+
|
|
12
|
+
Everything fails open: any unexpected error means "allow".
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
WRITE_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
|
|
21
|
+
READ_TOOLS = {"Read"}
|
|
22
|
+
WATCHED = WRITE_TOOLS | READ_TOOLS
|
|
23
|
+
|
|
24
|
+
MAX_LOG_LINES = 20000
|
|
25
|
+
PRUNE_KEEP = 10000
|
|
26
|
+
MAX_HASH_BYTES = 50 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def state_dir():
|
|
30
|
+
return os.environ.get(
|
|
31
|
+
"EDIT_GUARD_DIR",
|
|
32
|
+
os.path.join(os.path.expanduser("~"), ".config", "edit-guard"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def log_path():
|
|
36
|
+
return os.path.join(state_dir(), "writes.jsonl")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def digest_of(path):
|
|
40
|
+
"""sha256 of file contents; falls back to mtime+size for huge files."""
|
|
41
|
+
try:
|
|
42
|
+
size = os.path.getsize(path)
|
|
43
|
+
except OSError:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
if size > MAX_HASH_BYTES:
|
|
47
|
+
st = os.stat(path)
|
|
48
|
+
return "stat:%d:%d" % (st.st_mtime_ns, st.st_size)
|
|
49
|
+
h = hashlib.sha256()
|
|
50
|
+
with open(path, "rb") as fh:
|
|
51
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
52
|
+
h.update(chunk)
|
|
53
|
+
return "sha256:" + h.hexdigest()
|
|
54
|
+
except OSError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_log():
|
|
59
|
+
"""Return list of entry dicts; tolerate missing/corrupt log."""
|
|
60
|
+
entries = []
|
|
61
|
+
try:
|
|
62
|
+
fh = open(log_path(), "r", encoding="utf-8", errors="replace")
|
|
63
|
+
except OSError:
|
|
64
|
+
return entries
|
|
65
|
+
with fh:
|
|
66
|
+
for line in fh:
|
|
67
|
+
line = line.strip()
|
|
68
|
+
if not line:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
obj = json.loads(line)
|
|
72
|
+
except Exception:
|
|
73
|
+
continue
|
|
74
|
+
if isinstance(obj, dict) and obj.get("path"):
|
|
75
|
+
entries.append(obj)
|
|
76
|
+
return entries
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _prune_if_needed():
|
|
80
|
+
p = log_path()
|
|
81
|
+
try:
|
|
82
|
+
with open(p, "r", encoding="utf-8") as fh:
|
|
83
|
+
lines = fh.readlines()
|
|
84
|
+
except OSError:
|
|
85
|
+
return
|
|
86
|
+
if len(lines) <= MAX_LOG_LINES:
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
90
|
+
fh.writelines(lines[-PRUNE_KEEP:])
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def record(session_id, tool, path, digest):
|
|
96
|
+
"""Append an observation. Never raises."""
|
|
97
|
+
try:
|
|
98
|
+
os.makedirs(state_dir(), exist_ok=True)
|
|
99
|
+
entry = {"ts": time.time(), "session": session_id,
|
|
100
|
+
"tool": tool, "path": path, "digest": digest}
|
|
101
|
+
with open(log_path(), "a", encoding="utf-8") as fh:
|
|
102
|
+
fh.write(json.dumps(entry) + "\n")
|
|
103
|
+
_prune_if_needed()
|
|
104
|
+
except OSError:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check(session_id, path):
|
|
109
|
+
"""Decide whether session_id may write path right now.
|
|
110
|
+
|
|
111
|
+
Returns (allowed: bool, reason: str). Never raises.
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
return _check_inner(session_id, path)
|
|
115
|
+
except Exception:
|
|
116
|
+
return True, "" # fail open
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _check_inner(session_id, path):
|
|
120
|
+
current = digest_of(path)
|
|
121
|
+
if current is None:
|
|
122
|
+
return True, "" # new file or unreadable: nothing to be stale against
|
|
123
|
+
|
|
124
|
+
entries = [e for e in _read_log() if e.get("path") == path]
|
|
125
|
+
|
|
126
|
+
# Pass 1: my most recent observation of this path.
|
|
127
|
+
mine = None
|
|
128
|
+
for e in entries:
|
|
129
|
+
if e.get("session") == session_id:
|
|
130
|
+
if mine is None or e.get("ts", 0) > mine.get("ts", 0):
|
|
131
|
+
mine = e
|
|
132
|
+
|
|
133
|
+
if mine is None:
|
|
134
|
+
# I have never seen this file; someone else may have. First write
|
|
135
|
+
# is allowed (MVP does not track pre-read staleness for writers
|
|
136
|
+
# who never read through the hook), but we record it.
|
|
137
|
+
return True, ""
|
|
138
|
+
|
|
139
|
+
if mine.get("digest") == current:
|
|
140
|
+
return True, "" # unchanged since I last saw it
|
|
141
|
+
|
|
142
|
+
# Pass 2: another session's *write* newer than my last observation.
|
|
143
|
+
# Reads can never modify the file, so they are not blame candidates.
|
|
144
|
+
other_newer = None
|
|
145
|
+
for e in entries:
|
|
146
|
+
if e.get("session") == session_id:
|
|
147
|
+
continue
|
|
148
|
+
if e.get("tool") not in WRITE_TOOLS:
|
|
149
|
+
continue
|
|
150
|
+
if e.get("ts", 0) > mine.get("ts", 0):
|
|
151
|
+
if other_newer is None or e.get("ts", 0) > other_newer.get("ts", 0):
|
|
152
|
+
other_newer = e
|
|
153
|
+
|
|
154
|
+
if other_newer is not None:
|
|
155
|
+
other = other_newer.get("session", "another session")
|
|
156
|
+
return (False,
|
|
157
|
+
"Stale edit blocked: '%s' changed since you last saw it "
|
|
158
|
+
"(last modified by session '%s'). Re-read the file before "
|
|
159
|
+
"editing." % (path, other))
|
|
160
|
+
# Changed, but only by me (e.g. external tool or my own earlier write
|
|
161
|
+
# that raced the log): allow, this session's assumptions are its own.
|
|
162
|
+
return True, ""
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: edit-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Claude Code PreToolUse hook that blocks stale concurrent edits across agent sessions
|
|
5
|
+
Author: hao li
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hahahahahahahahah6/edit-guard
|
|
8
|
+
Keywords: claude-code,hooks,concurrency,developer-tools,agents
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# edit-guard
|
|
15
|
+
|
|
16
|
+
A tiny [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hook that
|
|
17
|
+
stops one agent session from editing a file another session changed
|
|
18
|
+
underneath it.
|
|
19
|
+
|
|
20
|
+
The failure mode, quoted from a real-world report: *"With several agent
|
|
21
|
+
sessions on one repository, session A reads `createSession`, session B
|
|
22
|
+
changes it, and A then edits on the old assumption."* edit-guard watches every
|
|
23
|
+
`Read`/`Edit`/`Write` through the hook, remembers what each session last saw,
|
|
24
|
+
and blocks the write when the file moved on without you — telling the agent to
|
|
25
|
+
re-read first.
|
|
26
|
+
|
|
27
|
+
Zero dependencies. Python standard library only. Everything stays local.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git clone https://github.com/hahahahahahahahah6/edit-guard
|
|
33
|
+
cd edit-guard
|
|
34
|
+
pip install .
|
|
35
|
+
edit-guard install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`install` merges three hook entries into `~/.claude/settings.json` (backing it up
|
|
39
|
+
first, never clobbering your existing settings):
|
|
40
|
+
|
|
41
|
+
- `PreToolUse` on `Edit|Write|MultiEdit|NotebookEdit` → `edit-guard hook` (may block)
|
|
42
|
+
- `PostToolUse` on `Edit|Write|MultiEdit|NotebookEdit` → `edit-guard hook --post-write`
|
|
43
|
+
(records the digest *after* your write, never blocks — this is what keeps
|
|
44
|
+
your own consecutive edits from looking stale)
|
|
45
|
+
- `PreToolUse` on `Read` → `edit-guard hook --observe` (records what you saw, never blocks)
|
|
46
|
+
|
|
47
|
+
Restart Claude Code afterwards. That's it — no MCP server, no daemon, no
|
|
48
|
+
accounts.
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
Each hook invocation appends one line to `~/.config/edit-guard/writes.jsonl`:
|
|
53
|
+
`{session_id, tool, path, digest_seen, timestamp}`. When a session tries to
|
|
54
|
+
write a file:
|
|
55
|
+
|
|
56
|
+
1. Hash the file's current content (sha256; mtime+size for files over 50 MB).
|
|
57
|
+
2. Compare against the digest **that session** last saw for that path.
|
|
58
|
+
3. If it changed **and** another session touched the file in between → block
|
|
59
|
+
(exit 2) with: *"Stale edit blocked: '…' changed since you last saw it
|
|
60
|
+
(last modified by session '…'). Re-read the file before editing."*
|
|
61
|
+
|
|
62
|
+
Same-session rewrites, new files, and files only you touched are always
|
|
63
|
+
allowed. The hook **fails open**: any internal error (corrupt log, unreadable
|
|
64
|
+
file, malformed input) means "allow".
|
|
65
|
+
|
|
66
|
+
Inspect what's being tracked:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
edit-guard log # recent guarded file events
|
|
70
|
+
edit-guard status # state dir / log / settings paths
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Honest limitations
|
|
74
|
+
|
|
75
|
+
- **Write-after-write staleness only.** The guard compares against what your
|
|
76
|
+
session last *saw through the hook*. Edits made outside any hooked session
|
|
77
|
+
(your editor, a script) are treated as your own session's drift and allowed.
|
|
78
|
+
- **Not a lock.** This is advisory staleness detection, not mutual exclusion.
|
|
79
|
+
Two sessions can still race within the same second; the loser is blocked,
|
|
80
|
+
the winner wins.
|
|
81
|
+
- **Digest, not semantics.** A reformat that changes bytes but not meaning
|
|
82
|
+
still counts as "changed" and can block you. Re-read and retry.
|
|
83
|
+
- **Hook protocol is undocumented.** The PreToolUse stdin shape (`session_id`,
|
|
84
|
+
`tool_name`, `tool_input`) and the exit-2-blocks convention come from
|
|
85
|
+
community documentation, not a stable API. If Claude Code changes the
|
|
86
|
+
protocol, the hook degrades to fail-open allow.
|
|
87
|
+
- **Per-machine only.** Sessions on different machines don't share the log.
|
|
88
|
+
- The log is append-only with light pruning (keeps the last 10k of 20k lines);
|
|
89
|
+
it is not a backup or audit trail.
|
|
90
|
+
|
|
91
|
+
## Development
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
python3 tests/test_guard.py
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
edit_guard/__init__.py,sha256=aCzHR5t9xZVMk6fqjxqz5517kL0JpBrPiuKXDIsyiaw,93
|
|
2
|
+
edit_guard/cli.py,sha256=T61HZjFpGLUPMf-uJz0hmzzIDnHdOEdsCRB3YiizZeE,6212
|
|
3
|
+
edit_guard/guard.py,sha256=cIMx7lYrjH5Gule9uMVteFzMiJm1KGspL6a8mkI_yKo,5122
|
|
4
|
+
edit_guard-0.1.0.dist-info/licenses/LICENSE,sha256=OYhQDg7nxWoFtnF6J8NkFUA8NMw5TKv9a1WTXHY-fu4,1063
|
|
5
|
+
edit_guard-0.1.0.dist-info/METADATA,sha256=JAFD-08nPAYWOwhsMSehcX0_LMIJnBcT2rMVqiYS1yg,3839
|
|
6
|
+
edit_guard-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
edit_guard-0.1.0.dist-info/entry_points.txt,sha256=TEa2bmkYloKaMWKHevTSTe5Y_xZxkicOdpcRqzqB3Y4,51
|
|
8
|
+
edit_guard-0.1.0.dist-info/top_level.txt,sha256=qQnoq-OnEucdBcojbifTtXBTP1NuSrc25d5e0Y9WBsQ,11
|
|
9
|
+
edit_guard-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hao li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
edit_guard
|