Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 53 additions & 52 deletions containers/agent/sealed-probe-wrapper.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/sh
# /usr/local/bin/sealed-probe
#
# Agent-facing sealed-probe CLI.
# Agent-facing sealed-probe CLI (protocol v2).
#
# Forwards a *narrow* request to the trusted sealed-probe broker over a
# dedicated Unix socket. It is analogous to gh-cli-proxy-wrapper.sh, but the
Expand All @@ -10,59 +10,55 @@
# or a credential. It accepts exactly:
#
# --repo owner/repo (exactly once)
# --outcome LABEL (exactly three times)
# --schema '<json>' (exactly once; a finite response schema, see
# src/sealed-probe/protocol.ts)
# the probe script on stdin
#
# Output contract: exactly one line of canonical JSON on stdout, nothing on
# stderr, and exit status 0 — for every outcome and for every failure.
# Transport, framing, and validation failures all produce the same local
# {"result":"ERROR"} so the agent cannot distinguish them.
# {"status":"error"} so the agent cannot distinguish them by exit status.
#
# Dependencies: curl (also required by the existing gh wrapper).
# The wrapper does not (and cannot, in POSIX sh) validate the schema's
# structure, cardinality, or information-budget charge — that is the trusted
# broker's job, enforced *before* it copies a seed or launches Python. The
# wrapper's only responsibilities are: enforce the fixed CLI shape, transport
# the request unmodified, and pass the broker's response through unmodified.
#
# Dependencies: curl, base64 (both already required/available in the agent
# image).

CANONICAL_ERROR='{"result":"ERROR"}'
CANONICAL_ERROR='{"status":"error"}'
SOCKET="${AWF_SEALED_PROBE_SOCKET:-/run/awf-sealed-probe/broker.sock}"
PROTOCOL_VERSION=1
MAX_OUTCOME_BYTES=64
PROTOCOL_VERSION=2
# Keep in sync with MAX_SCHEMA_BYTES in src/sealed-probe/protocol.ts and
# containers/sealed-probe/broker/protocol.js.
MAX_SCHEMA_BYTES=4096

emit_error() {
printf '%s\n' "$CANONICAL_ERROR"
exit 0
}

# Rejects anything that is not a bounded ASCII enum identifier.
# Mirrors (and is re-enforced by) the broker's protocol validation.
valid_outcome() {
[ -n "$1" ] || return 1
[ "$1" != "ERROR" ] || return 1
[ "$(printf '%s' "$1" | wc -c)" -le "$MAX_OUTCOME_BYTES" ] || return 1
printf '%s' "$1" | LC_ALL=C grep -Eq '^[A-Za-z][A-Za-z0-9_-]{0,63}$' || return 1
return 0
}

REPO=""
OUTCOME_1=""
OUTCOME_2=""
OUTCOME_3=""
OUTCOME_COUNT=0
SCHEMA=""
HAVE_REPO=0
HAVE_SCHEMA=0

while [ $# -gt 0 ]; do
case "$1" in
--repo)
[ $# -ge 2 ] || emit_error
[ -z "$REPO" ] || emit_error
[ "$HAVE_REPO" -eq 0 ] || emit_error
REPO="$2"
HAVE_REPO=1
shift 2
;;
--outcome)
--schema)
[ $# -ge 2 ] || emit_error
OUTCOME_COUNT=$((OUTCOME_COUNT + 1))
case "$OUTCOME_COUNT" in
1) OUTCOME_1="$2" ;;
2) OUTCOME_2="$2" ;;
3) OUTCOME_3="$2" ;;
*) emit_error ;;
esac
[ "$HAVE_SCHEMA" -eq 0 ] || emit_error
SCHEMA="$2"
HAVE_SCHEMA=1
shift 2
;;
*)
Expand All @@ -73,54 +69,59 @@ while [ $# -gt 0 ]; do
esac
done

[ -n "$REPO" ] || emit_error
[ "$OUTCOME_COUNT" -eq 3 ] || emit_error
[ "$HAVE_REPO" -eq 1 ] || emit_error
[ "$HAVE_SCHEMA" -eq 1 ] || emit_error

printf '%s' "$REPO" | LC_ALL=C grep -Eq '^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9._-]{1,100}$' || emit_error
case "$REPO" in
*..* ) emit_error ;;
esac

valid_outcome "$OUTCOME_1" || emit_error
valid_outcome "$OUTCOME_2" || emit_error
valid_outcome "$OUTCOME_3" || emit_error
[ -n "$SCHEMA" ] || emit_error
[ "$(printf '%s' "$SCHEMA" | wc -c)" -le "$MAX_SCHEMA_BYTES" ] || emit_error

[ "$OUTCOME_1" != "$OUTCOME_2" ] || emit_error
[ "$OUTCOME_1" != "$OUTCOME_3" ] || emit_error
[ "$OUTCOME_2" != "$OUTCOME_3" ] || emit_error
# base64url, no padding: standard base64 with `+/` -> `-_`, `=` stripped, and
# newlines removed (wrapping width varies across base64 implementations).
SCHEMA_B64=$(printf '%s' "$SCHEMA" | base64 | tr -d '\n' | tr '+/' '-_' | tr -d '=') || emit_error

# The script must arrive on stdin; an interactive terminal means no script.
[ ! -t 0 ] || emit_error

[ -S "$SOCKET" ] || emit_error

# --noproxy '*' keeps HTTP(S)_PROXY from redirecting a Unix-socket request.
# --max-time bounds the wait at the schema's maximum probe timeout plus slack;
# the broker always answers, so this only guards a dead socket.
# --max-time bounds the wait comfortably above the largest timing bucket (10
# minutes); the broker always answers at a fixed bucket boundary, so this
# only guards a dead socket.
RESPONSE=$(
curl --silent --show-error \
--noproxy '*' \
--unix-socket "$SOCKET" \
--max-time 3900 \
--max-time 660 \
-X POST \
-H "Expect:" \
-H "Content-Type: application/octet-stream" \
-H "X-AWF-Probe-Version: ${PROTOCOL_VERSION}" \
-H "X-AWF-Repo: ${REPO}" \
-H "X-AWF-Outcome-1: ${OUTCOME_1}" \
-H "X-AWF-Outcome-2: ${OUTCOME_2}" \
-H "X-AWF-Outcome-3: ${OUTCOME_3}" \
-H "X-AWF-Schema-B64: ${SCHEMA_B64}" \
--data-binary @- \
"http://localhost/probe" 2>/dev/null
) || emit_error

# Only the canonical serialization of a declared outcome (or the reserved
# ERROR sentinel) is ever printed. Anything else is treated as a failure.
for expected in "$OUTCOME_1" "$OUTCOME_2" "$OUTCOME_3" "ERROR"; do
if [ "$RESPONSE" = "{\"result\":\"${expected}\"}" ]; then
# Pass the broker's canonical response through unmodified, but only if it has
# one of the two shapes the protocol ever produces. Anything else (a dead or
# misbehaving broker, a transport-level fragment) is treated as a failure
# rather than forwarded verbatim.
case "$RESPONSE" in
'{"status":"error"}')
printf '%s\n' "$RESPONSE"
exit 0
fi
done

emit_error
;;
'{"status":"ok","result":'*'}')
printf '%s\n' "$RESPONSE"
exit 0
;;
*)
emit_error
;;
esac
Loading
Loading