edit-guard 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,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,86 @@
1
+ # edit-guard
2
+
3
+ A tiny [Claude Code](https://docs.anthropic.com/en/docs/claude-code) hook that
4
+ stops one agent session from editing a file another session changed
5
+ underneath it.
6
+
7
+ The failure mode, quoted from a real-world report: *"With several agent
8
+ sessions on one repository, session A reads `createSession`, session B
9
+ changes it, and A then edits on the old assumption."* edit-guard watches every
10
+ `Read`/`Edit`/`Write` through the hook, remembers what each session last saw,
11
+ and blocks the write when the file moved on without you — telling the agent to
12
+ re-read first.
13
+
14
+ Zero dependencies. Python standard library only. Everything stays local.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ git clone https://github.com/hahahahahahahahah6/edit-guard
20
+ cd edit-guard
21
+ pip install .
22
+ edit-guard install
23
+ ```
24
+
25
+ `install` merges three hook entries into `~/.claude/settings.json` (backing it up
26
+ first, never clobbering your existing settings):
27
+
28
+ - `PreToolUse` on `Edit|Write|MultiEdit|NotebookEdit` → `edit-guard hook` (may block)
29
+ - `PostToolUse` on `Edit|Write|MultiEdit|NotebookEdit` → `edit-guard hook --post-write`
30
+ (records the digest *after* your write, never blocks — this is what keeps
31
+ your own consecutive edits from looking stale)
32
+ - `PreToolUse` on `Read` → `edit-guard hook --observe` (records what you saw, never blocks)
33
+
34
+ Restart Claude Code afterwards. That's it — no MCP server, no daemon, no
35
+ accounts.
36
+
37
+ ## How it works
38
+
39
+ Each hook invocation appends one line to `~/.config/edit-guard/writes.jsonl`:
40
+ `{session_id, tool, path, digest_seen, timestamp}`. When a session tries to
41
+ write a file:
42
+
43
+ 1. Hash the file's current content (sha256; mtime+size for files over 50 MB).
44
+ 2. Compare against the digest **that session** last saw for that path.
45
+ 3. If it changed **and** another session touched the file in between → block
46
+ (exit 2) with: *"Stale edit blocked: '…' changed since you last saw it
47
+ (last modified by session '…'). Re-read the file before editing."*
48
+
49
+ Same-session rewrites, new files, and files only you touched are always
50
+ allowed. The hook **fails open**: any internal error (corrupt log, unreadable
51
+ file, malformed input) means "allow".
52
+
53
+ Inspect what's being tracked:
54
+
55
+ ```bash
56
+ edit-guard log # recent guarded file events
57
+ edit-guard status # state dir / log / settings paths
58
+ ```
59
+
60
+ ## Honest limitations
61
+
62
+ - **Write-after-write staleness only.** The guard compares against what your
63
+ session last *saw through the hook*. Edits made outside any hooked session
64
+ (your editor, a script) are treated as your own session's drift and allowed.
65
+ - **Not a lock.** This is advisory staleness detection, not mutual exclusion.
66
+ Two sessions can still race within the same second; the loser is blocked,
67
+ the winner wins.
68
+ - **Digest, not semantics.** A reformat that changes bytes but not meaning
69
+ still counts as "changed" and can block you. Re-read and retry.
70
+ - **Hook protocol is undocumented.** The PreToolUse stdin shape (`session_id`,
71
+ `tool_name`, `tool_input`) and the exit-2-blocks convention come from
72
+ community documentation, not a stable API. If Claude Code changes the
73
+ protocol, the hook degrades to fail-open allow.
74
+ - **Per-machine only.** Sessions on different machines don't share the log.
75
+ - The log is append-only with light pruning (keeps the last 10k of 20k lines);
76
+ it is not a backup or audit trail.
77
+
78
+ ## Development
79
+
80
+ ```bash
81
+ python3 tests/test_guard.py
82
+ ```
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "edit-guard"
7
+ version = "0.1.0"
8
+ description = "A Claude Code PreToolUse hook that blocks stale concurrent edits across agent sessions"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "hao li" }]
13
+ keywords = ["claude-code", "hooks", "concurrency", "developer-tools", "agents"]
14
+
15
+ [project.scripts]
16
+ edit-guard = "edit_guard.cli:main"
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/hahahahahahahahah6/edit-guard"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """edit-guard: block stale concurrent edits across agent sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -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())
@@ -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,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/edit_guard/__init__.py
5
+ src/edit_guard/cli.py
6
+ src/edit_guard/guard.py
7
+ src/edit_guard.egg-info/PKG-INFO
8
+ src/edit_guard.egg-info/SOURCES.txt
9
+ src/edit_guard.egg-info/dependency_links.txt
10
+ src/edit_guard.egg-info/entry_points.txt
11
+ src/edit_guard.egg-info/top_level.txt
12
+ tests/test_guard.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ edit-guard = edit_guard.cli:main
@@ -0,0 +1 @@
1
+ edit_guard
@@ -0,0 +1,194 @@
1
+ """Smoke tests for edit-guard: staleness detection across fake sessions."""
2
+
3
+ import io
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+
9
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
10
+
11
+ from edit_guard import guard, cli # noqa: E402
12
+
13
+
14
+ def fresh_env():
15
+ tmp = tempfile.mkdtemp()
16
+ os.environ["EDIT_GUARD_DIR"] = os.path.join(tmp, "state")
17
+ return tmp
18
+
19
+
20
+ def write_file(path, text):
21
+ with open(path, "w") as fh:
22
+ fh.write(text)
23
+
24
+
25
+ def hook_stdin(session, tool, path):
26
+ return json.dumps({"session_id": session, "tool_name": tool,
27
+ "tool_input": {"file_path": path}})
28
+
29
+
30
+ def run_hook(stdin_text, observe=False, post_write=False):
31
+ old_stdin, old_stderr = sys.stdin, sys.stderr
32
+ sys.stdin = io.StringIO(stdin_text)
33
+ sys.stderr = io.StringIO()
34
+ try:
35
+ rc = cli.cmd_hook(
36
+ type("A", (), {"observe": observe, "post_write": post_write})())
37
+ err = sys.stderr.getvalue()
38
+ finally:
39
+ sys.stdin, sys.stderr = old_stdin, old_stderr
40
+ return rc, err
41
+
42
+
43
+ def do_edit(session, f, new_text):
44
+ """Simulate the full hook cycle: PreToolUse -> write -> PostToolUse."""
45
+ rc, err = run_hook(hook_stdin(session, "Edit", f))
46
+ assert rc == 0, "pre-edit unexpectedly blocked: %s" % err
47
+ write_file(f, new_text)
48
+ rc, _ = run_hook(hook_stdin(session, "Edit", f), post_write=True)
49
+ assert rc == 0, "post-write hook should never block"
50
+ return rc
51
+
52
+
53
+ def test_cross_session_stale_write_blocked():
54
+ tmp = fresh_env()
55
+ f = os.path.join(tmp, "shared.py")
56
+ write_file(f, "v1")
57
+ assert run_hook(hook_stdin("sess-A", "Read", f), observe=True)[0] == 0
58
+ do_edit("sess-B", f, "v2") # B's edit lands via the full hook cycle
59
+ # A now tries to edit without re-reading: B's write is newer than A's read.
60
+ rc, err = run_hook(hook_stdin("sess-A", "Edit", f))
61
+ assert rc == 2, "expected block, got %d" % rc
62
+ assert "Stale edit blocked" in err
63
+ assert "sess-B" in err # the writer is correctly blamed
64
+ print("cross-session stale blocked ok")
65
+
66
+
67
+ def test_same_session_rewrite_allowed():
68
+ tmp = fresh_env()
69
+ f = os.path.join(tmp, "mine.py")
70
+ write_file(f, "v1")
71
+ assert run_hook(hook_stdin("sess-A", "Read", f), observe=True)[0] == 0
72
+ do_edit("sess-A", f, "v2")
73
+ # Second edit: my post-write digest matches current -> allowed directly.
74
+ rc, err = run_hook(hook_stdin("sess-A", "Edit", f))
75
+ assert rc == 0, "own second edit blocked: %s" % err
76
+ print("same-session rewrite ok")
77
+
78
+
79
+ def test_read_then_write_no_external_change():
80
+ tmp = fresh_env()
81
+ f = os.path.join(tmp, "calm.py")
82
+ write_file(f, "v1")
83
+ assert run_hook(hook_stdin("sess-A", "Read", f), observe=True)[0] == 0
84
+ rc, _ = run_hook(hook_stdin("sess-A", "Edit", f))
85
+ assert rc == 0
86
+ print("read-then-write ok")
87
+
88
+
89
+ def test_new_file_write_allowed():
90
+ tmp = fresh_env()
91
+ f = os.path.join(tmp, "brand-new.py") # does not exist
92
+ rc, _ = run_hook(hook_stdin("sess-A", "Write", f))
93
+ assert rc == 0
94
+ print("new file ok")
95
+
96
+
97
+ def test_corrupt_log_fails_open():
98
+ tmp = fresh_env()
99
+ os.makedirs(os.environ["EDIT_GUARD_DIR"], exist_ok=True)
100
+ with open(guard.log_path(), "w") as fh:
101
+ fh.write("garbage{{{\nnot json\n")
102
+ f = os.path.join(tmp, "x.py")
103
+ write_file(f, "v1")
104
+ rc, _ = run_hook(hook_stdin("sess-A", "Edit", f))
105
+ assert rc == 0
106
+ print("corrupt log fail-open ok")
107
+
108
+
109
+ def test_malformed_stdin_fails_open():
110
+ fresh_env()
111
+ rc, _ = run_hook("this is not json at all")
112
+ assert rc == 0
113
+ rc, _ = run_hook("{}")
114
+ assert rc == 0
115
+ print("malformed stdin ok")
116
+
117
+
118
+ def test_install_merges_without_clobbering():
119
+ tmp = fresh_env()
120
+ fake_home = os.path.join(tmp, "home")
121
+ os.makedirs(os.path.join(fake_home, ".claude"))
122
+ sp = os.path.join(fake_home, ".claude", "settings.json")
123
+ with open(sp, "w") as fh:
124
+ json.dump({"theme": "dark", "hooks": {"PreToolUse": [
125
+ {"matcher": "Bash", "hooks": [{"type": "command", "command": "my-check"}]}
126
+ ]}}, fh)
127
+ old_home = os.environ.get("HOME")
128
+ os.environ["HOME"] = fake_home
129
+ try:
130
+ rc = cli.cmd_install(type("A", (), {})())
131
+ assert rc == 0
132
+ settings = json.load(open(sp))
133
+ assert settings["theme"] == "dark" # untouched
134
+ matchers = [h["matcher"] for h in settings["hooks"]["PreToolUse"]]
135
+ assert "Bash" in matchers
136
+ assert "Edit|Write|MultiEdit|NotebookEdit" in matchers
137
+ assert "Read" in matchers
138
+ # idempotent: second install adds nothing
139
+ rc = cli.cmd_install(type("A", (), {})())
140
+ assert rc == 0
141
+ settings = json.load(open(sp))
142
+ # PreToolUse: original Bash entry + edit-guard Edit + edit-guard Read
143
+ assert len(settings["hooks"]["PreToolUse"]) == 3
144
+ # PostToolUse: edit-guard post-write entry
145
+ assert len(settings["hooks"]["PostToolUse"]) == 1
146
+ assert "--post-write" in settings["hooks"]["PostToolUse"][0]["hooks"][0]["command"]
147
+ finally:
148
+ if old_home is not None:
149
+ os.environ["HOME"] = old_home
150
+ print("install merge ok")
151
+
152
+
153
+ def test_other_session_read_never_blocks():
154
+ # Regression: session B only READS the file. Session A edits twice in a
155
+ # row. The second edit must be allowed -- a Read cannot modify the file,
156
+ # so B must never be blamed for a stale edit.
157
+ tmp = fresh_env()
158
+ f = os.path.join(tmp, "shared2.py")
159
+ write_file(f, "v1")
160
+ assert run_hook(hook_stdin("sess-B", "Read", f), observe=True)[0] == 0
161
+ do_edit("sess-A", f, "v2") # A's first edit lands via the full cycle
162
+ assert run_hook(hook_stdin("sess-B", "Read", f), observe=True)[0] == 0
163
+ # A's post-write digest matches current -> allowed, B not blamed.
164
+ rc, err = run_hook(hook_stdin("sess-A", "Edit", f))
165
+ assert rc == 0, "second edit by same session wrongly blocked: %s" % err
166
+ assert "sess-B" not in err
167
+ print("other-session read never blocks ok")
168
+
169
+
170
+ def test_post_write_hook_never_blocks_and_records():
171
+ # The PostToolUse hook records the post-write digest and always exits 0.
172
+ tmp = fresh_env()
173
+ f = os.path.join(tmp, "post.py")
174
+ write_file(f, "v1")
175
+ rc, _ = run_hook(hook_stdin("sess-A", "Edit", f), post_write=True)
176
+ assert rc == 0
177
+ entries = [e for e in guard._read_log()
178
+ if e.get("session") == "sess-A" and e.get("path") == f]
179
+ assert len(entries) == 1
180
+ assert entries[0]["digest"] == guard.digest_of(f)
181
+ print("post-write hook ok")
182
+
183
+
184
+ if __name__ == "__main__":
185
+ test_other_session_read_never_blocks()
186
+ test_cross_session_stale_write_blocked()
187
+ test_same_session_rewrite_allowed()
188
+ test_read_then_write_no_external_change()
189
+ test_new_file_write_allowed()
190
+ test_corrupt_log_fails_open()
191
+ test_malformed_stdin_fails_open()
192
+ test_install_merges_without_clobbering()
193
+ test_post_write_hook_never_blocks_and_records()
194
+ print("ALL EDIT-GUARD TESTS PASSED")