tripwire-git 0.1.0__tar.gz → 0.2.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.
- {tripwire_git-0.1.0/src/tripwire_git.egg-info → tripwire_git-0.2.0}/PKG-INFO +33 -1
- tripwire_git-0.1.0/PKG-INFO → tripwire_git-0.2.0/README.md +168 -155
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/pyproject.toml +1 -1
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/__init__.py +1 -1
- tripwire_git-0.2.0/src/tripwire/aiguard.py +135 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/cli.py +97 -1
- tripwire_git-0.1.0/README.md → tripwire_git-0.2.0/src/tripwire_git.egg-info/PKG-INFO +187 -136
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire_git.egg-info/SOURCES.txt +2 -0
- tripwire_git-0.2.0/tests/test_aiguard.py +136 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/LICENSE +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/setup.cfg +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/__main__.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/approvals.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/blocks.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/config.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/engine.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/gitea.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/github.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/gitio.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/gitlab.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/httpjson.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/models.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire/review.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire_git.egg-info/dependency_links.txt +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire_git.egg-info/entry_points.txt +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire_git.egg-info/requires.txt +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/src/tripwire_git.egg-info/top_level.txt +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/tests/test_engine.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/tests/test_git.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/tests/test_gitea.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/tests/test_github.py +0 -0
- {tripwire_git-0.1.0 → tripwire_git-0.2.0}/tests/test_gitlab.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tripwire-git
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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,38 @@ 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**:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
tripwire pending # what was blocked
|
|
115
|
+
tripwire grant <id> --approver anna # the agent can now retry the same edit
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The approval is bound to the exact before/after content, so a different edit
|
|
119
|
+
is blocked again. To let the agent edit a block freely, list its identity as an
|
|
120
|
+
owner of that lock. Rules are read from the committed version (`HEAD`), and the
|
|
121
|
+
agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
|
|
122
|
+
those files through `Bash`.
|
|
123
|
+
|
|
124
|
+
Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
|
|
125
|
+
inspected, so keep the pre-commit hook and the server / CI check as backstops.
|
|
126
|
+
`grant` trusts whoever runs it in a terminal; a person with shell access can
|
|
127
|
+
also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
|
|
128
|
+
payloads in the documented format, not yet end to end inside a live Claude Code
|
|
129
|
+
session.
|
|
130
|
+
|
|
99
131
|
## Platforms
|
|
100
132
|
|
|
101
133
|
| Platform | Enforce with | Approvals from | Verified |
|
|
@@ -1,155 +1,168 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
#
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
and
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
-
|
|
143
|
-
-
|
|
144
|
-
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
1
|
+
# tripwire
|
|
2
|
+
|
|
3
|
+
Protect blocks of code in Git. A protected block can only be changed by its
|
|
4
|
+
owner or a leader, or by someone whose change an authorized person approved.
|
|
5
|
+
Works from a local pre-commit check, a self-hosted `pre-receive` hook, or as a
|
|
6
|
+
required check on GitHub, GitLab and Gitea / Forgejo.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
1. Mark a block in any language, inside a comment:
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
# @lock-begin billing
|
|
14
|
+
RATE = 5
|
|
15
|
+
# @lock-end billing
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
2. Create `.tripwire/locks.json` (see `locks.example.json`):
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"leaders": ["alice"],
|
|
23
|
+
"locks": [
|
|
24
|
+
{"id": "billing", "owners": ["bob"], "approvers": ["carol"]},
|
|
25
|
+
{"id": "prod-config", "owners": ["bob"], "paths": ["config/prod/", "*.pem"]}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
3. Check a change:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install tripwire-git
|
|
34
|
+
tripwire preflight --actor dave # uncommitted changes vs HEAD
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Output: `tripwire: BLOCKED` with `block 'billing' in app.py is locked; ask:
|
|
38
|
+
alice, bob, carol`, exit code 1.
|
|
39
|
+
|
|
40
|
+
## Concepts
|
|
41
|
+
|
|
42
|
+
- Blocks are marked with `@lock-begin <id>` / `@lock-end <id>`; files and
|
|
43
|
+
folders are locked with path patterns (`dir/` prefix or glob).
|
|
44
|
+
- Owners and leaders may edit freely. Anyone else needs an approval from an
|
|
45
|
+
owner, an approver or a leader. You cannot approve your own change.
|
|
46
|
+
- Approvals are bound to the exact before/after content of the protected
|
|
47
|
+
block (a SHA-256 digest). Edit the code again and the approval is void.
|
|
48
|
+
- Rules are always read from the **base** revision (the target branch), so a
|
|
49
|
+
change cannot rewrite the rules that judge it. Changing the rules file is
|
|
50
|
+
leaders-only and cannot be approved.
|
|
51
|
+
- Whitespace-only edits are ignored. Deleting markers counts as a change.
|
|
52
|
+
Unclosed blocks extend to end of file. A marker whose id is missing from the
|
|
53
|
+
rules is leaders-only.
|
|
54
|
+
|
|
55
|
+
## Commands
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
tripwire preflight --actor alice # uncommitted changes vs HEAD
|
|
59
|
+
tripwire check --base origin/main --head HEAD --actor alice # a commit range
|
|
60
|
+
tripwire approve --base origin/main --head HEAD --approver carol # local approvals file
|
|
61
|
+
tripwire preflight --staged # staged changes only
|
|
62
|
+
tripwire install-hook # local pre-commit hook
|
|
63
|
+
tripwire pre-receive # server hook, reads stdin
|
|
64
|
+
tripwire github-check | gitlab-check | gitea-check # pull / merge requests
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Exit codes: 0 allowed, 1 blocked, 2 error (fails closed).
|
|
68
|
+
|
|
69
|
+
## Local early warning
|
|
70
|
+
|
|
71
|
+
`tripwire install-hook` installs a `pre-commit` hook that checks staged changes
|
|
72
|
+
and refuses the commit when a protected block is touched without permission
|
|
73
|
+
(or use `.pre-commit-hooks.yaml` with the pre-commit framework). Your Git
|
|
74
|
+
identity (`git config user.email`, or `TRIPWIRE_ACTOR`) must match a name in
|
|
75
|
+
`locks.json`. This is a convenience: `git commit --no-verify` skips it, so the
|
|
76
|
+
server-side or CI check below remains the real gate. Nothing can stop someone
|
|
77
|
+
from editing files in their own clone; tripwire stops unapproved changes from
|
|
78
|
+
landing on protected branches.
|
|
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**:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
tripwire pending # what was blocked
|
|
96
|
+
tripwire grant <id> --approver anna # the agent can now retry the same edit
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The approval is bound to the exact before/after content, so a different edit
|
|
100
|
+
is blocked again. To let the agent edit a block freely, list its identity as an
|
|
101
|
+
owner of that lock. Rules are read from the committed version (`HEAD`), and the
|
|
102
|
+
agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
|
|
103
|
+
those files through `Bash`.
|
|
104
|
+
|
|
105
|
+
Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
|
|
106
|
+
inspected, so keep the pre-commit hook and the server / CI check as backstops.
|
|
107
|
+
`grant` trusts whoever runs it in a terminal; a person with shell access can
|
|
108
|
+
also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
|
|
109
|
+
payloads in the documented format, not yet end to end inside a live Claude Code
|
|
110
|
+
session.
|
|
111
|
+
|
|
112
|
+
## Platforms
|
|
113
|
+
|
|
114
|
+
| Platform | Enforce with | Approvals from | Verified |
|
|
115
|
+
|---|---|---|---|
|
|
116
|
+
| Self-hosted Git | `hooks/pre-receive` | `.tripwire/approvals.json` | real git repos |
|
|
117
|
+
| GitHub | required check, `templates/github-actions.yml` | PR reviews | real GitHub |
|
|
118
|
+
| GitLab | required pipeline, `templates/gitlab-ci.yml` | MR approvals | simulated API only |
|
|
119
|
+
| Gitea / Forgejo | required check, `templates/gitea-actions.yml` | PR reviews | simulated API only |
|
|
120
|
+
|
|
121
|
+
On hosted services, also forbid direct pushes to the protected branch and add a
|
|
122
|
+
path lock for the CI configuration, otherwise a PR can delete the check.
|
|
123
|
+
|
|
124
|
+
**Self-hosted Git.** Copy `hooks/pre-receive` into the bare repo and set
|
|
125
|
+
`TRIPWIRE_ACTOR` from your server's authenticated user.
|
|
126
|
+
|
|
127
|
+
**GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
|
|
128
|
+
the PR's current head commit, so a new push voids it. Reviewer logins are
|
|
129
|
+
matched to names in `locks.json` case-insensitively; teams are not supported.
|
|
130
|
+
|
|
131
|
+
**GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
|
|
132
|
+
approvals endpoint is not bound to a commit, so enable "Remove all approvals
|
|
133
|
+
when commits are added to the source branch"; tripwire cannot verify it.
|
|
134
|
+
|
|
135
|
+
**Gitea / Forgejo.** Enable "Dismiss stale approvals". Stale and dismissed
|
|
136
|
+
reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
|
|
137
|
+
`GITHUB_API_URL` or `GITHUB_SERVER_URL`.
|
|
138
|
+
|
|
139
|
+
## Architecture
|
|
140
|
+
|
|
141
|
+
- `engine.py`: platform-neutral core (diff + actor + rules + approvals -> decision)
|
|
142
|
+
- `review.py`: shared pull / merge request check used by the platform adapters
|
|
143
|
+
- `github.py`, `gitlab.py`, `gitea.py`: API clients and approval mapping
|
|
144
|
+
- `approvals.py`: local approvals file
|
|
145
|
+
- `gitio.py`, `cli.py`: Git access and command line
|
|
146
|
+
|
|
147
|
+
## Limits
|
|
148
|
+
|
|
149
|
+
- Identity comes from `--actor` / `TRIPWIRE_ACTOR` locally; it is only as
|
|
150
|
+
trustworthy as whatever sets it. On hosted platforms it is the PR author.
|
|
151
|
+
- The local approvals file is trust-based: anyone able to commit could write an
|
|
152
|
+
entry naming an approver. Use platform approvals for real deployments.
|
|
153
|
+
- On every platform, people who can edit branch protection or CI settings are
|
|
154
|
+
trusted; tripwire cannot stop an admin from switching the rule off.
|
|
155
|
+
- Pushes with several authors are checked as one cumulative diff by the pusher.
|
|
156
|
+
- Whitespace-insensitive comparison also ignores indentation changes.
|
|
157
|
+
- Not yet: platform teams, Bitbucket, Azure DevOps, Gerrit, CODEOWNERS import.
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
pip install -e .[dev]
|
|
163
|
+
pytest
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tripwire-git"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
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"
|
|
@@ -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,85 @@ 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() and not os.environ.get("TRIPWIRE_ALLOW_NONINTERACTIVE"):
|
|
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
|
+
path = os.path.join(root, APPROVALS_PATH)
|
|
208
|
+
existing: List[Approval] = []
|
|
209
|
+
if os.path.isfile(path):
|
|
210
|
+
with open(path, encoding="utf-8") as fh:
|
|
211
|
+
existing = parse_local(fh.read())
|
|
212
|
+
existing.append(Approval(item["lock"], args.approver, item["digest"]))
|
|
213
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
214
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
215
|
+
fh.write(dump_local(existing))
|
|
216
|
+
save_pending(root, [i for i in items if i is not item])
|
|
217
|
+
print("tripwire: approved %s as %s" % (item["digest"][:12], args.approver))
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_install_claude_hook(args, root: str) -> int:
|
|
222
|
+
path = os.path.join(root, ".claude", "settings.json")
|
|
223
|
+
settings = {}
|
|
224
|
+
if os.path.isfile(path):
|
|
225
|
+
with open(path, encoding="utf-8") as fh:
|
|
226
|
+
settings = json.load(fh)
|
|
227
|
+
entry = {"type": "command", "command": args.command}
|
|
228
|
+
groups = settings.setdefault("hooks", {}).setdefault("PreToolUse", [])
|
|
229
|
+
if not any(entry in g.get("hooks", []) for g in groups):
|
|
230
|
+
groups.append({"matcher": "Edit|Write|MultiEdit|Bash", "hooks": [entry]})
|
|
231
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
232
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
233
|
+
json.dump(settings, fh, indent=2)
|
|
234
|
+
fh.write("\n")
|
|
235
|
+
print("tripwire: Claude Code hook installed in %s" % path)
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
|
|
160
239
|
def cmd_approve(args, root: str) -> int:
|
|
161
240
|
config, decision = run_check(root, args.base, args.head, actor="")
|
|
162
241
|
if config is None:
|
|
@@ -225,6 +304,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
225
304
|
gl.add_argument("--gitlab-project", help="project id or path (default: CI_PROJECT_ID)")
|
|
226
305
|
gl.set_defaults(func=cmd_gitlab_check)
|
|
227
306
|
|
|
307
|
+
sub.add_parser("claude-hook", help="Claude Code PreToolUse hook: reads the tool call from stdin").set_defaults(func=cmd_claude_hook)
|
|
308
|
+
ih = sub.add_parser("install-claude-hook", help="add the hook to .claude/settings.json")
|
|
309
|
+
ih.add_argument("--command", default="tripwire claude-hook", help="hook command (default: tripwire claude-hook)")
|
|
310
|
+
ih.set_defaults(func=cmd_install_claude_hook)
|
|
311
|
+
sub.add_parser("pending", help="list edits the AI agent was blocked from making").set_defaults(func=cmd_pending)
|
|
312
|
+
|
|
313
|
+
gr = sub.add_parser("grant", help="a human approves a pending AI edit (interactive terminal only)")
|
|
314
|
+
gr.add_argument("digest", help="id (prefix) from `tripwire pending`")
|
|
315
|
+
gr.add_argument("--approver", required=True)
|
|
316
|
+
gr.set_defaults(func=cmd_grant)
|
|
317
|
+
|
|
228
318
|
a = sub.add_parser("approve", help="record an approval for the pending change")
|
|
229
319
|
a.add_argument("--base", required=True)
|
|
230
320
|
a.add_argument("--head")
|
|
@@ -237,7 +327,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
237
327
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
238
328
|
args = build_parser().parse_args(argv)
|
|
239
329
|
try:
|
|
240
|
-
|
|
330
|
+
if args.command == "claude-hook":
|
|
331
|
+
try:
|
|
332
|
+
root = toplevel(args.repo)
|
|
333
|
+
except GitError:
|
|
334
|
+
return 0 # not a git repository: nothing to guard
|
|
335
|
+
else:
|
|
336
|
+
root = toplevel(args.repo) if args.command != "pre-receive" else os.path.abspath(args.repo)
|
|
241
337
|
return args.func(args, root)
|
|
242
338
|
except (ConfigError, GitError) as exc:
|
|
243
339
|
print("tripwire: error: %s" % exc, file=sys.stderr)
|
|
@@ -1,136 +1,187 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
`TRIPWIRE_ACTOR`
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tripwire-git
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Protect blocks of code in Git: changes need approval from an owner, an approver or a leader.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: git,code-owners,approval,pre-receive,ci
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# tripwire
|
|
21
|
+
|
|
22
|
+
Protect blocks of code in Git. A protected block can only be changed by its
|
|
23
|
+
owner or a leader, or by someone whose change an authorized person approved.
|
|
24
|
+
Works from a local pre-commit check, a self-hosted `pre-receive` hook, or as a
|
|
25
|
+
required check on GitHub, GitLab and Gitea / Forgejo.
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
1. Mark a block in any language, inside a comment:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
# @lock-begin billing
|
|
33
|
+
RATE = 5
|
|
34
|
+
# @lock-end billing
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
2. Create `.tripwire/locks.json` (see `locks.example.json`):
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"leaders": ["alice"],
|
|
42
|
+
"locks": [
|
|
43
|
+
{"id": "billing", "owners": ["bob"], "approvers": ["carol"]},
|
|
44
|
+
{"id": "prod-config", "owners": ["bob"], "paths": ["config/prod/", "*.pem"]}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
3. Check a change:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install tripwire-git
|
|
53
|
+
tripwire preflight --actor dave # uncommitted changes vs HEAD
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Output: `tripwire: BLOCKED` with `block 'billing' in app.py is locked; ask:
|
|
57
|
+
alice, bob, carol`, exit code 1.
|
|
58
|
+
|
|
59
|
+
## Concepts
|
|
60
|
+
|
|
61
|
+
- Blocks are marked with `@lock-begin <id>` / `@lock-end <id>`; files and
|
|
62
|
+
folders are locked with path patterns (`dir/` prefix or glob).
|
|
63
|
+
- Owners and leaders may edit freely. Anyone else needs an approval from an
|
|
64
|
+
owner, an approver or a leader. You cannot approve your own change.
|
|
65
|
+
- Approvals are bound to the exact before/after content of the protected
|
|
66
|
+
block (a SHA-256 digest). Edit the code again and the approval is void.
|
|
67
|
+
- Rules are always read from the **base** revision (the target branch), so a
|
|
68
|
+
change cannot rewrite the rules that judge it. Changing the rules file is
|
|
69
|
+
leaders-only and cannot be approved.
|
|
70
|
+
- Whitespace-only edits are ignored. Deleting markers counts as a change.
|
|
71
|
+
Unclosed blocks extend to end of file. A marker whose id is missing from the
|
|
72
|
+
rules is leaders-only.
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
tripwire preflight --actor alice # uncommitted changes vs HEAD
|
|
78
|
+
tripwire check --base origin/main --head HEAD --actor alice # a commit range
|
|
79
|
+
tripwire approve --base origin/main --head HEAD --approver carol # local approvals file
|
|
80
|
+
tripwire preflight --staged # staged changes only
|
|
81
|
+
tripwire install-hook # local pre-commit hook
|
|
82
|
+
tripwire pre-receive # server hook, reads stdin
|
|
83
|
+
tripwire github-check | gitlab-check | gitea-check # pull / merge requests
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Exit codes: 0 allowed, 1 blocked, 2 error (fails closed).
|
|
87
|
+
|
|
88
|
+
## Local early warning
|
|
89
|
+
|
|
90
|
+
`tripwire install-hook` installs a `pre-commit` hook that checks staged changes
|
|
91
|
+
and refuses the commit when a protected block is touched without permission
|
|
92
|
+
(or use `.pre-commit-hooks.yaml` with the pre-commit framework). Your Git
|
|
93
|
+
identity (`git config user.email`, or `TRIPWIRE_ACTOR`) must match a name in
|
|
94
|
+
`locks.json`. This is a convenience: `git commit --no-verify` skips it, so the
|
|
95
|
+
server-side or CI check below remains the real gate. Nothing can stop someone
|
|
96
|
+
from editing files in their own clone; tripwire stops unapproved changes from
|
|
97
|
+
landing on protected branches.
|
|
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**:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
tripwire pending # what was blocked
|
|
115
|
+
tripwire grant <id> --approver anna # the agent can now retry the same edit
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The approval is bound to the exact before/after content, so a different edit
|
|
119
|
+
is blocked again. To let the agent edit a block freely, list its identity as an
|
|
120
|
+
owner of that lock. Rules are read from the committed version (`HEAD`), and the
|
|
121
|
+
agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
|
|
122
|
+
those files through `Bash`.
|
|
123
|
+
|
|
124
|
+
Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
|
|
125
|
+
inspected, so keep the pre-commit hook and the server / CI check as backstops.
|
|
126
|
+
`grant` trusts whoever runs it in a terminal; a person with shell access can
|
|
127
|
+
also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
|
|
128
|
+
payloads in the documented format, not yet end to end inside a live Claude Code
|
|
129
|
+
session.
|
|
130
|
+
|
|
131
|
+
## Platforms
|
|
132
|
+
|
|
133
|
+
| Platform | Enforce with | Approvals from | Verified |
|
|
134
|
+
|---|---|---|---|
|
|
135
|
+
| Self-hosted Git | `hooks/pre-receive` | `.tripwire/approvals.json` | real git repos |
|
|
136
|
+
| GitHub | required check, `templates/github-actions.yml` | PR reviews | real GitHub |
|
|
137
|
+
| GitLab | required pipeline, `templates/gitlab-ci.yml` | MR approvals | simulated API only |
|
|
138
|
+
| Gitea / Forgejo | required check, `templates/gitea-actions.yml` | PR reviews | simulated API only |
|
|
139
|
+
|
|
140
|
+
On hosted services, also forbid direct pushes to the protected branch and add a
|
|
141
|
+
path lock for the CI configuration, otherwise a PR can delete the check.
|
|
142
|
+
|
|
143
|
+
**Self-hosted Git.** Copy `hooks/pre-receive` into the bare repo and set
|
|
144
|
+
`TRIPWIRE_ACTOR` from your server's authenticated user.
|
|
145
|
+
|
|
146
|
+
**GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
|
|
147
|
+
the PR's current head commit, so a new push voids it. Reviewer logins are
|
|
148
|
+
matched to names in `locks.json` case-insensitively; teams are not supported.
|
|
149
|
+
|
|
150
|
+
**GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
|
|
151
|
+
approvals endpoint is not bound to a commit, so enable "Remove all approvals
|
|
152
|
+
when commits are added to the source branch"; tripwire cannot verify it.
|
|
153
|
+
|
|
154
|
+
**Gitea / Forgejo.** Enable "Dismiss stale approvals". Stale and dismissed
|
|
155
|
+
reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
|
|
156
|
+
`GITHUB_API_URL` or `GITHUB_SERVER_URL`.
|
|
157
|
+
|
|
158
|
+
## Architecture
|
|
159
|
+
|
|
160
|
+
- `engine.py`: platform-neutral core (diff + actor + rules + approvals -> decision)
|
|
161
|
+
- `review.py`: shared pull / merge request check used by the platform adapters
|
|
162
|
+
- `github.py`, `gitlab.py`, `gitea.py`: API clients and approval mapping
|
|
163
|
+
- `approvals.py`: local approvals file
|
|
164
|
+
- `gitio.py`, `cli.py`: Git access and command line
|
|
165
|
+
|
|
166
|
+
## Limits
|
|
167
|
+
|
|
168
|
+
- Identity comes from `--actor` / `TRIPWIRE_ACTOR` locally; it is only as
|
|
169
|
+
trustworthy as whatever sets it. On hosted platforms it is the PR author.
|
|
170
|
+
- The local approvals file is trust-based: anyone able to commit could write an
|
|
171
|
+
entry naming an approver. Use platform approvals for real deployments.
|
|
172
|
+
- On every platform, people who can edit branch protection or CI settings are
|
|
173
|
+
trusted; tripwire cannot stop an admin from switching the rule off.
|
|
174
|
+
- Pushes with several authors are checked as one cumulative diff by the pusher.
|
|
175
|
+
- Whitespace-insensitive comparison also ignores indentation changes.
|
|
176
|
+
- Not yet: platform teams, Bitbucket, Azure DevOps, Gerrit, CODEOWNERS import.
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
pip install -e .[dev]
|
|
182
|
+
pytest
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
|
@@ -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,136 @@
|
|
|
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
|
+
monkeypatch.setenv("TRIPWIRE_ALLOW_NONINTERACTIVE", "1")
|
|
33
|
+
return tmp_path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def hook(repo, monkeypatch, tool, tool_input):
|
|
37
|
+
payload = {"tool_name": tool, "tool_input": tool_input, "cwd": str(repo)}
|
|
38
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(payload)))
|
|
39
|
+
return main(["--repo", str(repo), "claude-hook"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def edit(repo, old, new, **kw):
|
|
43
|
+
return {"file_path": str(repo / "app.py"), "old_string": old, "new_string": new, **kw}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_edit_of_protected_block_is_blocked_before_it_happens(repo, monkeypatch, capsys):
|
|
47
|
+
assert hook(repo, monkeypatch, "Edit", edit(repo, "rate = 5", "rate = 9")) == 2
|
|
48
|
+
err = capsys.readouterr().err
|
|
49
|
+
assert "billing" in err and "tripwire grant" in err
|
|
50
|
+
assert (repo / "app.py").read_text() == CODE # hook never touches the file
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_edit_of_unprotected_code_is_allowed(repo, monkeypatch):
|
|
54
|
+
assert hook(repo, monkeypatch, "Edit", edit(repo, "y = 2", "y = 3")) == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_write_that_changes_protected_block_is_blocked(repo, monkeypatch):
|
|
58
|
+
new = CODE.replace("rate = 5", "rate = 9")
|
|
59
|
+
assert hook(repo, monkeypatch, "Write", {"file_path": str(repo / "app.py"), "content": new}) == 2
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_write_that_leaves_block_unchanged_is_allowed(repo, monkeypatch):
|
|
63
|
+
new = CODE.replace("y = 2", "y = 7")
|
|
64
|
+
assert hook(repo, monkeypatch, "Write", {"file_path": str(repo / "app.py"), "content": new}) == 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_multiedit_is_evaluated_as_a_whole(repo, monkeypatch):
|
|
68
|
+
edits = [{"old_string": "y = 2", "new_string": "y = 3"}, {"old_string": "rate = 5", "new_string": "rate = 6"}]
|
|
69
|
+
assert hook(repo, monkeypatch, "MultiEdit", {"file_path": str(repo / "app.py"), "edits": edits}) == 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_deleting_markers_is_blocked(repo, monkeypatch):
|
|
73
|
+
assert hook(repo, monkeypatch, "Edit", edit(repo, "# @lock-begin billing\n", "")) == 2
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_failing_edit_is_left_to_the_tool(repo, monkeypatch):
|
|
77
|
+
assert hook(repo, monkeypatch, "Edit", edit(repo, "no such text", "z")) == 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_ai_actor_can_be_made_owner(repo, monkeypatch):
|
|
81
|
+
monkeypatch.setenv("TRIPWIRE_AI_ACTOR", "olga")
|
|
82
|
+
assert hook(repo, monkeypatch, "Edit", edit(repo, "rate = 5", "rate = 9")) == 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_path_outside_repo_and_other_tools_are_ignored(repo, monkeypatch, tmp_path_factory):
|
|
86
|
+
other = tmp_path_factory.mktemp("elsewhere") / "f.py"
|
|
87
|
+
assert hook(repo, monkeypatch, "Write", {"file_path": str(other), "content": "x"}) == 0
|
|
88
|
+
assert hook(repo, monkeypatch, "Read", {"file_path": str(repo / "app.py")}) == 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_bash_cannot_touch_approvals_or_grant(repo, monkeypatch):
|
|
92
|
+
assert hook(repo, monkeypatch, "Bash", {"command": "tripwire grant abc --approver anna"}) == 2
|
|
93
|
+
assert hook(repo, monkeypatch, "Bash", {"command": "echo [] > .tripwire/approvals.json"}) == 2
|
|
94
|
+
assert hook(repo, monkeypatch, "Bash", {"command": "ls"}) == 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_agent_cannot_edit_approvals_or_rules_files(repo, monkeypatch):
|
|
98
|
+
f = {"file_path": str(repo / ".tripwire" / "approvals.json"), "content": "[]"}
|
|
99
|
+
assert hook(repo, monkeypatch, "Write", f) == 2
|
|
100
|
+
r = {"file_path": str(repo / ".tripwire" / "locks.json"), "content": json.dumps(dict(RULES, leaders=["ai-agent"]))}
|
|
101
|
+
assert hook(repo, monkeypatch, "Write", r) == 2
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_grant_then_same_edit_is_allowed_but_other_edit_is_not(repo, monkeypatch, capsys):
|
|
105
|
+
e = edit(repo, "rate = 5", "rate = 9")
|
|
106
|
+
assert hook(repo, monkeypatch, "Edit", e) == 2
|
|
107
|
+
assert main(["--repo", str(repo), "pending"]) == 0
|
|
108
|
+
digest = json.loads((repo / ".git" / "tripwire" / "pending.json").read_text())[0]["digest"]
|
|
109
|
+
assert main(["--repo", str(repo), "grant", digest[:10], "--approver", "bob"]) == 1 # not an approver
|
|
110
|
+
assert main(["--repo", str(repo), "grant", digest[:10], "--approver", "anna"]) == 0
|
|
111
|
+
assert hook(repo, monkeypatch, "Edit", e) == 0
|
|
112
|
+
assert hook(repo, monkeypatch, "Edit", edit(repo, "rate = 5", "rate = 10")) == 2 # approval was for rate = 9
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_grant_refuses_non_interactive_use(repo, monkeypatch):
|
|
116
|
+
hook(repo, monkeypatch, "Edit", edit(repo, "rate = 5", "rate = 9"))
|
|
117
|
+
digest = json.loads((repo / ".git" / "tripwire" / "pending.json").read_text())[0]["digest"]
|
|
118
|
+
monkeypatch.delenv("TRIPWIRE_ALLOW_NONINTERACTIVE")
|
|
119
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(""))
|
|
120
|
+
assert main(["--repo", str(repo), "grant", digest[:10], "--approver", "anna"]) == 1
|
|
121
|
+
assert not (repo / ".tripwire" / "approvals.json").exists()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_install_claude_hook_is_idempotent_and_keeps_other_settings(repo):
|
|
125
|
+
(repo / ".claude").mkdir()
|
|
126
|
+
(repo / ".claude" / "settings.json").write_text(json.dumps({"model": "x"}))
|
|
127
|
+
assert main(["--repo", str(repo), "install-claude-hook"]) == 0
|
|
128
|
+
assert main(["--repo", str(repo), "install-claude-hook"]) == 0
|
|
129
|
+
settings = json.loads((repo / ".claude" / "settings.json").read_text())
|
|
130
|
+
assert settings["model"] == "x"
|
|
131
|
+
assert len(settings["hooks"]["PreToolUse"]) == 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_not_a_git_repo_is_allowed(tmp_path, monkeypatch):
|
|
135
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps({"tool_name": "Write", "tool_input": {}})))
|
|
136
|
+
assert main(["--repo", str(tmp_path), "claude-hook"]) == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|