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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -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)