Operator rule 2026-10-29: image creation, hosting and lifecycle belong to CI/CD, never to a workstation build/push. Nothing had ever been pushed to the Gitea registry. - .gitea/workflows/images.yaml + scripts/images.sh: PRs build+verify (no credential); main builds, verifies and pushes toolbox, agent-runner (FROM toolbox) and conformance-runner as sha-<commit> + main, reading the digest back from the registry. Only changed images build. - Each image's own build-image.sh does the build and in-image verification, now engine-neutral (DinD: no podman-only calls, probe over stdin not a bind mount). New toolbox build-image.sh. - Digest references carry `# renovate-digest:` markers; renovate.json5 gains a custom.regex manager for them. The worker Job template is deliberately untracked (it would loop). - renovate.yaml pins renovate-version 44.167.0: the action's default (39.264.0) rejects the current config's managerFilePatterns, so every run would have died at CONFIG_VALIDATION. - Push credential: REGISTRY_PUSH_TOKEN secret; the Actions token has no package scope in Gitea 1.24.3 (container.go Authenticate). Retention: infra/gitea/registry-retention.sh. Co-Authored-By: Claude Opus 5.5 <[email protected]>
133 lines
6.5 KiB
Bash
Executable File
133 lines
6.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
###############################################################################
|
|
# infra/agent/probe-validation.sh — prove pm-worker REFUSES hostile input.
|
|
#
|
|
# ./probe-validation.sh # from anywhere; needs only bash
|
|
#
|
|
# WHY THIS EXISTS. pm-worker's `command` is pinned by admission with no args, so everything a
|
|
# caller could influence arrives in the ENVIRONMENT — and the guards at the top of that
|
|
# script are what stand between an injected variable and a clone from the wrong host, a push
|
|
# to the wrong ref, or a session with the wrong credential. Guards that nothing exercises are
|
|
# a claim, not a control. Each case below asserts BOTH a non-zero exit AND the specific
|
|
# message, because a check that only grepped stderr would pass a build where the guard had
|
|
# stopped rejecting and something else had started failing instead.
|
|
#
|
|
# Every case must die BEFORE the script reaches any network call: the probe runs with a
|
|
# scrubbed environment and fake credentials, and if a guard ever stopped firing, the case
|
|
# would fail here rather than by really cloning something.
|
|
#
|
|
# Run offline, no cluster, no Gitea, no model. infra/agent/build-image.sh runs it inside the
|
|
# built image as well, where the interpreter and the script are the ones that will actually
|
|
# run in the cluster.
|
|
###############################################################################
|
|
set -uo pipefail
|
|
|
|
# `:-$0` because build-image.sh feeds this script over STDIN (`bash -s`), where BASH_SOURCE is
|
|
# empty and `set -u` would abort on it; PM_WORKER_PATH is always set in that case.
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
|
WORKER="${PM_WORKER_PATH:-${HERE}/pm-worker}"
|
|
[ -r "$WORKER" ] || { echo "cannot read ${WORKER}" >&2; exit 2; }
|
|
|
|
# A complete, VALID assignment. Each case below overrides exactly one thing, so a failure
|
|
# names the guard rather than a pile of missing variables.
|
|
BASE=(
|
|
PM_TASK_MODE=implement
|
|
PM_ISSUE_NUMBER=42
|
|
PM_BRANCH=feat/some-real-slug
|
|
PM_BASE_BRANCH=main
|
|
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:3000
|
|
PM_GITEA_OWNER=Home-Network
|
|
PM_GITEA_REPO=home-network-k3s-impl
|
|
PM_GITEA_USER=pm-agent
|
|
GITEA_TOKEN=not-a-real-token
|
|
CLAUDE_CODE_OAUTH_TOKEN=not-a-real-token
|
|
)
|
|
|
|
PASS=0
|
|
FAIL=0
|
|
|
|
# `env -i` so nothing from the invoking shell leaks in and accidentally satisfies a guard.
|
|
refuses() {
|
|
local label="$1" want="$2"; shift 2
|
|
local out rc=0
|
|
out="$(env -i PATH="$PATH" HOME=/tmp "${BASE[@]}" "$@" bash "$WORKER" 2>&1)" || rc=$?
|
|
if [ "$rc" -eq 0 ]; then
|
|
printf ' FAIL %s — exited 0; it should have been refused\n' "$label"
|
|
FAIL=$((FAIL + 1))
|
|
return
|
|
fi
|
|
if printf '%s' "$out" | grep -qF "$want"; then
|
|
printf ' ok %s (exit %s)\n' "$label" "$rc"
|
|
PASS=$((PASS + 1))
|
|
else
|
|
printf ' FAIL %s — refused (exit %s) but not for the expected reason %q:\n' "$label" "$rc" "$want"
|
|
printf '%s\n' "$out" | tail -3 | sed 's/^/ /'
|
|
FAIL=$((FAIL + 1))
|
|
fi
|
|
}
|
|
|
|
echo "pm-worker input-validation probe ($WORKER)"
|
|
|
|
# An argument on a pinned command is caller-supplied code. There is no argument surface.
|
|
out="$(env -i PATH="$PATH" HOME=/tmp "${BASE[@]}" bash "$WORKER" --layer l2 2>&1)" || rc=$?
|
|
if printf '%s' "$out" | grep -qF "takes no arguments"; then
|
|
echo " ok arguments are refused (exit ${rc:-?})"; PASS=$((PASS + 1))
|
|
else
|
|
echo " FAIL arguments are refused — got: $(printf '%s' "$out" | tail -1)"; FAIL=$((FAIL + 1))
|
|
fi
|
|
|
|
# --- the ref it may create and push ---------------------------------------
|
|
refuses "branch 'main' (CLAUDE.md §1)" "does not match" PM_BRANCH=main
|
|
refuses "branch with a wrong prefix" "does not match" PM_BRANCH=feature/x
|
|
refuses "branch with a shell metacharacter" "does not match" 'PM_BRANCH=feat/x;id'
|
|
refuses "branch with path traversal" "does not match" 'PM_BRANCH=../../etc/passwd'
|
|
refuses "branch too short for the slug rule" "does not match" PM_BRANCH=fix/ab
|
|
refuses "branch equal to the base branch" "must not be the base branch" \
|
|
PM_BRANCH=feat/same-slug PM_BASE_BRANCH=feat/same-slug
|
|
|
|
# --- where code comes FROM and goes TO -------------------------------------
|
|
# The namespace's egress policy permits tcp/443 to the world (the model needs it), so an
|
|
# injected host would be REACHABLE. These are the cases that matter most.
|
|
refuses "a redirected git host" "is not the allowed in-cluster git host" \
|
|
PM_GITEA_URL=https://evil.invalid
|
|
refuses "a real, reachable, WRONG git host" "is not the allowed in-cluster git host" \
|
|
PM_GITEA_URL=https://github.com
|
|
refuses "the right host on a smuggled path" "is not the allowed in-cluster git host" \
|
|
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:3000/../..
|
|
# This agent is IN the cluster, so it reaches Gitea by its cluster-local service name. The
|
|
# EXTERNAL hostname is refused too — it used to be a second accepted value, and as an
|
|
# accepted value it widened this allowlist to a host reachable over the `world:443` egress
|
|
# rule, which is the injection shape the allowlist exists to refuse.
|
|
refuses "this Gitea's EXTERNAL hostname" "is not the allowed in-cluster git host" \
|
|
PM_GITEA_URL=https://gitea.home.keithjackson.dev
|
|
refuses "the cluster-local name over https" "is not the allowed in-cluster git host" \
|
|
PM_GITEA_URL=https://gitea.gitea.svc.cluster.local:3000
|
|
refuses "the cluster-local name, wrong port" "is not the allowed in-cluster git host" \
|
|
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:8080
|
|
refuses "a malformed owner" "PM_GITEA_OWNER is malformed" \
|
|
'PM_GITEA_OWNER=../other-org'
|
|
refuses "a malformed repository" "PM_GITEA_REPO is malformed" \
|
|
'PM_GITEA_REPO=repo;id'
|
|
|
|
# --- the assignment itself -------------------------------------------------
|
|
refuses "an unknown task mode" "must be 'implement' or 'revise'" \
|
|
PM_TASK_MODE=merge
|
|
refuses "a non-numeric issue number" "must be a positive integer" \
|
|
PM_ISSUE_NUMBER=42x
|
|
refuses "issue number zero" "must be a positive integer" \
|
|
PM_ISSUE_NUMBER=0
|
|
|
|
# --- the credentials ------------------------------------------------------
|
|
refuses "a missing model credential" "CLAUDE_CODE_OAUTH_TOKEN is not set" \
|
|
CLAUDE_CODE_OAUTH_TOKEN=
|
|
refuses "a missing git credential" "GITEA_TOKEN is not set" \
|
|
GITEA_TOKEN=
|
|
|
|
echo
|
|
if [ "$FAIL" -eq 0 ]; then
|
|
echo "probe OK — ${PASS} refusal(s), 0 failure(s)"
|
|
exit 0
|
|
fi
|
|
echo "probe FAILED — ${FAIL} of $((PASS + FAIL)) case(s) did not refuse as expected"
|
|
exit 1
|