using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using CopilotHere.Commands.DockerBroker;
namespace CopilotHere.Infrastructure;
///
/// Phase 2 body inspection for Docker socket broker. Parses the JSON body of
/// `POST /containers/create` requests and applies a set of safety rules:
///
/// * Reject obviously dangerous container configurations:
/// - HostConfig.Privileged == true
/// - HostConfig.NetworkMode/PidMode/IpcMode/UsernsMode == "host"
/// - HostConfig.Binds containing forbidden host paths
/// (/, /etc, /root, /var, /usr, /bin, /sbin, /proc, /sys, the docker socket itself)
/// - HostConfig.CapAdd containing entries from the dangerous capability list
///
/// * In airlock + DinD mode, inject HostConfig.NetworkMode = "<airlock-network>"
/// so spawned sibling containers join the same internal-only network as the
/// workload. Without this, Testcontainers et al. spawn siblings on the
/// default bridge and the workload (which sits on `internal: true`) can't
/// reach them. With it, the workload connects to the sibling by Docker DNS
/// name and the airlock guarantees still hold.
///
/// The inspector treats the create request as a single JSON object: parse with
/// JsonNode (no source-generator since the schema is too dynamic), apply rules,
/// re-serialize, return the new bytes. The caller wraps that into a fresh
/// HTTP/1.1 request with an updated Content-Length.
///
/// Failures are conservative — if we can't parse the body we forward unchanged
/// rather than blocking valid Docker calls. The endpoint allowlist already
/// gates which paths are even reachable.
///
public static class DockerBrokerBodyInspector
{
///
/// Maximum body size we'll buffer for inspection. Container create payloads
/// are typically a few KB. Anything beyond this we forward unchanged with a
/// warning, on the theory that bypassing inspection is preferable to
/// breaking legitimate workflows. Body inspection is the second line of
/// defence — endpoint filtering is the first.
///
public const int MaxInspectableBodyBytes = 2 * 1024 * 1024;
///
/// Default list of host paths that container bind mounts MUST NOT target.
/// These are paths whose contents would let a sibling container compromise
/// the host or escape the airlock.
///
private static readonly string[] ForbiddenBindHostPaths =
[
"/",
"/etc",
"/root",
"/var",
"/usr",
"/bin",
"/sbin",
"/proc",
"/sys",
"/var/run/docker.sock",
"/run/docker.sock",
];
///
/// Default deny-list of Linux capabilities a sibling container should never request.
///
private static readonly HashSet DangerousCapabilities = new(StringComparer.OrdinalIgnoreCase)
{
"SYS_ADMIN",
"SYS_MODULE",
"SYS_PTRACE",
"SYS_RAWIO",
"SYS_BOOT",
"MAC_ADMIN",
"MAC_OVERRIDE",
"DAC_READ_SEARCH",
"NET_ADMIN",
"AUDIT_CONTROL",
};
public sealed record InspectionResult(
bool Allowed,
string Reason,
byte[]? RewrittenBody);
///
/// Inspects (and optionally rewrites) the JSON body of a POST /containers/create
/// request. Set to inject NetworkMode for
/// airlock-mode broker sessions; pass null in standard --dind mode.
///
public static InspectionResult Inspect(byte[] body, string? siblingNetworkName) =>
Inspect(body, siblingNetworkName, new DockerBrokerBodyInspectionConfig());
///
/// Inspects with explicit per-rule toggles. The broker passes its loaded
/// here so users can opt out
/// of specific safety rules without disabling the rest.
///
public static InspectionResult Inspect(byte[] body, string? siblingNetworkName, DockerBrokerBodyInspectionConfig policy)
{
if (body.Length == 0)
{
// Empty body — Docker rejects this anyway. Forward unchanged.
return new InspectionResult(true, "empty body", null);
}
JsonNode? root;
try
{
root = JsonNode.Parse(body);
}
catch (JsonException)
{
// Malformed JSON. Forward unchanged so the upstream daemon returns its
// own 400 — that's a more useful error for users than a broker-level
// "could not parse" message.
return new InspectionResult(true, "unparseable body forwarded as-is", null);
}
if (root is not JsonObject obj)
{
return new InspectionResult(true, "non-object body forwarded as-is", null);
}
var hostConfig = obj["HostConfig"] as JsonObject;
// ── Image allowlist (default-deny) ────────────────────────────────────
// The allowed_images list is a strict whitelist. An empty list means
// NO sibling containers may be spawned at all — the safe posture for an
// AI agent is "name every image you trust, deny everything else". Users
// who legitimately need to spawn things must enumerate them via
// --add-docker-broker-image; otherwise every POST /containers/create is
// refused.
{
var image = obj["Image"]?.GetValue() ?? "";
var allowedImages = policy.AllowedImages ?? [];
var matched = false;
foreach (var pattern in allowedImages)
{
if (ImagePatternMatches(pattern, image))
{
matched = true;
break;
}
}
if (!matched)
{
var reason = allowedImages.Count == 0
? $"image \"{image}\" rejected: docker broker has no trusted images configured (allowed_images is empty)"
: $"image \"{image}\" is not in docker broker allowed_images list";
return new InspectionResult(false, reason, null);
}
}
// ── Rejections ────────────────────────────────────────────────────────
if (hostConfig is not null)
{
if (policy.RejectPrivileged && hostConfig["Privileged"]?.GetValue() == true)
{
return new InspectionResult(false, "HostConfig.Privileged is denied by docker broker policy", null);
}
if (policy.RejectHostNamespaces)
{
foreach (var modeKey in new[] { "NetworkMode", "PidMode", "IpcMode", "UsernsMode" })
{
var mode = hostConfig[modeKey]?.GetValue();
if (string.Equals(mode, "host", StringComparison.OrdinalIgnoreCase))
{
return new InspectionResult(false, $"HostConfig.{modeKey}=\"host\" is denied by docker broker policy", null);
}
}
}
if (policy.RejectForbiddenBinds && hostConfig["Binds"] is JsonArray binds)
{
foreach (var bindNode in binds)
{
var bind = bindNode?.GetValue();
if (string.IsNullOrEmpty(bind)) continue;
var hostPath = bind.Split(':')[0];
if (IsForbiddenHostPath(hostPath))
{
return new InspectionResult(false, $"HostConfig.Binds entry \"{bind}\" targets a forbidden host path", null);
}
}
}
if (policy.RejectDangerousCapabilities && hostConfig["CapAdd"] is JsonArray capAdd)
{
foreach (var capNode in capAdd)
{
var cap = capNode?.GetValue();
if (string.IsNullOrEmpty(cap)) continue;
var stripped = cap.StartsWith("CAP_", StringComparison.OrdinalIgnoreCase) ? cap[4..] : cap;
if (DangerousCapabilities.Contains(stripped))
{
return new InspectionResult(false, $"HostConfig.CapAdd entry \"{cap}\" is on the broker deny list", null);
}
}
}
}
// ── Mutations ─────────────────────────────────────────────────────────
bool mutated = false;
// Inject NetworkMode for airlock-mode siblings. We only override the
// default — if the caller already set an explicit non-host network we
// leave it alone (they presumably know what they're doing). The "default"
// and "bridge" cases are the ones we have to fix because they put the
// sibling on a network the airlocked workload can't reach.
if (!string.IsNullOrEmpty(siblingNetworkName))
{
hostConfig ??= new JsonObject();
var existing = hostConfig["NetworkMode"]?.GetValue();
if (string.IsNullOrEmpty(existing) ||
string.Equals(existing, "default", StringComparison.OrdinalIgnoreCase) ||
string.Equals(existing, "bridge", StringComparison.OrdinalIgnoreCase))
{
hostConfig["NetworkMode"] = siblingNetworkName;
if (obj["HostConfig"] is null) obj["HostConfig"] = hostConfig;
mutated = true;
}
// Also drop the top-level NetworkingConfig.EndpointsConfig if present —
// Testcontainers sometimes sets it to "bridge" which Docker treats as
// an explicit network attachment that conflicts with NetworkMode.
if (obj["NetworkingConfig"] is JsonObject netCfg &&
netCfg["EndpointsConfig"] is JsonObject endpoints &&
endpoints.Count > 0)
{
netCfg["EndpointsConfig"] = new JsonObject();
mutated = true;
}
}
if (!mutated)
{
return new InspectionResult(true, "approved unchanged", null);
}
var rewritten = Encoding.UTF8.GetBytes(obj.ToJsonString());
return new InspectionResult(true, "approved with NetworkMode injection", rewritten);
}
///
/// Glob-matches an image reference against a pattern. '*' matches any
/// sequence of characters (including slashes and colons), so a single
/// pattern can cover registry+repo+tag in one go. Matching is case-
/// sensitive because Docker image references are case-sensitive.
///
internal static bool ImagePatternMatches(string pattern, string image)
{
if (string.IsNullOrEmpty(pattern)) return false;
if (image is null) image = "";
// Fast path: literal match.
if (!pattern.Contains('*'))
{
return string.Equals(pattern, image, StringComparison.Ordinal);
}
// Greedy backtracking glob match. Pattern length is bounded by the
// user's config (small) so the worst-case cost is irrelevant.
return GlobMatch(pattern.AsSpan(), 0, image.AsSpan(), 0);
}
private static bool GlobMatch(ReadOnlySpan pattern, int pi, ReadOnlySpan input, int ii)
{
while (pi < pattern.Length)
{
var c = pattern[pi];
if (c == '*')
{
// Collapse consecutive stars.
while (pi < pattern.Length && pattern[pi] == '*') pi++;
if (pi == pattern.Length) return true;
for (var k = ii; k <= input.Length; k++)
{
if (GlobMatch(pattern, pi, input, k)) return true;
}
return false;
}
if (ii >= input.Length || input[ii] != c) return false;
pi++;
ii++;
}
return ii == input.Length;
}
private static bool IsForbiddenHostPath(string hostPath)
{
if (string.IsNullOrEmpty(hostPath)) return false;
var normalized = hostPath.TrimEnd('/');
if (normalized.Length == 0) normalized = "/";
foreach (var forbiddenPath in ForbiddenBindHostPaths)
{
var forbidden = forbiddenPath.TrimEnd('/');
if (forbidden.Length == 0) forbidden = "/";
// Exact match: e.g. "/etc" itself, or the literal root "/".
if (string.Equals(normalized, forbidden, StringComparison.Ordinal))
return true;
// The root entry "/" only blocks the literal root path. We don't
// want to treat it as "block every absolute path" because that
// would refuse harmless mounts like /tmp/work or /home/user/proj.
// The other entries in the deny list cover the actually-dangerous
// host directories.
if (forbidden == "/") continue;
// Subpath: "/etc/passwd" is under "/etc", "/var/lib/docker" is
// under "/var", etc. Without this check the previous exact-match
// logic was bypassable — `-v /etc/passwd:/mnt/passwd` would slip
// through despite "/etc" being on the deny list.
if (normalized.StartsWith(forbidden + "/", StringComparison.Ordinal))
return true;
}
return false;
}
}