Security: - S1: bearer via tmpfile/--config, not cmdline arg (no /proc/<pid>/cmdline leak) - S2/S3: JSON-escape user input in --files, --ip-cidrs, token name - S4: URL-encode token name in revoke - S5: refuse to source cf.env unless 0600/0400 + owner-matched - S6: reject ; in upload paths to defeat curl @ filename injection Correctness: - B1: refuse cf run - on TTY stdin - B2: replace fragile files splice with proper JSON-array composer (raw: passthrough in _json_obj_from_assoc) - B3: disable glob on comma-split (set -f around loop) - B4: only create stdin tmpfile when actually used - B5: EXIT trap (was RETURN; missed _die exit) - B6/B7: --max-time + stderr capture on uploads - B8: drop bare Bearer header on healthz when no token - B9: validate admin subcommand before token - B10: wire _extract_error into HTTP-error path - U3: dedicated '# --- end help ---' sentinel for cmd_help New: clients/bash/test/test_cf.sh (curl wrapper mock + 23 assertions covering all of the above; fully shellcheck-clean). Audit: memory/clawdforge-audits/bash-347fdde.md
428 lines
13 KiB
Bash
Executable file
428 lines
13 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# cf — bash CLI for clawdforge
|
|
#
|
|
# Usage:
|
|
# cf healthz
|
|
# cf run "<prompt>" [--model sonnet] [--system "..."] [--timeout 60] [--files token1,token2]
|
|
# cf run - # read prompt from stdin (any size)
|
|
# cf upload <path> [--ttl 3600]
|
|
# cf admin token-mint <name> [--ip-cidrs cidr1,cidr2]
|
|
# cf admin token-list
|
|
# cf admin token-revoke <name>
|
|
#
|
|
# Configuration (env or ~/.config/clawdforge/cf.env, env wins):
|
|
# CLAWDFORGE_URL — default http://192.168.0.5:8800
|
|
# CLAWDFORGE_TOKEN — required for /run + /files
|
|
# CLAWDFORGE_ADMIN_TOKEN — required for admin/* subcommands
|
|
#
|
|
# The cf.env file MUST have permissions 0600 or 0400 and be owned by you,
|
|
# or cf will refuse to source it.
|
|
#
|
|
# Output:
|
|
# - successful command → response JSON to stdout (pipe to jq freely)
|
|
# - errors → message to stderr, non-zero exit code
|
|
#
|
|
# Exit codes:
|
|
# 0 ok
|
|
# 1 generic error / curl failure
|
|
# 2 bad usage
|
|
# 3 missing token
|
|
# 4 HTTP 4xx (auth / not-found / bad-request)
|
|
# 5 HTTP 5xx (server / claude failure)
|
|
# --- end help ---
|
|
|
|
set -euo pipefail
|
|
|
|
# ---------- config loading -------------------------------------------------
|
|
|
|
_CFG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/clawdforge/cf.env"
|
|
|
|
# S5: Refuse to source cf.env unless perms are 0600/0400 and owner-matched.
|
|
_check_cfg_perms() {
|
|
local f="$1" mode="" owner=""
|
|
if stat -c '%a %U' "$f" >/dev/null 2>&1; then
|
|
# GNU stat (Linux)
|
|
read -r mode owner < <(stat -c '%a %U' "$f")
|
|
elif stat -f '%Lp %Su' "$f" >/dev/null 2>&1; then
|
|
# BSD stat (macOS)
|
|
read -r mode owner < <(stat -f '%Lp %Su' "$f")
|
|
else
|
|
echo "cf: cannot stat $f to verify perms — refusing to source" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$mode" != "600" && "$mode" != "400" ]]; then
|
|
echo "cf: refusing to source $f — perms are $mode (must be 0600 or 0400)" >&2
|
|
echo "cf: fix with: chmod 600 $f" >&2
|
|
return 1
|
|
fi
|
|
if [[ "$owner" != "$(id -un)" ]]; then
|
|
echo "cf: refusing to source $f — owned by $owner, not $(id -un)" >&2
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
if [[ -f "$_CFG_FILE" ]]; then
|
|
if _check_cfg_perms "$_CFG_FILE"; then
|
|
set -a
|
|
# shellcheck disable=SC1090
|
|
. "$_CFG_FILE"
|
|
set +a
|
|
else
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
CLAWDFORGE_URL="${CLAWDFORGE_URL:-http://192.168.0.5:8800}"
|
|
CLAWDFORGE_URL="${CLAWDFORGE_URL%/}"
|
|
|
|
# ---------- helpers --------------------------------------------------------
|
|
|
|
_die() { echo "cf: $*" >&2; exit "${2:-1}"; }
|
|
|
|
_need_token() {
|
|
[[ -n "${CLAWDFORGE_TOKEN:-}" ]] || _die "CLAWDFORGE_TOKEN not set (env or ~/.config/clawdforge/cf.env)" 3
|
|
}
|
|
|
|
_need_admin_token() {
|
|
[[ -n "${CLAWDFORGE_ADMIN_TOKEN:-}" ]] || _die "CLAWDFORGE_ADMIN_TOKEN not set" 3
|
|
}
|
|
|
|
# JSON-escape a single string value (no surrounding quotes).
|
|
_json_escape() {
|
|
local v="$1"
|
|
v="${v//\\/\\\\}"
|
|
v="${v//\"/\\\"}"
|
|
v="${v//$'\n'/\\n}"
|
|
v="${v//$'\r'/\\r}"
|
|
v="${v//$'\t'/\\t}"
|
|
printf '%s' "$v"
|
|
}
|
|
|
|
# Build a JSON array of strings from a comma-separated list. Values are
|
|
# JSON-escaped. Globbing is disabled around the comma-split loop.
|
|
# Args: comma-separated string
|
|
_json_string_array() {
|
|
local raw="$1"
|
|
if [[ -z "$raw" ]]; then printf '[]'; return 0; fi
|
|
local out='['
|
|
local first=1 item esc
|
|
# B3: disable globbing on the split loop
|
|
set -f
|
|
local -a parts=()
|
|
local IFS=','
|
|
# shellcheck disable=SC2206
|
|
parts=( $raw )
|
|
set +f
|
|
for item in "${parts[@]}"; do
|
|
[[ "$first" == 0 ]] && out+=','
|
|
first=0
|
|
esc="$(_json_escape "$item")"
|
|
out+="\"$esc\""
|
|
done
|
|
out+=']'
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
# URL-encode a string. Prefer jq; fall back to pure-bash.
|
|
_url_encode() {
|
|
local v="$1"
|
|
if command -v jq >/dev/null 2>&1; then
|
|
printf '%s' "$v" | jq -sRr @uri
|
|
return 0
|
|
fi
|
|
local i c out=""
|
|
for (( i=0; i<${#v}; i++ )); do
|
|
c="${v:i:1}"
|
|
case "$c" in
|
|
[a-zA-Z0-9._~-]) out+="$c" ;;
|
|
*) out+="$(printf '%%%02X' "'$c")" ;;
|
|
esac
|
|
done
|
|
printf '%s' "$out"
|
|
}
|
|
|
|
# Build a JSON object from --key value flags (each value is JSON-quoted as string).
|
|
# Numbers passed as $key=number are kept numeric. Booleans true/false kept bool.
|
|
# Special key syntax:
|
|
# key=raw:... — value is inserted verbatim (already-JSON, e.g. an array literal)
|
|
_json_obj_from_assoc() {
|
|
local first=1 out='{'
|
|
local kv k v
|
|
for kv in "$@"; do
|
|
k="${kv%%=*}"
|
|
v="${kv#*=}"
|
|
if [[ "$first" == 0 ]]; then out+=','; fi
|
|
first=0
|
|
local k_esc
|
|
k_esc="$(_json_escape "$k")"
|
|
out+="\"$k_esc\":"
|
|
if [[ "$v" == raw:* ]]; then
|
|
out+="${v#raw:}"
|
|
elif [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
|
|
out+="$v"
|
|
elif [[ "$v" == "true" || "$v" == "false" ]]; then
|
|
out+="$v"
|
|
else
|
|
local esc
|
|
esc="$(_json_escape "$v")"
|
|
out+="\"$esc\""
|
|
fi
|
|
done
|
|
out+='}'
|
|
printf '%s\n' "$out"
|
|
}
|
|
|
|
# Print the error body's "error" or "detail" field if it's JSON, else the whole body.
|
|
_extract_error() {
|
|
local body="$1"
|
|
if command -v jq >/dev/null 2>&1; then
|
|
local parsed
|
|
parsed="$(jq -r '.error // .detail // empty' <<<"$body" 2>/dev/null || true)"
|
|
if [[ -n "$parsed" ]]; then
|
|
printf '%s' "$parsed"
|
|
return 0
|
|
fi
|
|
fi
|
|
printf '%s' "$body"
|
|
}
|
|
|
|
# Holds the path of any tmp file created within the current shell process so
|
|
# the EXIT trap can clean it up even on _die-driven exits. (B5)
|
|
_CF_TMP=""
|
|
_cleanup_tmp() {
|
|
if [[ -n "${_CF_TMP:-}" && -e "$_CF_TMP" ]]; then
|
|
rm -f "$_CF_TMP"
|
|
fi
|
|
}
|
|
trap _cleanup_tmp EXIT
|
|
|
|
# Fire a curl request, capture body + status, exit on HTTP error.
|
|
# Args: METHOD URL TOKEN [DATA_OR_- [CONTENT_TYPE]]
|
|
_request() {
|
|
local method="$1" url="$2" token="$3" data="${4:-}" ctype="${5:-application/json}"
|
|
local body status
|
|
|
|
# S1: pass bearer via curl --config <(...) so the token never appears in argv.
|
|
# B4: only mktemp the stdin tmp file when actually needed.
|
|
local -a curl_args=(
|
|
-sS -X "$method" "$url"
|
|
-H "Accept: application/json"
|
|
-w '\n__cf_status__=%{http_code}'
|
|
--max-time 600
|
|
)
|
|
|
|
# Auth header file (chmod 600). Always created when token is non-empty so
|
|
# the bearer never lands in argv via -H.
|
|
local auth_file=""
|
|
if [[ -n "$token" ]]; then
|
|
auth_file="$(mktemp)"
|
|
chmod 600 "$auth_file"
|
|
# curl --config syntax: 'header = "Authorization: Bearer <tok>"'
|
|
printf 'header = "Authorization: Bearer %s"\n' "$token" > "$auth_file"
|
|
curl_args+=(--config "$auth_file")
|
|
fi
|
|
|
|
if [[ -n "$data" ]]; then
|
|
if [[ "$data" == "-" ]]; then
|
|
_CF_TMP="$(mktemp)"
|
|
cat > "$_CF_TMP"
|
|
curl_args+=(-H "Content-Type: $ctype" --data-binary "@$_CF_TMP")
|
|
else
|
|
curl_args+=(-H "Content-Type: $ctype" --data-binary "$data")
|
|
fi
|
|
fi
|
|
|
|
local resp rc=0
|
|
resp="$(curl "${curl_args[@]}" 2>&1)" || rc=$?
|
|
if [[ -n "$auth_file" ]]; then rm -f "$auth_file"; fi
|
|
if (( rc != 0 )); then _die "curl failed: $resp" 1; fi
|
|
|
|
status="${resp##*__cf_status__=}"
|
|
body="${resp%__cf_status__=*}"
|
|
body="${body%$'\n'}" # trim trailing newline before status marker
|
|
|
|
if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then
|
|
printf '%s\n' "$body"
|
|
return 0
|
|
fi
|
|
# B10: surface parsed .error/.detail messages instead of raw body.
|
|
echo "cf: HTTP $status" >&2
|
|
local msg
|
|
msg="$(_extract_error "$body")"
|
|
if [[ -n "$msg" ]]; then
|
|
printf '%s\n' "$msg" >&2
|
|
else
|
|
printf '%s\n' "$body" >&2
|
|
fi
|
|
if [[ "$status" -ge 400 && "$status" -lt 500 ]]; then exit 4; fi
|
|
exit 5
|
|
}
|
|
|
|
# ---------- subcommands ----------------------------------------------------
|
|
|
|
cmd_healthz() {
|
|
# B8: drop Authorization entirely when no token is set.
|
|
local tok="${CLAWDFORGE_TOKEN:-${CLAWDFORGE_ADMIN_TOKEN:-}}"
|
|
_request GET "$CLAWDFORGE_URL/healthz" "$tok"
|
|
}
|
|
|
|
cmd_run() {
|
|
_need_token
|
|
local prompt="" model="" system="" timeout_secs="" files=""
|
|
if [[ $# -lt 1 ]]; then _die "usage: cf run <prompt|-> [--model] [--system] [--timeout] [--files]" 2; fi
|
|
prompt="$1"; shift
|
|
if [[ "$prompt" == "-" ]]; then
|
|
# B1: refuse to read from a TTY — would hang forever.
|
|
if [[ -t 0 ]]; then
|
|
_die "cf run -: stdin is a TTY, expected a pipe or redirect (e.g. 'echo hi | cf run -')" 2
|
|
fi
|
|
prompt="$(cat)"
|
|
fi
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--model) model="$2"; shift 2;;
|
|
--system) system="$2"; shift 2;;
|
|
--timeout) timeout_secs="$2"; shift 2;;
|
|
--files) files="$2"; shift 2;;
|
|
*) _die "unknown flag: $1" 2;;
|
|
esac
|
|
done
|
|
|
|
# Build JSON safely. S2/B2: use the shared array composer + raw: pass-through
|
|
# in _json_obj_from_assoc to avoid hand-splicing the body.
|
|
local args=("prompt=$prompt")
|
|
[[ -n "$model" ]] && args+=("model=$model")
|
|
[[ -n "$system" ]] && args+=("system=$system")
|
|
[[ -n "$timeout_secs" ]] && args+=("timeout_secs=$timeout_secs")
|
|
|
|
if [[ -n "$files" ]]; then
|
|
local farr
|
|
farr="$(_json_string_array "$files")"
|
|
args+=("files=raw:$farr")
|
|
fi
|
|
|
|
local body
|
|
body="$(_json_obj_from_assoc "${args[@]}")"
|
|
_request POST "$CLAWDFORGE_URL/run" "$CLAWDFORGE_TOKEN" "$body"
|
|
}
|
|
|
|
cmd_upload() {
|
|
_need_token
|
|
local path="${1:-}" ttl=""
|
|
[[ -n "$path" && -f "$path" ]] || _die "usage: cf upload <path> [--ttl 3600]" 2
|
|
shift
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--ttl) ttl="$2"; shift 2;;
|
|
*) _die "unknown flag: $1" 2;;
|
|
esac
|
|
done
|
|
|
|
# S6: reject ';' in upload paths — curl's -F @ syntax interprets
|
|
# ;type=...;filename=... suffixes, allowing a self-attacker to override
|
|
# the uploaded filename or content-type.
|
|
if [[ "$path" == *';'* ]]; then
|
|
_die "upload path contains ';' which curl @ syntax interprets specially — rename the file" 2
|
|
fi
|
|
|
|
# S1: bearer via --config tmpfile, not -H argv.
|
|
local auth_file
|
|
auth_file="$(mktemp)"
|
|
chmod 600 "$auth_file"
|
|
printf 'header = "Authorization: Bearer %s"\n' "$CLAWDFORGE_TOKEN" > "$auth_file"
|
|
|
|
local -a curl_args=(
|
|
-sS -X POST "$CLAWDFORGE_URL/files"
|
|
--config "$auth_file"
|
|
-H "Accept: application/json"
|
|
-w '\n__cf_status__=%{http_code}'
|
|
--max-time 600
|
|
-F "file=@$path"
|
|
)
|
|
[[ -n "$ttl" ]] && curl_args+=(-F "ttl_secs=$ttl")
|
|
|
|
# B7: capture stderr like _request does so failures are useful.
|
|
local resp rc=0
|
|
resp="$(curl "${curl_args[@]}" 2>&1)" || rc=$?
|
|
rm -f "$auth_file"
|
|
if (( rc != 0 )); then _die "curl failed: $resp" 1; fi
|
|
|
|
local status="${resp##*__cf_status__=}"
|
|
local body="${resp%__cf_status__=*}"; body="${body%$'\n'}"
|
|
if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then
|
|
printf '%s\n' "$body"
|
|
else
|
|
echo "cf: HTTP $status" >&2
|
|
local msg
|
|
msg="$(_extract_error "$body")"
|
|
if [[ -n "$msg" ]]; then
|
|
printf '%s\n' "$msg" >&2
|
|
else
|
|
printf '%s\n' "$body" >&2
|
|
fi
|
|
[[ "$status" -lt 500 ]] && exit 4 || exit 5
|
|
fi
|
|
}
|
|
|
|
cmd_admin() {
|
|
local sub="${1:-}"; shift || true
|
|
# B9: validate the subcommand BEFORE requiring the admin token so typos
|
|
# don't get swallowed by a "missing token" error.
|
|
case "$sub" in
|
|
token-mint|token-list|token-revoke) ;;
|
|
"") _die "usage: cf admin <token-mint|token-list|token-revoke>" 2;;
|
|
*) _die "unknown admin subcommand: $sub (token-mint|token-list|token-revoke)" 2;;
|
|
esac
|
|
_need_admin_token
|
|
case "$sub" in
|
|
token-mint)
|
|
local name="${1:-}"; shift || true
|
|
[[ -n "$name" ]] || _die "usage: cf admin token-mint <name> [--ip-cidrs cidr1,cidr2]" 2
|
|
local cidrs=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--ip-cidrs) cidrs="$2"; shift 2;;
|
|
*) _die "unknown flag: $1" 2;;
|
|
esac
|
|
done
|
|
# S3: route via the shared escape pipeline.
|
|
local -a args=("name=$name")
|
|
local arr
|
|
arr="$(_json_string_array "$cidrs")"
|
|
args+=("ip_cidrs=raw:$arr")
|
|
local body
|
|
body="$(_json_obj_from_assoc "${args[@]}")"
|
|
_request POST "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN" "$body"
|
|
;;
|
|
token-list)
|
|
_request GET "$CLAWDFORGE_URL/admin/tokens" "$CLAWDFORGE_ADMIN_TOKEN"
|
|
;;
|
|
token-revoke)
|
|
local name="${1:-}"
|
|
[[ -n "$name" ]] || _die "usage: cf admin token-revoke <name>" 2
|
|
# S4: URL-encode the name so '/', '?', '#', etc. can't smuggle path/query.
|
|
local enc
|
|
enc="$(_url_encode "$name")"
|
|
_request DELETE "$CLAWDFORGE_URL/admin/tokens/$enc" "$CLAWDFORGE_ADMIN_TOKEN"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
cmd_help() {
|
|
# U3: dedicated sentinel comment so help-text extraction survives reorgs.
|
|
sed -n '2,/^# --- end help ---/{/^# --- end help ---/q;p}' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
|
|
}
|
|
|
|
# ---------- dispatch -------------------------------------------------------
|
|
|
|
cmd="${1:-help}"
|
|
case "$cmd" in
|
|
-h|--help|help) cmd_help;;
|
|
healthz) shift; cmd_healthz "$@";;
|
|
run) shift; cmd_run "$@";;
|
|
upload) shift; cmd_upload "$@";;
|
|
admin) shift; cmd_admin "$@";;
|
|
*) _die "unknown command: $cmd (healthz|run|upload|admin|help)" 2;;
|
|
esac
|