master
sh 543 lines 21.7 KB
Raw
1 #!/usr/bin/env bash
2 # Helpers for the query-netdata-agents skill.
3 # Sourced from per-action scripts (and from any other skill that
4 # wants to call Netdata Cloud / Netdata Agent through token-safe
5 # wrappers). Not executed directly.
6 #
7 # Token-safety contract (HARD requirement):
8 # * No PUBLIC function (named `agents_*`, no leading underscore)
9 # ever emits NETDATA_CLOUD_TOKEN, a per-agent bearer, or a
10 # claim_id to stdout.
11 # * Internal helpers (named `_agents_*`, leading underscore) may
12 # handle token bytes inside their own scope but must return
13 # them only through validated caller-local variable names --
14 # never to stdout.
15 # * `_agents_log_masked` redacts token / bearer bytes in stderr
16 # argv echoes.
17 # * The unit test `agents_selftest_no_token_leak` drives every
18 # public wrapper with a sentinel token and asserts the
19 # sentinel never reaches captured stdout.
20 #
21 # Conventions mirrored from .agents/skills/coverity-audit/scripts/_lib.sh:
22 # * set -euo pipefail at the top
23 # * color vars defined with $'...' so ESC bytes are real
24 # * <prefix>_repo_root via `git rev-parse --show-toplevel`
25 # * <prefix>_load_env sources <repo>/.env, validates required keys
26 # * <prefix>_audit_dir creates <repo>/.local/audits/<topic>/
27 #
28 # Audit topic: "query-netdata-agents".
29
30 # Capture our source-file path BEFORE `set -u`. Bash exposes
31 # BASH_SOURCE[0]; zsh exposes the equivalent as `${(%):-%x}` (which
32 # bash cannot parse, so we gate it through `eval`).
33 if [ -n "${ZSH_VERSION-}" ]; then
34 eval '_agents_lib_self="${(%):-%x}"'
35 elif [ -n "${BASH_VERSION-}" ]; then
36 _agents_lib_self="${BASH_SOURCE[0]}"
37 else
38 _agents_lib_self="$0"
39 fi
40
41 set -euo pipefail
42
43 # shellcheck disable=SC2034
44 AGENTS_RED=$'\033[0;31m'
45 # shellcheck disable=SC2034
46 AGENTS_GREEN=$'\033[0;32m'
47 # shellcheck disable=SC2034
48 AGENTS_YELLOW=$'\033[1;33m'
49 # shellcheck disable=SC2034
50 AGENTS_GRAY=$'\033[0;90m'
51 # shellcheck disable=SC2034
52 AGENTS_NC=$'\033[0m'
53
54 # ---------------------------------------------------------------------------
55 # Repo + env helpers
56 # ---------------------------------------------------------------------------
57
58 agents_repo_root() {
59 git -C "$(dirname "${_agents_lib_self}")" rev-parse --show-toplevel
60 }
61
62 # Source <repo>/.env. Validate the keys this skill needs.
63 # Required:
64 # NETDATA_CLOUD_TOKEN -- long-lived Cloud REST token
65 # NETDATA_CLOUD_HOSTNAME -- Cloud REST host (e.g. app.netdata.cloud)
66 agents_load_env() {
67 local root env
68 root="$(agents_repo_root)"
69 env="${root}/.env"
70 if [[ ! -f "${env}" || ! -r "${env}" ]]; then
71 echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Missing ${env}. Copy .env.template to .env and fill it in. See ${root}/.agents/ENV.md." >&2
72 return 1
73 fi
74 set -a
75 # shellcheck disable=SC1090
76 source "${env}"
77 set +a
78
79 : "${NETDATA_CLOUD_TOKEN:?NETDATA_CLOUD_TOKEN is empty -- see <repo>/.agents/ENV.md to set it.}"
80 : "${NETDATA_CLOUD_HOSTNAME:?NETDATA_CLOUD_HOSTNAME is empty -- see <repo>/.agents/ENV.md to set it.}"
81 export NETDATA_CLOUD_TOKEN NETDATA_CLOUD_HOSTNAME
82 }
83
84 agents_audit_dir() {
85 local root dir
86 root="$(agents_repo_root)"
87 dir="${root}/.local/audits/query-netdata-agents"
88 mkdir -p "${dir}"
89 echo "${dir}"
90 }
91
92 # Autodetect the Netdata install prefix. Returns "" for system installs
93 # (paths like /var/lib/netdata, /etc/netdata) or e.g. "/opt/netdata" for
94 # bundled installs (paths under /opt/netdata/var/lib/netdata).
95 #
96 # Rule (per .agents/sow/specs/sensitive-data-discipline.md): probe
97 # candidates and pick the first whose <prefix>/var/lib/netdata or
98 # <prefix>/etc/netdata exists. NOT a config knob.
99 agents_netdata_prefix() {
100 local p
101 for p in "" "/opt/netdata" "/usr/local/netdata"; do
102 if [[ -d "${p}/var/lib/netdata" || -d "${p}/etc/netdata" ]]; then
103 printf '%s' "${p}"
104 return 0
105 fi
106 done
107 printf ''
108 return 0
109 }
110
111 # ---------------------------------------------------------------------------
112 # Masked-curl execution wrappers
113 # ---------------------------------------------------------------------------
114
115 # Print a curl invocation to stderr with the cloud token (and any
116 # minted bearer) masked. Then execute it. Honors AGENTS_DRY_RUN=1
117 # (write paths skip execution but still log).
118 agents_run() {
119 _agents_log_masked "$@"
120 if [[ "${AGENTS_DRY_RUN:-0}" == "1" ]]; then
121 return 0
122 fi
123 "$@"
124 }
125
126 agents_run_read() {
127 _agents_log_masked "$@"
128 "$@"
129 }
130
131 _agents_log_masked() {
132 local arg
133 printf >&2 '%s> %s' "${AGENTS_GRAY}" "${AGENTS_YELLOW}"
134 for arg in "$@"; do
135 # Mask the cloud token wherever it appears.
136 if [[ -n "${NETDATA_CLOUD_TOKEN:-}" && "${arg}" == *"${NETDATA_CLOUD_TOKEN}"* ]]; then
137 arg="${arg//${NETDATA_CLOUD_TOKEN}/<CLOUD_TOKEN>}"
138 fi
139 # Mask any UUID-shaped bearer in `Bearer <uuid>` form.
140 if [[ "${arg}" =~ Bearer\ [0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} ]]; then
141 arg="${arg%% Bearer *} Bearer <AGENT_BEARER>"
142 # The replace above also rebuilds the leading header
143 # name; the rejoined arg is harmless even if the
144 # leading text is the bare header. Tests cover this.
145 fi
146 arg="$(printf '%s' "${arg}" | sed -E \
147 -e 's/(node_id=)[^&]+/\1<NODE_ID>/g' \
148 -e 's/(machine_guid=)[^&]+/\1<MACHINE_GUID>/g' \
149 -e 's/(claim_id=)[^&]+/\1<CLAIM_ID>/g' \
150 -e 's#(/api/v2/nodes/)[0-9a-fA-F-]{36}#\1<NODE_ID>#g' \
151 -e 's#(/api/v[0-9]+/spaces/)[0-9a-fA-F-]{36}#\1<SPACE_ID>#g' \
152 -e 's#(/rooms/)[0-9a-fA-F-]{36}#\1<ROOM_ID>#g' \
153 -e 's#(/host/)[0-9a-fA-F-]{36}#\1<NODE_ID>#g')"
154 printf >&2 '%q ' "${arg}"
155 done
156 printf >&2 '%s\n' "${AGENTS_NC}"
157 }
158
159 # ---------------------------------------------------------------------------
160 # Internal: claim_id / bearer mint / cache
161 # ---------------------------------------------------------------------------
162
163 _agents_set_outvar() {
164 local _agents_out_name="${1:?output variable name required}"
165 local _agents_out_value="${2-}"
166 if [[ ! "${_agents_out_name}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
167 echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Invalid output variable name: ${_agents_out_name}" >&2
168 return 1
169 fi
170 # Avoid eval here because values come from curl/jq output.
171 if ! printf -v "${_agents_out_name}" '%s' "${_agents_out_value}"; then
172 echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Failed to set output variable: ${_agents_out_name}" >&2
173 return 1
174 fi
175 }
176
177 # Resolve claim_id from a node's /api/v3/info. The /info endpoint
178 # is unauthenticated. INTERNAL: writes to a caller-local variable;
179 # never prints to stdout.
180 #
181 # Args:
182 # $1 = OUTVAR -- caller-local variable name to receive the claim_id
183 # $2 = HOST -- host:port (e.g. "agent-events:19999")
184 _agents_get_claim_id() {
185 local _out_var="${1:?usage: _agents_get_claim_id OUTVAR <host:port>}"; shift
186 local host="${1:?usage: _agents_get_claim_id OUTVAR <host:port>}"
187 local resp resolved_claim
188 if resp="$(curl -sS --max-time 10 "http://${host}/api/v3/info" 2>/dev/null)"; then
189 resolved_claim="$(jq -r '.agents[0].cloud.claim_id // empty' <<< "${resp}" 2>/dev/null)"
190 if [[ -n "${resolved_claim}" && "${resolved_claim}" != "null" ]]; then
191 _agents_set_outvar "${_out_var}" "${resolved_claim}" || return 1
192 return 0
193 fi
194 fi
195 echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Could not resolve claim_id from http://${host}/api/v3/info" >&2
196 return 1
197 }
198
199 # Mint a per-agent bearer via Cloud. INTERNAL: prints the response
200 # JSON to stdout for the caller to capture in a local variable.
201 # stdout still carries the bearer here -- callers MUST capture into
202 # a local and never propagate. The PUBLIC wrapper that uses this
203 # does exactly that and emits only the response body.
204 _agents_mint_bearer_json() {
205 local node_id="${1:?usage: _agents_mint_bearer_json <node_id> <machine_guid> <claim_id>}"
206 local mg="${2:?machine_guid required}"
207 local claim="${3:?claim_id required}"
208 agents_run_read curl --fail --silent --show-error --max-time 30 \
209 -H "Authorization: Bearer ${NETDATA_CLOUD_TOKEN}" \
210 "https://${NETDATA_CLOUD_HOSTNAME}/api/v2/bearer_get_token?node_id=${node_id}&machine_guid=${mg}&claim_id=${claim}"
211 }
212
213 # Convert an `expiration` value (which may be unix-seconds or
214 # unix-milliseconds, depending on cloud version) to seconds.
215 # Heuristic: values > 10^12 are ms; lower are seconds. Returns 0
216 # for unparseable values so the caller treats the cache as expired.
217 _agents_exp_to_seconds() {
218 local exp="$1"
219 if [[ -z "${exp}" || "${exp}" == "null" ]]; then
220 echo 0; return
221 fi
222 if ! [[ "${exp}" =~ ^[0-9]+$ ]]; then
223 echo 0; return
224 fi
225 if (( exp > 1000000000000 )); then
226 echo $(( exp / 1000 ))
227 else
228 echo "${exp}"
229 fi
230 }
231
232 # Cache-aware bearer resolution. INTERNAL: writes to a caller-local
233 # variable; never prints the bearer to stdout.
234 #
235 # Args:
236 # $1 = OUTVAR -- caller-local variable to receive the bearer
237 # $2 = NODE_ID -- node UUID
238 # $3 = MACHINE_GUID -- agent machine_guid (cache key)
239 # $4 = HOST -- host:port for claim_id resolution and direct probe
240 #
241 # Cache file: <repo>/.local/audits/query-netdata-agents/bearers/<machine_guid>.json
242 # Mode 0600. Stamps `_cached_at` (unix-seconds) so the cache window
243 # survives Cloud responses with expiration=0.
244 _agents_resolve_bearer() {
245 local _out_var="${1:?usage: _agents_resolve_bearer OUTVAR <node_id> <machine_guid> <host:port>}"; shift
246 local node_id="${1:?usage: _agents_resolve_bearer OUTVAR <node_id> <machine_guid> <host:port>}"
247 local mg="${2:?machine_guid required}"
248 local host="${3:?host required}"
249
250 local cache_dir cache_file now exp_s
251 cache_dir="$(agents_audit_dir)/bearers"
252 mkdir -p "${cache_dir}"
253 chmod 0700 "${cache_dir}" 2>/dev/null || true
254 cache_file="${cache_dir}/${mg}.json"
255
256 now=$(date +%s)
257
258 if [[ -s "${cache_file}" ]]; then
259 local cached_exp cached_token cached_at
260 cached_exp=$(jq -r '.expiration // 0' "${cache_file}" 2>/dev/null || echo 0)
261 cached_token=$(jq -r '.token // empty' "${cache_file}" 2>/dev/null || true)
262 cached_at=$(jq -r '._cached_at // 0' "${cache_file}" 2>/dev/null || echo 0)
263 exp_s=$(_agents_exp_to_seconds "${cached_exp}")
264 if [[ -n "${cached_token}" && "${cached_token}" != "null" ]]; then
265 # Two cases:
266 # (a) Cloud returned a real expiration -- 1h refresh buffer
267 # (matches cloud-frontend useAgentBearer.js).
268 # (b) Cloud returned expiration=0 -- fall back to a fixed
269 # 2h window from our mint timestamp. The agent issues
270 # ~3h-TTL bearers, so 2h leaves a 1h safety margin.
271 if (( exp_s > 0 )); then
272 if (( exp_s - now > 3600 )); then
273 _agents_set_outvar "${_out_var}" "${cached_token}" || return 1
274 return 0
275 fi
276 elif (( cached_at > 0 )) && (( now - cached_at < 7200 )); then
277 _agents_set_outvar "${_out_var}" "${cached_token}" || return 1
278 return 0
279 fi
280 fi
281 fi
282
283 # Need to mint -- resolve claim_id first.
284 local claim
285 _agents_get_claim_id claim "${host}" || return 1
286
287 local resp
288 resp="$(_agents_mint_bearer_json "${node_id}" "${mg}" "${claim}")"
289 if ! jq -e '.token' >/dev/null 2>&1 <<< "${resp}"; then
290 rm -f "${cache_file}"
291 echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Bearer mint failed; first 200 chars: $(head -c 200 <<< "${resp}")" >&2
292 return 1
293 fi
294
295 # Stamp cache and persist.
296 jq --argjson t "${now}" '. + {_cached_at: $t}' <<< "${resp}" > "${cache_file}"
297 chmod 0600 "${cache_file}"
298 _agents_set_outvar "${_out_var}" "$(jq -r '.token' "${cache_file}")" || return 1
299 }
300
301 # ---------------------------------------------------------------------------
302 # PUBLIC wrappers (token-safe). These are what the assistant invokes.
303 # ---------------------------------------------------------------------------
304
305 # Call any Netdata Cloud REST endpoint. Reads NETDATA_CLOUD_TOKEN
306 # from .env internally; emits ONLY the response body to stdout.
307 # stderr shows the curl invocation with `<CLOUD_TOKEN>` masked.
308 #
309 # Args:
310 # $1 = METHOD -- GET / POST / PUT / DELETE / ...
311 # $2 = PATH -- e.g. /api/v2/spaces
312 # $3 = BODY (json) -- optional; passed via -d
313 #
314 # Example:
315 # agents_query_cloud GET /api/v2/spaces
316 # agents_query_cloud POST /api/v2/nodes/$NODE/function?function=systemd-journal '{"info":true}'
317 agents_query_cloud() {
318 local method="${1:?usage: agents_query_cloud METHOD PATH [BODY]}"
319 local api_path="${2:?path required}"
320 local body="${3:-}"
321
322 local args=(curl --fail --silent --show-error --max-time 120 -X "${method}" \
323 -H "Authorization: Bearer ${NETDATA_CLOUD_TOKEN}" \
324 -H 'Content-Type: application/json' \
325 "https://${NETDATA_CLOUD_HOSTNAME}${api_path}")
326 if [[ -n "${body}" ]]; then
327 args+=(-d "${body}")
328 fi
329 agents_run "${args[@]}"
330 }
331
332 # Call any Netdata Agent direct-HTTP path. Resolves the per-agent
333 # bearer internally (cache or mint via Cloud). Emits ONLY the
334 # response body to stdout. stderr shows curl with both
335 # `<CLOUD_TOKEN>` and `<AGENT_BEARER>` masked.
336 #
337 # Required flags (provided in any order before METHOD PATH):
338 # --node <node_id> -- target node UUID
339 # --host <host:port> -- agent's bind, e.g. "agent-events:19999"
340 # --machine-guid <mg> -- agent's machine_guid (bearer cache key)
341 #
342 # Example:
343 # agents_query_agent --node $NODE --host $HOST --machine-guid $MG \
344 # POST /api/v3/function?function=systemd-journal '{"info":true}'
345 agents_query_agent() {
346 local node="" host="" mg="" method="" api_path="" body=""
347 while (( $# > 0 )); do
348 local arg="$1"
349 case "$arg" in
350 --node) node="${2-}"; shift 2 ;;
351 --host) host="${2-}"; shift 2 ;;
352 --machine-guid) mg="${2-}"; shift 2 ;;
353 --) shift; break ;;
354 -*)
355 echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Unknown flag: $arg" >&2
356 return 1
357 ;;
358 *) break ;;
359 esac
360 done
361 method="${1:?usage: agents_query_agent --node N --host H --machine-guid M METHOD PATH [BODY]}"
362 api_path="${2:?path required}"
363 body="${3:-}"
364
365 : "${node:?--node required}"
366 : "${host:?--host required}"
367 : "${mg:?--machine-guid required}"
368
369 local bearer
370 _agents_resolve_bearer bearer "${node}" "${mg}" "${host}" || return 1
371
372 local args=(curl --fail --silent --show-error --max-time 120 -X "${method}" \
373 -H "X-Netdata-Auth: Bearer ${bearer}" \
374 -H 'Content-Type: application/json' \
375 "http://${host}/host/${node}${api_path}")
376 if [[ -n "${body}" ]]; then
377 args+=(-d "${body}")
378 fi
379 agents_run "${args[@]}"
380 }
381
382 # Convenience: Function call with automatic transport selection.
383 # Wraps agents_query_cloud (preferred) or agents_query_agent.
384 #
385 # Flags:
386 # --via cloud|agent default: cloud
387 # --node <node_id> REQUIRED
388 # --host <host:port> REQUIRED for --via agent
389 # --machine-guid <mg> REQUIRED for --via agent
390 # --function <name> REQUIRED (e.g. systemd-journal)
391 # --body <json> default: {"info":true}
392 agents_call_function() {
393 local via="cloud" node="" mg="" host="" fn="" body='{"info":true}'
394 while (( $# > 0 )); do
395 local arg="$1"
396 case "$arg" in
397 --via) via="${2-}"; shift 2 ;;
398 --node) node="${2-}"; shift 2 ;;
399 --machine-guid) mg="${2-}"; shift 2 ;;
400 --host) host="${2-}"; shift 2 ;;
401 --function) fn="${2-}"; shift 2 ;;
402 --body) body="${2-}"; shift 2 ;;
403 *)
404 echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Unknown arg: $arg" >&2
405 return 1
406 ;;
407 esac
408 done
409 : "${node:?--node required}"
410 : "${fn:?--function required}"
411
412 case "${via}" in
413 cloud)
414 agents_query_cloud POST "/api/v2/nodes/${node}/function?function=${fn}" "${body}"
415 ;;
416 agent)
417 : "${mg:?--machine-guid required for --via agent}"
418 : "${host:?--host required for --via agent}"
419 agents_query_agent --node "${node}" --host "${host}" --machine-guid "${mg}" \
420 POST "/api/v3/function?function=${fn}" "${body}"
421 ;;
422 *)
423 echo -e "${AGENTS_RED}[ERROR]${AGENTS_NC} Unknown --via: ${via}" >&2
424 return 1
425 ;;
426 esac
427 }
428
429 # ---------------------------------------------------------------------------
430 # Self-test: assert no token bytes leak through public wrappers.
431 # Run with: bash -c 'source _lib.sh; agents_selftest_no_token_leak'
432 # ---------------------------------------------------------------------------
433
434 agents_selftest_no_token_leak() {
435 local sentinel='UNIQUE_SENTINEL_TOKEN_xK4mP7qR9sT2vW8y'
436 local fake_bearer='deadbeef-1234-5678-9abc-def012345678'
437 local fake_claim='11111111-2222-3333-4444-555555555555'
438 local fake_node='22222222-3333-4444-5555-666666666666'
439 local fake_mg='33333333-4444-5555-6666-777777777777'
440
441 # Save real values, swap in sentinels, run wrappers in dry-run,
442 # capture stdout, restore.
443 local real_token="${NETDATA_CLOUD_TOKEN:-}"
444 local real_host="${NETDATA_CLOUD_HOSTNAME:-app.netdata.cloud}"
445 NETDATA_CLOUD_TOKEN="${sentinel}"
446 NETDATA_CLOUD_HOSTNAME="${real_host}"
447 AGENTS_DRY_RUN=1
448
449 local out=""
450
451 # 1. agents_query_cloud should not echo the sentinel.
452 out="$(agents_query_cloud GET /api/v2/spaces 2>/dev/null || true)"
453 if [[ "${out}" == *"${sentinel}"* ]]; then
454 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} agents_query_cloud leaked NETDATA_CLOUD_TOKEN to stdout" >&2
455 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
456 return 1
457 fi
458
459 # 2. _agents_log_masked must mask Bearer <uuid> patterns.
460 out="$(_agents_log_masked curl -H "Authorization: Bearer ${sentinel}" \
461 -H "X-Netdata-Auth: Bearer ${fake_bearer}" \
462 "https://app.netdata.cloud/api/v2/bearer_get_token?node_id=${fake_node}&machine_guid=${fake_mg}&claim_id=${fake_claim}" \
463 "https://app.netdata.cloud/api/v3/spaces/${fake_node}/rooms/${fake_mg}/nodes" \
464 "http://agent.test:19999/host/${fake_node}/api/v3/function?function=flows:netflow" \
465 https://example.invalid 2>&1 1>/dev/null)"
466 if [[ "${out}" == *"${sentinel}"* ]]; then
467 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_log_masked leaked NETDATA_CLOUD_TOKEN to stderr" >&2
468 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
469 return 1
470 fi
471 if [[ "${out}" == *"${fake_bearer}"* ]]; then
472 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_log_masked leaked Bearer <uuid> to stderr" >&2
473 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
474 return 1
475 fi
476 if [[ "${out}" == *"${fake_claim}"* || "${out}" == *"${fake_node}"* || "${out}" == *"${fake_mg}"* ]]; then
477 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_log_masked leaked node identity to stderr" >&2
478 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
479 return 1
480 fi
481
482 # 3. _agents_get_claim_id must write through the caller-provided
483 # output variable even when the caller names the output variable `claim`.
484 # This mirrors _agents_resolve_bearer and catches local-variable
485 # shadowing regressions before direct-agent calls need credentials.
486 local fake_bin_dir old_path claim
487 fake_bin_dir="$(mktemp -d)"
488 old_path="${PATH}"
489 cat > "${fake_bin_dir}/curl" <<EOF
490 #!/usr/bin/env bash
491 printf '%s\n' '{"agents":[{"cloud":{"claim_id":"${fake_claim}"}}]}'
492 EOF
493 chmod +x "${fake_bin_dir}/curl"
494 PATH="${fake_bin_dir}:${PATH}"
495 claim=""
496 if ! _agents_get_claim_id claim "agent.test:19999"; then
497 PATH="${old_path}"
498 rm -rf "${fake_bin_dir}"
499 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_get_claim_id failed with fake agent info" >&2
500 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
501 return 1
502 fi
503 if [[ "${claim}" != "${fake_claim}" ]]; then
504 PATH="${old_path}"
505 rm -rf "${fake_bin_dir}"
506 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_get_claim_id did not populate caller output variable" >&2
507 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
508 return 1
509 fi
510 if _agents_get_claim_id "not-a-valid-name" "agent.test:19999" 2>/dev/null; then
511 PATH="${old_path}"
512 rm -rf "${fake_bin_dir}"
513 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_get_claim_id ignored invalid output variable failure" >&2
514 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
515 return 1
516 fi
517 PATH="${old_path}"
518 rm -rf "${fake_bin_dir}"
519
520 # 4. _agents_set_outvar must preserve shell metacharacters as data.
521 # This protects bearer/claim assignment from accidental eval-style
522 # interpretation of external command output.
523 local assigned marker weird_value
524 marker="unchanged"
525 weird_value=$'space * ? ; marker=changed $(echo bad) `bad`\nline2 "quote"'
526 assigned=""
527 if ! _agents_set_outvar assigned "${weird_value}"; then
528 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_set_outvar failed on metacharacter payload" >&2
529 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
530 return 1
531 fi
532 if [[ "${assigned}" != "${weird_value}" || "${marker}" != "unchanged" ]]; then
533 echo -e "${AGENTS_RED}[FAIL]${AGENTS_NC} _agents_set_outvar interpreted metacharacters instead of assigning data" >&2
534 NETDATA_CLOUD_TOKEN="${real_token}"; unset AGENTS_DRY_RUN
535 return 1
536 fi
537
538 # 5. The unit test passes if all checks above passed.
539 NETDATA_CLOUD_TOKEN="${real_token}"
540 unset AGENTS_DRY_RUN
541 echo -e "${AGENTS_GREEN}[PASS]${AGENTS_NC} no-token-leak self-test" >&2
542 return 0
543 }