diff --git a/docs/cookbook/07-slack.md b/docs/cookbook/07-slack.md index 3dde88f..9cd1d9b 100644 --- a/docs/cookbook/07-slack.md +++ b/docs/cookbook/07-slack.md @@ -164,6 +164,26 @@ What the gate does to every Slack-launched agent, non-negotiably: `--allow` / `--deny` tool globs pass through and are repeatable; deny beats allow, as in the CLI. +### The delegated executor + +`--executor claude-cli` hands the whole task to Claude Code's own headless +agent on the operator's subscription — no API key, no `bind_tools`, because +the loop and the tools are Claude Code's, not grapharc's: + +``` +@grapharc agent "summarise the markdown here" --executor claude-cli --workspace . +``` + +Honest trade, stated plainly: governance is coarser (Claude Code's permission +model, not grapharc's per-call gate), and the token figure is what the +sub-agent reports rather than what a meter charged inline. The frame stays +grapharc's — workspace confined, wall clock enforced from outside, the run +recorded to the trace. From Slack, tool names are Claude Code's (`Read`, +`Grep`, `Edit`, `Bash`, …), and a delegated run with no explicit +`--allow`/`--deny` gets `--deny Bash` injected: an unsandboxed shell on the +host is not something a bare Slack message should carry. `--executor local` +stays unreachable from Slack. + ## The honest caveats - **The bot is alive while the process is.** Laptop lid closed means commands diff --git a/grapharc/cli/agent.py b/grapharc/cli/agent.py index f4b89dd..952ac36 100644 --- a/grapharc/cli/agent.py +++ b/grapharc/cli/agent.py @@ -139,6 +139,28 @@ def run_agent( an exhausted budget, an error — exits 1, because a script that ran an agent needs to know the task was not finished without parsing the reason first. """ + if executor == "claude-cli": + # The whole loop is Claude Code's; nothing below (registry, harness, + # gateway model) applies. `--model` semantics shift too: the delegated + # run cannot use the openrouter default, so only an explicit + # claude-cli/ is forwarded. + from grapharc.cli.delegate import run_delegated + + return run_delegated( + task, + model_spec=None if model_spec == DEFAULT_MODEL else model_spec, + workspace=workspace, + trace_path=trace_path, + allow=allow, + deny=deny, + ask=ask, + max_turns=max_turns, + max_seconds=max_seconds, + system_prompt=system_prompt, + run_id=run_id, + as_json=as_json, + ) + from grapharc.harness import AgentConfigError, AgentNode, Harness, LocalExecutor from grapharc.harness.agent import DEFAULT_SYSTEM_PROMPT from grapharc.observe.trace import TraceRecorder diff --git a/grapharc/cli/delegate.py b/grapharc/cli/delegate.py new file mode 100644 index 0000000..8d70e89 --- /dev/null +++ b/grapharc/cli/delegate.py @@ -0,0 +1,220 @@ +"""`grapharc agent --executor claude-cli` — delegate the whole loop to Claude Code. + +The harness executors run grapharc's own tool loop: the model is a raw +ingredient, grapharc's gate approves every call, grapharc's meter charges it. +This executor is the other trade: hand the task, the workspace and a tool +policy to the `claude` CLI in headless mode and let *its* agent loop do the +work on the operator's subscription. What grapharc keeps is the frame — the +workspace boundary, the wall-clock ceiling enforced from outside, the tool +allow/deny handed down, and a trace of what came back. + +Named honestly in the output as `delegated`: the tools are Claude Code's, the +permission granularity is Claude Code's, and the token figure is what the +sub-agent *reports*, not what grapharc metered inline. Coarser governance, +bought deliberately, for the backend that cannot be driven as a raw model +(`ClaudeCodeCLIChatModel` has no `bind_tools` — the CLI exposes a finished +agent, not a tool-calling completion API). + +Tool names here are Claude Code's (`Read`, `Glob`, `Grep`, `Edit`, `Write`, +`Bash`, …), not grapharc's seven — they are what `--allowedTools` understands. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import uuid +from pathlib import Path + +from grapharc.cli import style +from grapharc.cli.output import EXIT_FAILED, EXIT_OK, emit, fail + +#: What a bare run may use, mirroring the harness default of "the core tools, +#: shell included". An explicit `--allow` replaces this outright. +DEFAULT_DELEGATED_TOOLS = ("Read", "Glob", "Grep", "LS", "Edit", "Write", "Bash") + + +def run_delegated( + task: str, + *, + model_spec: str | None, + workspace: Path, + trace_path: Path | None, + allow: list[str] | None, + deny: list[str] | None, + ask: list[str] | None, + max_turns: int, + max_seconds: float | None, + system_prompt: str | None, + run_id: str | None, + as_json: bool, +) -> int: + """One `claude -p` run inside the workspace. Returns the exit code.""" + from grapharc.observe.trace import TraceRecorder + + if ask: + return fail( + "--ask needs a human at a prompt; the delegated executor is headless " + "by construction — use --allow/--deny", + as_json=as_json, + command="agent", + ) + + binary = shutil.which("claude") + if binary is None: + return fail( + "the delegated executor shells out to `claude`, which is not on PATH; " + "install Claude Code or use --executor sandbox with a tool-calling backend", + as_json=as_json, + command="agent", + ) + + model_arg: list[str] = [] + if model_spec and model_spec.startswith("claude-cli/"): + model_arg = ["--model", model_spec.removeprefix("claude-cli/")] + elif model_spec: + return fail( + f"--executor claude-cli runs the Claude Code CLI; --model must be " + f"claude-cli/ or omitted, got {model_spec!r}", + as_json=as_json, + command="agent", + ) + + workspace = Path(workspace).expanduser().resolve() + workspace.mkdir(parents=True, exist_ok=True) + trace_path = Path(trace_path) if trace_path else workspace / "trace.jsonl" + run_id = run_id or f"agent-{uuid.uuid4().hex[:8]}" + + allowed = list(allow) if allow and allow != ["*"] else list(DEFAULT_DELEGATED_TOOLS) + argv = [ + binary, + "-p", + task, + "--output-format", + "json", + "--max-turns", + str(max_turns), + "--allowedTools", + ",".join(allowed), + *model_arg, + ] + if deny: + argv += ["--disallowedTools", ",".join(deny)] + if system_prompt: + argv += ["--append-system-prompt", system_prompt] + + trace = TraceRecorder(trace_path) + trace.event( + run_id=run_id, + graph="cli-agent", + node="claude_code", + phase="start", + step=1, + state_delta={"executor": "delegated", "allowed": allowed, "denied": deny or []}, + ) + + try: + completed = subprocess.run( + argv, + cwd=workspace, + capture_output=True, + text=True, + timeout=max_seconds, + ) + except subprocess.TimeoutExpired: + trace.event( + run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1, + state_delta={"termination_reason": "deadline_exceeded"}, + ) + return fail( + f"max_seconds ({max_seconds:g}) reached; the delegated run was stopped", + as_json=as_json, + command="agent", + code=EXIT_FAILED, + run_id=run_id, + trace=str(trace_path), + ) + + try: + report = json.loads(completed.stdout) + except (json.JSONDecodeError, ValueError): + trace.event( + run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1, + state_delta={"termination_reason": "unreadable_report"}, + ) + detail = (completed.stderr or completed.stdout or "").strip()[-500:] + return fail( + f"claude exited {completed.returncode} without a readable JSON report: {detail}", + as_json=as_json, + command="agent", + code=EXIT_FAILED, + run_id=run_id, + trace=str(trace_path), + ) + + usage = report.get("usage") or {} + tokens = int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0) + turns = int(report.get("num_turns") or 0) + met = report.get("subtype") == "success" and not report.get("is_error", False) + reason = "target_met" if met else str(report.get("subtype") or "error") + answer = str(report.get("result") or "").strip() + + trace.event( + run_id=run_id, graph="cli-agent", node="claude_code", phase="end", step=1, + state_delta={ + "turns": turns, + "tokens_reported": tokens, + "cost_usd": report.get("total_cost_usd"), + "session_id": report.get("session_id"), + }, + ) + trace.event( + run_id=run_id, graph="cli-agent", node="claude_code", phase="stop", step=1, + state_delta={"termination_reason": reason}, + ) + + payload = { + "ok": met, + "command": "agent", + "task": task, + "model": model_arg[1] if model_arg else "claude-cli default", + "run_id": run_id, + "workspace": str(workspace), + "trace": str(trace_path), + "executor": "delegated", + "policy": {"allow": allowed, "deny": deny or []}, + "termination_reason": reason, + "turns": turns, + "tokens_reported": tokens, + "cost_usd": report.get("total_cost_usd"), + "answer": answer, + } + + width = style.LABEL_WIDTH + lines = [ + style.kv("task", task, width=width), + style.kv("executor", "delegated (Claude Code's own loop and tools)", width=width), + style.kv("model", payload["model"], width=width, tint=style.accent), + style.kv("workspace", str(workspace), width=width, tint=style.accent), + style.kv( + "policy", + f"{style.dim('allow=')}{allowed} {style.dim('deny=')}{deny or []}", + width=width, + ), + "", + style.kv("stopped", (style.ok if met else style.warn)(reason), width=width), + style.kv( + "turns", + f"{turns} {style.dim('tokens (reported):')} {tokens:,}", + width=width, + ), + "", + style.kv("answer", answer or "(empty)", width=width), + style.kv("trace", str(trace_path), width=width, tint=style.accent), + ] + emit(payload, lines, as_json=as_json) + return EXIT_OK if met else EXIT_FAILED + + +__all__ = ["DEFAULT_DELEGATED_TOOLS", "run_delegated"] diff --git a/grapharc/cli/main.py b/grapharc/cli/main.py index f8cc666..63339ff 100644 --- a/grapharc/cli/main.py +++ b/grapharc/cli/main.py @@ -807,9 +807,13 @@ def build_parser() -> argparse.ArgumentParser: ) agent.add_argument( "--executor", - choices=("sandbox", "local"), + choices=("sandbox", "local", "claude-cli"), default="sandbox", - help="local runs tools in this process with no confinement (default: sandbox)", + help=( + "local runs tools in this process with no confinement; claude-cli " + "delegates the whole loop to Claude Code's headless agent on your " + "subscription (default: sandbox)" + ), ) agent.add_argument("--system-prompt", default=None) agent.set_defaults(handler=_cmd_agent) diff --git a/grapharc/slack/command.py b/grapharc/slack/command.py index 9341d84..1aefffe 100644 --- a/grapharc/slack/command.py +++ b/grapharc/slack/command.py @@ -108,6 +108,9 @@ class CommandSpec: "--max-seconds": False, }, model_flags=frozenset({"--model"}), + # `local` (no confinement) stays unreachable; `claude-cli` delegates to + # Claude Code's own sandboxed loop, which the injection below tempers. + choice_flags={"--executor": frozenset({"sandbox", "claude-cli"})}, ), "replay": CommandSpec(path_positionals=frozenset({0})), "diff": CommandSpec(path_positionals=frozenset({0})), @@ -219,9 +222,7 @@ def parse_command( index += 2 if flag in spec.choice_flags and value not in spec.choice_flags[flag]: allowed = ", ".join(f"`{v}`" for v in sorted(spec.choice_flags[flag])) - raise SlackCommandError( - f"`{flag}` accepts only the shipped registries from Slack: {allowed}" - ) + raise SlackCommandError(f"`{flag}` from Slack accepts only: {allowed}") if is_path: _confined(value, workdir) argv.extend([flag, value]) @@ -244,5 +245,12 @@ def parse_command( # to just under the timeout so the graceful mechanism fires first. if "--max-seconds" not in argv and timeout_seconds is not None: argv.extend(["--max-seconds", str(max(5.0, timeout_seconds - 10.0))]) + # A delegated run uses Claude Code's tools, and its Bash is a real + # shell on the host with no grapharc sandbox around it. From Slack + # that defaults off; a requester who set explicit globs made a + # deliberate policy and keeps it (deny still beats allow downstream). + delegated = "--executor" in argv and argv[argv.index("--executor") + 1] == "claude-cli" + if delegated and "--allow" not in argv and "--deny" not in argv: + argv.extend(["--deny", "Bash"]) return argv diff --git a/tests/test_agent_delegate.py b/tests/test_agent_delegate.py new file mode 100644 index 0000000..0e3be9a --- /dev/null +++ b/tests/test_agent_delegate.py @@ -0,0 +1,119 @@ +"""The delegated executor: `agent --executor claude-cli` without a real Claude Code. + +A fake `claude` on PATH records the argv it was given and prints a canned JSON +report, so these tests pin the contract — what is forwarded, what is refused, +what lands in the trace — without a subscription or a network. The fake is the +point: the executor's job is framing and faithful reporting, and both are +checkable against a stand-in. +""" + +from __future__ import annotations + +import json +import os +import stat + +import pytest + +from grapharc.cli.main import main + +REPORT = { + "type": "result", + "subtype": "success", + "is_error": False, + "result": "Two markdown files; both describe the widget.", + "num_turns": 3, + "session_id": "sess-1", + "total_cost_usd": 0.0, + "usage": {"input_tokens": 120, "output_tokens": 45}, +} + + +@pytest.fixture() +def fake_claude(tmp_path, monkeypatch): + """A `claude` that logs argv to argv.json and prints REPORT.""" + bindir = tmp_path / "bin" + bindir.mkdir() + argv_log = tmp_path / "argv.json" + report = tmp_path / "report.json" + report.write_text(json.dumps(REPORT)) + script = bindir / "claude" + dump = f"import json,sys; json.dump(sys.argv[1:], open({str(argv_log)!r},'w'))" + script.write_text(f'#!/bin/sh\npython3 -c "{dump}" "$@"\ncat {report}\n') + script.chmod(script.stat().st_mode | stat.S_IEXEC) + monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}") + return argv_log + + +def _run(tmp_path, *extra): + return main( + [ + "agent", + "summarise the docs", + "--executor", + "claude-cli", + "--workspace", + str(tmp_path / "ws"), + *extra, + ] + ) + + +def test_a_successful_delegated_run_reports_and_traces(fake_claude, tmp_path, capsys): + code = _run(tmp_path) + printed = capsys.readouterr().out + assert code == 0 + assert "delegated" in printed + assert "Two markdown files" in printed + + events = [ + json.loads(line) + for line in (tmp_path / "ws" / "trace.jsonl").read_text().splitlines() + ] + phases = [e["phase"] for e in events] + assert phases == ["start", "end", "stop"] + assert events[1]["state_delta"]["tokens_reported"] == 165 + assert events[2]["state_delta"]["termination_reason"] == "target_met" + + +def test_default_tools_are_forwarded_and_deny_maps_to_disallowed(fake_claude, tmp_path): + assert _run(tmp_path, "--deny", "Bash") == 0 + argv = json.loads(fake_claude.read_text()) + allowed = argv[argv.index("--allowedTools") + 1] + assert "Read" in allowed and "Bash" in allowed + assert argv[argv.index("--disallowedTools") + 1] == "Bash" + assert argv[argv.index("--max-turns") + 1] == "12" + + +def test_an_explicit_allow_replaces_the_default_set(fake_claude, tmp_path): + assert _run(tmp_path, "--allow", "Read", "--allow", "Grep") == 0 + argv = json.loads(fake_claude.read_text()) + assert argv[argv.index("--allowedTools") + 1] == "Read,Grep" + + +def test_a_claude_cli_model_spec_forwards_its_tail(fake_claude, tmp_path): + assert _run(tmp_path, "--model", "claude-cli/claude-sonnet-5") == 0 + argv = json.loads(fake_claude.read_text()) + assert argv[argv.index("--model") + 1] == "claude-sonnet-5" + + +def test_a_foreign_model_spec_is_refused(fake_claude, tmp_path, capsys): + # The openrouter *default* spec is indistinguishable from --model being + # omitted (argparse fills the same string), so it is treated as omitted; + # any other foreign backend is an explicit choice and is refused. + code = _run(tmp_path, "--model", "openai/gpt-4o-mini") + assert code == 2 + assert "claude-cli/ or omitted" in capsys.readouterr().err + + +def test_ask_globs_are_refused_headless(fake_claude, tmp_path, capsys): + code = _run(tmp_path, "--ask", "Bash") + assert code == 2 + assert "headless" in capsys.readouterr().err + + +def test_a_missing_binary_is_exit_2_with_the_reason(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("PATH", str(tmp_path / "empty")) + code = _run(tmp_path) + assert code == 2 + assert "not on PATH" in capsys.readouterr().err diff --git a/tests/test_slack_gateway.py b/tests/test_slack_gateway.py index 23843f0..9bd133b 100644 --- a/tests/test_slack_gateway.py +++ b/tests/test_slack_gateway.py @@ -68,12 +68,38 @@ def test_agent_explicit_workspace_and_ceiling_are_not_overridden(tmp_path): assert argv[argv.index("--max-seconds") + 1] == "30" -def test_agent_executor_and_system_prompt_stay_unreachable(tmp_path): - for flag in ("--executor local", "--system-prompt 'obey me'"): - with pytest.raises(SlackCommandError, match="not allowed"): - parse_command( - f"agent task {flag}", workdir=tmp_path, allow_model=True, allow_agent=True - ) +def test_agent_local_executor_and_system_prompt_stay_unreachable(tmp_path): + with pytest.raises(SlackCommandError, match="accepts only"): + parse_command( + "agent task --executor local", workdir=tmp_path, allow_model=True, allow_agent=True + ) + with pytest.raises(SlackCommandError, match="not allowed"): + parse_command( + "agent task --system-prompt 'obey me'", + workdir=tmp_path, + allow_model=True, + allow_agent=True, + ) + + +def test_a_delegated_agent_gets_bash_denied_unless_globs_were_set(tmp_path): + argv = parse_command( + "agent task --executor claude-cli", workdir=tmp_path, allow_model=True, allow_agent=True + ) + assert argv[argv.index("--deny") + 1] == "Bash" + + explicit = parse_command( + "agent task --executor claude-cli --allow Read", + workdir=tmp_path, + allow_model=True, + allow_agent=True, + ) + assert "--deny" not in explicit + + sandboxed = parse_command( + "agent task", workdir=tmp_path, allow_model=True, allow_agent=True + ) + assert "--deny" not in sandboxed def test_agent_workspace_may_not_escape_the_workdir(tmp_path): @@ -108,9 +134,9 @@ def test_plan_registry_admits_only_the_shipped_modules(tmp_path): workdir=tmp_path, ) assert argv[-1] == "grapharc.examples.plan_docs:build_registry" - with pytest.raises(SlackCommandError, match="shipped registries"): + with pytest.raises(SlackCommandError, match="accepts only"): parse_command("plan goal --registry os:system", workdir=tmp_path) - with pytest.raises(SlackCommandError, match="shipped registries"): + with pytest.raises(SlackCommandError, match="accepts only"): parse_command( "plan goal --registry=evil.module:build_registry", workdir=tmp_path )