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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|