|
27 | 27 |
|
28 | 28 | import os |
29 | 29 | import subprocess |
30 | | -from typing import Any |
| 30 | +from typing import Any, Literal |
31 | 31 | from urllib.parse import quote |
32 | 32 |
|
33 | 33 | from config import AGENT_WORKSPACE |
@@ -132,7 +132,13 @@ def _setup_agent_env(config: TaskConfig) -> tuple[str | None, str | None]: |
132 | 132 | # writes, while the SDK is waiting on stdout). The stderr callback in |
133 | 133 | # ClaudeAgentOptions cannot drain fast enough to prevent this. |
134 | 134 | os.environ.pop("ANTHROPIC_LOG", None) |
135 | | - os.environ["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "anthropic.claude-haiku-4-5-20251001-v1:0" |
| 135 | + # Small/fast auxiliary model (WebFetch summarization etc.), from config like |
| 136 | + # ANTHROPIC_MODEL above — resolved from the deployed ANTHROPIC_DEFAULT_HAIKU_MODEL |
| 137 | + # env (agent.ts) with a platform default in config.py. Must be a cross-region |
| 138 | + # INFERENCE-PROFILE id (``us.`` prefix): Claude 4.x cannot be invoked on-demand |
| 139 | + # by bare model id on Bedrock (400 "on-demand throughput isn't supported", |
| 140 | + # seen on WebFetch's Haiku sub-calls); config.py resolves that default. |
| 141 | + os.environ["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = config.haiku_model |
136 | 142 |
|
137 | 143 | # Save OTLP endpoint/protocol configured by ADOT auto-instrumentation |
138 | 144 | # before stripping, so we can re-use it for Claude Code CLI telemetry. |
@@ -335,31 +341,87 @@ def _initialize_policy_engine_and_hooks( |
335 | 341 | # read-only workflow. |
336 | 342 | _WRITE_TOOLS = frozenset(("Write", "Edit")) |
337 | 343 |
|
| 344 | +# Tools that DEFER work off-session and are hard-blocked for every task. These |
| 345 | +# launch detached / cross-session orchestration that a one-shot headless agent |
| 346 | +# has no supervisor to await: the ``Workflow`` tool returns a task id and runs |
| 347 | +# in the background (its result arrives via a notification into an interactive |
| 348 | +# session that does not exist here), and ``Task``/``Agent`` can spawn background |
| 349 | +# subagents. We saw a repo-less task launch a background ``Workflow`` and then |
| 350 | +# finalize on the first ResultMessage with a placeholder artifact while the real |
| 351 | +# research ran on, detached (task 01KWDEFQH6...). CRITICAL: ``allowed_tools`` is |
| 352 | +# only an auto-APPROVE list — per the Agent SDK docs it does NOT restrict the |
| 353 | +# surface; unlisted tools fall through to ``permission_mode``, and under |
| 354 | +# ``bypassPermissions`` they are simply allowed. ``disallowed_tools`` is the |
| 355 | +# only hard lock (it removes the tool from the model's context even under |
| 356 | +# bypass), so the block must live there, not in the allow-list. |
| 357 | +# ``Workflow`` (background multi-agent orchestration) is the one that bit us; |
| 358 | +# ``Task``/``Agent`` are the sub-agent spawners (name varies by CLI version, so |
| 359 | +# block both); ``Monitor`` streams a background command's output mid-turn; |
| 360 | +# ``SendMessage`` resumes/relaunches background agents; the ``Cron*`` tools |
| 361 | +# schedule deferred work. All are "return now, work continues off-session" |
| 362 | +# vectors a one-shot task cannot await. NOT blockable here: background ``Bash`` |
| 363 | +# (a ``run_in_background`` PARAMETER of Bash, not a tool name) — but a detached |
| 364 | +# Bash child dies with the MicroVM on return, so it can't produce |
| 365 | +# arrives-later work the way a cloud Workflow does; the deliver-artifact |
| 366 | +# deferral guard (deliverers._reject_if_deferral) is the backstop for anything |
| 367 | +# that still ends in a placeholder. |
| 368 | +_DISALLOWED_TOOLS = [ |
| 369 | + "Workflow", |
| 370 | + "Task", |
| 371 | + "Agent", |
| 372 | + "Monitor", |
| 373 | + "SendMessage", |
| 374 | + "CronCreate", |
| 375 | + "CronDelete", |
| 376 | + "CronList", |
| 377 | +] |
338 | 378 |
|
339 | | -def _resolve_allowed_tools(config: TaskConfig) -> list[str]: |
340 | | - """Resolve the SDK ``allowed_tools`` list for a task. |
341 | 379 |
|
342 | | - This is the second enforcement layer the design promises alongside Cedar's |
343 | | - ``context.read_only`` (WORKFLOWS.md §"Agent configuration"): |
| 380 | +def _resolve_allowed_tools(config: TaskConfig) -> list[str]: |
| 381 | + """Resolve the SDK ``allowed_tools`` (auto-approve) list for a task. |
344 | 382 |
|
345 | 383 | - The resolved workflow's ``agent_config.allowed_tools`` (threaded onto |
346 | 384 | ``config.allowed_tools``) is passed to the SDK verbatim. An empty list — |
347 | 385 | legacy/batch callers that never resolved a workflow — falls back to the |
348 | 386 | built-in full surface. |
349 | | - - ``Write``/``Edit`` are dropped whenever ``config.read_only`` is true, so a |
350 | | - read-only lane physically cannot mutate the tree even where Cedar's |
351 | | - ``read_only`` rules do not fire (e.g. a ``read_only:false`` default that |
352 | | - restricts tools by list alone, like ``default/agent-v1``). |
353 | | -
|
354 | | - The Cedar PreToolUse hooks still enforce per-task restrictions on top of |
355 | | - whatever is allowed here; this list only ever narrows the surface. |
| 387 | + - ``Write``/``Edit`` are dropped whenever ``config.read_only`` is true. |
| 388 | +
|
| 389 | + IMPORTANT: this list only governs auto-approval, NOT the reachable surface. |
| 390 | + Per the Agent SDK, a tool omitted here is not blocked — it falls through to |
| 391 | + ``permission_mode`` (``bypassPermissions`` ⇒ allowed). The actual surface |
| 392 | + lock is ``_DISALLOWED_TOOLS`` passed to ``disallowed_tools``. NOTE the Cedar |
| 393 | + PreToolUse hooks are NOT a backstop for an unknown tool name: the engine |
| 394 | + default-permits on no-match (``policy.py``), so it only denies the specific |
| 395 | + actions it has ``forbid`` rules for (e.g. Write/Edit under read_only) — |
| 396 | + ``Workflow``/``Task``/``Agent`` match nothing and would be allowed. So |
| 397 | + ``disallowed_tools`` is the ONLY thing keeping them out; do not rely on this |
| 398 | + allow-list, nor on Cedar, to remove a tool from the surface. |
356 | 399 | """ |
357 | 400 | tools = list(config.allowed_tools) if config.allowed_tools else list(_FULL_TOOL_SURFACE) |
358 | 401 | if config.read_only: |
359 | 402 | tools = [t for t in tools if t not in _WRITE_TOOLS] |
360 | 403 | return tools |
361 | 404 |
|
362 | 405 |
|
| 406 | +def _resolve_setting_sources(config: TaskConfig) -> list[Literal["user", "project", "local"]]: |
| 407 | + """Which on-disk Claude Code settings the CLI may load for this task. |
| 408 | +
|
| 409 | + A task with a cloned repo loads ``["project"]`` so the repo's own |
| 410 | + ``.claude/`` config is honored. A task with no repo loads nothing — |
| 411 | + defense-in-depth that also stops a stray on-disk skill (e.g. one that spawns |
| 412 | + a background Workflow) from being reachable. Kept as a named helper so the |
| 413 | + policy is unit-testable without driving the SDK. |
| 414 | +
|
| 415 | + Keys on ``repo_url`` (repo presence), NOT ``requires_repo`` (a static |
| 416 | + workflow property): a repo-optional workflow given a repo takes the |
| 417 | + repo-bound clone path (``pipeline.py`` gates on ``not requires_repo and not |
| 418 | + repo_url``), so keying on ``requires_repo`` would clone the repo but drop |
| 419 | + its ``.claude/`` config. Mirrors ``create-task-core.ts`` keying |
| 420 | + ``branch_name`` on repo presence for the same reason. |
| 421 | + """ |
| 422 | + return ["project"] if config.repo_url else [] |
| 423 | + |
| 424 | + |
363 | 425 | async def run_agent( |
364 | 426 | prompt: str, |
365 | 427 | system_prompt: str, |
@@ -439,10 +501,15 @@ def _on_stderr(line: str) -> None: |
439 | 501 | model=config.anthropic_model, |
440 | 502 | system_prompt=system_prompt, |
441 | 503 | allowed_tools=allowed_tools, |
| 504 | + # Hard surface lock (NOT allowed_tools — that is auto-approve only). Keeps |
| 505 | + # off-session/defer vectors out of the model's context even under |
| 506 | + # bypassPermissions, so a one-shot headless task cannot launch detached |
| 507 | + # work it has no supervisor to await. See _DISALLOWED_TOOLS. |
| 508 | + disallowed_tools=list(_DISALLOWED_TOOLS), |
442 | 509 | permission_mode="bypassPermissions", |
443 | 510 | cwd=cwd, |
444 | 511 | max_turns=config.max_turns, |
445 | | - setting_sources=["project"], |
| 512 | + setting_sources=_resolve_setting_sources(config), |
446 | 513 | hooks=hooks, |
447 | 514 | max_budget_usd=config.max_budget_usd, |
448 | 515 | stderr=_on_stderr, |
|
0 commit comments