autoforge-cli 0.1.0a0__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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/redaction.py ADDED
@@ -0,0 +1,408 @@
1
+ """Secret redaction for anything that lands in logs.
2
+
3
+ Baseline protection only — no detector claims to catch every secret shape.
4
+ Covers common env-var assignments, ``Authorization`` headers, well-known
5
+ token shapes (GitHub PATs, OpenAI / Anthropic keys, Google API keys, JWTs),
6
+ OAuth credential fields (``refresh`` / ``access`` / ``id_token`` /
7
+ ``chatgpt-account-id``) and credentials embedded in URLs before stdout /
8
+ stderr / commands are persisted by the run logger. A shape is recognised
9
+ where it starts a token: at the start of the text, after a character that
10
+ cannot be part of it, or directly after an escape sequence (``ESC[0m``,
11
+ ``\\n``, ``\\u00e9``; see ``_ESCAPE``). An assignment's quoted value is
12
+ recognised behind an escaped quote too (``GH_TOKEN=\\"...\\"``, as JSON
13
+ writes it; see ``_QUOTE``).
14
+
15
+ Every pattern has a stable class name. :func:`credential_classes` reports
16
+ which classes a text carries without saying where, so a refusal of agent
17
+ text that would be published (``result_parser``'s content policy) can name
18
+ what to remove without quoting the secret.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import NamedTuple
25
+
26
+
27
+ class _Pattern(NamedTuple):
28
+ """One secret shape: its stable class name, the regexes and the replacements.
29
+
30
+ ``regex`` finds the shape and ``replacement`` rewrites it. A shape that
31
+ starts a token (:func:`_token`) has a second pass, ``after_escape``,
32
+ which finds it directly after an escape sequence and keeps that sequence
33
+ through ``escape_replacement``; the others have none.
34
+ """
35
+
36
+ name: str
37
+ regex: re.Pattern[str]
38
+ replacement: str
39
+ after_escape: re.Pattern[str] | None = None
40
+ escape_replacement: str = ""
41
+
42
+
43
+ # Where a recognised credential may start (#284). There is no regex word
44
+ # boundary (``\b``) after the ``m`` of an ANSI colour code (``ESC[0m``, which
45
+ # OpenCode writes right before an error's text on stderr) or after the ``n``
46
+ # of a ``\n`` written out as two characters (JSON text, a C or Python string
47
+ # literal): both are word characters, so a token directly after one was
48
+ # missed. A token therefore starts at the start of the text, after any
49
+ # character that cannot be part of it (any but an ASCII letter, a digit or
50
+ # ``_``, unless a pattern names its own set; a non-ASCII letter is no part of
51
+ # an ASCII token), or directly after one of these escape sequences:
52
+ #
53
+ # - an ANSI / VT escape sequence: a CSI (``ESC[``, or the C1 byte, then
54
+ # parameter, intermediate and final bytes) or a two-character escape
55
+ # (``ESC(B``, ``ESC7``), its ESC also as JSON, C, a shell and caret
56
+ # notation spell it (``\u001b``, ``\x1b``, ``\033``, ``\e``, ``^[``); an OSC
57
+ # or DCS string ends in BEL or ``ESC\``, already no token character;
58
+ # - a backslash escape as JSON, C and Python write one: a single letter
59
+ # (``\n``, ``\t``, ``\b``, ...), ``\xHH``, ``\uHHHH``, ``\UHHHHHHHH`` and octal.
60
+ #
61
+ # A backslash and such a letter count as an escape wherever they occur, a
62
+ # path separator before a token included (``C:\Users\nghp_...``): the rule
63
+ # errs on redacting. Only the escape right before a token matters, so a run
64
+ # of them (``ESC[91mESC[1m``) needs nothing more. The escapes are matched
65
+ # case-sensitively inside a ``(?i)`` pattern too: ``\N`` is no escape.
66
+ #
67
+ # Each alternative below starts with its literal ESC, backslash, caret or C1
68
+ # byte, so the regex engine scans for those characters and tries an
69
+ # ``after_escape`` pass nowhere else: on text with few of them the second
70
+ # pass costs a fraction of the first, and each candidate scans one escape,
71
+ # so both stay linear.
72
+ _ESCAPE_TAIL = r"(?:\[[0-?]*[ -/]*[@-~]|[ -/]*[0-~])"
73
+ _ESCAPE = (
74
+ r"(?-i:"
75
+ rf"\x1b{_ESCAPE_TAIL}"
76
+ rf"|\\(?:(?:u001[bB]|U0000001[bB]|x1[bB]|0?33|e){_ESCAPE_TAIL}"
77
+ r"|[abefnrtv]|x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|[0-7]{1,3})"
78
+ rf"|\^\[{_ESCAPE_TAIL}"
79
+ r"|\x9b[0-?]*[ -/]*[@-~]"
80
+ r")"
81
+ )
82
+
83
+
84
+ # The optional quote around an assignment's value, also as escaped text
85
+ # writes it (#281): JSON and a C or Python string literal write a quote as
86
+ # ``\"`` or ``\'``, so in a serialized document (an agent's CONTROL_RESULT on
87
+ # stdout, a structure printed as JSON) ``GH_TOKEN="..."`` reads
88
+ # ``GH_TOKEN=\"...\"``. A plain quote alone stopped the value at the
89
+ # backslash and left the secret after it in the clear. Up to seven
90
+ # backslashes (JSON carried in a JSON string, three deep) are taken with the
91
+ # quote, the bound ``oauth-field`` uses, so a run of them is never rescanned.
92
+ _QUOTE = r"(?:\\{0,7}[\"'])?"
93
+ # A backslash inside an assignment's value, unless it starts the escaped
94
+ # closing quote, which is kept in its own group with the backslashes before
95
+ # it: the replacement leaves ``\"`` whole and the JSON around it intact. The
96
+ # lookahead is bounded like ``_QUOTE``.
97
+ _VALUE_BACKSLASH = r"\\(?!\\{0,6}[\"'])"
98
+
99
+
100
+ def _token(
101
+ name: str, body: str, replacement: str, *, flags: str = "", token_chars: str = "A-Za-z0-9_"
102
+ ) -> _Pattern:
103
+ """The pattern for a shape that starts a token, by the rule above.
104
+
105
+ ``regex`` finds ``body`` at the start of the text or after any character
106
+ but ``token_chars``. ``after_escape`` finds it directly after an escape
107
+ sequence, taken in as the ``lead`` group, and ``escape_replacement``
108
+ puts that back before ``replacement``, whose ``\\N`` references move one
109
+ group on past it. A new pattern is built here, never on ``\\b``.
110
+ """
111
+ return _Pattern(
112
+ name,
113
+ re.compile(rf"{flags}(?<![{token_chars}]){body}"),
114
+ replacement,
115
+ re.compile(rf"{flags}(?P<lead>{_ESCAPE}){body}"),
116
+ r"\g<lead>" + re.sub(r"\\(\d+)", lambda m: rf"\g<{int(m[1]) + 1}>", replacement),
117
+ )
118
+
119
+
120
+ # The OAuth credential fields a quoted key names: the ``oauth-field`` pattern
121
+ # below, and :func:`redact_obj` for a mapping entry under such a key.
122
+ _OAUTH_FIELD_NAMES = r"refresh|access|(?:refresh|access|id)_token|chatgpt[-_]account[-_]id"
123
+
124
+ # Applied in this order, each to the previous one's output, a pattern's
125
+ # ``after_escape`` pass right after its ``regex``; the order is part of
126
+ # :func:`redact`'s output and of the class :func:`credential_classes`
127
+ # reports. An ``sk-ant-`` key with at least eight characters after ``sk-`` is
128
+ # replaced by ``openai-key`` before ``anthropic-key`` sees it, so it is
129
+ # reported under that class.
130
+ _PATTERNS: tuple[_Pattern, ...] = (
131
+ # VAR=secret / VAR: secret assignments for well-known names.
132
+ _token(
133
+ "env-assignment",
134
+ r"(GITHUB_TOKEN|GH_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|"
135
+ r"OPENCODE_API_KEY|GITLAB_TOKEN|HF_TOKEN|SLACK_TOKEN|AWS_SECRET_ACCESS_KEY|"
136
+ r"ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|OPENCODE_SERVER_PASSWORD)"
137
+ rf"(\s*[:=]\s*)({_QUOTE})((?:[^\s\"';\\]|{_VALUE_BACKSLASH})+)({_QUOTE})",
138
+ r"\1\2\3***REDACTED***\5",
139
+ flags="(?i)",
140
+ ),
141
+ # Authorization: Bearer <token> / Token <token> / Basic <base64>
142
+ _token(
143
+ "authorization-header",
144
+ r"(Authorization\s*:\s*(?:Bearer|Token|Basic)\s+)([^\s\"';]+)",
145
+ r"\1***REDACTED***",
146
+ flags="(?i)",
147
+ ),
148
+ # Credentials embedded in a URL: everything between ``scheme://`` and
149
+ # ``@`` (``https://x-access-token:<token>@github.com/...``,
150
+ # ``https://<token>@...``, ``postgresql://user:password@...``). The whole
151
+ # userinfo goes, username included: a bare userinfo is as often a token
152
+ # as a name, and the host and path that follow keep the line readable.
153
+ # The userinfo class stops at the characters that end a URL's authority
154
+ # (``/``, ``?``, ``#``; RFC 3986 §3.2), so an ``@`` in a path, query or
155
+ # fragment (``https://host?to=a@b``) is not mistaken for a credential
156
+ # delimiter. The scheme is bounded and the class excludes ``/``, so a
157
+ # candidate never scans past the ``://`` of the next one: the pass stays
158
+ # linear over the 16 MiB an executor capture can be.
159
+ _token(
160
+ "url-userinfo",
161
+ r"([a-z][a-z0-9+.-]{1,31}://)[^\s/?#@]+@",
162
+ r"\1***REDACTED***@",
163
+ flags="(?i)",
164
+ ),
165
+ # JWTs: three base64url segments, the first a ``{"`` JSON header. Only
166
+ # the header has a floor: ``eyJ`` is ``{"`` plus the first key character,
167
+ # and the smallest such object (``{"a":0}``) encodes to ten characters.
168
+ # The payload and signature may be short or empty, as the compact
169
+ # serialization permits: ``{}`` is ``e30``, an unsecured ``alg: none``
170
+ # token (RFC 7519 §6) has an empty signature, and detached content
171
+ # (RFC 7515 App. F) an empty payload. ``-`` is a token character here,
172
+ # so a ``-eyJ`` inside a segment is not a fresh candidate that rescans
173
+ # the run (base64url admits ``-``, and no escape starts inside one); each
174
+ # run is then scanned by at most three candidates and the pass stays
175
+ # linear. Placed before the shorter token shapes so one of them cannot
176
+ # replace a slice of the token and leave the rest unrecognised.
177
+ _token(
178
+ "jwt",
179
+ r"eyJ[A-Za-z0-9_-]{7,}\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*",
180
+ "***REDACTED***",
181
+ token_chars="A-Za-z0-9_-",
182
+ ),
183
+ # Google API keys (#282): ``AIza`` and 35 base64url characters, 39 in all,
184
+ # the key OpenCode launches inherit under ``GEMINI_*`` and ``GOOGLE_*``
185
+ # names, which no assignment pattern knows (#285); the shape recognises
186
+ # the value under any name, in a URL's ``?key=`` and alone. Case-sensitive
187
+ # and the whole token: ``-`` is a token character on both sides, as
188
+ # for a JWT, and a run of the key's characters that goes on past the 39
189
+ # is no key, so a near-miss of either length is left. The body is fixed,
190
+ # so each candidate scans at most 40 characters and the pass is linear.
191
+ # Placed before the GitHub and ``sk-`` shapes so that one starting after a
192
+ # ``-`` inside a key cannot replace a slice of it and leave the rest.
193
+ _token(
194
+ "google-api-key",
195
+ r"AIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])",
196
+ "***REDACTED***",
197
+ token_chars="A-Za-z0-9_-",
198
+ ),
199
+ # GitHub fine-grained PATs
200
+ _token("github-fine-grained-pat", r"github_pat_[A-Za-z0-9_]{8,}", "***REDACTED***"),
201
+ # GitHub classic PATs and app / OAuth tokens
202
+ _token("github-token", r"gh[pousr]_[A-Za-z0-9_]{8,}", "***REDACTED***"),
203
+ # OpenAI-style sk- keys (incl. project variant sk-proj-...)
204
+ _token("openai-key", r"sk-(?:proj-)?[A-Za-z0-9_-]{8,}", "***REDACTED***"),
205
+ # Anthropic keys
206
+ _token("anthropic-key", r"sk-ant-[A-Za-z0-9_-]{8,}", "***REDACTED***"),
207
+ # Generic long bearer-ish assignments: token/password/secret = <value>.
208
+ # ``_`` is a token character before the name, so a prefixed one
209
+ # (``GEMINI_API_KEY=``) is not recognised here (#285); a Google API key
210
+ # under it is, by its shape (``google-api-key``).
211
+ _token(
212
+ "secret-assignment",
213
+ rf"(token|passwd|password|secret|api[_-]?key)(\s*[:=]\s*)({_QUOTE})"
214
+ rf"([A-Za-z0-9_\-./+]{{12,}})({_QUOTE})",
215
+ r"\1\2\3***REDACTED***\5",
216
+ flags="(?i)",
217
+ ),
218
+ # OAuth credential fields as a quoted key and value, the shape of an OAuth
219
+ # token response, Pi's ``auth.json`` and a Python mapping's repr:
220
+ # ``"refresh": "..."``, ``'access_token': '...'``, ``"id_token": "..."``,
221
+ # ``"chatgpt_account_id": "..."``. Only a quoted key followed by a quoted
222
+ # value matches, so prose ("refresh the page", "access: denied") is
223
+ # untouched. A quote starts it, so it needs no ``after_escape`` pass. The
224
+ # same escaping before both quotes (``\"refresh\": \"...``)
225
+ # covers JSON carried inside a JSON string; the bound on it keeps a run of
226
+ # backslashes from being rescanned from every position.
227
+ _Pattern(
228
+ "oauth-field",
229
+ re.compile(
230
+ rf"(?i)(\\{{0,7}})([\"'])({_OAUTH_FIELD_NAMES})\1\2(\s*:\s*\\{{0,7}}[\"'])[^\"'\s\\]+"
231
+ ),
232
+ r"\1\2\3\1\2\4***REDACTED***",
233
+ ),
234
+ # The same credentials as an assignment or header: ``refresh_token=...``
235
+ # (a query string, a form body, a shell variable), ``id_token: ...`` and
236
+ # the ``chatgpt-account-id: ...`` request header. ``_`` is no token
237
+ # character before the name, so a prefixed one (``oauth_refresh_token=``)
238
+ # is caught too.
239
+ _token(
240
+ "oauth-assignment",
241
+ r"((?:refresh|access|id)_token|chatgpt[-_]account[-_]id)"
242
+ rf"(\s*[:=]\s*)({_QUOTE})(?:[^\s\"';,&\\]|{_VALUE_BACKSLASH})+",
243
+ r"\1\2\3***REDACTED***",
244
+ flags="(?i)",
245
+ token_chars="A-Za-z0-9",
246
+ ),
247
+ # Bare ``refresh=`` / ``access=`` with a token-length value. Those words
248
+ # are ordinary prose, so only an ``=`` assignment of at least 16
249
+ # characters counts; ``access: denied`` and ``refresh=true`` stay.
250
+ _token(
251
+ "oauth-bare-assignment",
252
+ rf"(refresh|access)(=)({_QUOTE})(?:[^\s\"';,&\\]|{_VALUE_BACKSLASH}){{16,}}",
253
+ r"\1\2\3***REDACTED***",
254
+ flags="(?i)",
255
+ ),
256
+ )
257
+
258
+ _REDACTED = "***REDACTED***"
259
+
260
+ # A mapping key that names an OAuth credential field, whole (``fullmatch``).
261
+ # ``{"refresh": "..."}`` is the shape ``oauth-field`` finds in a text, but
262
+ # :func:`redact_obj` hands :func:`redact` the key and its value apart, and
263
+ # neither matches on its own; this is how it still sees the shape.
264
+ _OAUTH_FIELD_KEY = re.compile(rf"(?i){_OAUTH_FIELD_NAMES}")
265
+
266
+ # :func:`redact` never returns more than this many times its input's length.
267
+ # Every pattern replaces a run of at least one character with the 14-character
268
+ # marker, so a text can *grow* under redaction; the worst shape is a one
269
+ # character credential in the shortest URL that carries one: ``ab://x@`` is 7
270
+ # characters and becomes 20, a factor of 2.86, and it repeats without a
271
+ # separator (``@`` ends a word), so a text of that shape and no other reaches
272
+ # the ratio and no text exceeds it. The next worst is a one character secret
273
+ # behind the shortest recognised name: ``HF_TOKEN=x``, 10 to 23 (``id_token=x``
274
+ # is the same). Growth does not compound across patterns: a later pattern can
275
+ # only lengthen the text by matching a run *shorter* than the marker, and a
276
+ # marker is never part of such a run. The value classes that admit ``*`` (the
277
+ # named-assignment, header, URL-userinfo and OAuth-field values) can only take
278
+ # a marker in whole, since none of the characters that end such a run occurs
279
+ # in the marker, so a match containing one is already at least as long as its
280
+ # replacement and shrinks or keeps the length; every other value class excludes
281
+ # ``*``, so a marker is never part of those matches at all. The characters
282
+ # around a replaced run are untouched, so no new short match appears beside it.
283
+ # An ``after_escape`` pass is one more pattern in that order, and the escape
284
+ # sequence it starts at (#284) is put back unchanged, no part of the replaced
285
+ # run; at two characters or more it separates repeated matches no tighter
286
+ # than the one-character separators above, so it raises no ratio either.
287
+ # The escaped quote an assignment's value may stand in (#281, ``_QUOTE``) is
288
+ # kept like a plain one, no part of the replaced run either.
289
+ # ``tests/test_redaction.py`` pins the factor against the worst-case shape of
290
+ # every pattern and the wrapping case; a new pattern must keep it, or raise
291
+ # it together with the persisted bound in :mod:`autoforge.loop_guard` that
292
+ # depends on it.
293
+ MAX_GROWTH_FACTOR = 3
294
+
295
+
296
+ def redact(text: str | None) -> str:
297
+ """Redact known secret shapes. Never raises; non-str input -> str()."""
298
+ if text is None:
299
+ return ""
300
+ if not isinstance(text, str):
301
+ text = str(text)
302
+ for entry in _PATTERNS:
303
+ text = _apply(entry, text)
304
+ return text
305
+
306
+
307
+ def _apply(entry: _Pattern, text: str) -> str:
308
+ """``text`` with one pattern's passes applied, ``after_escape`` second."""
309
+ text = entry.regex.sub(entry.replacement, text)
310
+ if entry.after_escape is not None:
311
+ text = entry.after_escape.sub(entry.escape_replacement, text)
312
+ return text
313
+
314
+
315
+ def credential_classes(text: str) -> tuple[str, ...]:
316
+ """The class names of the patterns :func:`redact` would apply to ``text``.
317
+
318
+ Runs the same sequential substitution as :func:`redact` and records each
319
+ pattern that changed the text, in pattern order and each name once. The
320
+ result is empty exactly when ``redact(text) == text``: a text with a
321
+ credential shape is refused by name, never redacted and published.
322
+ """
323
+ found: list[str] = []
324
+ redacted = text
325
+ for entry in _PATTERNS:
326
+ replaced = _apply(entry, redacted)
327
+ if replaced != redacted:
328
+ found.append(entry.name)
329
+ redacted = replaced
330
+ # Exact by construction: should a later pattern ever restore what an
331
+ # earlier one changed, the text is still what ``redact`` returns.
332
+ return tuple(found) if redacted != text else ()
333
+
334
+
335
+ def redact_argv(argv: list[str]) -> list[str]:
336
+ return [redact(a) for a in argv]
337
+
338
+
339
+ def redact_obj(value: object) -> object:
340
+ """Recursively redact every string inside a JSON-shaped structure.
341
+
342
+ Log metadata and agent-supplied CONTROL_RESULT fields are persisted as
343
+ nested dicts/lists, so redacting only the top-level text would leave a
344
+ secret sitting one level down (``metadata["validation_command"]``, a
345
+ finding's ``required_resolution``). Mapping *keys* are redacted too: a key
346
+ is as capable of carrying a token as a value. Non-string scalars are
347
+ returned unchanged; anything exotic is stringified through `redact`, which
348
+ never raises.
349
+
350
+ Each string is redacted on its own, never as part of a serialized
351
+ document: a pattern run over JSON text can consume the syntax around a
352
+ value (an empty ``GITHUB_TOKEN=`` takes the closing quote, #206), so the
353
+ redacted text may no longer parse. (A secret right after an escape there,
354
+ ``\\n`` or ``\\u00e9``, follows a letter or a digit, and a quoted value
355
+ stands behind ``\\"``; they are recognised since #284 and #281, but the
356
+ syntax is still at risk.) The one shape that spans a key
357
+ and its value, a quoted OAuth credential field (``"refresh": "..."``,
358
+ ``oauth-field``), is recognised by the key instead: a non-empty string
359
+ under a key that names such a field, whole and in any case, is replaced
360
+ whole. No key of a
361
+ parser-bounded field (a finding, a resolution) names one, so the bounds
362
+ that depend on :data:`MAX_GROWTH_FACTOR` still hold.
363
+
364
+ A mapping never loses an entry to redaction. Two distinct keys can
365
+ redact to the same text (``GITHUB_TOKEN=a`` and ``GITHUB_TOKEN=b`` both
366
+ become ``GITHUB_TOKEN=***REDACTED***``), and a hand-edited journal or a
367
+ free-form ``escalation`` mapping can carry such keys; letting the later
368
+ entry overwrite the earlier one would drop a value from the diagnostic
369
+ output while the file on disk still holds it. A colliding key is instead
370
+ suffixed ``#2``, ``#3``, ... in insertion order, so every value is kept,
371
+ every key stays redacted, and the collision is visible in the output.
372
+ The next suffix is remembered per colliding text, so a mapping whose keys
373
+ all collide (an ``escalation`` mapping is free-form and the state file can
374
+ be tens of megabytes) is redacted in time linear in its size rather than
375
+ rescanning the taken suffixes from ``#2`` for every entry.
376
+ """
377
+ if isinstance(value, str):
378
+ return redact(value)
379
+ if value is None or isinstance(value, (bool, int, float)):
380
+ return value
381
+ if isinstance(value, dict):
382
+ result: dict = {}
383
+ # Suffixes are handed out in increasing order per colliding text, so
384
+ # each taken suffix is stepped over at most once: an input key that
385
+ # already reads ``text#n`` is skipped, never rescanned.
386
+ next_suffix: dict[str, int] = {}
387
+ for k, v in value.items():
388
+ key = redact(str(k))
389
+ if key in result:
390
+ n = next_suffix.get(key, 2)
391
+ while f"{key}#{n}" in result:
392
+ n += 1
393
+ next_suffix[key] = n + 1
394
+ key = f"{key}#{n}"
395
+ if isinstance(k, str) and isinstance(v, str) and v and _OAUTH_FIELD_KEY.fullmatch(k):
396
+ result[key] = _REDACTED
397
+ else:
398
+ result[key] = redact_obj(v)
399
+ return result
400
+ if isinstance(value, (list, tuple)):
401
+ return [redact_obj(v) for v in value]
402
+ return redact(str(value))
403
+
404
+
405
+ def redact_dict(value: dict) -> dict:
406
+ """`redact_obj` for a mapping, typed for callers that persist dicts."""
407
+ result = redact_obj(value)
408
+ return result if isinstance(result, dict) else {}
autoforge/replan.py ADDED
@@ -0,0 +1,208 @@
1
+ """Deterministic REPLAN_REEXECUTE policy and compact history collection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from .config import ReplanConfig
9
+ from .github import GitHubClient
10
+ from .loop_guard import RESULT_NEEDS_FIX, truncated_evidence_rounds
11
+
12
+ # Any run of three or more tildes can close (or open) a tilde code fence, so
13
+ # the escape must break *runs*, not the literal fence string: replacing only
14
+ # "~~~~" is non-overlapping and left-to-right, which lets "~~~~~~~" re-form a
15
+ # closing fence out of the untouched tail.
16
+ _TILDE_RUN_RE = re.compile(r"~{3,}")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ReplanDecision:
21
+ action: str # continue_fix | replan | block_for_human
22
+ reason: str = ""
23
+ metadata: dict[str, object] | None = None
24
+
25
+
26
+ def evaluate_replan_policy(
27
+ *,
28
+ has_actionable_findings: bool,
29
+ current_review_round: int,
30
+ review_history: list[dict],
31
+ escalation_count: int,
32
+ config: ReplanConfig,
33
+ workflow_stagnation_reason: str = "",
34
+ ) -> ReplanDecision:
35
+ """Choose the post-review route without inspecting agent prose.
36
+
37
+ A clean review always wins. ``max_replans_per_issue`` counts completed
38
+ fresh reimplementations, not the original implementation attempt.
39
+
40
+ A replan is refused outright while any recorded round's findings are
41
+ truncated (:func:`~autoforge.loop_guard.truncated_evidence_rounds`): that
42
+ evidence is what the replacement must answer for, and the old PR is the
43
+ only remaining copy of it.
44
+
45
+ ``soft_threshold`` is the review round from which the controller may
46
+ *replace* an implementation instead of continuing to patch it. Every
47
+ stagnation trigger is gated behind it, including the ``workflow.stagnation_*``
48
+ verdict passed in as ``workflow_stagnation_reason``: an early streak of
49
+ identical resolutions is usually a single FIX round that missed a finding,
50
+ and discarding the whole PR over that is far more destructive than the
51
+ documented BLOCKED. Below the threshold this returns ``continue_fix`` and
52
+ the caller's ordinary loop bounds (cap / stagnation -> BLOCKED) apply
53
+ unchanged; at or above it, sustained non-convergence escalates to a replan.
54
+
55
+ The window rule (``stagnation_window`` trailing rounds with findings, each
56
+ holding at most ``max_findings_per_round`` of them) deliberately counts
57
+ rounds of *entirely new* findings. Unlike ``workflow.stagnation_*`` it
58
+ requires no recurring ``required_resolution``: recurrence is what those
59
+ rules already detect, and their verdict arrives here as
60
+ ``workflow_stagnation_reason``, so a recurrence requirement would leave
61
+ this rule nothing of its own to catch. Its purpose is the long tail -- an
62
+ implementation that, ``soft_threshold`` rounds in, still draws a small
63
+ but never-ending trickle of fresh findings from every review. Below the
64
+ threshold such rounds are progress bounded by the round cap only, exactly
65
+ as the workflow rules treat them; the threshold, not a recurrence, is
66
+ what protects a productive loop from being replaced.
67
+ """
68
+ if not has_actionable_findings or not config.enabled:
69
+ return ReplanDecision("continue_fix")
70
+
71
+ trigger = ""
72
+ metadata: dict[str, object] = {
73
+ "current_round": current_review_round,
74
+ "soft_threshold": config.soft_threshold,
75
+ "hard_threshold": config.hard_threshold,
76
+ }
77
+ if workflow_stagnation_reason and current_review_round >= config.soft_threshold:
78
+ trigger = "workflow_stagnation"
79
+ metadata["workflow_stagnation_reason"] = workflow_stagnation_reason
80
+ elif current_review_round >= config.hard_threshold:
81
+ trigger = "hard_review_round_threshold"
82
+ elif current_review_round >= config.soft_threshold:
83
+ window = config.stagnation_window
84
+ tail = review_history[-window:]
85
+ counts = [r.get("finding_count") for r in tail]
86
+ metadata.update(
87
+ {
88
+ "stagnation_window": window,
89
+ "max_findings_per_round": config.max_findings_per_round,
90
+ "recent_finding_counts": counts,
91
+ }
92
+ )
93
+ if (
94
+ len(tail) == window
95
+ and all(r.get("result") == RESULT_NEEDS_FIX for r in tail)
96
+ and all(
97
+ isinstance(count, int) and count <= config.max_findings_per_round
98
+ for count in counts
99
+ )
100
+ ):
101
+ trigger = "stagnation_after_soft_threshold"
102
+ if not trigger:
103
+ return ReplanDecision("continue_fix")
104
+ metadata["trigger"] = trigger
105
+ metadata["replan_count"] = escalation_count
106
+ metadata["max_replans_per_issue"] = config.max_replans_per_issue
107
+ truncated = truncated_evidence_rounds(review_history)
108
+ if truncated:
109
+ # Superseding the PR deletes the controller's record of its findings.
110
+ # If that record is already incomplete, the replacement protocol cannot
111
+ # require the agent to consider the missing findings, and closing the
112
+ # PR would drop them for good. Keep the PR and the findings for a human.
113
+ metadata["truncated_evidence_rounds"] = truncated
114
+ return ReplanDecision("block_for_human", "replan_evidence_truncated", metadata)
115
+ if escalation_count >= config.max_replans_per_issue:
116
+ return ReplanDecision("block_for_human", "replan_limit_exceeded", metadata)
117
+ return ReplanDecision("replan", trigger, metadata)
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class HistoricalReviewData:
122
+ findings: list[dict]
123
+ observations: list[str]
124
+ verification_failures: list[str]
125
+ # Number of findings actually rendered into the prompt, which the agent
126
+ # must account for exactly. A replan only gets this far when no round was
127
+ # truncated (``evaluate_replan_policy`` and ``_prepare_replan`` both refuse
128
+ # otherwise), so this equals the reviews' own untruncated finding counts.
129
+ recorded_finding_count: int = 0
130
+
131
+ @staticmethod
132
+ def _render_untrusted(text: str) -> str:
133
+ """Prevent collected text from terminating the prompt's outer fence.
134
+
135
+ Every run of 3+ tildes is broken apart, so no residual run of any
136
+ length (indented or not) can act as a fence delimiter.
137
+ """
138
+ return _TILDE_RUN_RE.sub(lambda m: " ".join(m.group(0)), text)
139
+
140
+ def render_findings(self) -> str:
141
+ if not self.findings:
142
+ return "(none)"
143
+ return self._render_untrusted(
144
+ "\n".join(
145
+ "- {id} (round {round}, {classification}): {required_resolution}".format(
146
+ id=f.get("id", "(unknown)"),
147
+ round=f.get("round", "?"),
148
+ classification=f.get("classification", "unknown"),
149
+ required_resolution=f.get("required_resolution", ""),
150
+ )
151
+ for f in self.findings
152
+ )
153
+ )
154
+
155
+ def render_observations(self) -> str:
156
+ if not self.observations:
157
+ return "(none)"
158
+ return self._render_untrusted("\n\n".join(self.observations))
159
+
160
+ def render_verification_failures(self) -> str:
161
+ if not self.verification_failures:
162
+ return "(none)"
163
+ return self._render_untrusted("\n".join(self.verification_failures))
164
+
165
+
166
+ class HistoricalReviewCollector:
167
+ """Collect bounded, typed review evidence before an implementation is abandoned.
168
+
169
+ State carries compact finding summaries and GitHub remains the source for
170
+ review-comment bodies. Only comments referenced by the persisted review
171
+ rounds are collected; arbitrary PR conversation is never injected.
172
+ """
173
+
174
+ def __init__(self, github: GitHubClient, max_comment_chars: int = 24000) -> None:
175
+ self.github = github
176
+ self.max_comment_chars = max_comment_chars
177
+
178
+ def collect(
179
+ self,
180
+ previous_pr_url: str,
181
+ review_history: list[dict],
182
+ verification_failures: list[str],
183
+ ) -> HistoricalReviewData:
184
+ findings: list[dict] = []
185
+ observations: list[str] = []
186
+ comments = {c.url: c for c in self.github.get_pr_comments(previous_pr_url)}
187
+ remaining = self.max_comment_chars
188
+ for record in review_history:
189
+ if record.get("result") != RESULT_NEEDS_FIX:
190
+ continue
191
+ round_number = record.get("round")
192
+ for finding in record.get("findings", []):
193
+ if isinstance(finding, dict):
194
+ findings.append({"round": round_number, **finding})
195
+ comment_url = record.get("review_comment_url", "")
196
+ comment = comments.get(comment_url)
197
+ if comment is not None and comment.body and remaining > 0:
198
+ body = comment.body[:remaining]
199
+ observations.append(
200
+ f"Review round {round_number} comment ({comment.url}, untrusted data):\n{body}"
201
+ )
202
+ remaining -= len(body)
203
+ return HistoricalReviewData(
204
+ findings,
205
+ observations,
206
+ list(verification_failures),
207
+ len(findings),
208
+ )