tripwire-git 0.1.0__tar.gz → 0.2.1__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.
Files changed (32) hide show
  1. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/PKG-INFO +37 -1
  2. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/README.md +36 -0
  3. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/pyproject.toml +1 -1
  4. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/__init__.py +1 -1
  5. tripwire_git-0.2.1/src/tripwire/aiguard.py +135 -0
  6. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/cli.py +104 -1
  7. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire_git.egg-info/PKG-INFO +37 -1
  8. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire_git.egg-info/SOURCES.txt +2 -0
  9. tripwire_git-0.2.1/tests/test_aiguard.py +148 -0
  10. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/LICENSE +0 -0
  11. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/setup.cfg +0 -0
  12. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/__main__.py +0 -0
  13. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/approvals.py +0 -0
  14. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/blocks.py +0 -0
  15. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/config.py +0 -0
  16. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/engine.py +0 -0
  17. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/gitea.py +0 -0
  18. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/github.py +0 -0
  19. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/gitio.py +0 -0
  20. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/gitlab.py +0 -0
  21. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/httpjson.py +0 -0
  22. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/models.py +0 -0
  23. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire/review.py +0 -0
  24. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire_git.egg-info/dependency_links.txt +0 -0
  25. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire_git.egg-info/entry_points.txt +0 -0
  26. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire_git.egg-info/requires.txt +0 -0
  27. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/src/tripwire_git.egg-info/top_level.txt +0 -0
  28. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/tests/test_engine.py +0 -0
  29. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/tests/test_git.py +0 -0
  30. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/tests/test_gitea.py +0 -0
  31. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/tests/test_github.py +0 -0
  32. {tripwire_git-0.1.0 → tripwire_git-0.2.1}/tests/test_gitlab.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tripwire-git
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Protect blocks of code in Git: changes need approval from an owner, an approver or a leader.
5
5
  License: MIT
6
6
  Keywords: git,code-owners,approval,pre-receive,ci
@@ -96,6 +96,42 @@ server-side or CI check below remains the real gate. Nothing can stop someone
96
96
  from editing files in their own clone; tripwire stops unapproved changes from
97
97
  landing on protected branches.
98
98
 
99
+ ## AI coding agents (Claude Code)
100
+
101
+ Stop an AI agent from changing protected code *before* it touches the disk:
102
+
103
+ ```bash
104
+ tripwire install-claude-hook # adds a PreToolUse hook to .claude/settings.json
105
+ ```
106
+
107
+ The agent acts as its own identity (`TRIPWIRE_AI_ACTOR`, default `ai-agent`),
108
+ which is not an owner unless you list it. When it tries to `Edit`, `MultiEdit`
109
+ or `Write` a protected block, the call is refused with a message naming who may
110
+ approve it, and the request is recorded. A human then approves that exact
111
+ change **from an interactive terminal**; `grant` shows the request and makes
112
+ the person type the approver's name to confirm (a pseudo-terminal alone is not
113
+ trusted):
114
+
115
+ ```bash
116
+ tripwire pending # what was blocked
117
+ tripwire grant <id> --approver anna # the agent can now retry the same edit
118
+ ```
119
+
120
+ The approval is bound to the exact before/after content, so a different edit
121
+ is blocked again. To let the agent edit a block freely, list its identity as an
122
+ owner of that lock. Rules are read from the committed version (`HEAD`), and the
123
+ agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
124
+ those files through `Bash`.
125
+
126
+ Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
127
+ inspected, so keep the pre-commit hook and the server / CI check as backstops.
128
+ `grant` trusts whoever runs it in a terminal; a person with shell access can
129
+ also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
130
+ payloads and end to end with a live Claude Code 2.1 session: the edit was
131
+ refused with the message shown to the agent, a human `grant` let the identical
132
+ retry through, and the agent's own attempt to run `tripwire grant` did not
133
+ approve anything.
134
+
99
135
  ## Platforms
100
136
 
101
137
  | Platform | Enforce with | Approvals from | Verified |
@@ -77,6 +77,42 @@ server-side or CI check below remains the real gate. Nothing can stop someone
77
77
  from editing files in their own clone; tripwire stops unapproved changes from
78
78
  landing on protected branches.
79
79
 
