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/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)