Condukt.Sandbox.NetworkPolicy (Condukt v1.5.0)

Copy Markdown View Source

Per-session network policy for sandbox egress.

Every outbound HTTP request the workspace makes runs through this policy. The policy is a struct carrying an ordered list of rules plus a default action. Each rule is a 2-tuple {kind, value}; the runtime walks the rules top to bottom and returns the first non-:continue answer. If every rule passes, :default fires.

Three rule kinds ship out of the box:

kindvalue shape
:allowlist of host glob patterns
:denylist of host glob patterns
:decidea 2-arity function, {module, function}, a module, or {module, opts}

Because the rule list is just a keyword list, the example reads naturally:

%Condukt.Sandbox.NetworkPolicy{
  rules: [
    deny: ["*.internal.example.com"],
    allow: ["api.github.com", "*.openai.com"],
    decide: {Condukt.Sandbox.NetworkPolicy.AgentDecider, agent: MyApp.NetGuard}
  ],
  default: :deny
}

Glob syntax for the host lists: * matches a single DNS label, ** matches one or more dot-separated labels, literal characters match themselves (case-insensitive).

The :decide value is a callable that receives a Condukt.Sandbox.NetworkPolicy.Context and a Condukt.Sandbox.NetworkPolicy.Request and returns :allow or {:deny, reason}. The callable can take four shapes:

  • A 2-arity function: fn ctx, req -> :allow end
  • {module, function} (atoms): module.function(ctx, req)
  • A module alone: module.decide(ctx, req, [])
  • {module, opts}: module.decide(ctx, req, opts)

Use Condukt.Sandbox.NetworkPolicy.AgentDecider to wrap a Condukt-defined agent as a decider.

The knobs that govern how the decide rule is invoked are scoped to the rule itself, not the policy. Pass a keyword list as the :decide value with the callable under :call:

decide: [
  call: {Condukt.Sandbox.NetworkPolicy.AgentDecider, agent: MyApp.NetGuard},
  timeout: 5_000,
  cache: true,
  context_messages: 5,
  context_metadata: %{tenant: "acme"}
]
  • :timeout — milliseconds the decide runtime waits before treating the call as failed. Default 5_000.
  • :cachetrue (default) to cache decider answers per-session per-host.
  • :context_messages — maximum recent session messages handed to the decider as context. Default 5.
  • :context_metadata — per-session static metadata exposed to the decider. Default %{}.

Other fields

  • :default:allow or :deny. Default :deny (fail closed).
  • :redact — list of regular expressions; matching content in request/response bodies and headers is redacted by the sidecar before events are emitted.
  • :max_body_capture — maximum bytes of body to retain in each event. Default 4096.

Telemetry

Every request lifecycle step emits one of:

[:condukt, :sandbox, :network_policy, :request_opened]
[:condukt, :sandbox, :network_policy, :request_allowed]
[:condukt, :sandbox, :network_policy, :request_denied]
[:condukt, :sandbox, :network_policy, :request_closed]
[:condukt, :sandbox, :network_policy, :request_failed]

:request_failed fires when an allowed request never completes cleanly (the workspace rejected the session CA, the upstream was unreachable, or the stream broke). reason carries the label.

Measurements: %{bytes_in: integer, bytes_out: integer}. Metadata: %{request: Condukt.Sandbox.NetworkPolicy.Request.t(), reason: atom() | binary() | nil, matched_rule: %{index: non_neg_integer(), kind: :allow | :deny | :decide} | nil, at: DateTime.t() | nil}. :matched_rule is the rule that produced an allow/deny (nil for the default action and lifecycle-only events).

The decide runtime additionally emits, on decider failure:

[:condukt, :sandbox, :network_policy, :decider_failure]

Measurements %{count: 1}, metadata %{reason: :decider_timeout | :decider_error | :decider_bad_return}.

Summary

Functions

Emits the telemetry event for a request lifecycle step.

Walks the rules pipeline. Returns :allow or {:deny, reason}. Reason from a deny rule is propagated verbatim; default-deny reasons are :default_deny / :matched_deny_list / :decider_timeout.

Normalises arbitrary policy input into a t(). Accepts an existing struct, a keyword list, a map, or nil (returns the default deny-all policy).

Functions

deliver(policy, kind, request, opts \\ [])

Emits the telemetry event for a request lifecycle step.

The K8s control bridge calls this on every NDJSON frame it decodes from the sidecar. kind is one of :request_opened, :request_allowed, :request_denied, :request_closed. opts may carry :reason (deny reason or free-form string).

evaluate(policy, request)

evaluate(policy, context, request)

Walks the rules pipeline. Returns :allow or {:deny, reason}. Reason from a deny rule is propagated verbatim; default-deny reasons are :default_deny / :matched_deny_list / :decider_timeout.

new(policy)

Normalises arbitrary policy input into a t(). Accepts an existing struct, a keyword list, a map, or nil (returns the default deny-all policy).