80
+ ## AI coding agents (Claude Code)
81
+
82
+ Stop an AI agent from changing protected code *before* it touches the disk:
83
+
84
+ ```bash
85
+ tripwire install-claude-hook # adds a PreToolUse hook to .claude/settings.json
86
+ ```
87
+
88
+ The agent acts as its own identity (`TRIPWIRE_AI_ACTOR`, default `ai-agent`),
89
+ which is not an owner unless you list it. When it tries to `Edit`, `MultiEdit`
90
+ or `Write` a protected block, the call is refused with a message naming who may
91
+ approve it, and the request is recorded. A human then approves that exact
92
+ change **from an interactive terminal**; `grant` shows the request and makes
93
+ the person type the approver's name to confirm (a pseudo-terminal alone is not
94
+ trusted):
95
+
96
+ ```bash
97
+ tripwire pending # what was blocked
98
+ tripwire grant <id> --approver anna # the agent can now retry the same edit
99
+ ```
100
+
101
+ The approval is bound to the exact before/after content, so a different edit
102
+ is blocked again. To let the agent edit a block freely, list its identity as an
103
+ owner of that lock. Rules are read from the committed version (`HEAD`), and the
104
+ agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
105
+ those files through `Bash`.
106
+
107
+ Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
108
+ inspected, so keep the pre-commit hook and the server / CI check as backstops.
109
+ `grant` trusts whoever runs it in a terminal; a person with shell access can
110
+ also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
111
+ payloads and end to end with a live Claude Code 2.1 session: the edit was
112
+ refused with the message shown to the agent, a human `grant` let the identical
113
+ retry through, and the agent's own attempt to run `tripwire grant` did not
114
+ approve anything.
115
+
80
116
  ## Platforms
81
117
 
