tripwire-git 0.2.1__tar.gz → 0.3.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.2.1/src/tripwire_git.egg-info → tripwire_git-0.3.0}/PKG-INFO +43 -4
- tripwire_git-0.2.1/PKG-INFO → tripwire_git-0.3.0/README.md +211 -191
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/pyproject.toml +1 -1
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/__init__.py +1 -1
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/blocks.py +15 -7
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/cli.py +60 -0
- tripwire_git-0.3.0/src/tripwire/codeowners.py +79 -0
- tripwire_git-0.3.0/src/tripwire/config.py +153 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/engine.py +23 -12
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/github.py +10 -1
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/review.py +11 -2
- tripwire_git-0.3.0/src/tripwire/validate.py +69 -0
- tripwire_git-0.2.1/README.md → tripwire_git-0.3.0/src/tripwire_git.egg-info/PKG-INFO +230 -172
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/SOURCES.txt +5 -1
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_github.py +3 -0
- tripwire_git-0.3.0/tests/test_groups_and_modes.py +171 -0
- tripwire_git-0.3.0/tests/test_validate_codeowners.py +181 -0
- tripwire_git-0.2.1/src/tripwire/config.py +0 -82
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/LICENSE +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/setup.cfg +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/__main__.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/aiguard.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/approvals.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/gitea.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/gitio.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/gitlab.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/httpjson.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire/models.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/dependency_links.txt +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/entry_points.txt +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/requires.txt +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/src/tripwire_git.egg-info/top_level.txt +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_aiguard.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_engine.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_git.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.0}/tests/test_gitea.py +0 -0
- {tripwire_git-0.2.1 → tripwire_git-0.3.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.3.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
|
|
@@ -71,6 +71,41 @@ required check on GitHub, GitLab and Gitea / Forgejo.
|
|
|
71
71
|
Unclosed blocks extend to end of file. A marker whose id is missing from the
|
|
72
72
|
rules is leaders-only.
|
|
73
73
|
|
|
74
|
+
## Rules file reference
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"whitespace": "ignore",
|
|
79
|
+
"groups": {"finance": ["bob", "carol"], "all": ["@finance", "dave"]},
|
|
80
|
+
"leaders": ["alice", "@org/platform"],
|
|
81
|
+
"locks": [
|
|
82
|
+
{"id": "billing", "owners": ["@finance"], "approvers": ["@all"]},
|
|
83
|
+
{"id": "py-core", "owners": ["bob"], "paths": ["src/core/"], "whitespace": "indent"}
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- `groups`: reusable lists, referenced as `@name` (groups may contain groups).
|
|
89
|
+
- `@org/team`: a platform team. The GitHub adapter resolves it through the API
|
|
90
|
+
(the token needs `read:org`); other adapters leave it unresolved, so it never
|
|
91
|
+
matches anybody. `tripwire validate` warns about this.
|
|
92
|
+
- `whitespace`: `ignore` (default) treats re-indenting as no change; `indent`
|
|
93
|
+
keeps leading whitespace significant, for Python and similar. Set it globally
|
|
94
|
+
or per lock. Trailing whitespace and blank lines never count.
|
|
95
|
+
- `paths`: `dir/` prefix or a glob (`*` also matches `/`).
|
|
96
|
+
|
|
97
|
+
Helpers:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
tripwire validate # lint the rules against the repository
|
|
101
|
+
tripwire import-codeowners --leader alice --write # start from an existing CODEOWNERS
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`import-codeowners` turns every CODEOWNERS pattern into a path lock. CODEOWNERS
|
|
105
|
+
applies "last match wins"; tripwire locks are additive, so it is stricter only
|
|
106
|
+
where a later pattern deliberately narrows an earlier one. Sections and email
|
|
107
|
+
owners are skipped with a warning. Set `leaders` before relying on the result.
|
|
108
|
+
|
|
74
109
|
## Commands
|
|
75
110
|
|
|
76
111
|
```bash
|
|
@@ -149,7 +184,8 @@ path lock for the CI configuration, otherwise a PR can delete the check.
|
|
|
149
184
|
|
|
150
185
|
**GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
|
|
151
186
|
the PR's current head commit, so a new push voids it. Reviewer logins are
|
|
152
|
-
matched to names in `locks.json` case-insensitively;
|
|
187
|
+
matched to names in `locks.json` case-insensitively; `@org/team` entries are
|
|
188
|
+
resolved through the API.
|
|
153
189
|
|
|
154
190
|
**GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
|
|
155
191
|
approvals endpoint is not bound to a commit, so enable "Remove all approvals
|
|
@@ -176,8 +212,11 @@ reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
|
|
|
176
212
|
- On every platform, people who can edit branch protection or CI settings are
|
|
177
213
|
trusted; tripwire cannot stop an admin from switching the rule off.
|
|
178
214
|
- Pushes with several authors are checked as one cumulative diff by the pusher.
|
|
179
|
-
-
|
|
180
|
-
|
|
215
|
+
- By default re-indenting is not a change; set `"whitespace": "indent"` for
|
|
216
|
+
languages where indentation matters.
|
|
217
|
+
- Platform teams (`@org/team`) are resolved on GitHub only (simulated API tests;
|
|
218
|
+
not run against a real organization).
|
|
219
|
+
- Not yet: GitLab groups and Gitea teams, Bitbucket, Azure DevOps, Gerrit.
|
|
181
220
|
|
|
182
221
|
## Development
|
|
183
222
|
|
|
@@ -1,191 +1,211 @@
|
|
|
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
|
-
tripwire
|
|
82
|
-
tripwire
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
agent
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
## Rules file reference
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"whitespace": "ignore",
|
|
60
|
+
"groups": {"finance": ["bob", "carol"], "all": ["@finance", "dave"]},
|
|
61
|
+
"leaders": ["alice", "@org/platform"],
|
|
62
|
+
"locks": [
|
|
63
|
+
{"id": "billing", "owners": ["@finance"], "approvers": ["@all"]},
|
|
64
|
+
{"id": "py-core", "owners": ["bob"], "paths": ["src/core/"], "whitespace": "indent"}
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `groups`: reusable lists, referenced as `@name` (groups may contain groups).
|
|
70
|
+
- `@org/team`: a platform team. The GitHub adapter resolves it through the API
|
|
71
|
+
(the token needs `read:org`); other adapters leave it unresolved, so it never
|
|
72
|
+
matches anybody. `tripwire validate` warns about this.
|
|
73
|
+
- `whitespace`: `ignore` (default) treats re-indenting as no change; `indent`
|
|
74
|
+
keeps leading whitespace significant, for Python and similar. Set it globally
|
|
75
|
+
or per lock. Trailing whitespace and blank lines never count.
|
|
76
|
+
- `paths`: `dir/` prefix or a glob (`*` also matches `/`).
|
|
77
|
+
|
|
78
|
+
Helpers:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
tripwire validate # lint the rules against the repository
|
|
82
|
+
tripwire import-codeowners --leader alice --write # start from an existing CODEOWNERS
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`import-codeowners` turns every CODEOWNERS pattern into a path lock. CODEOWNERS
|
|
86
|
+
applies "last match wins"; tripwire locks are additive, so it is stricter only
|
|
87
|
+
where a later pattern deliberately narrows an earlier one. Sections and email
|
|
88
|
+
owners are skipped with a warning. Set `leaders` before relying on the result.
|
|
89
|
+
|
|
90
|
+
## Commands
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
tripwire preflight --actor alice # uncommitted changes vs HEAD
|
|
94
|
+
tripwire check --base origin/main --head HEAD --actor alice # a commit range
|
|
95
|
+
tripwire approve --base origin/main --head HEAD --approver carol # local approvals file
|
|
96
|
+
tripwire preflight --staged # staged changes only
|
|
97
|
+
tripwire install-hook # local pre-commit hook
|
|
98
|
+
tripwire pre-receive # server hook, reads stdin
|
|
99
|
+
tripwire github-check | gitlab-check | gitea-check # pull / merge requests
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Exit codes: 0 allowed, 1 blocked, 2 error (fails closed).
|
|
103
|
+
|
|
104
|
+
## Local early warning
|
|
105
|
+
|
|
106
|
+
`tripwire install-hook` installs a `pre-commit` hook that checks staged changes
|
|
107
|
+
and refuses the commit when a protected block is touched without permission
|
|
108
|
+
(or use `.pre-commit-hooks.yaml` with the pre-commit framework). Your Git
|
|
109
|
+
identity (`git config user.email`, or `TRIPWIRE_ACTOR`) must match a name in
|
|
110
|
+
`locks.json`. This is a convenience: `git commit --no-verify` skips it, so the
|
|
111
|
+
server-side or CI check below remains the real gate. Nothing can stop someone
|
|
112
|
+
from editing files in their own clone; tripwire stops unapproved changes from
|
|
113
|
+
landing on protected branches.
|
|
114
|
+
|
|
115
|
+
## AI coding agents (Claude Code)
|
|
116
|
+
|
|
117
|
+
Stop an AI agent from changing protected code *before* it touches the disk:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
tripwire install-claude-hook # adds a PreToolUse hook to .claude/settings.json
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The agent acts as its own identity (`TRIPWIRE_AI_ACTOR`, default `ai-agent`),
|
|
124
|
+
which is not an owner unless you list it. When it tries to `Edit`, `MultiEdit`
|
|
125
|
+
or `Write` a protected block, the call is refused with a message naming who may
|
|
126
|
+
approve it, and the request is recorded. A human then approves that exact
|
|
127
|
+
change **from an interactive terminal**; `grant` shows the request and makes
|
|
128
|
+
the person type the approver's name to confirm (a pseudo-terminal alone is not
|
|
129
|
+
trusted):
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
tripwire pending # what was blocked
|
|
133
|
+
tripwire grant <id> --approver anna # the agent can now retry the same edit
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The approval is bound to the exact before/after content, so a different edit
|
|
137
|
+
is blocked again. To let the agent edit a block freely, list its identity as an
|
|
138
|
+
owner of that lock. Rules are read from the committed version (`HEAD`), and the
|
|
139
|
+
agent cannot edit the rules or approvals files, run `tripwire grant`, or touch
|
|
140
|
+
those files through `Bash`.
|
|
141
|
+
|
|
142
|
+
Limits: shell commands that rewrite files (`sed -i`, redirects, scripts) are not
|
|
143
|
+
inspected, so keep the pre-commit hook and the server / CI check as backstops.
|
|
144
|
+
`grant` trusts whoever runs it in a terminal; a person with shell access can
|
|
145
|
+
also edit `.tripwire/approvals.json` by hand. Verified with simulated hook
|
|
146
|
+
payloads and end to end with a live Claude Code 2.1 session: the edit was
|
|
147
|
+
refused with the message shown to the agent, a human `grant` let the identical
|
|
148
|
+
retry through, and the agent's own attempt to run `tripwire grant` did not
|
|
149
|
+
approve anything.
|
|
150
|
+
|
|
151
|
+
## Platforms
|
|
152
|
+
|
|
153
|
+
| Platform | Enforce with | Approvals from | Verified |
|
|
154
|
+
|---|---|---|---|
|
|
155
|
+
| Self-hosted Git | `hooks/pre-receive` | `.tripwire/approvals.json` | real git repos |
|
|
156
|
+
| GitHub | required check, `templates/github-actions.yml` | PR reviews | real GitHub |
|
|
157
|
+
| GitLab | required pipeline, `templates/gitlab-ci.yml` | MR approvals | simulated API only |
|
|
158
|
+
| Gitea / Forgejo | required check, `templates/gitea-actions.yml` | PR reviews | simulated API only |
|
|
159
|
+
|
|
160
|
+
On hosted services, also forbid direct pushes to the protected branch and add a
|
|
161
|
+
path lock for the CI configuration, otherwise a PR can delete the check.
|
|
162
|
+
|
|
163
|
+
**Self-hosted Git.** Copy `hooks/pre-receive` into the bare repo and set
|
|
164
|
+
`TRIPWIRE_ACTOR` from your server's authenticated user.
|
|
165
|
+
|
|
166
|
+
**GitHub.** Only a reviewer's latest review counts; it must be `APPROVED` on
|
|
167
|
+
the PR's current head commit, so a new push voids it. Reviewer logins are
|
|
168
|
+
matched to names in `locks.json` case-insensitively; `@org/team` entries are
|
|
169
|
+
resolved through the API.
|
|
170
|
+
|
|
171
|
+
**GitLab.** Store a `read_api` project access token as `GITLAB_TOKEN`. GitLab's
|
|
172
|
+
approvals endpoint is not bound to a commit, so enable "Remove all approvals
|
|
173
|
+
when commits are added to the source branch"; tripwire cannot verify it.
|
|
174
|
+
|
|
175
|
+
**Gitea / Forgejo.** Enable "Dismiss stale approvals". Stale and dismissed
|
|
176
|
+
reviews are ignored. The API base comes from `--gitea-url`, `GITEA_API_URL`,
|
|
177
|
+
`GITHUB_API_URL` or `GITHUB_SERVER_URL`.
|
|
178
|
+
|
|
179
|
+
## Architecture
|
|
180
|
+
|
|
181
|
+
- `engine.py`: platform-neutral core (diff + actor + rules + approvals -> decision)
|
|
182
|
+
- `review.py`: shared pull / merge request check used by the platform adapters
|
|
183
|
+
- `github.py`, `gitlab.py`, `gitea.py`: API clients and approval mapping
|
|
184
|
+
- `approvals.py`: local approvals file
|
|
185
|
+
- `gitio.py`, `cli.py`: Git access and command line
|
|
186
|
+
|
|
187
|
+
## Limits
|
|
188
|
+
|
|
189
|
+
- Identity comes from `--actor` / `TRIPWIRE_ACTOR` locally; it is only as
|
|
190
|
+
trustworthy as whatever sets it. On hosted platforms it is the PR author.
|
|
191
|
+
- The local approvals file is trust-based: anyone able to commit could write an
|
|
192
|
+
entry naming an approver. Use platform approvals for real deployments.
|
|
193
|
+
- On every platform, people who can edit branch protection or CI settings are
|
|
194
|
+
trusted; tripwire cannot stop an admin from switching the rule off.
|
|
195
|
+
- Pushes with several authors are checked as one cumulative diff by the pusher.
|
|
196
|
+
- By default re-indenting is not a change; set `"whitespace": "indent"` for
|
|
197
|
+
languages where indentation matters.
|
|
198
|
+
- Platform teams (`@org/team`) are resolved on GitHub only (simulated API tests;
|
|
199
|
+
not run against a real organization).
|
|
200
|
+
- Not yet: GitLab groups and Gitea teams, Bitbucket, Azure DevOps, Gerrit.
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
pip install -e .[dev]
|
|
206
|
+
pytest
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
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.3.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"
|
|
@@ -9,12 +9,20 @@ END = re.compile(r"@lock-end\s+([A-Za-z0-9_.\-]+)")
|
|
|
9
9
|
Norm = Tuple[str, ...]
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def
|
|
13
|
-
|
|
14
|
-
return tuple(line.strip() for line in text.splitlines() if line.strip())
|
|
12
|
+
def _clean(line: str, mode: str) -> str:
|
|
13
|
+
return line.rstrip() if mode == "indent" else line.strip()
|
|
15
14
|
|
|
16
15
|
|
|
17
|
-
def
|
|
16
|
+
def normalize(text: str, mode: str = "ignore") -> Norm:
|
|
17
|
+
"""Drop blank lines and trailing whitespace.
|
|
18
|
+
|
|
19
|
+
mode "ignore" also strips leading whitespace, so re-indenting is not a change;
|
|
20
|
+
mode "indent" keeps it (for languages where indentation carries meaning).
|
|
21
|
+
"""
|
|
22
|
+
return tuple(c for c in (_clean(line, mode) for line in text.splitlines()) if c.strip())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_blocks(text: str, mode: str = "ignore") -> Dict[str, Norm]:
|
|
18
26
|
"""Map block id -> normalized content between its markers.
|
|
19
27
|
|
|
20
28
|
An unclosed block extends to the end of the file (fail closed).
|
|
@@ -34,8 +42,8 @@ def extract_blocks(text: str) -> Dict[str, Norm]:
|
|
|
34
42
|
open_ids.remove(end.group(1))
|
|
35
43
|
open_ids.reverse()
|
|
36
44
|
continue
|
|
37
|
-
|
|
38
|
-
if
|
|
45
|
+
cleaned = _clean(line, mode)
|
|
46
|
+
if cleaned.strip():
|
|
39
47
|
for block_id in open_ids:
|
|
40
|
-
content[block_id].append(
|
|
48
|
+
content[block_id].append(cleaned)
|
|
41
49
|
return {k: tuple(v) for k, v in content.items()}
|
|
@@ -243,6 +243,55 @@ def cmd_install_claude_hook(args, root: str) -> int:
|
|
|
243
243
|
return 0
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
def cmd_validate(args, root: str) -> int:
|
|
247
|
+
from .validate import validate
|
|
248
|
+
|
|
249
|
+
path = args.file or os.path.join(root, RULES_PATH)
|
|
250
|
+
if not os.path.isfile(path):
|
|
251
|
+
print("tripwire: %s not found" % path, file=sys.stderr)
|
|
252
|
+
return 2
|
|
253
|
+
with open(path, encoding="utf-8") as fh:
|
|
254
|
+
errors, warnings = validate(root, fh.read())
|
|
255
|
+
for e in errors:
|
|
256
|
+
print("error: " + e, file=sys.stderr)
|
|
257
|
+
for w in warnings:
|
|
258
|
+
print("warning: " + w)
|
|
259
|
+
print("tripwire: %d error(s), %d warning(s)" % (len(errors), len(warnings)))
|
|
260
|
+
return 1 if errors else 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_import_codeowners(args, root: str) -> int:
|
|
264
|
+
from . import codeowners
|
|
265
|
+
|
|
266
|
+
source = args.file
|
|
267
|
+
if source is None:
|
|
268
|
+
for cand in codeowners.CANDIDATES:
|
|
269
|
+
if os.path.isfile(os.path.join(root, cand)):
|
|
270
|
+
source = os.path.join(root, cand)
|
|
271
|
+
break
|
|
272
|
+
if source is None or not os.path.isfile(source):
|
|
273
|
+
print("tripwire: no CODEOWNERS file found; pass --file", file=sys.stderr)
|
|
274
|
+
return 2
|
|
275
|
+
with open(source, encoding="utf-8") as fh:
|
|
276
|
+
entries, warnings = codeowners.parse(fh.read())
|
|
277
|
+
config = codeowners.to_config(entries, args.leader or [])
|
|
278
|
+
for w in warnings:
|
|
279
|
+
print("warning: " + w, file=sys.stderr)
|
|
280
|
+
text = json.dumps(config, indent=2) + "\n"
|
|
281
|
+
if not args.write:
|
|
282
|
+
sys.stdout.write(text)
|
|
283
|
+
return 0
|
|
284
|
+
target = os.path.join(root, RULES_PATH)
|
|
285
|
+
if os.path.exists(target) and not args.force:
|
|
286
|
+
print("tripwire: %s already exists; use --force to replace it" % RULES_PATH, file=sys.stderr)
|
|
287
|
+
return 1
|
|
288
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
289
|
+
with open(target, "w", encoding="utf-8") as fh:
|
|
290
|
+
fh.write(text)
|
|
291
|
+
print("tripwire: wrote %d lock(s) to %s; set leaders before relying on it" % (len(config["locks"]), RULES_PATH))
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
246
295
|
def cmd_approve(args, root: str) -> int:
|
|
247
296
|
config, decision = run_check(root, args.base, args.head, actor="")
|
|
248
297
|
if config is None:
|
|
@@ -322,6 +371,17 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
322
371
|
gr.add_argument("--approver", required=True)
|
|
323
372
|
gr.set_defaults(func=cmd_grant)
|
|
324
373
|
|
|
374
|
+
v = sub.add_parser("validate", help="check the rules file against the repository")
|
|
375
|
+
v.add_argument("--file", help="rules file (default: .tripwire/locks.json)")
|
|
376
|
+
v.set_defaults(func=cmd_validate)
|
|
377
|
+
|
|
378
|
+
ic = sub.add_parser("import-codeowners", help="turn a CODEOWNERS file into path locks")
|
|
379
|
+
ic.add_argument("--file", help="CODEOWNERS path (default: search the usual places)")
|
|
380
|
+
ic.add_argument("--leader", action="append", help="leader name (repeatable)")
|
|
381
|
+
ic.add_argument("--write", action="store_true", help="write .tripwire/locks.json instead of printing")
|
|
382
|
+
ic.add_argument("--force", action="store_true", help="overwrite an existing rules file")
|
|
383
|
+
ic.set_defaults(func=cmd_import_codeowners)
|
|
384
|
+
|
|
325
385
|
a = sub.add_parser("approve", help="record an approval for the pending change")
|
|
326
386
|
a.add_argument("--base", required=True)
|
|
327
387
|
a.add_argument("--head")
|