Fremde Laufzeiten: Claude Code und Befehl in der Sandbox, Egress-Proxy, Vorschlag statt Einsatz (#712)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7d6c2c9495
commit
367ea387f6
@@ -0,0 +1,318 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Sandbox;
|
||||
|
||||
namespace ClawdDotNet.Core.Runtimes;
|
||||
|
||||
/// <summary>
|
||||
/// #712: Claude Code as a runtime — <c>claude -p</c> in the sandbox.
|
||||
///
|
||||
/// <para><b>Allow lists, never the bypass.</b> Claude Code gets <c>--allowedTools</c> and
|
||||
/// <c>--max-turns</c>; <c>--dangerously-skip-permissions</c> is never passed. In print mode
|
||||
/// there is no one to ask, so what is not allowed is denied.</para>
|
||||
///
|
||||
/// <para><b>Only our configuration counts.</b> The work copy may carry a
|
||||
/// <c>.claude/settings.json</c> or an <c>.mcp.json</c> — written by whoever wrote the
|
||||
/// workspace. <c>--setting-sources user</c> reads only the settings this class writes into
|
||||
/// the runtime's home before each run; <c>--strict-mcp-config</c> 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.</para>
|
||||
///
|
||||
/// <para><b>Nothing secret on the command line.</b> The key goes through the launcher's
|
||||
/// file; the prompt through stdin; the briefing through a file.</para>
|
||||
/// </summary>
|
||||
public sealed class ClaudeCodeRuntime(Func<ClaudeCodeSettings> settings) : IForeignRuntime
|
||||
{
|
||||
public string Kind => RuntimeKinds.ClaudeCode;
|
||||
|
||||
/// <summary>Reading and editing the work copy, and our MCP server. No shell, no web.</summary>
|
||||
public static readonly IReadOnlyList<string> DefaultAllowedTools = ["Read", "Edit", "Write", "Glob", "Grep", "mcp__clawd"];
|
||||
|
||||
/// <summary>Denied even when listed — the web goes through our tools (#193), not around them.</summary>
|
||||
public static readonly IReadOnlyList<string> AlwaysDenied = ["WebFetch", "WebSearch"];
|
||||
|
||||
/// <summary>The hosts Claude Code needs: the API. Updates and telemetry are switched off.</summary>
|
||||
public static readonly IReadOnlyList<string> Egress = ["api.anthropic.com"];
|
||||
|
||||
private static readonly string[] Candidates =
|
||||
[
|
||||
"~/.local/bin/claude", "~/.claude/local/claude", "/usr/local/bin/claude", "/usr/bin/claude"
|
||||
];
|
||||
|
||||
/// <summary>The program, as configured or where an installer puts it; links resolved.</summary>
|
||||
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<string>
|
||||
{
|
||||
"-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<string, string>(context.McpEnvironment(), StringComparer.Ordinal)
|
||||
{
|
||||
["DISABLE_AUTOUPDATER"] = "1",
|
||||
["DISABLE_TELEMETRY"] = "1",
|
||||
["DISABLE_ERROR_REPORTING"] = "1",
|
||||
["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"
|
||||
};
|
||||
|
||||
var readOnly = new List<string>(config.ReadOnlyPaths);
|
||||
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<string, string>
|
||||
{
|
||||
[config.OnSubscription ? "CLAUDE_CODE_OAUTH_TOKEN" : "ANTHROPIC_API_KEY"] = secret.Trim()
|
||||
},
|
||||
Egress = [.. Egress, .. runtime.Egress],
|
||||
StandardInput = context.Message,
|
||||
Limits = ForeignRunner.Limits(runtime)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The home as this run finds it: our settings, and nothing a previous run left to
|
||||
/// instruct this one. Login state and history stay.
|
||||
/// </summary>
|
||||
internal static void PrepareHome(string home, IReadOnlyList<string> 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 }));
|
||||
}
|
||||
|
||||
/// <summary>Removes a file, a folder or a link — never what a link points to.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary><c>anthropic/claude-sonnet-5</c> (OpenRouter's name) → <c>claude-sonnet-5</c>; empty stays the runtime's default.</summary>
|
||||
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<string> 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<decimal>(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<string>(out var text) ? text : null;
|
||||
|
||||
private static long Number(JsonNode? node)
|
||||
=> node is JsonValue value && value.TryGetValue<long>(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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user