82
118
  | Platform | Enforce with | Approvals from | Verified |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tripwire-git"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Protect blocks of code in Git: changes need approval from an owner, an approver or a leader."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """tripwire: approval-gated protection for blocks of code in Git."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.1"
@@ -0,0 +1,135 @@
1
+ """Guard for AI coding agents (Claude Code PreToolUse hook).
2
+
3
+ The agent acts as its own identity (TRIPWIRE_AI_ACTOR, default "ai-agent"). An
4
+ edit to a protected block is refused before it reaches the disk; the refusal is
5
+ recorded as a pending request. A human approves it from a real terminal with
6
+ `tripwire grant`, after which the agent can retry the exact same edit.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+ from typing import List, Optional
15
+
16
+ from .approvals import parse_local
17
+ from .config import APPROVALS_PATH, RULES_PATH, parse_config
18
+ from .engine import evaluate
19
+ from .gitio import RevSource, WorktreeSource, git
20
+ from .models import Decision, FileChange, Violation
21
+
22
+ DEFAULT_AI_ACTOR = "ai-agent"
23
+ # Best-effort: stop the agent from approving itself or rewriting the rules via the shell.
24
+ GUARDED_BASH = (
25
+ "tripwire grant",
26
+ ".tripwire/approvals.json",
27
+ ".tripwire\\approvals.json",
28
+ ".tripwire/locks.json",
29
+ ".tripwire\\locks.json",
30
+ )
31
+
32
+
33
+ def ai_actor() -> str:
34
+ return os.environ.get("TRIPWIRE_AI_ACTOR") or DEFAULT_AI_ACTOR
35
+
36
+
37
+ def _apply_edit(content: str, old: str, new: str, replace_all: bool) -> Optional[str]:
38
+ if old not in content:
39
+ return None # the tool itself will fail; nothing to guard
40
+ if replace_all:
41
+ return content.replace(old, new)
42
+ if content.count(old) != 1:
43
+ return None
44
+ return content.replace(old, new, 1)
45
+
46
+
47
+ def proposed_changes(root: str, tool: str, tool_input: dict, cwd: str) -> List[FileChange]:
48
+ path = tool_input.get("file_path")
49
+ if not path:
50
+ return []
51
+ full = path if os.path.isabs(path) else os.path.join(cwd, path)
52
+ try:
53
+ rel = os.path.relpath(full, root).replace(os.sep, "/")
54
+ except ValueError: # different drive
55
+ return []
56
+ if rel.startswith(".."):
57
+ return []
58
+ old = WorktreeSource(root).read(rel)
59
+ if tool == "Write":
60
+ return [FileChange(rel, old, tool_input.get("content", ""))]
61
+ if old is None:
62
+ return []
63
+ if tool == "Edit":
64
+ edits = [tool_input]
65
+ elif tool == "MultiEdit":
66
+ edits = tool_input.get("edits") or []
67
+ else:
68
+ return []
69
+ new = old.replace("\r\n", "\n") # match regardless of the file's line endings
70
+ for e in edits:
71
+ new = _apply_edit(
72
+ new,
73
+ e.get("old_string", "").replace("\r\n", "\n"),
74
+ e.get("new_string", "").replace("\r\n", "\n"),
75
+ bool(e.get("replace_all")),
76
+ )
77
+ if new is None:
78
+ return []
79
+ return [FileChange(rel, old, new)]
80
+
81
+
82
+ def pending_path(root: str) -> str:
83
+ git_dir = git(root, "rev-parse", "--absolute-git-dir").decode().strip()
84
+ return os.path.join(git_dir, "tripwire", "pending.json")
85
+
86
+
87
+ def load_pending(root: str) -> List[dict]:
88
+ path = pending_path(root)
89
+ if not os.path.isfile(path):
90
+ return []
91
+ with open(path, encoding="utf-8") as fh:
92
+ return json.load(fh)
93
+
94
+
95
+ def save_pending(root: str, items: List[dict]) -> None:
96
+ path = pending_path(root)
97
+ os.makedirs(os.path.dirname(path), exist_ok=True)
98
+ with open(path, "w", encoding="utf-8") as fh:
99
+ json.dump(items, fh, indent=2)
100
+
101
+
102
+ def _record_pending(root: str, decision: Decision) -> None:
103
+ items = load_pending(root)
104
+ known = {i["digest"] for i in items}
105
+ for v in decision.violations:
106
+ if v.digest and v.digest not in known:
107
+ items.append({"digest": v.digest, "lock": v.lock_id, "path": v.path, "message": v.message, "time": int(time.time())})
108
+ save_pending(root, items)
109
+
110
+
111
+ def check_tool_call(root: str, payload: dict) -> Decision:
112
+ tool = payload.get("tool_name", "")
113
+ tool_input = payload.get("tool_input") or {}
114
+
115
+ if tool == "Bash":
116
+ command = tool_input.get("command", "")
117
+ if any(token in command for token in GUARDED_BASH):
118
+ return Decision([Violation("<bash>", "", "shell commands may not touch tripwire approvals or rules")])
119
+ return Decision()
120
+
121
+ changes = proposed_changes(root, tool, tool_input, payload.get("cwd") or root)
122
+ if not changes:
123
+ return Decision()
124
+ if changes[0].path == APPROVALS_PATH:
125
+ return Decision([Violation("<approvals>", APPROVALS_PATH, "the agent may not edit the approvals file; a human uses `tripwire grant`")])
126
+
127
+ rules = RevSource(root, "HEAD").read(RULES_PATH) # rules always come from the committed version
128
+ if rules is None:
129
+ return Decision()
130
+ config = parse_config(rules)
131
+ approvals = parse_local(WorktreeSource(root).read(APPROVALS_PATH))
132
+ decision = evaluate(config, changes, ai_actor(), approvals)
133
+ if not decision.allowed:
134
+ _record_pending(root, decision)
135
+ return decision
@@ -157,6 +157,92 @@ def cmd_gitlab_check(args, root: str) -> int:
157
157
  return report(check_merge_request(root, GitLabClient.from_env(args.gitlab_project), int(iid)))
158
158
 
159
159
 
