using System.Text.Json; using System.Text.Json.Nodes; using ClawdDotNet.Core.Engine; using ClawdDotNet.Core.Sandbox; namespace ClawdDotNet.Core.Runtimes; /// /// #712: Claude Code as a runtime — claude -p in the sandbox. /// /// Allow lists, never the bypass. Claude Code gets --allowedTools and /// --max-turns; --dangerously-skip-permissions is never passed. In print mode /// there is no one to ask, so what is not allowed is denied. /// /// Only our configuration counts. The work copy may carry a /// .claude/settings.json or an .mcp.json — written by whoever wrote the /// workspace. --setting-sources user reads only the settings this class writes into /// the runtime's home before each run; --strict-mcp-config only the one MCP server, /// ours. Hooks are off, and what a run left in the home that would instruct the next one /// (agents, commands, skills, a CLAUDE.md) is removed before it starts. /// /// Nothing secret on the command line. The key goes through the launcher's /// file; the prompt through stdin; the briefing through a file. /// public sealed class ClaudeCodeRuntime(Func settings) : IForeignRuntime { public string Kind => RuntimeKinds.ClaudeCode; /// Reading and editing the work copy, and our MCP server. No shell, no web. public static readonly IReadOnlyList DefaultAllowedTools = ["Read", "Edit", "Write", "Glob", "Grep", "mcp__clawd"]; /// Denied even when listed — the web goes through our tools (#193), not around them. public static readonly IReadOnlyList AlwaysDenied = ["WebFetch", "WebSearch"]; /// The hosts Claude Code needs: the API. Updates and telemetry are switched off. public static readonly IReadOnlyList Egress = ["api.anthropic.com"]; private static readonly string[] Candidates = [ "~/.local/bin/claude", "~/.claude/local/claude", "/usr/local/bin/claude", "/usr/bin/claude" ]; /// The program, as configured or where an installer puts it; links resolved. public static string? Locate(ClaudeCodeSettings settings) { var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var candidates = string.IsNullOrWhiteSpace(settings.Executable) ? Candidates : [settings.Executable]; foreach (var candidate in candidates) { var path = candidate.StartsWith("~/", StringComparison.Ordinal) ? Path.Combine(home, candidate[2..]) : candidate; if (!File.Exists(path)) continue; var target = new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true); return target?.FullName ?? Path.GetFullPath(path); } return null; } public SandboxRequest Prepare(ForeignRunContext context) { var config = settings(); var executable = Locate(config) ?? throw new ForeignRunException("Claude Code is not installed here — set 'claudeCode.executable' in the instance configuration."); var secret = config.OnSubscription ? config.OAuthToken : config.ApiKey; if (string.IsNullOrWhiteSpace(secret)) throw new ForeignRunException(config.OnSubscription ? "No subscription token for Claude Code — run 'claude setup-token' and enter it in the instance settings." : "No API key for Claude Code in the instance settings."); var runtime = context.Agent.Runtime; var allowed = (runtime.AllowedTools.Count > 0 ? runtime.AllowedTools : DefaultAllowedTools) .Where(t => !AlwaysDenied.Contains(t, StringComparer.OrdinalIgnoreCase)) .Where(t => context.HasMcp || !t.StartsWith("mcp__", StringComparison.Ordinal)) .ToList(); PrepareHome(context.HomeDirectory, allowed); File.WriteAllText(Path.Combine(context.PrivateDirectory, ForeignRunContext.BriefingFileName), Briefing(context)); File.WriteAllText(Path.Combine(context.PrivateDirectory, "mcp.json"), McpConfig(context)); var arguments = new List { "-p", "--output-format", "stream-json", "--verbose", "--max-turns", Math.Clamp(runtime.MaxTurns, 1, 500).ToString(System.Globalization.CultureInfo.InvariantCulture), "--setting-sources", "user", "--strict-mcp-config", "--mcp-config", ForeignRunContext.Private + "/mcp.json", "--append-system-prompt-file", ForeignRunContext.Private + "/" + ForeignRunContext.BriefingFileName, "--allowedTools", string.Join(",", allowed), "--disallowedTools", string.Join(",", AlwaysDenied) }; if (ModelName(context.Agent.Model) is { } model) arguments.AddRange(["--model", model]); var environment = new Dictionary(context.McpEnvironment(), StringComparer.Ordinal) { ["DISABLE_AUTOUPDATER"] = "1", ["DISABLE_TELEMETRY"] = "1", ["DISABLE_ERROR_REPORTING"] = "1", ["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" }; var readOnly = new List(config.ReadOnlyPaths.Concat(context.HelperPaths())); var directory = Path.GetDirectoryName(executable)!; if (!directory.StartsWith("/usr/", StringComparison.Ordinal)) readOnly.Add(directory); return new SandboxRequest { Executable = executable, Arguments = arguments, WorkspacePath = context.WorkCopy, ProtectedPaths = [], ReadOnlyPaths = readOnly, Mounts = context.Mounts(), Environment = environment, SecretEnvironment = new Dictionary { [config.OnSubscription ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY"] = secret.Trim() }, Egress = [.. Egress, .. runtime.Egress], StandardInput = context.Message, Limits = ForeignRunner.Limits(runtime) }; } /// /// The home as this run finds it: our settings, and nothing a previous run left to /// instruct this one. Login state and history stay. /// internal static void PrepareHome(string home, IReadOnlyList allowed) { // A previous run could have made .claude a link to anywhere on the host — writing // through it would put our file there. Links go; only a real folder is used. var claude = Path.Combine(home, ".claude"); if (new DirectoryInfo(claude).LinkTarget is not null || File.Exists(claude)) DeleteEntry(claude); Directory.CreateDirectory(claude); foreach (var name in new[] { "agents", "commands", "skills", "hooks", "output-styles", "plugins" }) { var path = Path.Combine(claude, name); if (Directory.Exists(path) || File.Exists(path)) DeleteEntry(path); } foreach (var name in new[] { "CLAUDE.md", "settings.local.json" }) { var path = Path.Combine(claude, name); if (File.Exists(path) || Directory.Exists(path)) DeleteEntry(path); } var settings = new JsonObject { ["permissions"] = new JsonObject { ["allow"] = new JsonArray(allowed.Select(a => (JsonNode)a).ToArray()), ["deny"] = new JsonArray(AlwaysDenied.Select(a => (JsonNode)a).ToArray()) }, ["disableAllHooks"] = true }; var file = Path.Combine(claude, "settings.json"); if (File.Exists(file) || new FileInfo(file).LinkTarget is not null) DeleteEntry(file); File.WriteAllText(file, settings.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); } /// Removes a file, a folder or a link — never what a link points to. private static void DeleteEntry(string path) { var info = new FileInfo(path); if (info.LinkTarget is not null || !Directory.Exists(path)) info.Delete(); else Directory.Delete(path, recursive: true); } private static string Briefing(ForeignRunContext context) => context.Briefing + "\n\n## How this run works\n\n" + "You work in /work, a copy of your workspace. Nothing you change there is used directly: " + "the changed files become a proposal that is reviewed before anything is applied.\n" + (context.HasMcp ? "The taskboard, your colleagues and your other tools are reachable only through the MCP server 'clawd'.\n" : "") + "End with a short summary of what you did and what is left."; private static string McpConfig(ForeignRunContext context) { var servers = new JsonObject(); if (context.HasMcp) { var env = new JsonObject { [Mcp.McpStdioBridge.TokenFileVariable] = ForeignRunContext.Private + "/" + ForeignRunContext.TokenFileName }; // An MCP server does not inherit the runtime's environment, only a few variables. foreach (var (key, value) in context.Helper!.Environment) env[key] = value; servers["clawd"] = new JsonObject { ["type"] = "stdio", ["command"] = context.Helper.Executable, ["args"] = new JsonArray("--mcp-bridge", Mcp.McpStdioBridge.SocketArgument, ForeignRunContext.McpSocketInSandbox), ["env"] = env }; } return new JsonObject { ["mcpServers"] = servers }.ToJsonString(); } /// anthropic/claude-sonnet-5 (OpenRouter's name) → claude-sonnet-5; empty stays the runtime's default. public static string? ModelName(string? model) { if (string.IsNullOrWhiteSpace(model)) return null; var slash = model.IndexOf('/'); if (slash >= 0 && !model.StartsWith("anthropic/", StringComparison.OrdinalIgnoreCase)) return null; return slash >= 0 ? model[(slash + 1)..] : model; } public ForeignRunOutcome Interpret(SandboxResult result, ForeignRunContext context, Action note) { var onSubscription = settings().OnSubscription; JsonObject? final = null; string? model = null; var turns = 0; foreach (var line in result.StandardOutput.Split('\n')) { if (!line.StartsWith('{')) continue; JsonObject? message; try { message = JsonNode.Parse(line) as JsonObject; } catch (JsonException) { continue; } switch (Text(message?["type"])) { case "system" when Text(message!["subtype"]) == "init": model = Text(message["model"]); break; case "assistant": turns++; foreach (var part in message!["message"]?["content"] as JsonArray ?? []) { if (Text(part?["type"]) == "tool_use") note($"Tool: {Text(part!["name"])}"); else if (Text(part?["type"]) == "text" && Text(part!["text"]) is { Length: > 0 } text) note(text.Length > 500 ? text[..500] + " …" : text); } break; case "result": final = message; break; } } if (final is null) { var reason = result.TimedOut ? "timed out" : result.OutputTruncated ? "produced more output than allowed" : $"ended with exit code {result.ExitCode} and no result"; return new ForeignRunOutcome { Status = result.TimedOut ? AgentRunStatus.Cancelled : AgentRunStatus.Failed, Text = $"[Claude Code {reason}]" + Tail(result.StandardError), Turns = turns, Model = model, OnSubscription = onSubscription }; } var usage = final["usage"] as JsonObject; var input = Number(usage?["input_tokens"]); var cacheWrite = Number(usage?["cache_creation_input_tokens"]); var cacheRead = Number(usage?["cache_read_input_tokens"]); var subtype = Text(final["subtype"]) ?? ""; var isError = final["is_error"]?.GetValueKind() == JsonValueKind.True; var status = subtype == "error_max_turns" ? AgentRunStatus.LoopLimitExceeded : isError || subtype != "success" ? AgentRunStatus.Failed : AgentRunStatus.Completed; decimal? cost = onSubscription ? 0m : null; if (!onSubscription && final["total_cost_usd"] is JsonValue value && value.TryGetValue(out var usd)) cost = usd; return new ForeignRunOutcome { Status = status, Text = Text(final["result"]) ?? (status == AgentRunStatus.Completed ? "" : $"[Claude Code: {subtype}]"), Turns = (int)Math.Max(turns, Number(final["num_turns"])), PromptTokens = (int)(input + cacheWrite + cacheRead), CompletionTokens = (int)Number(usage?["output_tokens"]), CachedTokens = (int)cacheRead, CostUsd = cost, OnSubscription = onSubscription, Model = model }; } private static string? Text(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var text) ? text : null; private static long Number(JsonNode? node) => node is JsonValue value && value.TryGetValue(out var number) ? number : 0; internal static string Tail(string stderr) { var text = stderr.Trim(); if (text.Length == 0) return ""; return "\n" + (text.Length > 1000 ? "…" + text[^1000..] : text); } }