Compare commits

...
Author SHA1 Message Date
branderson 158667a8c7 Let cline's local endpoint be a host the operator allowlisted
tests / runner-env (confined) (pull_request_target) Successful in 36s
tests / confined (remote) (pull_request_target) Successful in 3m42s
tests / confined (static) (pull_request_target) Successful in 3m54s
tests / confined (project) (pull_request_target) Successful in 5m20s
tests / confined (providers) (pull_request_target) Successful in 2m12s
tests / privileged (pull_request_target) Successful in 3m48s
tests / runner-env (privileged) (pull_request_target) Successful in 38s
tests / confined (config) (pull_request_target) Successful in 6m46s
tests / confined (audit) (pull_request_target) Successful in 9m6s
tests / confined (proxy) (pull_request_target) Successful in 1m12s
tests / confined (shellcheck) (pull_request_target) Successful in 51s
tests / confined (conversation) (pull_request_target) Successful in 1m19s
The base_url check accepted only a literal loopback or RFC 1918 address. A
model server on someone's own network normally has a name, so that refused
the ordinary case of the thing this harness was added for - being pointed at
an open-weight model you serve yourself.

A name now qualifies when the operator has allowlisted it, through
dejima-allow --project or --global, read through approved_entries so it is
the same store, validator and answer bin/dejima uses when it builds the
container's allowlist. That ties acceptance to a grant the operator made
deliberately rather than to how the URL is spelled, and an endpoint failing
the check is one the session could not have connected to anyway.

Worth being clear about what the check was never doing. dejima writes a
placeholder into providers.json on this path whatever the endpoint is, so no
real credential enters the container either way - R5 holds by construction
here, not by this test. What the check decides is whether the configuration
can WORK: an endpoint needing a key would get the placeholder and fail, and
saying so at launch beats an auth error three layers down.