160
+ def cmd_claude_hook(args, root: str) -> int:
161
+ from .aiguard import check_tool_call
162
+
163
+ decision = check_tool_call(root, json.load(sys.stdin))
164
+ if decision.allowed:
165
+ return 0
166
+ print("tripwire: blocked for the AI agent", file=sys.stderr)
167
+ for v in decision.violations:
168
+ print(" - " + v.message, file=sys.stderr)
169
+ if any(v.digest for v in decision.violations):
170
+ print("A human can approve this exact change with `tripwire pending` and `tripwire grant <id> --approver <name>`; then retry the same edit.", file=sys.stderr)
171
+ return 2 # exit code 2 makes Claude Code refuse the tool call and show stderr to the agent
172
+
173
+
174
+ def cmd_pending(args, root: str) -> int:
175
+ from .aiguard import load_pending
176
+
177
+ items = load_pending(root)
178
+ for i in items:
179
+ print("%s %s %s" % (i["digest"][:12], i["lock"], i["message"]))
180
+ if not items:
181
+ print("tripwire: no pending requests")
182
+ return 0
183
+
184
+
185
+ def cmd_grant(args, root: str) -> int:
186
+ from .aiguard import load_pending, save_pending
187
+
188
+ if not sys.stdin.isatty():
189
+ print("tripwire: grant must be run by a human in an interactive terminal", file=sys.stderr)
190
+ return 1
191
+ items = load_pending(root)
192
+ matches = [i for i in items if i["digest"].startswith(args.digest)]
193
+ if len(matches) != 1:
194
+ print("tripwire: %d pending requests match %r" % (len(matches), args.digest), file=sys.stderr)
195
+ return 1
196
+ item = matches[0]
197
+ rules = RevSource(root, "HEAD").read(RULES_PATH)
198
+ if rules is None:
199
+ print("tripwire: no rules file at HEAD", file=sys.stderr)
200
+ return 2
201
+ config = parse_config(rules)
202
+ lock = config.get(item["lock"])
203
+ allowed = set(config.leaders) | (set(lock.owners) | set(lock.approvers) if lock else set())
204
+ if args.approver not in allowed:
205
+ print("tripwire: %s may not approve %s" % (args.approver, item["lock"]), file=sys.stderr)
206
+ return 1
207
+ # A pseudo-terminal alone proves nothing (agents can have one), so a person must type the name.
208
+ print("Approving: %s" % item["message"])
209
+ sys.stdout.write("Type the approver name '%s' to confirm: " % args.approver)
210
+ sys.stdout.flush()
211
+ if sys.stdin.readline().strip() != args.approver:
212
+ print("tripwire: confirmation did not match; nothing approved", file=sys.stderr)
213
+ return 1
214
+ path = os.path.join(root, APPROVALS_PATH)
215
+ existing: List[Approval] = []
216
+ if os.path.isfile(path):
217
+ with open(path, encoding="utf-8") as fh:
218
+ existing = parse_local(fh.read())
219
+ existing.append(Approval(item["lock"], args.approver, item["digest"]))
220
+ os.makedirs(os.path.dirname(path), exist_ok=True)
221
+ with open(path, "w", encoding="utf-8") as fh:
222
+ fh.write(dump_local(existing))
223
+ save_pending(root, [i for i in items if i is not item])
224
+ print("tripwire: approved %s as %s" % (item["digest"][:12], args.approver))
225
+ return 0
226
+
227
+
228
+ def cmd_install_claude_hook(args, root: str) -> int:
229
+ path = os.path.join(root, ".claude", "settings.json")
230
+ settings = {}
231
+ if os.path.isfile(path):
232
+ with open(path, encoding="utf-8") as fh:
233
+ settings = json.load(fh)
234
+ entry = {"type": "command", "command": args.command}
235
+ groups = settings.setdefault("hooks", {}).setdefault("PreToolUse", [])
236
+ if not any(entry in g.get("hooks", []) for g in groups):
237
+ groups.append({"matcher": "Edit|Write|MultiEdit|Bash", "hooks": [entry]})
238
+ os.makedirs(os.path.dirname(path), exist_ok=True)
239
+ with open(path, "w", encoding="utf-8") as fh:
240
+ json.dump(settings, fh, indent=2)
241
+ fh.write("\n")
242
+ print("tripwire: Claude Code hook installed in %s" % path)
243
+ return 0
244
+
245
+
160
246
  def cmd_approve(args, root: str) -> int:
161
247
  config, decision = run_check(root, args.base, args.head, actor="")
162
248
  if config is None:
@@ -225,6 +311,17 @@ def build_parser() -> argparse.ArgumentParser:
225
311
  gl.add_argument("--gitlab-project", help="project id or path (default: CI_PROJECT_ID)")
226
312
  gl.set_defaults(func=cmd_gitlab_check)
227
313
 
