repoward 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- repoward/__init__.py +1 -0
- repoward/adapters/__init__.py +1 -0
- repoward/adapters/ci.py +50 -0
- repoward/adapters/github.py +150 -0
- repoward/approvals/__init__.py +1 -0
- repoward/approvals/store.py +199 -0
- repoward/audit.py +235 -0
- repoward/canonical.py +20 -0
- repoward/catalog.py +29 -0
- repoward/cli.py +391 -0
- repoward/flow.py +239 -0
- repoward/pipeline.py +333 -0
- repoward/policy/__init__.py +1 -0
- repoward/policy/engine.py +101 -0
- repoward/policy/loader.py +245 -0
- repoward/policy/models.py +100 -0
- repoward/principal.py +15 -0
- repoward/py.typed +0 -0
- repoward/ratelimit.py +39 -0
- repoward/registry.py +68 -0
- repoward/sanitize.py +207 -0
- repoward/server.py +101 -0
- repoward/setup.py +118 -0
- repoward/storage.py +26 -0
- repoward/tools/__init__.py +1 -0
- repoward/tools/ci_read.py +121 -0
- repoward/tools/github_dangerous.py +135 -0
- repoward/tools/github_read.py +312 -0
- repoward/tools/github_write.py +239 -0
- repoward/tools/system.py +26 -0
- repoward/validation.py +63 -0
- repoward-0.1.0.dist-info/METADATA +317 -0
- repoward-0.1.0.dist-info/RECORD +36 -0
- repoward-0.1.0.dist-info/WHEEL +4 -0
- repoward-0.1.0.dist-info/entry_points.txt +2 -0
- repoward-0.1.0.dist-info/licenses/LICENSE +21 -0
repoward/catalog.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Every tool name this project defines, with its risk class.
|
|
2
|
+
|
|
3
|
+
Policy files are validated against this catalog, including tools not implemented yet.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RiskClass(StrEnum):
|
|
10
|
+
READ = "read"
|
|
11
|
+
WRITE = "write"
|
|
12
|
+
DANGEROUS = "dangerous"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TOOL_CATALOG: dict[str, RiskClass] = {
|
|
16
|
+
"ping": RiskClass.READ,
|
|
17
|
+
"github_list_issues": RiskClass.READ,
|
|
18
|
+
"github_get_issue": RiskClass.READ,
|
|
19
|
+
"github_search_code": RiskClass.READ,
|
|
20
|
+
"github_get_file": RiskClass.READ,
|
|
21
|
+
"ci_get_status": RiskClass.READ,
|
|
22
|
+
"ci_get_job_logs": RiskClass.READ,
|
|
23
|
+
"github_create_branch": RiskClass.WRITE,
|
|
24
|
+
"github_commit_files": RiskClass.WRITE,
|
|
25
|
+
"github_create_pull_request": RiskClass.WRITE,
|
|
26
|
+
"github_comment": RiskClass.WRITE,
|
|
27
|
+
"github_merge_pull_request": RiskClass.DANGEROUS,
|
|
28
|
+
"github_delete_branch": RiskClass.DANGEROUS,
|
|
29
|
+
}
|
repoward/cli.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import getpass
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from repoward.approvals.store import Approval, ApprovalStore, ApprovalStoreError
|
|
12
|
+
from repoward.audit import (
|
|
13
|
+
DEFAULT_KEY_ENV,
|
|
14
|
+
AuditKeyError,
|
|
15
|
+
AuditLog,
|
|
16
|
+
AuditWriteError,
|
|
17
|
+
VerifyReport,
|
|
18
|
+
load_hmac_key,
|
|
19
|
+
verify_log,
|
|
20
|
+
write_checkpoint,
|
|
21
|
+
)
|
|
22
|
+
from repoward.flow import FlowStore, FlowStoreError
|
|
23
|
+
from repoward.policy.loader import PolicyError, load_policy
|
|
24
|
+
from repoward.sanitize import display_safe
|
|
25
|
+
from repoward.server import run_stdio
|
|
26
|
+
from repoward.setup import (
|
|
27
|
+
APPROVALS_FILE,
|
|
28
|
+
AUDIT_FILE,
|
|
29
|
+
FLOW_FILE,
|
|
30
|
+
POLICY_FILE,
|
|
31
|
+
STARTER_ROLES,
|
|
32
|
+
client_config,
|
|
33
|
+
default_dir,
|
|
34
|
+
write_starter,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _error(message: str) -> None:
|
|
39
|
+
print(f"repoward: {message}", file=sys.stderr)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_policy_error(path: Path, exc: PolicyError) -> None:
|
|
43
|
+
try:
|
|
44
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
45
|
+
except (OSError, UnicodeDecodeError):
|
|
46
|
+
lines = []
|
|
47
|
+
for issue in exc.issues:
|
|
48
|
+
where = f"{path}:{issue.line}:{issue.column}" if issue.line else str(path)
|
|
49
|
+
print(f"{where}: {issue.message}", file=sys.stderr)
|
|
50
|
+
if issue.line and issue.line <= len(lines):
|
|
51
|
+
print(f" {lines[issue.line - 1]}", file=sys.stderr)
|
|
52
|
+
if issue.column:
|
|
53
|
+
print(f" {' ' * (issue.column - 1)}^", file=sys.stderr)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _print_report(report: VerifyReport) -> int:
|
|
57
|
+
for warning in report.warnings:
|
|
58
|
+
print(f"warning: {warning}")
|
|
59
|
+
for error in report.errors:
|
|
60
|
+
print(f"error: {error}")
|
|
61
|
+
if report.ok:
|
|
62
|
+
print(f"OK: {report.records} records, tip {report.tip_hash}")
|
|
63
|
+
return 0
|
|
64
|
+
print(f"FAILED after {report.records} valid records")
|
|
65
|
+
return 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _validate_policy(args: argparse.Namespace) -> int:
|
|
69
|
+
path = args.path or default_dir() / POLICY_FILE
|
|
70
|
+
try:
|
|
71
|
+
compiled = load_policy(path)
|
|
72
|
+
except PolicyError as exc:
|
|
73
|
+
_print_policy_error(path, exc)
|
|
74
|
+
return 1
|
|
75
|
+
print(f"OK: {len(compiled.roles)} roles, sha256 {compiled.sha256}")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _serve(args: argparse.Namespace) -> int:
|
|
80
|
+
directory = args.dir or default_dir()
|
|
81
|
+
policy_path = args.policy or directory / POLICY_FILE
|
|
82
|
+
if not policy_path.exists():
|
|
83
|
+
_error(f"no policy at {policy_path}. Create one with: repoward init --repo owner/name")
|
|
84
|
+
return 1
|
|
85
|
+
try:
|
|
86
|
+
compiled = load_policy(policy_path)
|
|
87
|
+
key = load_hmac_key(DEFAULT_KEY_ENV)
|
|
88
|
+
except PolicyError as exc:
|
|
89
|
+
_print_policy_error(policy_path, exc)
|
|
90
|
+
return 1
|
|
91
|
+
except AuditKeyError as exc:
|
|
92
|
+
_error(str(exc))
|
|
93
|
+
return 1
|
|
94
|
+
try:
|
|
95
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
run_stdio(
|
|
97
|
+
compiled,
|
|
98
|
+
args.audit or directory / AUDIT_FILE,
|
|
99
|
+
args.approvals or directory / APPROVALS_FILE,
|
|
100
|
+
args.flow or directory / FLOW_FILE,
|
|
101
|
+
key,
|
|
102
|
+
)
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
_error(f"cannot use {directory}: {exc.strerror}")
|
|
105
|
+
return 1
|
|
106
|
+
except (ApprovalStoreError, FlowStoreError) as exc:
|
|
107
|
+
_error(str(exc))
|
|
108
|
+
return 1
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _init(args: argparse.Namespace) -> int:
|
|
113
|
+
directory = args.dir or default_dir()
|
|
114
|
+
try:
|
|
115
|
+
path = write_starter(directory, args.repo, args.role)
|
|
116
|
+
except FileExistsError:
|
|
117
|
+
_error(f"{directory / POLICY_FILE} already exists, and it was left unchanged")
|
|
118
|
+
return 1
|
|
119
|
+
except PolicyError as exc:
|
|
120
|
+
for issue in exc.issues:
|
|
121
|
+
_error(issue.message)
|
|
122
|
+
return 1
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
_error(f"cannot write to {directory}: {exc.strerror}")
|
|
125
|
+
return 1
|
|
126
|
+
print(f"Created {path}, with role '{args.role}'.\n")
|
|
127
|
+
print("Next steps:\n")
|
|
128
|
+
print("1. Create a fine-grained GitHub token limited to those repositories:")
|
|
129
|
+
print(" https://github.com/settings/personal-access-tokens/new\n")
|
|
130
|
+
print("2. Add this to your MCP client's config, replacing <your-token>:\n")
|
|
131
|
+
print(client_config(directory))
|
|
132
|
+
print(
|
|
133
|
+
"\n3. Protect your default branch with a ruleset that has no bypass for the token's owner."
|
|
134
|
+
)
|
|
135
|
+
print("\nImportant: this is not a security boundary for an agent that has a shell running as")
|
|
136
|
+
print(
|
|
137
|
+
"the same OS user. It could use git directly, read the token or approve its own requests."
|
|
138
|
+
)
|
|
139
|
+
print("Separate them, or give the agent MCP tools only. Details:")
|
|
140
|
+
print("https://github.com/Sanoy24/repoward/blob/main/docs/THREAT_MODEL.md")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _audit_key(args: argparse.Namespace) -> bytes | None:
|
|
145
|
+
return load_hmac_key(args.key_env) if args.key_env else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _audit_verify(args: argparse.Namespace) -> int:
|
|
149
|
+
try:
|
|
150
|
+
path = args.file or default_dir() / AUDIT_FILE
|
|
151
|
+
return _print_report(verify_log(path, _audit_key(args), args.anchor))
|
|
152
|
+
except AuditKeyError as exc:
|
|
153
|
+
_error(str(exc))
|
|
154
|
+
return 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _audit_checkpoint(args: argparse.Namespace) -> int:
|
|
158
|
+
try:
|
|
159
|
+
path = args.file or default_dir() / AUDIT_FILE
|
|
160
|
+
return _print_report(write_checkpoint(path, args.out, _audit_key(args)))
|
|
161
|
+
except AuditKeyError as exc:
|
|
162
|
+
_error(str(exc))
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _in_home(path: Path | None, filename: str) -> Path:
|
|
167
|
+
"""Use the given path, or a file in the home directory, which is created if needed."""
|
|
168
|
+
if path is not None:
|
|
169
|
+
return path
|
|
170
|
+
directory = default_dir()
|
|
171
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
return directory / filename
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _approval_store(args: argparse.Namespace) -> ApprovalStore:
|
|
176
|
+
return ApprovalStore(_in_home(args.db, APPROVALS_FILE))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _flow_store(args: argparse.Namespace) -> FlowStore:
|
|
180
|
+
return FlowStore(_in_home(args.db, FLOW_FILE))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _audit_path(args: argparse.Namespace) -> Path:
|
|
184
|
+
return _in_home(args.audit, AUDIT_FILE)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _when(timestamp: float | None) -> str:
|
|
188
|
+
if timestamp is None:
|
|
189
|
+
return "-"
|
|
190
|
+
return datetime.fromtimestamp(timestamp, UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _print_approval(approval: Approval) -> None:
|
|
194
|
+
"""Print verified fields first. Everything is cleaned, so text cannot spoof the terminal."""
|
|
195
|
+
print(f"Request: {display_safe(approval.id)}")
|
|
196
|
+
print(f"Status: {display_safe(approval.status)}")
|
|
197
|
+
print(f"Tool: {display_safe(approval.tool)}")
|
|
198
|
+
print(f"Repository: {display_safe(approval.repo or '-')}")
|
|
199
|
+
print(f"Requested: {display_safe(approval.principal)} at {_when(approval.ts)}")
|
|
200
|
+
print(f"Expires: {_when(approval.expires_at)}")
|
|
201
|
+
print(f"Arguments: {display_safe(approval.args_redacted)}")
|
|
202
|
+
if approval.decided_by:
|
|
203
|
+
print(f"Decided: {display_safe(approval.decided_by)} at {_when(approval.decided_at)}")
|
|
204
|
+
if approval.context:
|
|
205
|
+
print("From GitHub (untrusted, may be written by anyone):")
|
|
206
|
+
for line in display_safe(approval.context).splitlines():
|
|
207
|
+
print(f" | {line}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _approvals_list(args: argparse.Namespace) -> int:
|
|
211
|
+
items = _approval_store(args).list(include_all=args.all)
|
|
212
|
+
if not items:
|
|
213
|
+
print("No requests.")
|
|
214
|
+
for item in items:
|
|
215
|
+
print(
|
|
216
|
+
f"{display_safe(item.id)} {display_safe(item.status)} {display_safe(item.tool)} "
|
|
217
|
+
f"{display_safe(item.repo or '-')} expires {_when(item.expires_at)}"
|
|
218
|
+
)
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _approvals_show(args: argparse.Namespace) -> int:
|
|
223
|
+
approval = _approval_store(args).get(args.id)
|
|
224
|
+
if approval is None:
|
|
225
|
+
_error("no such request")
|
|
226
|
+
return 1
|
|
227
|
+
_print_approval(approval)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _decide(args: argparse.Namespace, approved: bool) -> int:
|
|
232
|
+
"""Audit first: if the decision cannot be recorded, it is not made."""
|
|
233
|
+
store = _approval_store(args)
|
|
234
|
+
approval = store.get(args.id)
|
|
235
|
+
if approval is None:
|
|
236
|
+
_error("no such request")
|
|
237
|
+
return 1
|
|
238
|
+
if approval.status != "pending":
|
|
239
|
+
_error(f"this request is {display_safe(approval.status)}, not pending")
|
|
240
|
+
return 1
|
|
241
|
+
decided_by = getpass.getuser()
|
|
242
|
+
try:
|
|
243
|
+
audit = AuditLog(_audit_path(args), load_hmac_key(DEFAULT_KEY_ENV))
|
|
244
|
+
audit.append(
|
|
245
|
+
{
|
|
246
|
+
"event": "approval_decision",
|
|
247
|
+
"approval_id": approval.id,
|
|
248
|
+
"decision": "approved" if approved else "denied",
|
|
249
|
+
"decided_by": decided_by,
|
|
250
|
+
"principal": approval.principal,
|
|
251
|
+
"tool": approval.tool,
|
|
252
|
+
"repo": approval.repo,
|
|
253
|
+
"args_hash": approval.args_hash,
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
except (AuditWriteError, AuditKeyError) as exc:
|
|
257
|
+
_error(f"not decided, because the decision could not be audited: {exc}")
|
|
258
|
+
return 1
|
|
259
|
+
if not store.decide(approval.id, approved, decided_by):
|
|
260
|
+
_error("the request changed before it could be decided")
|
|
261
|
+
return 1
|
|
262
|
+
_print_approval(store.get(approval.id) or approval)
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _approvals_approve(args: argparse.Namespace) -> int:
|
|
267
|
+
return _decide(args, approved=True)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _approvals_deny(args: argparse.Namespace) -> int:
|
|
271
|
+
return _decide(args, approved=False)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _flow_status(args: argparse.Namespace) -> int:
|
|
275
|
+
labels = _flow_store(args).active()
|
|
276
|
+
if not labels:
|
|
277
|
+
print("No active labels.")
|
|
278
|
+
for label in labels:
|
|
279
|
+
print(
|
|
280
|
+
f"{display_safe(label.principal)} {display_safe(label.kind)} "
|
|
281
|
+
f"{display_safe(label.source)} expires {_when(label.expires_at)}"
|
|
282
|
+
)
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _flow_reset(args: argparse.Namespace) -> int:
|
|
287
|
+
"""Audit first: if the reset cannot be recorded, it does not happen."""
|
|
288
|
+
store = _flow_store(args)
|
|
289
|
+
try:
|
|
290
|
+
AuditLog(_audit_path(args), load_hmac_key(DEFAULT_KEY_ENV)).append(
|
|
291
|
+
{
|
|
292
|
+
"event": "flow_reset",
|
|
293
|
+
"principal": args.principal,
|
|
294
|
+
"decided_by": getpass.getuser(),
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
except (AuditWriteError, AuditKeyError) as exc:
|
|
298
|
+
_error(f"not reset, because the reset could not be audited: {exc}")
|
|
299
|
+
return 1
|
|
300
|
+
print(f"Cleared {store.reset(args.principal)} labels.")
|
|
301
|
+
return 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
305
|
+
parser = argparse.ArgumentParser(prog="repoward", description=__doc__)
|
|
306
|
+
parser.add_argument("--version", action="version", version=version("repoward"))
|
|
307
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
308
|
+
|
|
309
|
+
init = commands.add_parser("init", help="create a safe starter policy")
|
|
310
|
+
init.add_argument(
|
|
311
|
+
"--repo", action="append", required=True, help="owner/name to allow; repeatable"
|
|
312
|
+
)
|
|
313
|
+
init.add_argument(
|
|
314
|
+
"--role", choices=STARTER_ROLES, default="reader", help="role for this server"
|
|
315
|
+
)
|
|
316
|
+
init.add_argument("--dir", type=Path, help="directory for repoward's files")
|
|
317
|
+
init.set_defaults(run=_init)
|
|
318
|
+
|
|
319
|
+
serve = commands.add_parser("serve", help="serve MCP over stdio")
|
|
320
|
+
serve.add_argument("--dir", type=Path, help="directory for repoward's files")
|
|
321
|
+
serve.add_argument("--policy", type=Path, help="policy YAML file")
|
|
322
|
+
serve.add_argument("--audit", type=Path, help="audit JSONL file")
|
|
323
|
+
serve.add_argument("--approvals", type=Path, help="approvals database")
|
|
324
|
+
serve.add_argument("--flow", type=Path, help="flow guard database")
|
|
325
|
+
serve.set_defaults(run=_serve)
|
|
326
|
+
|
|
327
|
+
validate = commands.add_parser("validate-policy", help="check a policy file")
|
|
328
|
+
validate.add_argument("path", type=Path, nargs="?")
|
|
329
|
+
validate.set_defaults(run=_validate_policy)
|
|
330
|
+
|
|
331
|
+
audit = commands.add_parser("audit", help="work with audit logs")
|
|
332
|
+
audit_commands = audit.add_subparsers(dest="audit_command", required=True)
|
|
333
|
+
|
|
334
|
+
verify = audit_commands.add_parser("verify", help="check a log's hash chain")
|
|
335
|
+
verify.add_argument("file", type=Path, nargs="?")
|
|
336
|
+
verify.add_argument("--anchor", type=Path, help="anchor file from audit checkpoint")
|
|
337
|
+
verify.add_argument("--key-env", help="environment variable holding the HMAC key")
|
|
338
|
+
verify.set_defaults(run=_audit_verify)
|
|
339
|
+
|
|
340
|
+
checkpoint = audit_commands.add_parser("checkpoint", help="record the log's current tip")
|
|
341
|
+
checkpoint.add_argument("file", type=Path, nargs="?")
|
|
342
|
+
checkpoint.add_argument("--out", type=Path, required=True, help="anchor file to append to")
|
|
343
|
+
checkpoint.add_argument("--key-env", help="environment variable holding the HMAC key")
|
|
344
|
+
checkpoint.set_defaults(run=_audit_checkpoint)
|
|
345
|
+
|
|
346
|
+
approvals = commands.add_parser("approvals", help="review requests waiting for a human")
|
|
347
|
+
approvals.add_argument("--db", type=Path, help="approvals database")
|
|
348
|
+
approval_commands = approvals.add_subparsers(dest="approvals_command", required=True)
|
|
349
|
+
|
|
350
|
+
listing = approval_commands.add_parser("list", help="show pending requests")
|
|
351
|
+
listing.add_argument("--all", action="store_true", help="include decided requests")
|
|
352
|
+
listing.set_defaults(run=_approvals_list)
|
|
353
|
+
|
|
354
|
+
show = approval_commands.add_parser("show", help="show one request in full")
|
|
355
|
+
show.add_argument("id")
|
|
356
|
+
show.set_defaults(run=_approvals_show)
|
|
357
|
+
|
|
358
|
+
for name, handler in (("approve", _approvals_approve), ("deny", _approvals_deny)):
|
|
359
|
+
decision = approval_commands.add_parser(name, help=f"{name} a request")
|
|
360
|
+
decision.add_argument("id")
|
|
361
|
+
decision.add_argument("--audit", type=Path, help="audit JSONL file")
|
|
362
|
+
decision.set_defaults(run=handler)
|
|
363
|
+
|
|
364
|
+
flow = commands.add_parser(
|
|
365
|
+
"flow", help="inspect or clear what agents are marked as having read"
|
|
366
|
+
)
|
|
367
|
+
flow.add_argument("--db", type=Path, help="flow guard database")
|
|
368
|
+
flow_commands = flow.add_subparsers(dest="flow_command", required=True)
|
|
369
|
+
|
|
370
|
+
status = flow_commands.add_parser("status", help="show active labels")
|
|
371
|
+
status.set_defaults(run=_flow_status)
|
|
372
|
+
|
|
373
|
+
reset = flow_commands.add_parser("reset", help="clear labels so writes are allowed again")
|
|
374
|
+
reset.add_argument("--principal", help="only this principal (default: everyone)")
|
|
375
|
+
reset.add_argument("--audit", type=Path, help="audit JSONL file")
|
|
376
|
+
reset.set_defaults(run=_flow_reset)
|
|
377
|
+
return parser
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
381
|
+
"""Run the command line interface and return an exit code."""
|
|
382
|
+
args = _build_parser().parse_args(argv)
|
|
383
|
+
try:
|
|
384
|
+
return int(args.run(args))
|
|
385
|
+
except (ApprovalStoreError, FlowStoreError) as exc:
|
|
386
|
+
_error(str(exc))
|
|
387
|
+
return 1
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
raise SystemExit(main())
|
repoward/flow.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Flow guard: decide each write by what this principal has already read.
|
|
2
|
+
|
|
3
|
+
Per-tool rules cannot stop the toxic flow, where a malicious public issue steers an agent to
|
|
4
|
+
read a private repository and publish it, because each call is allowed on its own. This guard
|
|
5
|
+
remembers two facts taken from GitHub, not from scanning content:
|
|
6
|
+
|
|
7
|
+
- private_read: the principal read content from a private repository.
|
|
8
|
+
- untrusted_input: the principal read text from an author outside the trusted associations.
|
|
9
|
+
|
|
10
|
+
State is keyed by principal and survives restarts. Only a person can clear it, through the
|
|
11
|
+
CLI. No tool can, and no agent-supplied value can start a clean slate, because an injected
|
|
12
|
+
agent would simply do that while the data stayed in its context.
|
|
13
|
+
|
|
14
|
+
The limits: this only sees this server's tools, and content can stay in an agent's context after
|
|
15
|
+
a label expires.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import anyio.to_thread
|
|
24
|
+
|
|
25
|
+
from repoward.policy.engine import Decision, Effect
|
|
26
|
+
from repoward.policy.models import FlowGuardRules
|
|
27
|
+
from repoward.storage import transaction
|
|
28
|
+
|
|
29
|
+
PRIVATE_READ = "private_read"
|
|
30
|
+
UNTRUSTED_INPUT = "untrusted_input"
|
|
31
|
+
TOUCHED_REPO = "touched_repo"
|
|
32
|
+
_VISIBILITY_TTL_SECONDS = 60.0
|
|
33
|
+
_SCHEMA = """
|
|
34
|
+
CREATE TABLE IF NOT EXISTS flow_labels (
|
|
35
|
+
principal TEXT NOT NULL,
|
|
36
|
+
kind TEXT NOT NULL,
|
|
37
|
+
source TEXT NOT NULL,
|
|
38
|
+
first_seen REAL NOT NULL,
|
|
39
|
+
expires_at REAL NOT NULL,
|
|
40
|
+
PRIMARY KEY (principal, kind, source)
|
|
41
|
+
)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FlowStoreError(Exception):
|
|
46
|
+
"""Flow state could not be read or saved. Callers must fail closed."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class Taint:
|
|
51
|
+
"""Text a tool returned from someone. No association means treat as untrusted."""
|
|
52
|
+
|
|
53
|
+
source: str
|
|
54
|
+
association: str | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class FlowLabel:
|
|
59
|
+
principal: str
|
|
60
|
+
kind: str
|
|
61
|
+
source: str
|
|
62
|
+
first_seen: float
|
|
63
|
+
expires_at: float
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FlowStore:
|
|
67
|
+
def __init__(self, path: Path, clock: Callable[[], float] = time.time) -> None:
|
|
68
|
+
self._path = path
|
|
69
|
+
self._clock = clock
|
|
70
|
+
with transaction(self._path, FlowStoreError) as connection:
|
|
71
|
+
connection.execute(_SCHEMA)
|
|
72
|
+
|
|
73
|
+
def add(self, principal: str, kind: str, source: str, window_seconds: float) -> bool:
|
|
74
|
+
"""Set or extend a label. Returns True if it was not already active."""
|
|
75
|
+
now = self._clock()
|
|
76
|
+
with transaction(self._path, FlowStoreError) as connection:
|
|
77
|
+
connection.execute("DELETE FROM flow_labels WHERE expires_at <= ?", (now,))
|
|
78
|
+
active = connection.execute(
|
|
79
|
+
"SELECT 1 FROM flow_labels WHERE principal = ? AND kind = ? AND source = ?",
|
|
80
|
+
(principal, kind, source),
|
|
81
|
+
).fetchone()
|
|
82
|
+
if active:
|
|
83
|
+
connection.execute(
|
|
84
|
+
"UPDATE flow_labels SET expires_at = ? "
|
|
85
|
+
"WHERE principal = ? AND kind = ? AND source = ?",
|
|
86
|
+
(now + window_seconds, principal, kind, source),
|
|
87
|
+
)
|
|
88
|
+
return False
|
|
89
|
+
connection.execute(
|
|
90
|
+
"INSERT INTO flow_labels (principal, kind, source, first_seen, expires_at) "
|
|
91
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
92
|
+
(principal, kind, source, now, now + window_seconds),
|
|
93
|
+
)
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
def active(self, principal: str | None = None) -> list[FlowLabel]:
|
|
97
|
+
now = self._clock()
|
|
98
|
+
with transaction(self._path, FlowStoreError) as connection:
|
|
99
|
+
connection.execute("DELETE FROM flow_labels WHERE expires_at <= ?", (now,))
|
|
100
|
+
query = "SELECT principal, kind, source, first_seen, expires_at FROM flow_labels"
|
|
101
|
+
params: tuple[str, ...] = ()
|
|
102
|
+
if principal is not None:
|
|
103
|
+
query += " WHERE principal = ?"
|
|
104
|
+
params = (principal,)
|
|
105
|
+
rows = connection.execute(query + " ORDER BY first_seen", params).fetchall()
|
|
106
|
+
return [FlowLabel(*row) for row in rows]
|
|
107
|
+
|
|
108
|
+
def reset(self, principal: str | None = None) -> int:
|
|
109
|
+
"""Clear labels. Only the operator CLI calls this."""
|
|
110
|
+
with transaction(self._path, FlowStoreError) as connection:
|
|
111
|
+
if principal is None:
|
|
112
|
+
cursor = connection.execute("DELETE FROM flow_labels")
|
|
113
|
+
else:
|
|
114
|
+
cursor = connection.execute(
|
|
115
|
+
"DELETE FROM flow_labels WHERE principal = ?", (principal,)
|
|
116
|
+
)
|
|
117
|
+
return cursor.rowcount
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class FlowGuard:
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
rules: FlowGuardRules,
|
|
124
|
+
store: FlowStore,
|
|
125
|
+
repo_is_private: Callable[[str], Awaitable[bool]],
|
|
126
|
+
clock: Callable[[], float] = time.monotonic,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._rules = rules
|
|
129
|
+
self._store = store
|
|
130
|
+
self._repo_is_private = repo_is_private
|
|
131
|
+
self._clock = clock
|
|
132
|
+
self._window = rules.window_minutes * 60.0
|
|
133
|
+
self._visibility: dict[str, tuple[bool, float]] = {}
|
|
134
|
+
|
|
135
|
+
async def _is_private(self, repo: str) -> bool | None:
|
|
136
|
+
"""Repository visibility, or None if GitHub could not say."""
|
|
137
|
+
key = repo.casefold()
|
|
138
|
+
cached = self._visibility.get(key)
|
|
139
|
+
if cached is not None and self._clock() < cached[1]:
|
|
140
|
+
return cached[0]
|
|
141
|
+
try:
|
|
142
|
+
private = await self._repo_is_private(repo)
|
|
143
|
+
except Exception:
|
|
144
|
+
return None
|
|
145
|
+
self._visibility[key] = (private, self._clock() + _VISIBILITY_TTL_SECONDS)
|
|
146
|
+
return private
|
|
147
|
+
|
|
148
|
+
async def _active(self, principal: str) -> list[FlowLabel]:
|
|
149
|
+
return await anyio.to_thread.run_sync(self._store.active, principal)
|
|
150
|
+
|
|
151
|
+
async def _add(self, principal: str, kind: str, source: str) -> bool:
|
|
152
|
+
return await anyio.to_thread.run_sync(
|
|
153
|
+
self._store.add, principal, kind, source, self._window
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def check_write(self, principal: str, repo: str | None) -> Decision | None:
|
|
157
|
+
"""Decide a write. None means the guard has no objection. Fails closed."""
|
|
158
|
+
try:
|
|
159
|
+
labels = await self._active(principal)
|
|
160
|
+
kinds = {label.kind for label in labels}
|
|
161
|
+
if (
|
|
162
|
+
repo is not None
|
|
163
|
+
and PRIVATE_READ in kinds
|
|
164
|
+
and await self._is_private(repo) is not True
|
|
165
|
+
):
|
|
166
|
+
# An unknown target is treated as public.
|
|
167
|
+
if self._rules.on_private_to_public == "deny":
|
|
168
|
+
return Decision(
|
|
169
|
+
Effect.DENY,
|
|
170
|
+
"flow.private_to_public",
|
|
171
|
+
"This agent has read private content, so it cannot write to a public "
|
|
172
|
+
"repository. A person can clear this with repoward flow reset.",
|
|
173
|
+
)
|
|
174
|
+
return Decision(
|
|
175
|
+
Effect.APPROVAL_REQUIRED,
|
|
176
|
+
"flow.private_to_public",
|
|
177
|
+
"This write needs human approval because the agent has read private content.",
|
|
178
|
+
)
|
|
179
|
+
if self._rules.single_repo and repo is not None:
|
|
180
|
+
touched = [label.source for label in labels if label.kind == TOUCHED_REPO]
|
|
181
|
+
if touched and touched[0] != repo.casefold():
|
|
182
|
+
return Decision(
|
|
183
|
+
Effect.DENY,
|
|
184
|
+
"flow.single_repo",
|
|
185
|
+
"This agent is limited to one repository until a person resets it.",
|
|
186
|
+
)
|
|
187
|
+
await self._add(principal, TOUCHED_REPO, repo.casefold())
|
|
188
|
+
if UNTRUSTED_INPUT in kinds:
|
|
189
|
+
if self._rules.on_untrusted_author == "deny":
|
|
190
|
+
return Decision(
|
|
191
|
+
Effect.DENY,
|
|
192
|
+
"flow.untrusted_author",
|
|
193
|
+
"This agent has read text from an untrusted author, so it cannot write. "
|
|
194
|
+
"A person can clear this with repoward flow reset.",
|
|
195
|
+
)
|
|
196
|
+
return Decision(
|
|
197
|
+
Effect.APPROVAL_REQUIRED,
|
|
198
|
+
"flow.untrusted_author",
|
|
199
|
+
"This write needs human approval because the agent read untrusted text.",
|
|
200
|
+
)
|
|
201
|
+
except FlowStoreError:
|
|
202
|
+
return Decision(
|
|
203
|
+
Effect.DENY,
|
|
204
|
+
"flow.unavailable",
|
|
205
|
+
"This write was blocked because the flow guard's state is unavailable.",
|
|
206
|
+
)
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
async def record_read(
|
|
210
|
+
self, principal: str, repo: str | None, taints: Sequence[Taint]
|
|
211
|
+
) -> list[tuple[str, str]]:
|
|
212
|
+
"""Save what was just read, before it is returned. Raises FlowStoreError on failure."""
|
|
213
|
+
added: list[tuple[str, str]] = []
|
|
214
|
+
|
|
215
|
+
async def add(kind: str, source: str) -> None:
|
|
216
|
+
if await self._add(principal, kind, source):
|
|
217
|
+
added.append((kind, source))
|
|
218
|
+
|
|
219
|
+
if repo is not None:
|
|
220
|
+
# A repository of unknown visibility is treated as private.
|
|
221
|
+
if await self._is_private(repo) is not False:
|
|
222
|
+
await add(PRIVATE_READ, repo.casefold())
|
|
223
|
+
if self._rules.single_repo:
|
|
224
|
+
await add(TOUCHED_REPO, repo.casefold())
|
|
225
|
+
trusted = set(self._rules.trusted_associations)
|
|
226
|
+
for taint in taints:
|
|
227
|
+
if taint.association not in trusted:
|
|
228
|
+
await add(UNTRUSTED_INPUT, taint.source)
|
|
229
|
+
return added
|
|
230
|
+
|
|
231
|
+
async def describe(self, principal: str) -> str:
|
|
232
|
+
"""Why this principal's writes are restricted, for the person reviewing an approval."""
|
|
233
|
+
labels = await self._active(principal)
|
|
234
|
+
lines = [
|
|
235
|
+
f"Flow guard: {label.kind} from {label.source}"
|
|
236
|
+
for label in labels
|
|
237
|
+
if label.kind != TOUCHED_REPO
|
|
238
|
+
]
|
|
239
|
+
return "\n".join(lines)
|