autoforge-cli 0.1.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/sdk_policy.py
ADDED
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
"""Unattended policy for the SDK providers (#149; ADR 0007 D2.7, D10.1, D10.2).
|
|
2
|
+
|
|
3
|
+
What a ``claude-sdk`` or ``opencode-sdk`` launch may load, which tools it
|
|
4
|
+
gets and which of their calls may ask a human, where its configuration and
|
|
5
|
+
session state live, how it authenticates, which variables of the
|
|
6
|
+
controller's environment it inherits, and the bounds every piece of
|
|
7
|
+
interaction data is held to before it is normalized, persisted or shown.
|
|
8
|
+
``docs/sdk-policy.md`` records each decision with its evidence and whether
|
|
9
|
+
it was verified, and on which runtime.
|
|
10
|
+
|
|
11
|
+
Provider layer, and pure: no process, no file, no network, no SDK import,
|
|
12
|
+
and no change to the controller's own environment (the launch allow-list
|
|
13
|
+
reads the *names* in it, never a value). The engine and the executor name
|
|
14
|
+
nothing of it. Its consumers:
|
|
15
|
+
|
|
16
|
+
- the SDK adapters in :mod:`autoforge.providers`, whose
|
|
17
|
+
``validate_profile`` (config load and every launch) builds the policy, so
|
|
18
|
+
an unknown option, a value outside its closed set or an escape hatch is a
|
|
19
|
+
:class:`~autoforge.errors.ConfigurationError` before anything starts;
|
|
20
|
+
- the helpers of #151 and #152, which receive :meth:`ClaudeSdkPolicy.start_fields`
|
|
21
|
+
or :meth:`OpenCodeSdkPolicy.start_fields` as their ``start`` record's
|
|
22
|
+
``policy`` and the policy's environment over the launch's;
|
|
23
|
+
- the interaction broker of #134, which bounds and renders a provider's ask
|
|
24
|
+
with the functions at the end of this module.
|
|
25
|
+
|
|
26
|
+
There is no pass-through: no SDK option, CLI flag, settings document,
|
|
27
|
+
hook, MCP server, plugin or environment variable reaches a helper unless a
|
|
28
|
+
field here names it, and no profile option carries free text into one.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import secrets
|
|
37
|
+
import unicodedata
|
|
38
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
from .child_env import refuse_forbidden_env
|
|
43
|
+
from .errors import ConfigurationError, InteractionDataError
|
|
44
|
+
from .progress import clean
|
|
45
|
+
from .redaction import redact, redact_obj
|
|
46
|
+
|
|
47
|
+
CLAUDE_SDK = "claude-sdk"
|
|
48
|
+
OPENCODE_SDK = "opencode-sdk"
|
|
49
|
+
SDK_PROVIDERS = (CLAUDE_SDK, OPENCODE_SDK)
|
|
50
|
+
|
|
51
|
+
# The version of the flat policy a helper receives (``start_fields``). It
|
|
52
|
+
# moves whenever a field is added or changes meaning, together with the
|
|
53
|
+
# helper envelope's protocol (ADR 0007 D4.5).
|
|
54
|
+
POLICY_SCHEMA = 1
|
|
55
|
+
|
|
56
|
+
# The profiles whose agent writes the worktree; every other profile (the
|
|
57
|
+
# reviewers, ``update_epic``) gets the read inventory. The same split as
|
|
58
|
+
# Pi's (docs/pi-policy.md §6): the read set is defence in depth, never a
|
|
59
|
+
# write barrier, because the shell stays.
|
|
60
|
+
WRITE_PROFILES = ("analyze_execute", "fix", "replan_reexecute")
|
|
61
|
+
|
|
62
|
+
# -- interaction policy and limits (ADR 0007 D7.1, D7.3) ----------------------
|
|
63
|
+
INTERACTION_FAIL = "fail"
|
|
64
|
+
INTERACTION_PROMPT = "prompt"
|
|
65
|
+
INTERACTION_PAUSE = "pause"
|
|
66
|
+
INTERACTION_POLICIES = (INTERACTION_FAIL, INTERACTION_PROMPT, INTERACTION_PAUSE)
|
|
67
|
+
DEFAULT_MAX_WAIT_SECONDS = 120
|
|
68
|
+
DEFAULT_MAX_INTERACTIONS = 8
|
|
69
|
+
# Bounds on what an operator may configure: a wait is a person at a
|
|
70
|
+
# terminal, and the aggregate bound keeps an agent from turning a run into
|
|
71
|
+
# a conversation.
|
|
72
|
+
MAX_WAIT_SECONDS_LIMIT = 24 * 3600
|
|
73
|
+
MAX_INTERACTIONS_LIMIT = 64
|
|
74
|
+
INTERACTION_OPTION_KEYS = ("interaction_policy", "max_wait_seconds", "max_interactions")
|
|
75
|
+
|
|
76
|
+
_WHOLE_NUMBER = re.compile(r"[1-9][0-9]{0,8}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class InteractionSettings:
|
|
81
|
+
"""A profile's ``interaction_policy`` and its limits, validated.
|
|
82
|
+
|
|
83
|
+
``fail`` (the default) fails the invocation on any ask; ``prompt`` asks
|
|
84
|
+
the operator live. ``pause`` is refused until #126 lands and the
|
|
85
|
+
provider's pause gates pass (ADR 0007 D7.1, D9.4, D3.4).
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
policy: str = INTERACTION_FAIL
|
|
89
|
+
max_wait_seconds: int = DEFAULT_MAX_WAIT_SECONDS
|
|
90
|
+
max_interactions: int = DEFAULT_MAX_INTERACTIONS
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def live(self) -> bool:
|
|
94
|
+
"""Whether an ask reaches a person at all (only under ``prompt``)."""
|
|
95
|
+
return self.policy == INTERACTION_PROMPT
|
|
96
|
+
|
|
97
|
+
def as_dict(self) -> dict[str, object]:
|
|
98
|
+
return {
|
|
99
|
+
"policy": self.policy,
|
|
100
|
+
"max_wait_seconds": self.max_wait_seconds,
|
|
101
|
+
"max_interactions": self.max_interactions,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _whole_number(options: Mapping[str, str], key: str, default: int, high: int, where: str) -> int:
|
|
106
|
+
raw = options.get(key)
|
|
107
|
+
if raw is None:
|
|
108
|
+
return default
|
|
109
|
+
if not _WHOLE_NUMBER.fullmatch(raw) or int(raw) > high:
|
|
110
|
+
raise ConfigurationError(
|
|
111
|
+
f"{where}: option {key} must be a whole number from 1 to {high}, got {raw!r}"
|
|
112
|
+
)
|
|
113
|
+
return int(raw)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def interaction_settings(options: Mapping[str, str], where: str) -> InteractionSettings:
|
|
117
|
+
"""The interaction settings ``options`` name, or :class:`ConfigurationError`."""
|
|
118
|
+
policy = options.get("interaction_policy", INTERACTION_FAIL)
|
|
119
|
+
if policy not in INTERACTION_POLICIES:
|
|
120
|
+
raise ConfigurationError(
|
|
121
|
+
f"{where}: option interaction_policy must be one of "
|
|
122
|
+
f"{', '.join(INTERACTION_POLICIES)}, got {policy!r}"
|
|
123
|
+
)
|
|
124
|
+
if policy == INTERACTION_PAUSE:
|
|
125
|
+
raise ConfigurationError(
|
|
126
|
+
f"{where}: interaction_policy pause is not available: a pause needs the durable "
|
|
127
|
+
"pending-interaction record of #126 and the provider's pause gates (ADR 0007 "
|
|
128
|
+
"D7.1: G-C6 and G-C7 for Claude; G-O6 and, for a permission, G-O11 for OpenCode), "
|
|
129
|
+
"and none has landed. Use fail (the default) or prompt"
|
|
130
|
+
)
|
|
131
|
+
return InteractionSettings(
|
|
132
|
+
policy=policy,
|
|
133
|
+
max_wait_seconds=_whole_number(
|
|
134
|
+
options, "max_wait_seconds", DEFAULT_MAX_WAIT_SECONDS, MAX_WAIT_SECONDS_LIMIT, where
|
|
135
|
+
),
|
|
136
|
+
max_interactions=_whole_number(
|
|
137
|
+
options, "max_interactions", DEFAULT_MAX_INTERACTIONS, MAX_INTERACTIONS_LIMIT, where
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _tool_list(
|
|
143
|
+
raw: str, known: Sequence[str], key: str, where: str, provider: str
|
|
144
|
+
) -> tuple[str, ...]:
|
|
145
|
+
"""``raw`` as a tuple of tools of ``known``: comma-separated, no spaces, no duplicates."""
|
|
146
|
+
names = raw.split(",") if raw else []
|
|
147
|
+
problem = ""
|
|
148
|
+
if not raw or any(not name or name != name.strip() for name in names):
|
|
149
|
+
problem = "expected names separated by ',' with no spaces and no empty entry"
|
|
150
|
+
elif unknown := [name for name in names if name not in known]:
|
|
151
|
+
problem = f"unknown or unsupported tool(s) {', '.join(unknown)}"
|
|
152
|
+
elif len(set(names)) != len(names):
|
|
153
|
+
problem = "a tool is listed twice"
|
|
154
|
+
if problem:
|
|
155
|
+
raise ConfigurationError(
|
|
156
|
+
f"{where}: {provider} option {key} must list tools of {', '.join(known)}: "
|
|
157
|
+
f"{problem}, got {raw!r}"
|
|
158
|
+
)
|
|
159
|
+
return tuple(names)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# -- environment (ADR 0007 D2.1, D10.1) ---------------------------------------
|
|
163
|
+
# The variables of other model providers and other agent CLIs, credentials
|
|
164
|
+
# and the settings that route them alike. An SDK launch inherits none of
|
|
165
|
+
# them, whatever the allow-list says: its own names are added back after
|
|
166
|
+
# the filter (:func:`sdk_launch_allowlist`).
|
|
167
|
+
FOREIGN_PREFIXES = (
|
|
168
|
+
"ANTHROPIC_",
|
|
169
|
+
"CLAUDE_",
|
|
170
|
+
"OPENAI_",
|
|
171
|
+
"OPENCODE_",
|
|
172
|
+
"PI_",
|
|
173
|
+
"GEMINI_",
|
|
174
|
+
"GOOGLE_",
|
|
175
|
+
"VERTEX_",
|
|
176
|
+
"CLOUD_ML_",
|
|
177
|
+
"AWS_",
|
|
178
|
+
"AZURE_",
|
|
179
|
+
"BEDROCK_",
|
|
180
|
+
"OPENROUTER_",
|
|
181
|
+
"MISTRAL_",
|
|
182
|
+
"GROQ_",
|
|
183
|
+
"XAI_",
|
|
184
|
+
"DEEPSEEK_",
|
|
185
|
+
"TOGETHER_",
|
|
186
|
+
"FIREWORKS_",
|
|
187
|
+
"CEREBRAS_",
|
|
188
|
+
"COHERE_",
|
|
189
|
+
"PERPLEXITY_",
|
|
190
|
+
"HF_",
|
|
191
|
+
"HUGGINGFACE_",
|
|
192
|
+
"OLLAMA_",
|
|
193
|
+
)
|
|
194
|
+
# A name shaped like a credential, whoever it belongs to.
|
|
195
|
+
CREDENTIAL_SUFFIXES = (
|
|
196
|
+
"_API_KEY",
|
|
197
|
+
"_KEY",
|
|
198
|
+
"_TOKEN",
|
|
199
|
+
"_SECRET",
|
|
200
|
+
"_PASSWORD",
|
|
201
|
+
"_PASSWD",
|
|
202
|
+
"_CREDENTIALS",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def foreign_name(name: str) -> bool:
|
|
207
|
+
"""Whether variable ``name`` is withheld from every SDK launch (compared upper-cased)."""
|
|
208
|
+
upper = name.upper()
|
|
209
|
+
return upper.startswith(FOREIGN_PREFIXES) or upper.endswith(CREDENTIAL_SUFFIXES)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def sdk_launch_allowlist(
|
|
213
|
+
base: Iterable[str],
|
|
214
|
+
own: Iterable[str],
|
|
215
|
+
environ_names: Iterable[str],
|
|
216
|
+
*,
|
|
217
|
+
passthrough: Iterable[str] = (),
|
|
218
|
+
) -> tuple[str, ...]:
|
|
219
|
+
"""The exact variable names an SDK launch inherits.
|
|
220
|
+
|
|
221
|
+
``base`` is the configured allow-list (``execution.env_allowlist`` plus
|
|
222
|
+
``env_allowlist_extra``); a ``*`` prefix in it is expanded against
|
|
223
|
+
``environ_names`` (the names in the controller's environment, never a
|
|
224
|
+
value), so the result holds exact names only and no prefix can carry a
|
|
225
|
+
credential through. Every :func:`foreign_name` is dropped, unless it is
|
|
226
|
+
in ``passthrough`` and ``base`` lists it exactly; ``own`` (the
|
|
227
|
+
provider's credential) is added last. A forbidden name (#165) is
|
|
228
|
+
refused, as for every launch.
|
|
229
|
+
"""
|
|
230
|
+
base = tuple(base)
|
|
231
|
+
refuse_forbidden_env(base, "an SDK launch allow-list")
|
|
232
|
+
allowed = set(passthrough)
|
|
233
|
+
environ = list(dict.fromkeys(environ_names))
|
|
234
|
+
names: list[str] = []
|
|
235
|
+
for entry in base:
|
|
236
|
+
if entry.endswith("*"):
|
|
237
|
+
prefix = entry[:-1]
|
|
238
|
+
names += [n for n in environ if n.startswith(prefix) and not foreign_name(n)]
|
|
239
|
+
elif entry in allowed or not foreign_name(entry):
|
|
240
|
+
names.append(entry)
|
|
241
|
+
result = tuple(dict.fromkeys([*names, *own]))
|
|
242
|
+
refuse_forbidden_env(result, "an SDK launch allow-list")
|
|
243
|
+
return result
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# -- configuration and session roots (ADR 0007 D2.7; ADR 0005 C5, D8.3, D8.4) --
|
|
247
|
+
# Under the issue's controller-owned sandbox directory
|
|
248
|
+
# (``<git common dir>/autoforge/sandbox/<worktree>`` in REMOTE,
|
|
249
|
+
# ``<git dir>/autoforge/sandbox/local`` in LOCAL; ``sandbox_policy``), so a
|
|
250
|
+
# root is never inside a working tree, the LOCAL fingerprinted tree
|
|
251
|
+
# included (its git directory is excluded by identity), is per issue, and
|
|
252
|
+
# persists for a continuation on the same directory (D8.4).
|
|
253
|
+
SDK_ROOT_DIR = "sdk"
|
|
254
|
+
# How many days a dedicated root keeps a session transcript (Claude's
|
|
255
|
+
# ``cleanupPeriodDays``); a continuation whose transcript is gone is
|
|
256
|
+
# ``BLOCKED``, never a new session under the recorded id (D2.7, D9.3).
|
|
257
|
+
SESSION_RETENTION_DAYS = 30
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def sdk_root(sandbox_dir: Path, provider: str) -> Path:
|
|
261
|
+
"""The root of ``provider``'s state under an issue's sandbox directory."""
|
|
262
|
+
if provider not in SDK_PROVIDERS:
|
|
263
|
+
raise ConfigurationError(f"{provider!r} is not an SDK provider")
|
|
264
|
+
if not sandbox_dir.is_absolute():
|
|
265
|
+
raise ConfigurationError(f"an SDK root needs an absolute sandbox directory: {sandbox_dir}")
|
|
266
|
+
return sandbox_dir / SDK_ROOT_DIR / provider
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def root_inside(root: Path, tree: Path) -> bool:
|
|
270
|
+
"""Whether ``root`` is, or lies inside, ``tree`` (lexically, both absolute)."""
|
|
271
|
+
return root == tree or tree in root.parents
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@dataclass(frozen=True)
|
|
275
|
+
class ClaudeSdkRoots:
|
|
276
|
+
"""Where a ``claude-sdk`` launch keeps its state: one directory.
|
|
277
|
+
|
|
278
|
+
``config`` is ``CLAUDE_CONFIG_DIR``: the CLI's global configuration
|
|
279
|
+
(``.claude.json``), its backups and the session transcripts
|
|
280
|
+
(``projects/<cwd>/<session id>.jsonl``), so it is the session root of
|
|
281
|
+
ADR 0007 D2.7. AutoForge writes nothing there and copies no credential
|
|
282
|
+
into it; ``setting_sources=[]`` keeps it from reading settings, agents,
|
|
283
|
+
skills or ``CLAUDE.md`` there (docs/sdk-policy.md §2).
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
config: Path
|
|
287
|
+
|
|
288
|
+
@classmethod
|
|
289
|
+
def under(cls, sandbox_dir: Path) -> ClaudeSdkRoots:
|
|
290
|
+
return cls(config=sdk_root(sandbox_dir, CLAUDE_SDK) / "config")
|
|
291
|
+
|
|
292
|
+
def writable(self) -> tuple[Path, ...]:
|
|
293
|
+
return (self.config,)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True)
|
|
297
|
+
class OpenCodeSdkRoots:
|
|
298
|
+
"""Where an ``opencode-sdk`` launch keeps its state.
|
|
299
|
+
|
|
300
|
+
The server and every tool it starts get ``HOME`` and the four XDG base
|
|
301
|
+
directories here, so no global configuration, plugin, package cache,
|
|
302
|
+
credential store or session database of the operator's is read, and
|
|
303
|
+
what the server writes (its database and session history, logs, caches)
|
|
304
|
+
stays in the issue's root. ``empty_config`` is ``OPENCODE_CONFIG_DIR``:
|
|
305
|
+
an empty directory projected read-only, so no agent, command or plugin
|
|
306
|
+
directory is read from it.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
home: Path
|
|
310
|
+
config: Path
|
|
311
|
+
data: Path
|
|
312
|
+
cache: Path
|
|
313
|
+
state: Path
|
|
314
|
+
empty_config: Path
|
|
315
|
+
|
|
316
|
+
@classmethod
|
|
317
|
+
def under(cls, sandbox_dir: Path) -> OpenCodeSdkRoots:
|
|
318
|
+
base = sdk_root(sandbox_dir, OPENCODE_SDK)
|
|
319
|
+
return cls(
|
|
320
|
+
home=base / "home",
|
|
321
|
+
config=base / "config",
|
|
322
|
+
data=base / "data",
|
|
323
|
+
cache=base / "cache",
|
|
324
|
+
state=base / "state",
|
|
325
|
+
empty_config=base / "empty-config",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def writable(self) -> tuple[Path, ...]:
|
|
329
|
+
return (self.home, self.config, self.data, self.cache, self.state)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _digest(document: object) -> str:
|
|
333
|
+
return hashlib.sha256(canonical_json(document)).hexdigest()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# -- Claude Agent SDK (ADR 0007 D1.1, D9.2, D10.1, D10.2) ---------------------
|
|
337
|
+
# The built-in tools a profile may list. Anything else is refused: the
|
|
338
|
+
# subagent tools (attribution is gate G-C8), skills, slash commands, plan
|
|
339
|
+
# mode, every MCP tool, whose servers are never configured, and
|
|
340
|
+
# ``TodoWrite``, which CLI 2.1.304 no longer offers (docs/sdk-policy.md §3).
|
|
341
|
+
CLAUDE_SDK_TOOLS = (
|
|
342
|
+
"Read",
|
|
343
|
+
"Glob",
|
|
344
|
+
"Grep",
|
|
345
|
+
"Edit",
|
|
346
|
+
"Write",
|
|
347
|
+
"NotebookEdit",
|
|
348
|
+
"Bash",
|
|
349
|
+
"WebFetch",
|
|
350
|
+
"WebSearch",
|
|
351
|
+
)
|
|
352
|
+
CLAUDE_SDK_WRITE_TOOLS = ("Read", "Glob", "Grep", "Edit", "Write", "Bash")
|
|
353
|
+
CLAUDE_SDK_READ_TOOLS = ("Read", "Glob", "Grep", "Bash")
|
|
354
|
+
# Offered only under ``interaction_policy: prompt``: the CLI offers it only
|
|
355
|
+
# with a permission host (ADR 0007 D9.2), and under ``fail`` an ask could
|
|
356
|
+
# only fail the invocation.
|
|
357
|
+
CLAUDE_SDK_QUESTION_TOOL = "AskUserQuestion"
|
|
358
|
+
# Named in ``disallowed_tools`` whatever the inventory, as defence in depth
|
|
359
|
+
# beside ``tools``: the subagent tool under both of its names, skills,
|
|
360
|
+
# slash commands and plan mode.
|
|
361
|
+
CLAUDE_SDK_DENIED_TOOLS = ("Agent", "Task", "Skill", "SlashCommand", "ExitPlanMode")
|
|
362
|
+
# The permission mode whose calls outside ``allowed_tools`` reach the
|
|
363
|
+
# permission host (``can_use_tool``), so the PreToolUse hook and the host
|
|
364
|
+
# both run. CLI 2.1.304 calls it ``manual`` in its help, accepts ``default``
|
|
365
|
+
# too and reports ``default`` in ``system/init`` for both; never a bypass,
|
|
366
|
+
# auto-approval, plan or deny-everything mode, whose callbacks would not run.
|
|
367
|
+
CLAUDE_SDK_PERMISSION_MODE = "default"
|
|
368
|
+
# The only credential (D10.2): an API key, never a claude.ai login.
|
|
369
|
+
CLAUDE_SDK_CREDENTIAL = "ANTHROPIC_API_KEY"
|
|
370
|
+
# What ``system/init`` must report as the key's source once the CLI runs.
|
|
371
|
+
CLAUDE_SDK_KEY_SOURCE = "ANTHROPIC_API_KEY"
|
|
372
|
+
# A variable the operator may forward by listing it exactly: where the API
|
|
373
|
+
# is (a gateway, or the stand-in endpoint of the gates' probes).
|
|
374
|
+
CLAUDE_SDK_PASSTHROUGH = ("ANTHROPIC_BASE_URL",)
|
|
375
|
+
# Never in a claude-sdk launch: a subscription login's token, a gateway
|
|
376
|
+
# bearer token and the cloud routes, which are a separately tested
|
|
377
|
+
# extension. :func:`sdk_launch_allowlist` already withholds them; the auth
|
|
378
|
+
# check refuses them again by name.
|
|
379
|
+
CLAUDE_SDK_REFUSED_AUTH = (
|
|
380
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
381
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
382
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
383
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
384
|
+
"CLAUDE_CODE_USE_FOUNDRY",
|
|
385
|
+
)
|
|
386
|
+
# Set over the launch's environment besides ``CLAUDE_CONFIG_DIR``: no
|
|
387
|
+
# auto-updater, telemetry or error reporting.
|
|
388
|
+
CLAUDE_SDK_FIXED_ENV = {"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1"}
|
|
389
|
+
# The one settings document the CLI gets (the SDK's ``settings``, which
|
|
390
|
+
# becomes ``--settings``): no auto memory, and the retention of the session
|
|
391
|
+
# root. No hook, permission rule, ``env``, ``apiKeyHelper`` or MCP entry.
|
|
392
|
+
CLAUDE_SDK_SETTINGS: dict[str, object] = {
|
|
393
|
+
"autoMemoryEnabled": False,
|
|
394
|
+
"cleanupPeriodDays": SESSION_RETENTION_DAYS,
|
|
395
|
+
}
|
|
396
|
+
# Claude Code's own system prompt, with nothing appended: repository
|
|
397
|
+
# instructions are task data the phase prompt points to, read from the
|
|
398
|
+
# worktree, never injected (docs/sdk-policy.md §4).
|
|
399
|
+
CLAUDE_SDK_SYSTEM_PROMPT: dict[str, str] = {"type": "preset", "preset": "claude_code"}
|
|
400
|
+
# The permission host's answer to every call it is asked about: the
|
|
401
|
+
# inventory is pre-approved and the asks go through the hook, so a call
|
|
402
|
+
# that still reaches the host is one the policy did not foresee.
|
|
403
|
+
CLAUDE_SDK_HOST_DENY = (
|
|
404
|
+
"AutoForge's unattended policy denies this call: it is not one this profile "
|
|
405
|
+
"approves or asks a person about (docs/sdk-policy.md)"
|
|
406
|
+
)
|
|
407
|
+
CLAUDE_SDK_OPTION_KEYS = ("tools", "ask_tools", *INTERACTION_OPTION_KEYS)
|
|
408
|
+
|
|
409
|
+
# What the PreToolUse hook does with a call (ADR 0007 D9.2).
|
|
410
|
+
PASS = "pass" # decides nothing: ``allowed_tools`` approves the call
|
|
411
|
+
ASK = "ask" # asks the broker and maps its decision or directive
|
|
412
|
+
DENY = "deny" # denies with :data:`CLAUDE_SDK_HOST_DENY`
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@dataclass(frozen=True)
|
|
416
|
+
class ClaudeSdkPolicy:
|
|
417
|
+
"""What one ``claude-sdk`` profile's launches get (docs/sdk-policy.md §2-6)."""
|
|
418
|
+
|
|
419
|
+
profile: str
|
|
420
|
+
tools: tuple[str, ...]
|
|
421
|
+
allowed_tools: tuple[str, ...]
|
|
422
|
+
ask_tools: tuple[str, ...]
|
|
423
|
+
disallowed_tools: tuple[str, ...]
|
|
424
|
+
interaction: InteractionSettings
|
|
425
|
+
permission_mode: str = CLAUDE_SDK_PERMISSION_MODE
|
|
426
|
+
|
|
427
|
+
def disposition(self, tool_name: str) -> str:
|
|
428
|
+
"""What the PreToolUse hook does with a call of ``tool_name``.
|
|
429
|
+
|
|
430
|
+
Every call goes through it, so a tool the runtime offers that the
|
|
431
|
+
policy did not list (an MCP tool, a renamed or new built-in) is
|
|
432
|
+
denied before any callback could approve it.
|
|
433
|
+
"""
|
|
434
|
+
if tool_name in self.ask_tools:
|
|
435
|
+
return ASK
|
|
436
|
+
if tool_name in self.allowed_tools:
|
|
437
|
+
return PASS
|
|
438
|
+
return DENY
|
|
439
|
+
|
|
440
|
+
def settings_document(self) -> str:
|
|
441
|
+
"""The ``settings`` JSON, fixed by the policy."""
|
|
442
|
+
return json.dumps(CLAUDE_SDK_SETTINGS, sort_keys=True, separators=(",", ":"))
|
|
443
|
+
|
|
444
|
+
def start_fields(self) -> dict[str, object]:
|
|
445
|
+
"""The ``policy`` of the helper's ``start`` record: every SDK option it
|
|
446
|
+
sets, as flat values, and nothing else (ADR 0007 §5)."""
|
|
447
|
+
return {
|
|
448
|
+
"schema": POLICY_SCHEMA,
|
|
449
|
+
"provider": CLAUDE_SDK,
|
|
450
|
+
"tools": list(self.tools),
|
|
451
|
+
"allowed_tools": list(self.allowed_tools),
|
|
452
|
+
"disallowed_tools": list(self.disallowed_tools),
|
|
453
|
+
"ask_tools": list(self.ask_tools),
|
|
454
|
+
"permission_mode": self.permission_mode,
|
|
455
|
+
"setting_sources": [],
|
|
456
|
+
"strict_mcp_config": True,
|
|
457
|
+
"mcp_servers": {},
|
|
458
|
+
"plugins": [],
|
|
459
|
+
"agents": {},
|
|
460
|
+
"extra_args": {},
|
|
461
|
+
"settings": self.settings_document(),
|
|
462
|
+
"system_prompt": dict(CLAUDE_SDK_SYSTEM_PROMPT),
|
|
463
|
+
"can_use_tool": DENY,
|
|
464
|
+
"can_use_tool_message": CLAUDE_SDK_HOST_DENY,
|
|
465
|
+
"hooks": ["PreToolUse"],
|
|
466
|
+
# No approval outlives its call: the hook answers allow or deny,
|
|
467
|
+
# never ``updated_permissions``, so no setting is written.
|
|
468
|
+
"session_approval": False,
|
|
469
|
+
"interaction": self.interaction.as_dict(),
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
def environment(self, roots: ClaudeSdkRoots) -> dict[str, str]:
|
|
473
|
+
"""What the launch sets over its allow-listed environment."""
|
|
474
|
+
return {"CLAUDE_CONFIG_DIR": str(roots.config), **CLAUDE_SDK_FIXED_ENV}
|
|
475
|
+
|
|
476
|
+
def digest(self) -> str:
|
|
477
|
+
"""The policy digest of ADR 0007 D5.1's ``SessionIdentity``: the fields
|
|
478
|
+
and the fixed environment, not the per-issue roots (the identity
|
|
479
|
+
records the session root on its own)."""
|
|
480
|
+
return _digest({"start": self.start_fields(), "env": CLAUDE_SDK_FIXED_ENV})
|
|
481
|
+
|
|
482
|
+
def init_problems(self, init: Mapping[str, object]) -> list[str]:
|
|
483
|
+
"""What a ``system/init`` record shows the runtime loaded or chose
|
|
484
|
+
against this policy; empty when it matches.
|
|
485
|
+
|
|
486
|
+
The pair is pinned (ADR 0007 D4.1), so a difference is a provider
|
|
487
|
+
failure, never something to adapt to: a tool offered or missing, an
|
|
488
|
+
MCP server, a plugin that is not built in, a permission mode other
|
|
489
|
+
than :data:`CLAUDE_SDK_PERMISSION_MODE`, a key from anywhere but
|
|
490
|
+
``ANTHROPIC_API_KEY``, or auto memory. Names only, never a value.
|
|
491
|
+
"""
|
|
492
|
+
problems: list[str] = []
|
|
493
|
+
tools = init.get("tools")
|
|
494
|
+
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
|
495
|
+
problems.append("its tools are not a list of names")
|
|
496
|
+
else:
|
|
497
|
+
offered = set(tools)
|
|
498
|
+
expected = set(self.tools)
|
|
499
|
+
if extra := sorted(offered - expected):
|
|
500
|
+
problems.append(f"it offers tools the policy does not list: {', '.join(extra)}")
|
|
501
|
+
if missing := sorted(expected - offered):
|
|
502
|
+
problems.append(f"it does not offer {', '.join(missing)}")
|
|
503
|
+
if init.get("mcp_servers") not in ([], None):
|
|
504
|
+
problems.append("it started MCP servers")
|
|
505
|
+
plugins = init.get("plugins") or []
|
|
506
|
+
if not isinstance(plugins, list) or any(
|
|
507
|
+
not isinstance(p, dict) or p.get("path") != "builtin" for p in plugins
|
|
508
|
+
):
|
|
509
|
+
problems.append("it loaded a plugin that is not built in")
|
|
510
|
+
if init.get("permissionMode") != self.permission_mode:
|
|
511
|
+
problems.append(f"its permission mode is not {self.permission_mode}")
|
|
512
|
+
if init.get("apiKeySource") != CLAUDE_SDK_KEY_SOURCE:
|
|
513
|
+
problems.append(f"its credential does not come from {CLAUDE_SDK_KEY_SOURCE}")
|
|
514
|
+
if init.get("memory_paths"):
|
|
515
|
+
problems.append("auto memory is on")
|
|
516
|
+
return [clean(p, 300) for p in problems]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def claude_sdk_policy(profile_name: str, options: Mapping[str, str]) -> ClaudeSdkPolicy:
|
|
520
|
+
"""The policy of a ``claude-sdk`` profile, or :class:`ConfigurationError`."""
|
|
521
|
+
where = f"profile {profile_name!r}"
|
|
522
|
+
unknown = sorted(set(options) - set(CLAUDE_SDK_OPTION_KEYS))
|
|
523
|
+
if unknown:
|
|
524
|
+
raise ConfigurationError(
|
|
525
|
+
f"{where}: unknown {CLAUDE_SDK} option(s) {', '.join(unknown)} "
|
|
526
|
+
f"(accepted: {', '.join(CLAUDE_SDK_OPTION_KEYS)})"
|
|
527
|
+
)
|
|
528
|
+
interaction = interaction_settings(options, where)
|
|
529
|
+
if "tools" in options:
|
|
530
|
+
tools = _tool_list(options["tools"], CLAUDE_SDK_TOOLS, "tools", where, CLAUDE_SDK)
|
|
531
|
+
else:
|
|
532
|
+
tools = CLAUDE_SDK_WRITE_TOOLS if profile_name in WRITE_PROFILES else CLAUDE_SDK_READ_TOOLS
|
|
533
|
+
ask: tuple[str, ...] = ()
|
|
534
|
+
if "ask_tools" in options:
|
|
535
|
+
ask = _tool_list(options["ask_tools"], tools, "ask_tools", where, CLAUDE_SDK)
|
|
536
|
+
if not interaction.live:
|
|
537
|
+
raise ConfigurationError(
|
|
538
|
+
f"{where}: {CLAUDE_SDK} option ask_tools needs interaction_policy prompt: "
|
|
539
|
+
"under fail every ask fails the invocation"
|
|
540
|
+
)
|
|
541
|
+
if interaction.live:
|
|
542
|
+
ask = (*ask, CLAUDE_SDK_QUESTION_TOOL)
|
|
543
|
+
tools = (*tools, CLAUDE_SDK_QUESTION_TOOL)
|
|
544
|
+
allowed = tuple(t for t in tools if t not in ask)
|
|
545
|
+
unoffered = [t for t in (*CLAUDE_SDK_TOOLS, CLAUDE_SDK_QUESTION_TOOL) if t not in tools]
|
|
546
|
+
denied = tuple(dict.fromkeys([*CLAUDE_SDK_DENIED_TOOLS, *unoffered]))
|
|
547
|
+
return ClaudeSdkPolicy(
|
|
548
|
+
profile=profile_name,
|
|
549
|
+
tools=tools,
|
|
550
|
+
allowed_tools=allowed,
|
|
551
|
+
ask_tools=ask,
|
|
552
|
+
disallowed_tools=denied,
|
|
553
|
+
interaction=interaction,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def claude_sdk_auth_problems(env: Mapping[str, str]) -> list[str]:
|
|
558
|
+
"""Why a ``claude-sdk`` launch with environment ``env`` would not
|
|
559
|
+
authenticate with an API key alone; empty when it would (D10.2).
|
|
560
|
+
|
|
561
|
+
Names only: a value is never read past whether it is set.
|
|
562
|
+
"""
|
|
563
|
+
problems: list[str] = []
|
|
564
|
+
if not env.get(CLAUDE_SDK_CREDENTIAL):
|
|
565
|
+
problems.append(
|
|
566
|
+
f"{CLAUDE_SDK_CREDENTIAL} is not set: {CLAUDE_SDK} authenticates with an Anthropic "
|
|
567
|
+
"API key only, and never falls back to a claude.ai login, CLAUDE_CODE_OAUTH_TOKEN, "
|
|
568
|
+
"a `claude setup-token` token or a keychain. Set it in the environment autoforge "
|
|
569
|
+
"runs in"
|
|
570
|
+
)
|
|
571
|
+
refused = sorted(name for name in CLAUDE_SDK_REFUSED_AUTH if env.get(name))
|
|
572
|
+
if refused:
|
|
573
|
+
problems.append(
|
|
574
|
+
f"{', '.join(refused)} would reach the launch: a subscription login, a bearer "
|
|
575
|
+
f"token or a cloud route is not a {CLAUDE_SDK} credential"
|
|
576
|
+
)
|
|
577
|
+
return problems
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# -- OpenCode SDK and runtime (ADR 0007 D2.5, D3.1, D3.4, D4.3, D10.1, D10.2) --
|
|
581
|
+
# The tools a profile may list, by their OpenCode permission names.
|
|
582
|
+
OPENCODE_SDK_TOOLS = (
|
|
583
|
+
"read",
|
|
584
|
+
"glob",
|
|
585
|
+
"grep",
|
|
586
|
+
"list",
|
|
587
|
+
"edit",
|
|
588
|
+
"bash",
|
|
589
|
+
"todowrite",
|
|
590
|
+
"todoread",
|
|
591
|
+
"webfetch",
|
|
592
|
+
"websearch",
|
|
593
|
+
)
|
|
594
|
+
OPENCODE_SDK_WRITE_TOOLS = ("read", "glob", "grep", "list", "edit", "bash")
|
|
595
|
+
OPENCODE_SDK_READ_TOOLS = ("read", "glob", "grep", "list", "bash")
|
|
596
|
+
OPENCODE_SDK_QUESTION = "question"
|
|
597
|
+
# Denied whatever the inventory: subagents (attribution is gate G-O9),
|
|
598
|
+
# skills, code search, language-server queries, and the repeated-call ask,
|
|
599
|
+
# which nobody could answer (the controller's loop detector covers it).
|
|
600
|
+
OPENCODE_SDK_DENIED = ("task", "skill", "codesearch", "lsp", "doom_loop")
|
|
601
|
+
# The agent every session runs: the controller's own, with a complete
|
|
602
|
+
# ruleset, never a built-in agent patched (a built-in agent carries rules of
|
|
603
|
+
# its own; seen on 2.0.32: ``build`` allows ``*``).
|
|
604
|
+
OPENCODE_SDK_AGENT = "autoforge"
|
|
605
|
+
OPENCODE_SDK_HOSTNAME = "127.0.0.1"
|
|
606
|
+
OPENCODE_SDK_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD"
|
|
607
|
+
OPENCODE_SDK_USERNAME = "opencode"
|
|
608
|
+
# At least 128 bits from the operating system's random source (D3.4).
|
|
609
|
+
OPENCODE_SDK_PASSWORD_BYTES = 32
|
|
610
|
+
# Set over the launch's environment besides the roots and the document.
|
|
611
|
+
# Unknown names are inert, so the names of both release lines are set
|
|
612
|
+
# (docs/sdk-policy.md §7 says which line reads which).
|
|
613
|
+
OPENCODE_SDK_FIXED_ENV = {
|
|
614
|
+
"OPENCODE_CONFIG": "",
|
|
615
|
+
"OPENCODE_DISABLE_PROJECT_CONFIG": "1",
|
|
616
|
+
"OPENCODE_CONFIG_PROJECT_DISABLE": "1",
|
|
617
|
+
"OPENCODE_DISABLE_AUTOUPDATE": "1",
|
|
618
|
+
"OPENCODE_DISABLE_DEFAULT_PLUGINS": "1",
|
|
619
|
+
"OPENCODE_DISABLE_LSP_DOWNLOAD": "1",
|
|
620
|
+
"OPENCODE_DISABLE_CLAUDE_CODE": "1",
|
|
621
|
+
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "1",
|
|
622
|
+
"OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "1",
|
|
623
|
+
}
|
|
624
|
+
OPENCODE_SDK_OPTION_KEYS = (
|
|
625
|
+
"server_command",
|
|
626
|
+
"credential_env",
|
|
627
|
+
"tools",
|
|
628
|
+
*INTERACTION_OPTION_KEYS,
|
|
629
|
+
)
|
|
630
|
+
# A provider API key's name: the one variable the server may read for its
|
|
631
|
+
# model credential (no store of ``opencode auth login`` is used).
|
|
632
|
+
_CREDENTIAL_ENV_RE = re.compile(r"[A-Z][A-Z0-9_]{0,55}_API_KEY")
|
|
633
|
+
_SERVER_COMMAND_RE = re.compile(r"[^\x00-\x20\x7f]{1,4096}")
|
|
634
|
+
_OPENCODE_MODEL_RE = re.compile(r"[a-z0-9][a-z0-9._-]*/[^\s#]+")
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@dataclass(frozen=True)
|
|
638
|
+
class OpenCodeSdkPolicy:
|
|
639
|
+
"""What one ``opencode-sdk`` profile's launches get (docs/sdk-policy.md §7-9)."""
|
|
640
|
+
|
|
641
|
+
profile: str
|
|
642
|
+
tools: tuple[str, ...]
|
|
643
|
+
credential_env: str
|
|
644
|
+
server_command: str
|
|
645
|
+
interaction: InteractionSettings
|
|
646
|
+
|
|
647
|
+
def ruleset(self) -> dict[str, str]:
|
|
648
|
+
"""The permission rules, in order: everything denied, then the
|
|
649
|
+
inventory allowed, then the fixed denials, which overlap no allowed
|
|
650
|
+
name, so first-match and last-match evaluation agree.
|
|
651
|
+
|
|
652
|
+
``external_directory`` is allowed: the REMOTE read context lies
|
|
653
|
+
outside the worktree. The boundary for paths is the outer sandbox
|
|
654
|
+
(ADR 0005), never this ruleset (ADR 0007 D3.4, D10.1).
|
|
655
|
+
"""
|
|
656
|
+
rules = {"*": "deny"}
|
|
657
|
+
rules.update({tool: "allow" for tool in self.tools})
|
|
658
|
+
rules["external_directory"] = "allow"
|
|
659
|
+
rules[OPENCODE_SDK_QUESTION] = "allow" if self.interaction.live else "deny"
|
|
660
|
+
rules.update({name: "deny" for name in OPENCODE_SDK_DENIED})
|
|
661
|
+
return rules
|
|
662
|
+
|
|
663
|
+
def config_document(self) -> dict[str, object]:
|
|
664
|
+
"""The inline configuration (``OPENCODE_CONFIG_CONTENT``), the only
|
|
665
|
+
configuration the server reads besides the empty directory: no
|
|
666
|
+
plugin, MCP server, instruction file, language server or formatter,
|
|
667
|
+
no sharing or self-update, and the controller's agent."""
|
|
668
|
+
rules = self.ruleset()
|
|
669
|
+
return {
|
|
670
|
+
"$schema": "https://opencode.ai/config.json",
|
|
671
|
+
"autoupdate": False,
|
|
672
|
+
"share": "disabled",
|
|
673
|
+
"plugin": [],
|
|
674
|
+
"mcp": {},
|
|
675
|
+
"instructions": [],
|
|
676
|
+
"lsp": False,
|
|
677
|
+
"formatter": False,
|
|
678
|
+
"permission": dict(rules),
|
|
679
|
+
"agent": {
|
|
680
|
+
OPENCODE_SDK_AGENT: {
|
|
681
|
+
"mode": "primary",
|
|
682
|
+
"description": "AutoForge's unattended agent (docs/sdk-policy.md)",
|
|
683
|
+
"permission": dict(rules),
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
def environment(self, roots: OpenCodeSdkRoots) -> dict[str, str]:
|
|
689
|
+
"""What the bridge and its server get over the allow-listed
|
|
690
|
+
environment: the roots, the inline document and the fixed switches.
|
|
691
|
+
The server password is the bridge's, per launch (D3.4), and is not
|
|
692
|
+
part of the policy."""
|
|
693
|
+
return {
|
|
694
|
+
"HOME": str(roots.home),
|
|
695
|
+
"XDG_CONFIG_HOME": str(roots.config),
|
|
696
|
+
"XDG_DATA_HOME": str(roots.data),
|
|
697
|
+
"XDG_CACHE_HOME": str(roots.cache),
|
|
698
|
+
"XDG_STATE_HOME": str(roots.state),
|
|
699
|
+
"OPENCODE_CONFIG_DIR": str(roots.empty_config),
|
|
700
|
+
"OPENCODE_CONFIG_CONTENT": json.dumps(
|
|
701
|
+
self.config_document(), sort_keys=True, separators=(",", ":")
|
|
702
|
+
),
|
|
703
|
+
**OPENCODE_SDK_FIXED_ENV,
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
def server_argv(self, server_command: str) -> list[str]:
|
|
707
|
+
"""The server's argv; ``server_command`` is the resolved absolute
|
|
708
|
+
path of :attr:`server_command` (D2.5). Loopback, an assigned port, no
|
|
709
|
+
mDNS and no CORS origin; never the password."""
|
|
710
|
+
return [server_command, "serve", f"--hostname={OPENCODE_SDK_HOSTNAME}", "--port=0"]
|
|
711
|
+
|
|
712
|
+
def start_fields(self) -> dict[str, object]:
|
|
713
|
+
"""The ``policy`` of the bridge's ``start`` record."""
|
|
714
|
+
return {
|
|
715
|
+
"schema": POLICY_SCHEMA,
|
|
716
|
+
"provider": OPENCODE_SDK,
|
|
717
|
+
"agent": OPENCODE_SDK_AGENT,
|
|
718
|
+
"tools": list(self.tools),
|
|
719
|
+
"ruleset": self.ruleset(),
|
|
720
|
+
"credential_env": self.credential_env,
|
|
721
|
+
# Never ``always``, which approves a pattern for the rest of the
|
|
722
|
+
# session: the server's rules stay the document's.
|
|
723
|
+
"replies": ["once", "reject"],
|
|
724
|
+
"session_approval": False,
|
|
725
|
+
"interaction": self.interaction.as_dict(),
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
def digest(self) -> str:
|
|
729
|
+
"""The policy digest of ``SessionIdentity`` (ADR 0007 D5.1)."""
|
|
730
|
+
return _digest(
|
|
731
|
+
{
|
|
732
|
+
"start": self.start_fields(),
|
|
733
|
+
"document": self.config_document(),
|
|
734
|
+
"env": OPENCODE_SDK_FIXED_ENV,
|
|
735
|
+
}
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def opencode_sdk_policy(profile_name: str, options: Mapping[str, str]) -> OpenCodeSdkPolicy:
|
|
740
|
+
"""The policy of an ``opencode-sdk`` profile, or :class:`ConfigurationError`."""
|
|
741
|
+
where = f"profile {profile_name!r}"
|
|
742
|
+
unknown = sorted(set(options) - set(OPENCODE_SDK_OPTION_KEYS))
|
|
743
|
+
if unknown:
|
|
744
|
+
raise ConfigurationError(
|
|
745
|
+
f"{where}: unknown {OPENCODE_SDK} option(s) {', '.join(unknown)} "
|
|
746
|
+
f"(accepted: {', '.join(OPENCODE_SDK_OPTION_KEYS)})"
|
|
747
|
+
)
|
|
748
|
+
interaction = interaction_settings(options, where)
|
|
749
|
+
server_command = options.get("server_command", "")
|
|
750
|
+
if not _SERVER_COMMAND_RE.fullmatch(server_command):
|
|
751
|
+
raise ConfigurationError(
|
|
752
|
+
f"{where}: {OPENCODE_SDK} needs option server_command, the OpenCode runtime "
|
|
753
|
+
"binary of the pinned release (a command name or an absolute path, with no "
|
|
754
|
+
f"whitespace or control character; ADR 0007 D2.5), got {server_command!r}"
|
|
755
|
+
)
|
|
756
|
+
credential = options.get("credential_env", "")
|
|
757
|
+
if not _CREDENTIAL_ENV_RE.fullmatch(credential) or (
|
|
758
|
+
credential.startswith("OPENCODE_") and credential != "OPENCODE_API_KEY"
|
|
759
|
+
):
|
|
760
|
+
raise ConfigurationError(
|
|
761
|
+
f"{where}: {OPENCODE_SDK} needs option credential_env, the one variable that "
|
|
762
|
+
"holds the model provider's API key (for example OPENAI_API_KEY): a name ending "
|
|
763
|
+
"in _API_KEY. A sign-in stored by `opencode auth login` is not used "
|
|
764
|
+
f"(docs/sdk-policy.md §9), got {credential!r}"
|
|
765
|
+
)
|
|
766
|
+
refuse_forbidden_env((credential,), f"{where}: {OPENCODE_SDK} option credential_env")
|
|
767
|
+
if "tools" in options:
|
|
768
|
+
tools = _tool_list(options["tools"], OPENCODE_SDK_TOOLS, "tools", where, OPENCODE_SDK)
|
|
769
|
+
else:
|
|
770
|
+
tools = (
|
|
771
|
+
OPENCODE_SDK_WRITE_TOOLS if profile_name in WRITE_PROFILES else OPENCODE_SDK_READ_TOOLS
|
|
772
|
+
)
|
|
773
|
+
return OpenCodeSdkPolicy(
|
|
774
|
+
profile=profile_name,
|
|
775
|
+
tools=tools,
|
|
776
|
+
credential_env=credential,
|
|
777
|
+
server_command=server_command,
|
|
778
|
+
interaction=interaction,
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def opencode_model_problem(model: str) -> str:
|
|
783
|
+
"""Why ``model`` is not an OpenCode ``provider/model`` id, or ''.
|
|
784
|
+
|
|
785
|
+
The reasoning variant is the bridge's to map (#152), so a ``#`` suffix
|
|
786
|
+
is refused here rather than passed as part of the id.
|
|
787
|
+
"""
|
|
788
|
+
if not _OPENCODE_MODEL_RE.fullmatch(model):
|
|
789
|
+
return "expected '<provider>/<model>' with no whitespace and no '#'"
|
|
790
|
+
return ""
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def opencode_sdk_auth_problems(policy: OpenCodeSdkPolicy, env: Mapping[str, str]) -> list[str]:
|
|
794
|
+
"""Why an ``opencode-sdk`` launch with ``env`` would have no model
|
|
795
|
+
credential; names only (D10.2)."""
|
|
796
|
+
if env.get(policy.credential_env):
|
|
797
|
+
return []
|
|
798
|
+
return [
|
|
799
|
+
f"{policy.credential_env} (option credential_env) is not set in the environment "
|
|
800
|
+
f"autoforge runs in: {OPENCODE_SDK} reads its model credential from that variable "
|
|
801
|
+
"only, never from a stored sign-in"
|
|
802
|
+
]
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def server_password() -> str:
|
|
806
|
+
"""A fresh server password for one launch (D3.4): never in an argv, a
|
|
807
|
+
record, a log or state."""
|
|
808
|
+
return secrets.token_urlsafe(OPENCODE_SDK_PASSWORD_BYTES)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# -- interaction data (ADR 0007 D5.1; docs/sdk-policy.md §10) ------------------
|
|
812
|
+
# Bounds on what a provider's ask may carry. A field that decides what a
|
|
813
|
+
# tool may do or what the agent is told is refused past its bound, never
|
|
814
|
+
# clipped: approving a clipped input would approve different work.
|
|
815
|
+
MAX_TOOL_NAME_CHARS = 128
|
|
816
|
+
MAX_INPUT_BYTES = 64 * 1024
|
|
817
|
+
MAX_QUESTIONS = 4
|
|
818
|
+
MAX_OPTIONS = 16
|
|
819
|
+
MAX_QUESTION_CHARS = 2000
|
|
820
|
+
MAX_OPTION_CHARS = 200
|
|
821
|
+
MAX_ANSWER_CHARS = 2000
|
|
822
|
+
# What an operator is shown of one value: one line, cleaned and redacted.
|
|
823
|
+
MAX_SHOWN_CHARS = 160
|
|
824
|
+
# The input keys a permission ask shows per tool: a path, a pattern, a
|
|
825
|
+
# query, a command and its description. Never a file's content, a diff, an
|
|
826
|
+
# edit's old or new text, a notebook cell or a web page.
|
|
827
|
+
DISPLAY_KEYS: dict[str, tuple[str, ...]] = {
|
|
828
|
+
"Bash": ("command", "description"),
|
|
829
|
+
"Read": ("file_path",),
|
|
830
|
+
"Glob": ("pattern", "path"),
|
|
831
|
+
"Grep": ("pattern", "path", "glob"),
|
|
832
|
+
"Edit": ("file_path",),
|
|
833
|
+
"Write": ("file_path",),
|
|
834
|
+
"NotebookEdit": ("notebook_path",),
|
|
835
|
+
"WebFetch": ("url",),
|
|
836
|
+
"WebSearch": ("query",),
|
|
837
|
+
"bash": ("command", "description"),
|
|
838
|
+
"read": ("filePath",),
|
|
839
|
+
"edit": ("filePath",),
|
|
840
|
+
"write": ("filePath",),
|
|
841
|
+
"glob": ("pattern", "path"),
|
|
842
|
+
"grep": ("pattern", "path"),
|
|
843
|
+
"list": ("path",),
|
|
844
|
+
"webfetch": ("url",),
|
|
845
|
+
"websearch": ("query",),
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def canonical_json(value: object) -> bytes:
|
|
850
|
+
"""``value`` as canonical JSON: sorted keys, no spaces, UTF-8, no NaN.
|
|
851
|
+
|
|
852
|
+
A value JSON cannot represent, or a string UTF-8 cannot encode (a lone
|
|
853
|
+
surrogate, legal in a JSON string's escapes), raises
|
|
854
|
+
:class:`InteractionDataError`.
|
|
855
|
+
"""
|
|
856
|
+
try:
|
|
857
|
+
text = json.dumps(
|
|
858
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
|
|
859
|
+
)
|
|
860
|
+
except (TypeError, ValueError) as exc:
|
|
861
|
+
raise InteractionDataError(f"a value is not JSON ({type(exc).__name__})") from exc
|
|
862
|
+
try:
|
|
863
|
+
return text.encode("utf-8")
|
|
864
|
+
except UnicodeEncodeError as exc:
|
|
865
|
+
raise InteractionDataError("a value carries a lone surrogate, not UTF-8") from exc
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def input_digest(value: object, what: str, limit: int = MAX_INPUT_BYTES) -> str:
|
|
869
|
+
"""The SHA-256 of ``value``'s canonical JSON, the identity a decision is
|
|
870
|
+
bound to (ADR 0007 D9.3); refused past ``limit`` bytes, never clipped."""
|
|
871
|
+
data = canonical_json(value)
|
|
872
|
+
if len(data) > limit:
|
|
873
|
+
raise InteractionDataError(
|
|
874
|
+
f"{what} is {len(data)} bytes of JSON, more than {limit}: refused rather than "
|
|
875
|
+
"shown in part and approved whole"
|
|
876
|
+
)
|
|
877
|
+
return hashlib.sha256(data).hexdigest()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _unsafe_character(text: str) -> bool:
|
|
881
|
+
"""Whether ``text`` holds a control, format or separator character (an
|
|
882
|
+
escape sequence starts with one) other than a newline or a tab."""
|
|
883
|
+
for ch in text:
|
|
884
|
+
if ch in "\n\t":
|
|
885
|
+
continue
|
|
886
|
+
if unicodedata.category(ch) in ("Cc", "Cf", "Cs", "Zl", "Zp"):
|
|
887
|
+
return True
|
|
888
|
+
return False
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def exact_text(value: object, what: str, limit: int) -> str:
|
|
892
|
+
"""``value`` as a string the controller may use as an identity or send back
|
|
893
|
+
verbatim (an option a person picked, an answer, a tool name), or
|
|
894
|
+
:class:`InteractionDataError`.
|
|
895
|
+
|
|
896
|
+
Refused, never adjusted: a non-string, past ``limit`` characters, a
|
|
897
|
+
control, format or separator character other than a newline or a tab
|
|
898
|
+
(terminal injection), or anything :func:`autoforge.redaction.redact`
|
|
899
|
+
would change, since its shown form would then differ from what is sent.
|
|
900
|
+
"""
|
|
901
|
+
if not isinstance(value, str):
|
|
902
|
+
raise InteractionDataError(f"{what} is not a string")
|
|
903
|
+
if len(value) > limit:
|
|
904
|
+
raise InteractionDataError(f"{what} is longer than {limit} characters")
|
|
905
|
+
if _unsafe_character(value):
|
|
906
|
+
raise InteractionDataError(f"{what} carries a control or escape character")
|
|
907
|
+
if redact(value) != value:
|
|
908
|
+
raise InteractionDataError(f"{what} carries a credential-shaped value")
|
|
909
|
+
return value
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def exact_input(value: object, what: str, limit: int = MAX_INPUT_BYTES) -> object:
|
|
913
|
+
"""A modified tool input the controller may send back, unchanged, or
|
|
914
|
+
:class:`InteractionDataError`: JSON within ``limit`` bytes, and every
|
|
915
|
+
string in it, key or value, safe for :func:`exact_text` (redaction would
|
|
916
|
+
change none, and a carriage return is refused like any other control
|
|
917
|
+
character: it redraws the line it is shown on)."""
|
|
918
|
+
input_digest(value, what, limit)
|
|
919
|
+
if redact_obj(value) != value:
|
|
920
|
+
raise InteractionDataError(f"{what} carries a credential-shaped value")
|
|
921
|
+
for text in _strings(value):
|
|
922
|
+
if _unsafe_character(text):
|
|
923
|
+
raise InteractionDataError(f"{what} carries a control or escape character")
|
|
924
|
+
return value
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _strings(value: object) -> Iterable[str]:
|
|
928
|
+
if isinstance(value, str):
|
|
929
|
+
yield value
|
|
930
|
+
elif isinstance(value, dict):
|
|
931
|
+
for key, item in value.items():
|
|
932
|
+
yield str(key)
|
|
933
|
+
yield from _strings(item)
|
|
934
|
+
elif isinstance(value, (list, tuple)):
|
|
935
|
+
for item in value:
|
|
936
|
+
yield from _strings(item)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def shown(value: object, limit: int = MAX_SHOWN_CHARS) -> str:
|
|
940
|
+
"""``value`` as one line an operator may be shown: escape sequences and
|
|
941
|
+
control characters removed, redacted, then clipped. For display only:
|
|
942
|
+
never an identity, never sent back."""
|
|
943
|
+
return clean(value, limit)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@dataclass(frozen=True)
|
|
947
|
+
class ShownOption:
|
|
948
|
+
"""An option of a question: the raw label (the identity a reply carries)
|
|
949
|
+
apart from what the operator is shown."""
|
|
950
|
+
|
|
951
|
+
raw: str
|
|
952
|
+
shown: str
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def question_options(labels: object, what: str) -> tuple[ShownOption, ...]:
|
|
956
|
+
"""A question's options, each kept raw and shown apart; or
|
|
957
|
+
:class:`InteractionDataError`.
|
|
958
|
+
|
|
959
|
+
More than :data:`MAX_OPTIONS`, a label :func:`exact_text` refuses, or
|
|
960
|
+
two labels shown alike (a person could not tell which one they pick) is
|
|
961
|
+
refused.
|
|
962
|
+
"""
|
|
963
|
+
if not isinstance(labels, list) or not labels:
|
|
964
|
+
raise InteractionDataError(f"{what} has no list of options")
|
|
965
|
+
if len(labels) > MAX_OPTIONS:
|
|
966
|
+
raise InteractionDataError(f"{what} has more than {MAX_OPTIONS} options")
|
|
967
|
+
options = tuple(
|
|
968
|
+
ShownOption(raw=raw, shown=shown(raw, MAX_OPTION_CHARS))
|
|
969
|
+
for raw in (
|
|
970
|
+
exact_text(label, f"{what} option {n}", MAX_OPTION_CHARS)
|
|
971
|
+
for n, label in enumerate(labels, 1)
|
|
972
|
+
)
|
|
973
|
+
)
|
|
974
|
+
if len({option.shown.casefold() for option in options}) != len(options):
|
|
975
|
+
raise InteractionDataError(f"{what} has two options shown alike")
|
|
976
|
+
return options
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def permission_summary(tool_name: str, tool_input: Mapping[str, object]) -> str:
|
|
980
|
+
"""What a permission ask shows of its input: the allow-listed keys of
|
|
981
|
+
:data:`DISPLAY_KEYS` for ``tool_name``, each cleaned, redacted and
|
|
982
|
+
bounded. Every other key is counted, never shown."""
|
|
983
|
+
keys = DISPLAY_KEYS.get(tool_name, ())
|
|
984
|
+
parts = [f"{key}: {shown(tool_input[key])}" for key in keys if key in tool_input]
|
|
985
|
+
hidden = len([key for key in tool_input if key not in keys])
|
|
986
|
+
if hidden:
|
|
987
|
+
parts.append(f"(+{hidden} field(s) not shown)")
|
|
988
|
+
return shown("; ".join(parts), 3 * MAX_SHOWN_CHARS)
|