314
+ sub.add_parser("claude-hook", help="Claude Code PreToolUse hook: reads the tool call from stdin").set_defaults(func=cmd_claude_hook)
315
+ ih = sub.add_parser("install-claude-hook", help="add the hook to .claude/settings.json")
316
+ ih.add_argument("--command", default="tripwire claude-hook", help="hook command (default: tripwire claude-hook)")
317
+ ih.set_defaults(func=cmd_install_claude_hook)
318
+ sub.add_parser("pending", help="list edits the AI agent was blocked from making").set_defaults(func=cmd_pending)
319
+
320
+ gr = sub.add_parser("grant", help="a human approves a pending AI edit (interactive terminal only)")
321
+ gr.add_argument("digest", help="id (prefix) from `tripwire pending`")
322
+ gr.add_argument("--approver", required=True)
323
+ gr.set_defaults(func=cmd_grant)
324
+
228
325
  a = sub.add_parser("approve", help="record an approval for the pending change")
229
326
  a.add_argument("--base", required=True)
230
327
  a.add_argument("--head")
@@ -237,7 +334,13 @@ def build_parser() -> argparse.ArgumentParser:
237
334
  def main(argv: Optional[List[str]] = None) -> int:
238
335
  args = build_parser().parse_args(argv)
239
336
  try:
240
- root = toplevel(args.repo) if args.command != "pre-receive" else os.path.abspath(args.repo)
337
+ if args.command == "claude-hook":
338
+ try:
339
+ root = toplevel(args.repo)
340
+ except GitError:
341
+ return 0 # not a git repository: nothing to guard
342
+ else:
343
+ root = toplevel(args.repo) if args.command != "pre-receive" else os.path.abspath(args.repo)
241
344
  return args.func(args, root)
242
345
  except (ConfigError, GitError) as exc:
243
346
  print("tripwire: error: %s" % exc, file=sys.stderr)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tripwire-git
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Protect blocks of code in Git: changes need approval from an owner, an approver or a leader.
5
5
  License: MIT
6
6
  Keywords: git,code-owners,approval,pre-receive,ci
@@ -96,6 +96,42 @@ server-side or CI check below remains the real gate. Nothing can stop someone
96
96
  from editing files in their own clone; tripwire stops unapproved changes from
97
97
  landing on protected branches.
98
98
 
99
+ ## AI coding agents (Claude Code)
100
+
101
+ Stop an AI agent from changing protected code *before* it touches the disk:
102
+
103
+ ```bash
104
+ tripwire install-claude-hook # adds a PreToolUse hook to .claude/settings.json
105
+ ```
106
+
107
+ The agent acts as its own identity (`TRIPWIRE_AI_ACTOR`, default `ai-agent`),
108
+ which is not an owner unless you list it. When it tries to `Edit`, `MultiEdit`
109
+ or `Write` a protected block, the call is refused with a message naming who may
110
+ approve it, and the request is recorded. A human then approves that exact
111
+ change **from an interactive terminal**; `grant` shows the request and makes
112
+ the person type the approver's name to confirm (a pseudo-terminal alone is not
113
+ trusted):
114
+
115
+ ```bash
116
+ tripwire pending # what was blocked
117
+ tripwire grant <id> --approver anna # the agent can now retry the same edit
118
+ ```
119
+
120
+ The approval is bound to the exact before/after content, so a different edit
121
+ is blocked again. To let the agent edit a block freely, list its identity as an
122
+ owner of that lock. Rules are read from the committed version (`HEAD`), and the
123
+ agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
124
+ those files through `Bash`.
125
+
126
+ Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
127
+ inspected, so keep the pre-commit hook and the server / CI check as backstops.
128
+ `grant` trusts whoever runs it in a terminal; a person with shell access can
129
+ also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
130
+ payloads and end to end with a live Claude Code 2.1 session: the edit was
131
+ refused with the message shown to the agent, a human `grant` let the identical
132
+ retry through, and the agent's own attempt to run `tripwire grant` did not
133
+ approve anything.
134
+
99
135
  ## Platforms
100
136
 
101
137
  | Platform | Enforce with | Approvals from | Verified |
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/tripwire/__init__.py
5
5
  src/tripwire/__main__.py
6
+ src/tripwire/aiguard.py
6
7
  src/tripwire/approvals.py
7
8
  src/tripwire/blocks.py
8
9
  src/tripwire/cli.py
@@ -21,6 +22,7 @@ src/tripwire_git.egg-info/dependency_links.txt
21
22
  src/tripwire_git.egg-info/entry_points.txt
22
23
  src/tripwire_git.egg-info/requires.txt
23
24
  src/tripwire_git.egg-info/top_level.txt
25
+ tests/test_aiguard.py
24
26
  tests/test_engine.py
25
27
  tests/test_git.py
26
28
  tests/test_gitea.py