A literal address still qualifies on its own, and the octet comparison stays
numeric, so 10.example.com is still a name rather than the network it looks
like.
2026-08-22 02:27:58 -07:00
4 changed files with 118 additions and 33 deletions
+65 -23
View File
@@ -53,13 +53,30 @@ harness_briefing_state_dir() {
# to withhold, which is why an open-weight model served locally is supported
# today and api.openai.com is not.
#
# A literal address or `localhost`, never a resolvable name: a name is a
# promise about what DNS will say later, and this has to be decidable now.
# The octets are compared as numbers rather than matched as text - `10.*`
# would accept the hostname 10.example.com as readily as the network, and
# a dotted quad written into this file at all is a shape the suite refuses
# repo-wide because that is how an operator's real LAN address leaks into a
# public repo.
# Two ways an endpoint qualifies, and neither is about the URL's syntax.
#
# A literal loopback or RFC 1918 address is host-local by construction. The
# octets are compared as numbers rather than matched as text - `10.*` would
# accept the hostname 10.example.com as readily as the network - and a dotted
# quad is never written into this file, because that is how an operator's real
# LAN address leaks into a public repo.
#
# A NAME qualifies when the operator has already allowlisted it, through
# `dejima-allow --project` or `--global`. That is the same set that decides
# what the container can reach at all, so an endpoint failing this check is
# one the session could not have connected to anyway. Requiring a literal
# address instead was too strict by half: a model server on someone's own
# network normally has a name, and refusing it made the local-endpoint path -
# the reason this harness is provider-agnostic at all - unusable for the
# ordinary case.
#
# What neither branch is doing is protecting the model credential. dejima
# writes a placeholder into providers.json on this path whatever the endpoint
# is, so no real credential enters the container either way. What this decides
# is whether the configuration can WORK: an endpoint that needs a key would
# get the placeholder and fail, and saying so at launch beats an auth error
# three layers down. Pointing it somewhere is the operator's call, bounded by
# an allowlist entry they made deliberately.
_cline_host_local() {
local url="$1" host a b
host="${url#*://}"
@@ -74,23 +91,42 @@ _cline_host_local() {
case "$host" in
localhost|::1) return 0 ;;
esac
# Four numeric octets, or it is a name.
# Four numeric octets means an address; anything else is a name, and is
# decided by the allowlist below rather than refused here.
local -a oct=()
local numeric=1 o
IFS=. read -ra oct <<< "$host"
[ "${#oct[@]}" -eq 4 ] || return 1
local o
for o in "${oct[@]}"; do
case "$o" in ''|*[!0-9]*) return 1 ;; esac
[ "$o" -le 255 ] || return 1
case "$o" in ''|*[!0-9]*) numeric=0; break ;; esac
[ "$o" -le 255 ] || { numeric=0; break; }
done
if [ "${#oct[@]}" -eq 4 ] && [ "$numeric" -eq 1 ]; then
a="${oct[0]}" b="${oct[1]}"
# Loopback, and the three private ranges. The last of these is where
# Docker's own bridge gateway sits, which is how a container reaches a
# model server running on the host.
[ "$a" -eq 127 ] && return 0
[ "$a" -eq 10 ] && return 0
{ [ "$a" -eq 192 ] && [ "$b" -eq 168 ]; } && return 0
{ [ "$a" -eq 172 ] && [ "$b" -ge 16 ] && [ "$b" -le 31 ]; } && return 0
return 1
fi
_cline_host_allowlisted "$host"
}
# Whether the operator has allowlisted this name, project or global. Read
# through approved_entries so it is the same store, the same validator and the
# same answer bin/dejima uses when it builds the container's allowlist - a
# second reader of that file would be a second thing to keep in step.
_cline_host_allowlisted() {
local host="$1" store entry
for store in "$(config_root)" "${PROJECT_STORE:-}"; do
[ -n "$store" ] || continue
while IFS= read -r entry; do
[ -n "$entry" ] || continue
[ "$entry" = "$host" ] && return 0
done < <(approved_entries "$store" allowed-domains 2>/dev/null)
done
a="${oct[0]}" b="${oct[1]}"
# Loopback, and the three private ranges. The last of these is where
# Docker's own bridge gateway sits, which is how a container reaches a
# model server running on the host.
[ "$a" -eq 127 ] && return 0
[ "$a" -eq 10 ] && return 0
{ [ "$a" -eq 192 ] && [ "$b" -eq 168 ]; } && return 0
{ [ "$a" -eq 172 ] && [ "$b" -ge 16 ] && [ "$b" -le 31 ]; } && return 0
return 1
}
@@ -176,9 +212,15 @@ harness_prepare_config() {
echo "something the sandbox never sees." >&2
echo "" >&2
echo "Supported today: leave base_url unset for Anthropic through" >&2
echo "dejima's own credential proxy, or point it at a model served on" >&2
echo "the Docker host or the sandbox network, which needs no key -" >&2
echo "as a literal address or localhost, not a name." >&2
echo "dejima's own credential proxy, or point it at a model you serve" >&2
echo "yourself, which needs no key. That means a loopback or RFC 1918" >&2
echo "address, or a host you have allowlisted:" >&2
echo "" >&2
echo " bin/dejima-allow --project <path> <host>" >&2
echo "" >&2
echo "The allowlist is what decides whether the container can reach it" >&2
echo "at all, so an endpoint that fails this check is one the session" >&2
echo "could not have connected to anyway." >&2
return 1
fi
+18 -7
View File
@@ -301,14 +301,25 @@ not yours either. Two shapes are supported:
proxy exactly as Claude Code is. What is written into the container is the
placeholder `sandbox-credential-proxy-placeholder`; the real credential
stays on the host.
- **`base_url` set to a loopback or RFC 1918 address** - a model you are
serving yourself, on the Docker host or the sandbox network. Nothing needs
a credential, so there is nothing to withhold.
- **`base_url` set to a model you serve yourself** - either a loopback or
RFC 1918 address, or a host you have allowlisted with `dejima-allow`.
Nothing needs a credential, so there is nothing to withhold.
A hosted provider other than Anthropic is refused at launch. Its API key
would have to go inside the container, and dejima has no proxy that could
hold one back on its behalf - which is the one property the sandbox does not
trade away. See [development.md](development.md#worked-example-cline).
A name qualifies through the allowlist rather than through how it is spelled,
because that is the same store deciding whether the container can reach the
endpoint at all: one that fails the check is one the session could not have
connected to anyway. A model server on your own network normally has a name,
so requiring a literal address would have made this path unusable for the
ordinary case.
Anything else is refused at launch - a hosted provider's API key would have to
go inside the container, and dejima has no proxy that could hold one back on
its behalf. Note what the check is *not* doing: dejima writes a placeholder
into the container on this path whatever the endpoint is, so no real
credential enters it either way. What the check decides is whether the
configuration can work at all, which is worth saying at launch rather than as
an auth error three layers down. See
[development.md](development.md#worked-example-cline).
`[harness.cline] model` has no default and must be set. Any model id shipped
here would go stale with the next Cline release, and choosing the model an
+8 -3
View File
@@ -341,9 +341,14 @@ against Anthropic through dejima's own credential proxy and the key never
enters the container. Pointed at someone else's hosted API there is no proxy
that could hold a key back, so the key would have to go in - which is R5
failing, not a network restriction. The plugin therefore accepts `base_url`
only for a literal loopback or RFC 1918 address, where an open-weight model
served locally needs no credential at all and R5 has nothing to protect. A
provider-generic proxy would widen this; nobody has written one.
only for a model the operator serves themselves - a loopback or RFC 1918
address, or a host they have allowlisted - where an open-weight model needs no
credential at all and R5 has nothing to protect. The allowlist is what
qualifies a name, because it is the same store deciding what the container can
reach: an endpoint failing the check is one the session could not have
connected to anyway, and a model server on someone's own network normally has
a name rather than a literal address. A provider-generic proxy would widen
this to hosted providers; nobody has written one (#171).
*Conversation logging works; epitaphs do not, and only one of those is
Cline's fault.* Cline's transcripts are
+27
View File
@@ -921,6 +921,7 @@ _cline_cfg() {
_cline_prepare() {
DEJIMA_CONFIG_DIR="$1" bash -c '
source "$2/bin/dejima-provider-lib.sh"
source "$2/bin/dejima-project-lib.sh"
source "$2/bin/dejima-harness-lib.sh"
source "$2/bin/dejima-slug-lib.sh"
harness_plugin_load cline
@@ -991,6 +992,32 @@ _cline_prepare() {
done
}
@test "cline accepts an endpoint the operator has allowlisted by name" {
# A model server on someone's own network normally has a name, not a
# literal address. Requiring the literal made the local-endpoint path -
# the reason this harness is provider-agnostic at all - unusable for the
# ordinary case.
#
# The name qualifies because the operator allowlisted it, which is the
# same store that decides what the container can reach: an endpoint that
# fails this is one the session could not have connected to anyway.
local cfg
cfg="$(_cline_cfg 'model = m' 'base_url = https://llm.example.test/v1')"
printf 'llm.example.test\n' > "$cfg/allowed-domains"
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
[ "$status" -eq 0 ] || { echo "refused an allowlisted endpoint: $output"; return 1; }
}
@test "cline refuses a name the operator has not allowlisted" {
# The same endpoint without the grant. Nothing about the URL changed;
# what changed is whether the operator asked for it.
local cfg
cfg="$(_cline_cfg 'model = m' 'base_url = https://llm.example.test/v1')"
run _cline_prepare "$cfg" "$BATS_TEST_TMPDIR/ws"
[ "$status" -ne 0 ] || { echo "accepted an endpoint nothing granted"; return 1; }
[[ "$output" == *"refusing base_url"* ]] || { echo "$output"; return 1; }
}
@test "cline does not mistake a hostname for the private range it starts with" {
# A glob match on "10." accepts 10.example.com, which resolves wherever
# its owner says and would take the key with it. The octets are compared