@@ -0,0 +1,148 @@
1
+ import io
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import sys
6
+
7
+ import pytest
8
+
9
+ from tripwire.cli import main
10
+
11
+ RULES = {
12
+ "leaders": ["lead"],
13
+ "locks": [{"id": "billing", "owners": ["olga"], "approvers": ["anna"]}],
14
+ }
15
+ CODE = "x = 1\n# @lock-begin billing\nrate = 5\n# @lock-end billing\ny = 2\n"
16
+
17
+
18
+ def git(cwd, *args):
19
+ env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
20
+ return subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True, text=True, check=True).stdout.strip()
21
+
22
+
23
+ @pytest.fixture
24
+ def repo(tmp_path, monkeypatch):
25
+ git(tmp_path, "init", "-q", "-b", "main")
26
+ (tmp_path / ".tripwire").mkdir()
27
+ (tmp_path / ".tripwire" / "locks.json").write_text(json.dumps(RULES))
28
+ (tmp_path / "app.py").write_text(CODE)
29
+ git(tmp_path, "add", "-A")
30
+ git(tmp_path, "commit", "-q", "-m", "init")
31
+ monkeypatch.delenv("TRIPWIRE_AI_ACTOR", raising=False)
32
+ return tmp_path
33
+
34
+
35
+ class FakeTTY(io.StringIO):
36
+ def isatty(self):
37
+ return True
38
+
39
+
40
+ def tty(monkeypatch, text):
41
+ monkeypatch.setattr(sys, "stdin", FakeTTY(text))
42
+
43
+
44
+ def hook(repo, monkeypatch, tool, tool_input):
45
+ payload = {"tool_name": tool, "tool_input": tool_input, "cwd": str(repo)}
46
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
47
+ return main(["--repo", str(repo), "claude-hook"])
48
+
49
+
50
+ def edit(repo, old, new, **kw):
51
+ return {"file_path": str(repo / "app.py"), "old_string": old, "new_string": new, **kw}
52
+
53
+
54
+ def test_edit_of_protected_block_is_blocked_before_it_happens(repo, monkeypatch, capsys):
55
+ assert hook(repo, monkeypatch, "Edit", edit(repo, "rate = 5", "rate = 9")) == 2
56
+ err = capsys.readouterr().err
57
+ assert "billing" in err and "tripwire grant" in err
58
+ assert (repo / "app.py").read_text() == CODE # hook never touches the file
59
+
60
+
61
+ def test_edit_of_unprotected_code_is_allowed(repo, monkeypatch):
62
+ assert hook(repo, monkeypatch, "Edit", edit(repo, "y = 2", "y = 3")) == 0
63
+
64
+
65
+ def test_write_that_changes_protected_block_is_blocked(repo, monkeypatch):
66
+ new = CODE.replace("rate = 5", "rate = 9")
67
+ assert hook(repo, monkeypatch, "Write", {"file_path": str(repo / "app.py"), "content": new}) == 2
68
+
69
+
70
+ def test_write_that_leaves_block_unchanged_is_allowed(repo, monkeypatch):
71
+ new = CODE.replace("y = 2", "y = 7")
72
+ assert hook(repo, monkeypatch, "Write", {"file_path": str(repo / "app.py"), "content": new}) == 0
73
+
74
+
75
+ def test_multiedit_is_evaluated_as_a_whole(repo, monkeypatch):
76
+ edits = [{"old_string": "y = 2", "new_string": "y = 3"}, {"old_string": "rate = 5", "new_string": "rate = 6"}]
77
+ assert hook(repo, monkeypatch, "MultiEdit", {"file_path": str(repo / "app.py"), "edits": edits}) == 2
78
+
79
+
80
+ def test_deleting_markers_is_blocked(repo, monkeypatch):
81
+ assert hook(repo, monkeypatch, "Edit", edit(repo, "# @lock-begin billing\n", "")) == 2
82
+
83
+
84
+ def test_failing_edit_is_left_to_the_tool(repo, monkeypatch):
85
+ assert hook(repo, monkeypatch, "Edit", edit(repo, "no such text", "z")) == 0
86
+
87
+
88
+ def test_ai_actor_can_be_made_owner(repo, monkeypatch):
89
+ monkeypatch.setenv("TRIPWIRE_AI_ACTOR", "olga")
90
+ assert hook(repo, monkeypatch, "Edit", edit(repo, "rate = 5", "rate = 9")) == 0
91
+
92
+
93
+ def test_path_outside_repo_and_other_tools_are_ignored(repo, monkeypatch, tmp_path_factory):
94
+ other = tmp_path_factory.mktemp("elsewhere") / "f.py"
95
+ assert hook(repo, monkeypatch, "Write", {"file_path": str(other), "content": "x"}) == 0
96
+ assert hook(repo, monkeypatch, "Read", {"file_path": str(repo / "app.py")}) == 0
97
+
98
+
99
+ def test_bash_cannot_touch_approvals_or_grant(repo, monkeypatch):
100
+ assert hook(repo, monkeypatch, "Bash", {"command": "tripwire grant abc --approver anna"}) == 2
101
+ assert hook(repo, monkeypatch, "Bash", {"command": "echo [] > .tripwire/approvals.json"}) == 2
102
+ assert hook(repo, monkeypatch, "Bash", {"command": "ls"}) == 0
103
+
104
+
105
+ def test_agent_cannot_edit_approvals_or_rules_files(repo, monkeypatch):
106
+ f = {"file_path": str(repo / ".tripwire" / "approvals.json"), "content": "[]"}
107
+ assert hook(repo, monkeypatch, "Write", f) == 2
108
+ r = {"file_path": str(repo / ".tripwire" / "locks.json"), "content": json.dumps(dict(RULES, leaders=["ai-agent"]))}
109
+ assert hook(repo, monkeypatch, "Write", r) == 2
110
+
111
+
112
+ def test_grant_then_same_edit_is_allowed_but_other_edit_is_not(repo, monkeypatch, capsys):
113
+ e = edit(repo, "rate = 5", "rate = 9")
114
+ assert hook(repo, monkeypatch, "Edit", e) == 2
115
+ assert main(["--repo", str(repo), "pending"]) == 0
116
+ digest = json.loads((repo / ".git" / "tripwire" / "pending.json").read_text())[0]["digest"]
117
+ tty(monkeypatch, "bob\n")
118
+ assert main(["--repo", str(repo), "grant", digest[:10], "--approver", "bob"]) == 1 # not an approver
119
+ tty(monkeypatch, "olga\n")
120
+ assert main(["--repo", str(repo), "grant", digest[:10], "--approver", "anna"]) == 1 # wrong confirmation
121
+ assert not (repo / ".tripwire" / "approvals.json").exists()
122
+ tty(monkeypatch, "anna\n")
123
+ assert main(["--repo", str(repo), "grant", digest[:10], "--approver", "anna"]) == 0
124
+ assert hook(repo, monkeypatch, "Edit", e) == 0
125
+ assert hook(repo, monkeypatch, "Edit", edit(repo, "rate = 5", "rate = 10")) == 2 # approval was for rate = 9
126
+
127
+
128
+ def test_grant_refuses_non_interactive_use(repo, monkeypatch):
129
+ hook(repo, monkeypatch, "Edit", edit(repo, "rate = 5", "rate = 9"))
130
+ digest = json.loads((repo / ".git" / "tripwire" / "pending.json").read_text())[0]["digest"]
131
+ monkeypatch.setattr(sys, "stdin", io.StringIO("anna\n")) # piped input is not a terminal
132
+ assert main(["--repo", str(repo), "grant", digest[:10], "--approver", "anna"]) == 1
133
+ assert not (repo / ".tripwire" / "approvals.json").exists()
134
+
135
+
136
+ def test_install_claude_hook_is_idempotent_and_keeps_other_settings(repo):
137
+ (repo / ".claude").mkdir()
138
+ (repo / ".claude" / "settings.json").write_text(json.dumps({"model": "x"}))
139
+ assert main(["--repo", str(repo), "install-claude-hook"]) == 0
140
+ assert main(["--repo", str(repo), "install-claude-hook"]) == 0
141
+ settings = json.loads((repo / ".claude" / "settings.json").read_text())
142
+ assert settings["model"] == "x"
143
+ assert len(settings["hooks"]["PreToolUse"]) == 1
144
+
145
+
146
+ def test_not_a_git_repo_is_allowed(tmp_path, monkeypatch):
147
+ monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({"tool_name": "Write", "tool_input": {}})))
148
+ assert main(["--repo", str(tmp_path), "claude-hook"]) == 0
File without changes
File without changes