From 4cb664ae2ade964b4d9d3defe3854a3b30fe4dd1 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 08:29:00 -0400 Subject: [PATCH 01/16] Add inactive evidence integrity evaluator --- control/v1/evaluate-evidence-integrity.sh | 412 ++++++++++++++++++ control/v1/evidence-integrity-decision.json | 1 + control/v1/evidence-integrity-policy.json | 1 + control/v1/evidence-integrity.jq | 177 ++++++++ .../test/control-evidence-integrity.test.sh | 362 +++++++++++++++ 5 files changed, 953 insertions(+) create mode 100755 control/v1/evaluate-evidence-integrity.sh create mode 100644 control/v1/evidence-integrity-decision.json create mode 100644 control/v1/evidence-integrity-policy.json create mode 100644 control/v1/evidence-integrity.jq create mode 100755 scripts/test/control-evidence-integrity.test.sh diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh new file mode 100755 index 0000000..7e6e272 --- /dev/null +++ b/control/v1/evaluate-evidence-integrity.sh @@ -0,0 +1,412 @@ +#!/bin/bash +# shellcheck disable=SC2016 +set -uo pipefail +export LC_ALL=C +umask 077 + +emit_error() { + case "${1:-}" in + E_USAGE|E_RUNTIME|E_LIMIT|E_RELATION|E_POLICY_SET|E_CORE) + /usr/bin/printf '%s\n' "$1" >&2 + ;; + *) /usr/bin/printf '%s\n' E_RUNTIME >&2 ;; + esac + exit 1 +} + +[ "$#" -eq 6 ] && [ "$1" = evaluate ] || emit_error E_USAGE +shift +source_path=${BASH_SOURCE[0]} +case "$source_path" in /*) ;; *) source_path="$(pwd -P)/$source_path" ;; esac +source_dir=$(CDPATH='' cd -P -- "${source_path%/*}" 2>/dev/null && pwd -P) || + emit_error E_RUNTIME +source_path="$source_dir/${source_path##*/}" +[ "$source_path" = "$source_dir/evaluate-evidence-integrity.sh" ] || + emit_error E_RUNTIME +repo=$(CDPATH='' cd -P -- "$source_dir/../.." 2>/dev/null && pwd -P) || + emit_error E_RUNTIME +policy="$source_dir/evidence-integrity-policy.json" +decision="$source_dir/evidence-integrity-decision.json" +program="$source_dir/evidence-integrity.jq" +policy_validator="$source_dir/validate.sh" +validator_program="$source_dir/policy-set.jq" +core_driver="$repo/scripts/core-contract.sh" +for required in "$source_path" "$policy" "$decision" "$program" \ + "$policy_validator" "$validator_program" "$core_driver"; do + [ -f "$required" ] && [ ! -L "$required" ] || emit_error E_RUNTIME +done +for input in "$@"; do + [ -f "$input" ] && [ ! -L "$input" ] || emit_error E_RUNTIME +done +jq_bin=$(command -v jq 2>/dev/null) || emit_error E_RUNTIME +case "$jq_bin" in /*) ;; *) emit_error E_RUNTIME ;; esac +[ -f "$jq_bin" ] && [ -x "$jq_bin" ] && [ ! -L "$jq_bin" ] && + [ "$($jq_bin --version 2>/dev/null)" = jq-1.6 ] || emit_error E_RUNTIME + +sha256_path() { /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}'; } +sha256_text() { + /usr/bin/printf '%s' "$1" | /usr/bin/shasum -a 256 | /usr/bin/awk '{print $1}' +} +selected_core_generation() { + local wrapper=$1 selected assignment_count + assignment_count=$(/usr/bin/grep -Ec \ + '^[[:space:]]*PORTABLE_CORE_GENERATION=' "$wrapper") || return 1 + [ "$assignment_count" -eq 1 ] || return 1 + selected=$(/usr/bin/sed -n \ + "s/^PORTABLE_CORE_GENERATION='\(g-[0-9a-f]\{64\}\)'$/\1/p" \ + "$wrapper") || return 1 + [[ "$selected" =~ ^g-[0-9a-f]{64}$ ]] || return 1 + /usr/bin/printf '%s\n' "$selected" +} + +scratch=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence.XXXXXX" 2>/dev/null) || + emit_error E_RUNTIME +scratch=$(CDPATH='' cd -P -- "$scratch" 2>/dev/null && pwd -P) || + emit_error E_RUNTIME +cleanup() { /bin/rm -rf -- "$scratch" >/dev/null 2>&1 || :; } +signal_exit() { trap - EXIT HUP INT TERM; cleanup; exit 1; } +trap cleanup EXIT +trap signal_exit HUP INT TERM + +snapshot_fixed() { + local source=$1 target=$2 size + /bin/dd if="$source" of="$target" bs=1048577 count=1 2>/dev/null || + emit_error E_RUNTIME + size=$(/usr/bin/wc -c <"$target" | /usr/bin/tr -d ' ') || + emit_error E_RUNTIME + [ "$size" -le 1048576 ] || emit_error E_LIMIT +} +canonical_json() { + local input=$1 canonical=$2 + "$jq_bin" -s -S -c 'if length==1 then .[0] else error("root-count") end' \ + "$input" >"$canonical" 2>/dev/null || return 1 + /usr/bin/cmp -s "$input" "$canonical" +} +validator_pair_ok() { + local pair_dir=$1 driver=$2 validator_jq=$3 expected_driver=$4 expected_program=$5 + local physical_dir + [ -d "$pair_dir" ] && [ ! -L "$pair_dir" ] || return 1 + physical_dir=$(CDPATH='' cd -P -- "$pair_dir" 2>/dev/null && pwd -P) || return 1 + [ "$physical_dir" = "$pair_dir" ] && + [ "$driver" = "$pair_dir/validate.sh" ] && + [ "$validator_jq" = "$pair_dir/policy-set.jq" ] && + [ -f "$driver" ] && [ ! -L "$driver" ] && + [ -f "$validator_jq" ] && [ ! -L "$validator_jq" ] && + [ "$(sha256_path "$driver")" = "$expected_driver" ] && + [ "$(sha256_path "$validator_jq")" = "$expected_program" ] +} +build_validator_mirror() { + local mirror="$scratch/policy-validator/control/v1" source target size + /bin/mkdir -p "$mirror" || return 1 + for source in "$policy_validator" "$validator_program"; do + target="$mirror/${source##*/}" + /bin/dd if="$source" of="$target" bs=1048577 count=1 2>/dev/null || return 1 + size=$(/usr/bin/wc -c <"$target" | /usr/bin/tr -d ' ') || return 1 + [ "$size" -le 1048576 ] || return 1 + done + /bin/chmod 0500 "$mirror/validate.sh" || return 1 + /usr/bin/printf '%s\n' "$mirror" +} +core_closure_sha() { + local root=$1 wrapper=$2 selected=$3 tag=$4 registry generation_root canonical + local relative file digest members descriptor physical selected_sha count modules + local -a paths + registry="$root/core/v2/generation-registry.json" + generation_root="$root/core/v2/generations/$selected" + for required_dir in "$root" "$root/scripts" "$root/core" "$root/core/v2" \ + "$root/core/v2/generations" "$generation_root" "$generation_root/modules"; do + [ -d "$required_dir" ] && [ ! -L "$required_dir" ] || return 1 + physical=$(CDPATH='' cd -P -- "$required_dir" 2>/dev/null && pwd -P) || return 1 + [ "$physical" = "$required_dir" ] || return 1 + done + [ "$wrapper" = "$root/scripts/core-contract.sh" ] || return 1 + [ "$(selected_core_generation "$wrapper")" = "$selected" ] || return 1 + count=$(/usr/bin/find "$generation_root" -mindepth 1 -maxdepth 1 -print 2>/dev/null | + /usr/bin/wc -l | /usr/bin/tr -d ' ') || return 1 + modules=$(/usr/bin/find "$generation_root/modules" -mindepth 1 -maxdepth 1 \ + -print 2>/dev/null | /usr/bin/wc -l | /usr/bin/tr -d ' ') || return 1 + [ "$count" -eq 3 ] && [ "$modules" -eq 5 ] || return 1 + [ -f "$registry" ] && [ ! -L "$registry" ] || return 1 + canonical="$scratch/registry-$tag.json" + "$jq_bin" -s -S -c 'if length==1 then .[0] else error("root-count") end' \ + "$registry" >"$canonical" 2>/dev/null || return 1 + /usr/bin/cmp -s "$registry" "$canonical" || return 1 + "$jq_bin" -e --arg selected "$selected" ' + type=="array" and length>=1 and + ([.[]|select(.generation_id==$selected and .semantic_identity=="core.contracts.v2")] + |length)==1 + ' "$registry" >/dev/null 2>&1 || return 1 + paths=( + scripts/core-contract.sh + core/v2/generation-registry.json + "core/v2/generations/$selected/contracts.jq" + "core/v2/generations/$selected/core-ingress.sh" + "core/v2/generations/$selected/modules/profile_graph.jq" + "core/v2/generations/$selected/modules/result_facts.jq" + "core/v2/generations/$selected/modules/result_truth.jq" + "core/v2/generations/$selected/modules/schema.jq" + "core/v2/generations/$selected/modules/stage_request.jq" + ) + members="$scratch/core-members-$tag.tsv" + : >"$members" || return 1 + for relative in "${paths[@]}"; do + file="$root/$relative" + [ -f "$file" ] && [ ! -L "$file" ] || return 1 + digest=$(sha256_path "$file") || return 1 + /usr/bin/printf '%s\t%s\n' "$relative" "$digest" >>"$members" || return 1 + done + selected_sha=$(sha256_text "$selected") || return 1 + descriptor=$("$jq_bin" -Rn -S -c --arg selected_sha "$selected_sha" ' + [inputs|split("\t")|{path:.[0],sha256:.[1]}] as $members | + {schema_version:1,kind:"core_contract_package_closure", + semantic_identity:"core.contracts.v2", + selected_generation_id_sha256:$selected_sha,members:$members} + ' <"$members") || return 1 + sha256_text "$descriptor" +} +build_core_mirror() { + local selected=$1 mirror="$scratch/core-package" relative source target size + local -a paths + /bin/mkdir -p "$mirror/scripts" "$mirror/core/v2/generations/$selected/modules" || + return 1 + paths=( + scripts/core-contract.sh + core/v2/generation-registry.json + "core/v2/generations/$selected/contracts.jq" + "core/v2/generations/$selected/core-ingress.sh" + "core/v2/generations/$selected/modules/profile_graph.jq" + "core/v2/generations/$selected/modules/result_facts.jq" + "core/v2/generations/$selected/modules/result_truth.jq" + "core/v2/generations/$selected/modules/schema.jq" + "core/v2/generations/$selected/modules/stage_request.jq" + ) + for relative in "${paths[@]}"; do + source="$repo/$relative" + target="$mirror/$relative" + /bin/dd if="$source" of="$target" bs=1048577 count=1 2>/dev/null || return 1 + size=$(/usr/bin/wc -c <"$target" | /usr/bin/tr -d ' ') || return 1 + [ "$size" -le 1048576 ] || return 1 + done + /bin/chmod 0500 "$mirror/scripts/core-contract.sh" || return 1 + /usr/bin/printf '%s\n' "$mirror" +} +fixed_files_ok() { + [ "$(sha256_path "$source_path")" = "$driver_sha" ] && + [ "$(sha256_path "$program")" = "$program_sha" ] && + [ "$(sha256_path "$policy")" = "$policy_sha" ] && + [ "$(sha256_path "$decision")" = "$decision_sha" ] +} + +names=(policy-set request resolved result presentation) +index=0 +for input in "$@"; do + snapshot_fixed "$input" "$scratch/${names[$index]}.json" + canonical_json "$scratch/${names[$index]}.json" \ + "$scratch/${names[$index]}.canonical" || emit_error E_RELATION + index=$((index + 1)) +done +"$jq_bin" -e ' + (keys|sort)==["body","id","kind","schema_version"] and + .schema_version==1 and .kind=="evidence_integrity_presentation" and + (.id|type=="string" and test("\\A[a-z0-9][a-z0-9._:-]{0,127}\\z")) and + (.body|type=="object") +' "$scratch/presentation.json" >/dev/null 2>&1 || emit_error E_RELATION +snapshot_fixed "$policy" "$scratch/policy.json" +snapshot_fixed "$decision" "$scratch/decision.json" +snapshot_fixed "$program" "$scratch/program.jq" +canonical_json "$scratch/policy.json" "$scratch/policy.canonical" || + emit_error E_RELATION +canonical_json "$scratch/decision.json" "$scratch/decision.canonical" || + emit_error E_RELATION +for control_dir in "$repo/control" "$source_dir"; do + [ -d "$control_dir" ] && [ ! -L "$control_dir" ] || emit_error E_RELATION +done +[ "$source_dir" = "$repo/control/v1" ] || emit_error E_RELATION + +driver_sha=$(sha256_path "$source_path") || emit_error E_RUNTIME +program_sha=$(sha256_path "$scratch/program.jq") || emit_error E_RUNTIME +policy_sha=$(sha256_path "$scratch/policy.json") || emit_error E_RUNTIME +decision_sha=$(sha256_path "$scratch/decision.json") || emit_error E_RUNTIME +validator_driver_sha=$(sha256_path "$policy_validator") || emit_error E_RUNTIME +validator_program_sha=$(sha256_path "$validator_program") || emit_error E_RUNTIME +"$jq_bin" -e --arg policy_sha "$policy_sha" --arg driver_sha "$driver_sha" \ + --arg program_sha "$program_sha" --arg validator_driver_sha "$validator_driver_sha" \ + --arg validator_program_sha "$validator_program_sha" \ + --slurpfile policy "$scratch/policy.json" \ + --slurpfile definition "$scratch/decision.json" ' + $definition[0] == { + schema_version:1,kind:"evidence_integrity_decision", + id:"control-decision.evidence-integrity", + body:{activation_state:"inactive",decision:"allow-observation-only-evaluation", + evaluator:{ + driver_ref:{content_id:"control-evaluator-driver.evidence-integrity.v1", + media_type:"text/x-shellscript",sha256:$driver_sha}, + policy_set_validator:{ + driver_ref:{content_id:"control-policy-set-validator-driver.v1", + media_type:"text/x-shellscript",sha256:$validator_driver_sha}, + program_ref:{content_id:"control-policy-set-validator-program.v1", + media_type:"text/x-jq",sha256:$validator_program_sha}}, + program_ref:{content_id:"control-evaluator-program.evidence-integrity.v1", + media_type:"text/x-jq",sha256:$program_sha}}, + fail_mode:"closed", + policy_ref:{content_id:$policy[0].id, + media_type:"application/vnd.ystack.control-policy+json",sha256:$policy_sha}, + semantics:{authority_effect:"none",candidate_execution:"none", + credential_access:"none", + input_contract:"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1", + network_access:"none",output_kind:"evidence_integrity_evaluation", + output_schema_version:1,qualification_effect:"none", + reference_semantics:"identity-only",storage_effect:"none", + verdicts:["satisfied","violated"]}} + } +' >/dev/null 2>&1 || emit_error E_RELATION + +validator_pair_ok "$source_dir" "$policy_validator" "$validator_program" \ + "$validator_driver_sha" "$validator_program_sha" || emit_error E_RELATION +mirror_validator_dir=$(build_validator_mirror) || emit_error E_RELATION +mirror_policy_validator="$mirror_validator_dir/validate.sh" +mirror_validator_program="$mirror_validator_dir/policy-set.jq" +validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ + "$mirror_validator_program" "$validator_driver_sha" "$validator_program_sha" || + emit_error E_RELATION +policy_status=0 +PATH="${jq_bin%/*}:/usr/bin:/bin" "$mirror_policy_validator" validate \ + "$scratch/policy-set.json" >"$scratch/policy.out" 2>"$scratch/policy.err" || + policy_status=$? +if ! validator_pair_ok "$source_dir" "$policy_validator" "$validator_program" \ + "$validator_driver_sha" "$validator_program_sha" || + ! validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ + "$mirror_validator_program" "$validator_driver_sha" "$validator_program_sha"; then + emit_error E_RELATION +fi +[ "$policy_status" -eq 0 ] || emit_error E_POLICY_SET + +selected=$(selected_core_generation "$core_driver") || emit_error E_RELATION +selected_sha=$(sha256_text "$selected") || emit_error E_RUNTIME +live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-pre) || + emit_error E_RELATION +mirror_root=$(build_core_mirror "$selected") || emit_error E_RELATION +mirror_core_driver="$mirror_root/scripts/core-contract.sh" +mirror_core_sha=$(core_closure_sha "$mirror_root" "$mirror_core_driver" "$selected" mirror-pre) || + emit_error E_RELATION +[ "$mirror_core_sha" = "$live_core_sha" ] || emit_error E_RELATION +"$jq_bin" -e --arg policy_sha "$policy_sha" --arg decision_sha "$decision_sha" \ + --arg selected "$selected" --arg selected_sha "$selected_sha" \ + --arg core_sha "$live_core_sha" --slurpfile policy "$scratch/policy.json" ' + .body.core_contract == { + semantic_identity:$policy[0].body.core_contract.semantic_identity, + generation_id:$selected,package_ref:$policy[0].body.core_contract.package_ref} and + $selected_sha == $policy[0].body.core_contract.generation_id_sha256 and + $core_sha == $policy[0].body.core_contract.package_ref.sha256 and + ([.body.sections[]|select(.section_id=="evidence-integrity")]|length)==1 and + ([.body.sections[]|select(.section_id=="evidence-integrity")][0]) == { + section_id:"evidence-integrity", + policy_ref:{content_id:$policy[0].id, + media_type:"application/vnd.ystack.control-policy+json",sha256:$policy_sha}, + decision_ref:{content_id:"control-decision.evidence-integrity", + media_type:"application/vnd.ystack.control-decision+json",sha256:$decision_sha}} +' "$scratch/policy-set.json" >/dev/null 2>&1 || emit_error E_RELATION + +core_status=0 +PATH="${jq_bin%/*}:/usr/bin:/bin" "$mirror_core_driver" validate-stage-run \ + "$scratch/request.json" "$scratch/resolved.json" "$scratch/result.json" \ + >"$scratch/core.out" 2>"$scratch/core.err" || core_status=$? +post_live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-post) || + emit_error E_RELATION +post_mirror_core_sha=$(core_closure_sha \ + "$mirror_root" "$mirror_core_driver" "$selected" mirror-post) || emit_error E_RELATION +[ "$post_live_core_sha" = "$live_core_sha" ] && + [ "$post_mirror_core_sha" = "$mirror_core_sha" ] || emit_error E_RELATION +if ! validator_pair_ok "$source_dir" "$policy_validator" "$validator_program" \ + "$validator_driver_sha" "$validator_program_sha" || + ! validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ + "$mirror_validator_program" "$validator_driver_sha" "$validator_program_sha"; then + emit_error E_RELATION +fi +[ "$core_status" -eq 0 ] || emit_error E_CORE + +policy_set_sha=$(sha256_path "$scratch/policy-set.json") || emit_error E_RUNTIME +request_sha=$(sha256_path "$scratch/request.json") || emit_error E_RUNTIME +resolved_sha=$(sha256_path "$scratch/resolved.json") || emit_error E_RUNTIME +result_sha=$(sha256_path "$scratch/result.json") || emit_error E_RUNTIME +presentation_sha=$(sha256_path "$scratch/presentation.json") || emit_error E_RUNTIME +"$jq_bin" -S -c -n -f "$scratch/program.jq" \ + --slurpfile policy "$scratch/policy.json" \ + --slurpfile decision "$scratch/decision.json" \ + --slurpfile policy_set "$scratch/policy-set.json" \ + --slurpfile request "$scratch/request.json" \ + --slurpfile resolved "$scratch/resolved.json" \ + --slurpfile result "$scratch/result.json" \ + --slurpfile presentation "$scratch/presentation.json" \ + --arg policy_sha "$policy_sha" --arg decision_sha "$decision_sha" \ + --arg policy_set_sha "$policy_set_sha" --arg request_sha "$request_sha" \ + --arg resolved_sha "$resolved_sha" --arg result_sha "$result_sha" \ + --arg presentation_sha "$presentation_sha" >"$scratch/evaluation.json" 2>/dev/null || + emit_error E_RUNTIME +fixed_files_ok || emit_error E_RELATION +final_live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-final) || + emit_error E_RELATION +final_mirror_core_sha=$(core_closure_sha \ + "$mirror_root" "$mirror_core_driver" "$selected" mirror-final) || emit_error E_RELATION +[ "$final_live_core_sha" = "$live_core_sha" ] && + [ "$final_mirror_core_sha" = "$mirror_core_sha" ] || emit_error E_RELATION +if ! validator_pair_ok "$source_dir" "$policy_validator" "$validator_program" \ + "$validator_driver_sha" "$validator_program_sha" || + ! validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ + "$mirror_validator_program" "$validator_driver_sha" "$validator_program_sha"; then + emit_error E_RELATION +fi +canonical_json "$scratch/evaluation.json" "$scratch/evaluation.canonical" || + emit_error E_RUNTIME +"$jq_bin" -e --arg policy_sha "$policy_sha" --arg decision_sha "$decision_sha" \ + --arg policy_set_sha "$policy_set_sha" --arg request_sha "$request_sha" \ + --arg resolved_sha "$resolved_sha" --arg result_sha "$result_sha" \ + --arg presentation_sha "$presentation_sha" \ + --slurpfile policy_set "$scratch/policy-set.json" \ + --slurpfile request "$scratch/request.json" --slurpfile resolved "$scratch/resolved.json" \ + --slurpfile result "$scratch/result.json" --slurpfile presentation "$scratch/presentation.json" ' + (keys|sort)==["body","id","kind","schema_version"] and .schema_version==1 and + .kind=="evidence_integrity_evaluation" and .id==$result[0].id and + (.body|keys|sort)==["activation_state","authority_effect","core_contract", + "decision_ref","evaluation_mode","evidence_refs","policy_ref","policy_set", + "presentation_ref","prior_evidence_refs","qualification_observation", + "qualification_semantics","reason_ids","reference_semantics","stage", + "storage_effect","verdict"] and + .body.activation_state=="inactive" and .body.authority_effect=="none" and + .body.evaluation_mode=="observation-only" and .body.storage_effect=="none" and + .body.reference_semantics=="identity-only" and + .body.qualification_semantics=="identity-only-unqualified" and + .body.core_contract==$policy_set[0].body.core_contract and + .body.policy_set=={id:$policy_set[0].id,sha256:$policy_set_sha} and + .body.policy_ref=={content_id:"control-policy.evidence-integrity", + media_type:"application/vnd.ystack.control-policy+json",sha256:$policy_sha} and + .body.decision_ref=={content_id:"control-decision.evidence-integrity", + media_type:"application/vnd.ystack.control-decision+json",sha256:$decision_sha} and + .body.presentation_ref=={content_id:$presentation[0].id, + media_type:"application/vnd.ystack.evidence-integrity-presentation+json", + sha256:$presentation_sha} and + .body.stage=={ + request_ref:{schema_version:$request[0].schema_version,kind:$request[0].kind, + id:$request[0].id,sha256:$request_sha}, + resolved_profile_ref:{schema_version:$resolved[0].schema_version,kind:$resolved[0].kind, + id:$resolved[0].id,sha256:$resolved_sha}, + result_ref:{schema_version:$result[0].schema_version,kind:$result[0].kind, + id:$result[0].id,sha256:$result_sha}} and + .body.evidence_refs==($result[0].body.evidence|map({evidence_id,kind,proof_ref,verdict})) and + .body.prior_evidence_refs==$request[0].body.prior_evidence_refs and + .body.qualification_observation== + (if $request[0].body|has("qualification_ref") then + {state:"present",value:$request[0].body.qualification_ref} + else {state:"absent"} end) and + (.body.verdict=="satisfied" or .body.verdict=="violated") and + (.body.reason_ids|type=="array" and length>=1 and .==(sort|unique)) and + (if .body.verdict=="satisfied" then + .body.reason_ids==["evidence.integrity-satisfied"] + else (.body.reason_ids|index("evidence.integrity-satisfied")==null) end) and + ((.body|has("grant_ref") or has("qualification_ref") or has("activation") or + has("credential") or has("network") or has("candidate_execution"))|not) +' "$scratch/evaluation.json" >/dev/null 2>&1 || emit_error E_RUNTIME + +/bin/cat "$scratch/evaluation.json" || emit_error E_RUNTIME +trap - EXIT HUP INT TERM +cleanup diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json new file mode 100644 index 0000000..5985f0a --- /dev/null +++ b/control/v1/evidence-integrity-decision.json @@ -0,0 +1 @@ +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"45862a20bb4f46f816ad74f0c2eb26a5a27d6af739c6aafffcb49ca98cdf8194"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"71b470e4f38132936e34a7e8b92f03c7438b534d40f924a55708b3a387dcc5bd"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/control/v1/evidence-integrity-policy.json b/control/v1/evidence-integrity-policy.json new file mode 100644 index 0000000..0d448c0 --- /dev/null +++ b/control/v1/evidence-integrity-policy.json @@ -0,0 +1 @@ +{"body":{"activation_state":"inactive","core_contract":{"generation_id_sha256":"6f6acbbd0cf40ab3c913328d6c0070635424ffe920bcdb900fbd0718345d7137","package_ref":{"content_id":"core-contract-package.v2","media_type":"application/vnd.ystack.core-contract+json","sha256":"005431c5c7e3a39dc3ab75dfcafd0f09359331667fdcacb140514a4384592716"},"semantic_identity":"core.contracts.v2"},"evaluation_mode":"observation-only","evidence_contract":{"current_result_binding":"exact-document-digest","presentation_kind":"evidence_integrity_presentation","prior_evidence_binding":"exact-stage-result-digest-and-evidence-id","proof_binding":"content-ref-sha256","qualification_binding":"exact-scope-ref-or-absent"},"fail_mode":"closed","policy_version":"v1","qualification_semantics":"identity-only-unqualified","storage_effect":"none"},"id":"control-policy.evidence-integrity","kind":"evidence_integrity_policy","schema_version":1} diff --git a/control/v1/evidence-integrity.jq b/control/v1/evidence-integrity.jq new file mode 100644 index 0000000..5e91942 --- /dev/null +++ b/control/v1/evidence-integrity.jq @@ -0,0 +1,177 @@ +def exact($fields): + type == "object" and (keys | sort) == ($fields | sort); + +def id_ok: + type == "string" and test("\\A[a-z0-9][a-z0-9._:-]{0,127}\\z"); + +def sha256_ok: + type == "string" and test("\\A[0-9a-f]{64}\\z"); + +def content_ref_ok: + exact(["content_id","media_type","sha256"]) and + (.content_id | id_ok) and (.media_type | type == "string") and + (.sha256 | sha256_ok); + +def document_ref_ok($kind): + exact(["id","kind","schema_version","sha256"]) and + .schema_version == 2 and .kind == $kind and (.id | id_ok) and + (.sha256 | sha256_ok); + +def scope_ref_ok($purpose): + exact(["decision_record_ref","purpose","scope_sha256","subject_ref"]) and + .purpose == $purpose and (.decision_record_ref | content_ref_ok) and + (.scope_sha256 | sha256_ok) and (.subject_ref | type == "object"); + +def presence_ok(value_ok): + type == "object" and + ((exact(["state"]) and .state == "absent") or + (exact(["state","value"]) and .state == "present" and (.value | value_ok))); + +def evidence_ok: + exact(["evidence_id","kind","proof_ref","verdict"]) and + (.evidence_id | id_ok) and (.kind | id_ok) and + (.verdict == "passed" or .verdict == "failed" or .verdict == "inconclusive") and + (.proof_ref | content_ref_ok); + +def evidence_ref_ok: + exact(["evidence_id","stage_result_ref"]) and + (.evidence_id | id_ok) and (.stage_result_ref | document_ref_ok("stage_result")); + +def expected_core: + {semantic_identity:"core.contracts.v2", + generation_id_sha256: + "6f6acbbd0cf40ab3c913328d6c0070635424ffe920bcdb900fbd0718345d7137", + package_ref:{content_id:"core-contract-package.v2", + media_type:"application/vnd.ystack.core-contract+json", + sha256:"005431c5c7e3a39dc3ab75dfcafd0f09359331667fdcacb140514a4384592716"}}; + +def policy_ok: + exact(["body","id","kind","schema_version"]) and .schema_version == 1 and + .kind == "evidence_integrity_policy" and + .id == "control-policy.evidence-integrity" and + (.body | + exact(["activation_state","core_contract","evaluation_mode", + "evidence_contract","fail_mode","policy_version", + "qualification_semantics","storage_effect"]) and + .activation_state == "inactive" and .core_contract == expected_core and + .evaluation_mode == "observation-only" and .fail_mode == "closed" and + .policy_version == "v1" and .qualification_semantics == "identity-only-unqualified" and + .storage_effect == "none" and + .evidence_contract == { + current_result_binding:"exact-document-digest", + presentation_kind:"evidence_integrity_presentation", + prior_evidence_binding:"exact-stage-result-digest-and-evidence-id", + proof_binding:"content-ref-sha256", + qualification_binding:"exact-scope-ref-or-absent"}); + +def presentation_shape_ok: + exact(["body","id","kind","schema_version"]) and .schema_version == 1 and + .kind == "evidence_integrity_presentation" and (.id | id_ok) and + (.body | + exact(["evidence","prior_evidence_refs","qualification_ref","request_ref", + "resolved_profile_ref","result_ref"]) and + (.request_ref | document_ref_ok("stage_request")) and + (.resolved_profile_ref | document_ref_ok("resolved_profile")) and + (.result_ref | document_ref_ok("stage_result")) and + (.evidence | type == "array" and length <= 256 and all(.[];evidence_ok)) and + (.prior_evidence_refs | + type == "array" and length <= 256 and all(.[];evidence_ref_ok)) and + (.qualification_ref | presence_ok(scope_ref_ok("qualification")))); + +def document_ref($document;$digest): + {schema_version:$document.schema_version,kind:$document.kind, + id:$document.id,sha256:$digest}; + +def content_ref($id;$media;$digest): + {content_id:$id,media_type:$media,sha256:$digest}; + +def normalized_array($value): + if ($value | type) == "array" then $value else [] end; + +def ambiguous_entries($entries;$key_filter): + ($entries | normalized_array(.)) as $items | + ($items | map($key_filter)) as $keys | + ($keys | length) != ($keys | unique | length); + +($policy[0]) as $p | +($decision[0]) as $definition | +($policy_set[0]) as $set | +($request[0]) as $request_doc | +($resolved[0]) as $resolved_doc | +($result[0]) as $result_doc | +($presentation[0]) as $presented | +(if ($p | policy_ok) then true else error("invalid shipped evidence policy") end) | + +document_ref($request_doc;$request_sha) as $expected_request_ref | +document_ref($resolved_doc;$resolved_sha) as $expected_resolved_ref | +document_ref($result_doc;$result_sha) as $expected_result_ref | +(if $request_doc.body | has("qualification_ref") then + {state:"present",value:$request_doc.body.qualification_ref} + else {state:"absent"} end) as $expected_qualification | +(if ($presented.body? | type) == "object" then $presented.body else {} end) as + $presented_body | +($presented_body.evidence? | normalized_array(.)) as $presented_evidence | +($presented_body.prior_evidence_refs? | normalized_array(.)) as $presented_prior | + +content_ref($p.id;"application/vnd.ystack.control-policy+json";$policy_sha) as + $policy_ref | +content_ref($definition.id;"application/vnd.ystack.control-decision+json"; + $decision_sha) as $decision_ref | +([$set.body.sections[] | select(.section_id == "evidence-integrity")]) as + $evidence_sections | +(if ($evidence_sections | length) == 1 and + $evidence_sections[0].policy_ref == $policy_ref and + $evidence_sections[0].decision_ref == $decision_ref + then true else error("invalid policy-set evidence binding") end) | + +((if ($presented | presentation_shape_ok) then [] + else ["evidence.presentation-malformed"] end) + + (if $presented_body.request_ref? == $expected_request_ref then [] + else ["evidence.request-moved"] end) + + (if $presented_body.resolved_profile_ref? == $expected_resolved_ref then [] + else ["evidence.resolved-profile-moved"] end) + + (if $presented_body.result_ref? == $expected_result_ref then [] + else ["evidence.result-moved"] end) + + (if $presented_evidence == $result_doc.body.evidence then [] + else ["evidence.current-mismatch"] end) + + (if $presented_prior == $request_doc.body.prior_evidence_refs then [] + else ["evidence.prior-stale"] end) + + (if $presented_body.qualification_ref? == $expected_qualification then [] + else ["evidence.qualification-mismatch"] end) + + (if ambiguous_entries($presented_evidence; + if type == "object" then [.evidence_id?,.kind?] else [null,null] end) or + ambiguous_entries($presented_prior; + if type == "object" then [.stage_result_ref.sha256?,.evidence_id?] + else [null,null] end) + then ["evidence.presentation-ambiguous"] else [] end) | + sort | unique) as $violations | +(if ($violations | length) == 0 then + {verdict:"satisfied",reasons:["evidence.integrity-satisfied"]} + else {verdict:"violated",reasons:$violations} end) as $evaluation | + +{ + schema_version:1, + kind:"evidence_integrity_evaluation", + id:$result_doc.id, + body:{ + activation_state:"inactive", + authority_effect:"none", + core_contract:$set.body.core_contract, + decision_ref:$decision_ref, + evaluation_mode:"observation-only", + evidence_refs:($result_doc.body.evidence | map({evidence_id,kind,proof_ref,verdict})), + policy_ref:$policy_ref, + policy_set:{id:$set.id,sha256:$policy_set_sha}, + presentation_ref:content_ref($presented.id; + "application/vnd.ystack.evidence-integrity-presentation+json";$presentation_sha), + prior_evidence_refs:$request_doc.body.prior_evidence_refs, + qualification_observation:$expected_qualification, + qualification_semantics:"identity-only-unqualified", + reason_ids:$evaluation.reasons, + reference_semantics:"identity-only", + stage:{request_ref:$expected_request_ref,resolved_profile_ref:$expected_resolved_ref, + result_ref:$expected_result_ref}, + storage_effect:"none", + verdict:$evaluation.verdict + } +} diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh new file mode 100755 index 0000000..0f9ad1e --- /dev/null +++ b/scripts/test/control-evidence-integrity.test.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail +export LC_ALL=C +umask 077 + +if [ "${YSTACK_EVIDENCE_TEST_BOUNDED:-0}" != 1 ]; then + YSTACK_EVIDENCE_TEST_BOUNDED=1 exec /usr/bin/perl -e 'alarm 240; exec @ARGV' "$0" +fi + +root=$(CDPATH='' cd -P -- "${BASH_SOURCE[0]%/*}/../.." && pwd -P) +evaluator="$root/control/v1/evaluate-evidence-integrity.sh" +policy="$root/control/v1/evidence-integrity-policy.json" +definition="$root/control/v1/evidence-integrity-decision.json" +program="$root/control/v1/evidence-integrity.jq" +core_wrapper="$root/scripts/core-contract.sh" +tmp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence-test.XXXXXX") +cleanup() { /bin/rm -rf -- "$tmp"; } +trap cleanup EXIT HUP INT TERM +fail() { /usr/bin/printf 'FAIL: %s\n' "$1" >&2; exit 1; } +passes=0 +pass() { passes=$((passes + 1)); /usr/bin/printf 'ok %s - %s\n' "$passes" "$1"; } +sha256_path() { /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}'; } + +platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) +case "$platform" in + Darwin:*) + jq_asset=jq-osx-amd64 + jq_sha=5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef + ;; + Linux:x86_64) + jq_asset=jq-linux64 + jq_sha=af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 + ;; + *) fail "unsupported host $platform" ;; +esac +jq_cache_dir="${TMPDIR:-/tmp}/ystack-portable-core-jq16" +/bin/mkdir -p "$jq_cache_dir" +jq_cache="$jq_cache_dir/$jq_asset" +if [ ! -f "$jq_cache" ] || [ "$(sha256_path "$jq_cache")" != "$jq_sha" ]; then + download=$(/usr/bin/mktemp "$jq_cache_dir/.jq-1.6.XXXXXX") + /usr/bin/curl --proto '=https' --tlsv1.2 -fsSL \ + "https://github.com/jqlang/jq/releases/download/jq-1.6/$jq_asset" -o "$download" + [ "$(sha256_path "$download")" = "$jq_sha" ] || fail 'jq release digest' + /bin/chmod 0555 "$download" + /bin/mv "$download" "$jq_cache" +fi +bin="$tmp/bin" +/bin/mkdir -m 700 "$bin" +/bin/cp "$jq_cache" "$bin/jq" +/bin/chmod 0555 "$bin/jq" +jq_bin="$bin/jq" +[ "$($jq_bin --version)" = jq-1.6 ] || fail 'jq identity' + +generation=$(/usr/bin/sed -n \ + "s/^PORTABLE_CORE_GENERATION='\(g-[0-9a-f]\{64\}\)'$/\1/p" "$core_wrapper") || + fail 'selected generation' +[[ "$generation" =~ ^g-[0-9a-f]{64}$ ]] || fail 'selected generation shape' +policy_sha=$(sha256_path "$policy") +definition_sha=$(sha256_path "$definition") +core_package_sha=$("$jq_bin" -er '.body.core_contract.package_ref.sha256' "$policy") +for canonical_source in "$policy" "$definition"; do + "$jq_bin" -S -c . "$canonical_source" >"$tmp/canonical" + /usr/bin/cmp -s "$canonical_source" "$tmp/canonical" || + fail "canonical ${canonical_source##*/}" +done +for source_path in control/v1/evidence-integrity-policy.json \ + control/v1/evidence-integrity-decision.json control/v1/evidence-integrity.jq \ + control/v1/evaluate-evidence-integrity.sh scripts/test/control-evidence-integrity.test.sh; do + ! /usr/bin/grep -Fq "$generation" "$root/$source_path" || + fail "raw generation $source_path" +done +pass 'canonical definitions and opaque core generation' + +policy_set="$tmp/policy-set.json" +"$jq_bin" -S -c -n --arg policy_sha "$policy_sha" \ + --arg definition_sha "$definition_sha" --arg generation "$generation" \ + --arg core_package_sha "$core_package_sha" ' + def ref($id;$media;$sha): {content_id:$id,media_type:$media,sha256:$sha}; + def section($id;$policy_sha;$decision_sha): + {section_id:$id, + policy_ref:ref("control-policy."+$id; + "application/vnd.ystack.control-policy+json";$policy_sha), + decision_ref:ref("control-decision."+$id; + "application/vnd.ystack.control-decision+json";$decision_sha)}; + {schema_version:1,kind:"control_policy_set",id:"control-policy-set.test", + body:{activation_state:"inactive",fail_mode:"closed",policy_version:"v1", + core_contract:{semantic_identity:"core.contracts.v2",generation_id:$generation, + package_ref:ref("core-contract-package.v2"; + "application/vnd.ystack.core-contract+json";$core_package_sha)}, + sections:[section("credential-policy";("1"*64);("a"*64)), + section("duty-separation";("2"*64);("b"*64)), + section("evidence-integrity";$policy_sha;$definition_sha), + section("kill-switch";("4"*64);("d"*64)), + section("risk-gates";("5"*64);("e"*64)), + section("sandbox";("6"*64);("f"*64))]}} +' >"$policy_set" + +resolved="$tmp/resolved.json" +"$jq_bin" -L "$root/scripts/test" -S -c -n ' + import "portable-core-profile-graph-fixtures" as profile; + def v2: walk(if type == "object" and has("schema_version") + then .schema_version=2 else . end); + def forge_binding($sha): + {binding_id:"binding.forge",role:"forge", + manifest_ref:{schema_version:2,kind:"adapter_manifest",id:"manifest.forge",sha256:$sha}, + execution_kind:"deterministic",adapter_instance_id:"instance.forge", + principal_id:"principal.forge",execution_boundary_id:"boundary.forge", + authority_ref:profile::scope("authority";"authority-forge";profile::sha("5")), + package_ref:profile::blob("packages/forge.bin";"6"),skill_refs:[],requested_tools:[], + requested_capabilities:["core.forge.materialize-candidate.v2"], + requested_permissions:["core.perm.candidate-repository.write.v2", + "core.perm.evidence.write.v1","core.perm.scratch.write.v1", + "core.perm.target.read.v1"]}; + {forge:("1"*64),producer:("2"*64),publisher:("3"*64), + reviewer:("4"*64),verifier:("5"*64)} as $shas | + (profile::profile_doc($shas) | v2 | .body.profile_version="v2" | + .body.bindings += [forge_binding($shas.forge)] | + .body.bindings |= sort_by(.binding_id)) as $profile | + profile::resolved_profile_doc($profile;("0"*64);$shas) | v2 | + .body.bindings |= map(if .binding.role == "forge" then + .adapter_implementation.version="v2" | + .manifest_source=profile::source_value( + profile::blob("manifests/forge.json";"a");"canonical-json";$shas.forge) + else . end) +' >"$resolved" +resolved_sha=$(sha256_path "$resolved") + +request="$tmp/request.json" +"$jq_bin" -L "$root/scripts/test" -S -c -n --arg resolved_sha "$resolved_sha" ' + import "portable-core-profile-graph-fixtures" as profile; + import "portable-core-stage-request-fixtures" as request; + request::request_doc("producer";$resolved_sha) | + walk(if type == "object" and has("schema_version") then .schema_version=2 else . end) | + .body.qualification_ref=profile::scope("qualification";"qualification.test";("7"*64)) | + .body.prior_evidence_refs=[{stage_result_ref:{schema_version:2,kind:"stage_result", + id:"result.previous",sha256:("8"*64)},evidence_id:"evidence.previous"}] +' >"$request" +request_sha=$(sha256_path "$request") +result="$tmp/result.json" +"$jq_bin" -L "$root/scripts/test" -S -c -n \ + --slurpfile request "$request" --slurpfile resolved "$resolved" \ + --arg request_sha "$request_sha" --arg resolved_sha "$resolved_sha" ' + import "portable-core-result-truth-fixtures" as result; + result::completed_result_doc($request[0];$request_sha;$resolved[0];$resolved_sha) | + walk(if type == "object" and has("schema_version") then .schema_version=2 else . end) +' >"$result" +result_sha=$(sha256_path "$result") +presentation="$tmp/presentation.json" +"$jq_bin" -S -c -n --arg request_sha "$request_sha" --arg resolved_sha "$resolved_sha" \ + --arg result_sha "$result_sha" --slurpfile request "$request" \ + --slurpfile resolved "$resolved" --slurpfile result "$result" ' + def doc($value;$sha): + {schema_version:$value.schema_version,kind:$value.kind,id:$value.id,sha256:$sha}; + {schema_version:1,kind:"evidence_integrity_presentation",id:"evidence.presentation.test", + body:{evidence:$result[0].body.evidence, + prior_evidence_refs:$request[0].body.prior_evidence_refs, + qualification_ref:{state:"present",value:$request[0].body.qualification_ref}, + request_ref:doc($request[0];$request_sha), + resolved_profile_ref:doc($resolved[0];$resolved_sha), + result_ref:doc($result[0];$result_sha)}} +' >"$presentation" + +run_eval() { + local name=$1 input=${2:-$presentation} runtime=${3:-$root} + local out="$tmp/$name.out" err="$tmp/$name.err" + PATH="$bin:/usr/bin:/bin" "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$policy_set" "$request" "$resolved" "$result" "$input" >"$out" 2>"$err" || + fail "$name status" + [ ! -s "$err" ] || fail "$name stderr" + "$jq_bin" -S -c . "$out" >"$tmp/$name.canonical" + /usr/bin/cmp -s "$out" "$tmp/$name.canonical" || fail "$name canonical" +} +expect_error() { + local name=$1 expected=$2 policy_input=${3:-$policy_set} request_input=${4:-$request} + local resolved_input=${5:-$resolved} result_input=${6:-$result} + local presentation_input=${7:-$presentation} runtime=${8:-$root} status=0 + PATH="$bin:/usr/bin:/bin" "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$policy_input" "$request_input" "$resolved_input" "$result_input" \ + "$presentation_input" >"$tmp/$name.out" 2>"$tmp/$name.err" || status=$? + [ "$status" -ne 0 ] && [ ! -s "$tmp/$name.out" ] && + [ "$(/bin/cat "$tmp/$name.err")" = "$expected" ] || fail "$name error" + pass "$name" +} +pure_eval() { + local input=$1 output=$2 + "$jq_bin" -S -c -n -f "$program" --slurpfile policy "$policy" \ + --slurpfile decision "$definition" --slurpfile policy_set "$policy_set" \ + --slurpfile request "$request" --slurpfile resolved "$resolved" \ + --slurpfile result "$result" --slurpfile presentation "$input" \ + --arg policy_sha "$policy_sha" --arg decision_sha "$definition_sha" \ + --arg policy_set_sha "$(sha256_path "$policy_set")" --arg request_sha "$request_sha" \ + --arg resolved_sha "$resolved_sha" --arg result_sha "$result_sha" \ + --arg presentation_sha "$(sha256_path "$input")" >"$output" +} +expect_pure_violation() { + local name=$1 filter=$2 reason=$3 + local input="$tmp/$name.presentation" output="$tmp/$name.pure" + "$jq_bin" -S -c "$filter" "$presentation" >"$input" + pure_eval "$input" "$output" + "$jq_bin" -e --arg reason "$reason" ' + .body.verdict=="violated" and (.body.reason_ids|index($reason)!=null) and + .body.authority_effect=="none" and .body.storage_effect=="none" and + ((.body|has("grant_ref") or has("qualification_ref") or has("activation"))|not) + ' "$output" >/dev/null || fail "$name" + pass "$name" +} + +run_eval valid +"$jq_bin" -e '.body.verdict=="satisfied" and + .body.reason_ids==["evidence.integrity-satisfied"] and + .body.qualification_semantics=="identity-only-unqualified" and + (.body.evidence_refs|length)==1' "$tmp/valid.out" >/dev/null || fail 'valid output' +pass 'valid exact immutable evidence presentation' + +expect_pure_violation result-moved '.body.result_ref.sha256=("0"*64)' \ + evidence.result-moved +expect_pure_violation request-moved '.body.request_ref.sha256=("0"*64)' \ + evidence.request-moved +expect_pure_violation resolved-moved '.body.resolved_profile_ref.sha256=("0"*64)' \ + evidence.resolved-profile-moved +expect_pure_violation current-mismatch '.body.evidence[0].proof_ref.sha256=("0"*64)' \ + evidence.current-mismatch +expect_pure_violation prior-stale '.body.prior_evidence_refs[0].stage_result_ref.sha256=("0"*64)' \ + evidence.prior-stale +expect_pure_violation qualification-mismatch '.body.qualification_ref={state:"absent"}' \ + evidence.qualification-mismatch +expect_pure_violation malformed-presentation '.body.evidence=1' \ + evidence.presentation-malformed +expect_pure_violation ambiguous-presentation '.body.evidence += [.body.evidence[0]]' \ + evidence.presentation-ambiguous + +malformed="$tmp/malformed.full" +"$jq_bin" -S -c '.body.evidence=1' "$presentation" >"$malformed" +run_eval malformed-full "$malformed" +"$jq_bin" -e '.body.verdict=="violated" and + (.body.reason_ids|index("evidence.presentation-malformed")!=null)' \ + "$tmp/malformed-full.out" >/dev/null || fail 'malformed full output' +pass 'malformed presentation returns canonical fail-closed observation' + +bad_result="$tmp/result.bad" +"$jq_bin" -S -c '.body.evidence[0].proof_ref.sha256="bad"' "$result" >"$bad_result" +expect_error malformed-core E_CORE "$policy_set" "$request" "$resolved" "$bad_result" + +bad_policy_set="$tmp/policy-set.bad" +"$jq_bin" -S -c '.body.sections[] |= + if .section_id=="evidence-integrity" then .decision_ref.sha256=("9"*64) else . end' \ + "$policy_set" >"$bad_policy_set" +expect_error policy-binding E_RELATION "$bad_policy_set" + +link="$tmp/presentation-link.json" +/bin/ln -s "$presentation" "$link" +expect_error symlink-input E_RUNTIME "$policy_set" "$request" "$resolved" "$result" "$link" + +relative_bin="$tmp/relative-bin" +/bin/mkdir "$relative_bin" +/bin/cp "$jq_bin" "$relative_bin/jq" +status=0 +(cd "$relative_bin" && PATH=".:/usr/bin:/bin" "$evaluator" evaluate "$policy_set" \ + "$request" "$resolved" "$result" "$presentation") >"$tmp/relative.out" \ + 2>"$tmp/relative.err" || status=$? +[ "$status" -ne 0 ] && [ ! -s "$tmp/relative.out" ] && + [ "$(/bin/cat "$tmp/relative.err")" = E_RUNTIME ] || fail 'relative jq rejection' +pass 'relative jq interpreter rejected' + +copy_runtime() { + local destination=$1 path + /bin/mkdir -p "$destination/control/v1" "$destination/scripts" "$destination/core" + for path in evidence-integrity-policy.json evidence-integrity-decision.json \ + evidence-integrity.jq evaluate-evidence-integrity.sh validate.sh policy-set.jq; do + /bin/cp "$root/control/v1/$path" "$destination/control/v1/$path" + done + /bin/cp "$root/scripts/core-contract.sh" "$destination/scripts/core-contract.sh" + /bin/cp -R "$root/core/v2" "$destination/core/v2" +} +wait_marker() { + local marker=$1 pid=$2 attempt=0 + while [ ! -e "$marker" ] && kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 400 ]; do + attempt=$((attempt + 1)) + /bin/sleep 0.01 + done + [ -e "$marker" ] +} +wait_exit() { + local pid=$1 attempt=0 + while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 500 ]; do + attempt=$((attempt + 1)) + /bin/sleep 0.01 + done + ! kill -0 "$pid" 2>/dev/null +} +make_delaying_jq() { + local destination=$1 marker=$2 delay=$3 + /usr/bin/printf '%s\n' '#!/bin/bash' "real_jq='$jq_bin'" "marker='$marker'" \ + "delay='$delay'" \ + 'if [ "${1:-}" = --version ]; then exec "$real_jq" "$@"; fi' \ + 'for arg in "$@"; do' \ + ' case "$arg" in' \ + ' */program.jq) if [ ! -e "$marker" ]; then : >"$marker"; /bin/sleep "$delay"; fi ;;' \ + ' esac' \ + 'done' \ + 'exec "$real_jq" "$@"' >"$destination" + /bin/chmod 0555 "$destination" +} + +race_runtime="$tmp/race-runtime" +copy_runtime "$race_runtime" +race_bin="$tmp/race-bin" +race_marker="$tmp/race-marker" +/bin/mkdir "$race_bin" +make_delaying_jq "$race_bin/jq" "$race_marker" 1 +( + race_status=0 + PATH="$race_bin:/usr/bin:/bin" \ + "$race_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate "$policy_set" \ + "$request" "$resolved" "$result" "$presentation" >"$tmp/race.out" \ + 2>"$tmp/race.err" || race_status=$? + /usr/bin/printf '%s\n' "$race_status" >"$tmp/race.status" +) & +race_pid=$! +wait_marker "$race_marker" "$race_pid" || { + kill -TERM "$race_pid" 2>/dev/null || : + wait "$race_pid" 2>/dev/null || : + fail 'TOCTOU marker' +} +/usr/bin/printf '\n' >>"$race_runtime/control/v1/evidence-integrity.jq" +wait "$race_pid" +[ "$(/bin/cat "$tmp/race.status")" -ne 0 ] && [ ! -s "$tmp/race.out" ] && + [ "$(/bin/cat "$tmp/race.err")" = E_RELATION ] || fail 'TOCTOU closure' +pass 'program mutation closes after snapshot execution' + +signal_runtime="$tmp/signal-runtime" +copy_runtime "$signal_runtime" +signal_bin="$tmp/signal-bin" +signal_scratch="$tmp/signal-scratch" +signal_marker="$tmp/signal-marker" +/bin/mkdir "$signal_bin" "$signal_scratch" +make_delaying_jq "$signal_bin/jq" "$signal_marker" 2 +TMPDIR="$signal_scratch" PATH="$signal_bin:/usr/bin:/bin" \ + "$signal_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate "$policy_set" \ + "$request" "$resolved" "$result" "$presentation" >"$tmp/signal.out" \ + 2>"$tmp/signal.err" & +signal_pid=$! +wait_marker "$signal_marker" "$signal_pid" || { + kill -TERM "$signal_pid" 2>/dev/null || : + wait "$signal_pid" 2>/dev/null || : + fail 'signal marker' +} +kill -TERM "$signal_pid" +wait_exit "$signal_pid" || { + kill -KILL "$signal_pid" 2>/dev/null || : + wait "$signal_pid" 2>/dev/null || : + fail 'signal bounded exit' +} +signal_status=0 +wait "$signal_pid" || signal_status=$? +[ "$signal_status" -ne 0 ] && [ ! -s "$tmp/signal.out" ] && + [ -z "$(/usr/bin/find "$signal_scratch" -mindepth 1 -print -quit)" ] || + fail 'signal cleanup' +pass 'signal lifecycle is bounded and removes private scratch' + +/usr/bin/printf 'control evidence integrity: %s passed\n' "$passes" From 1872842d707d2f25bf318705b053ba6caf55b605 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 17:44:29 -0400 Subject: [PATCH 02/16] Add inactive evidence integrity evaluator --- README.md | 15 ++ RESTORE.md | 13 ++ ci/required-files.txt | 7 + control/v1/evidence-integrity-decision.json | 2 +- control/v1/evidence-integrity.jq | 44 ++++-- .../test/control-evidence-integrity.test.sh | 130 +++++++++++++++++- 6 files changed, 199 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7f23c47..d04e78d 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,21 @@ credential-like environment value, grants no authority or qualification, activat no profile, and performs no candidate, adapter, network, publish, deploy, or external-write action. +## Inactive evidence-integrity evaluator + +`control/v1/evaluate-evidence-integrity.sh` compares caller-supplied evidence +references with one exact public-core stage tuple. It binds the policy set, +policy, decision, evaluator, and selected core package before returning a +canonical `satisfied` or `violated` identity observation. Evidence and prior +references must keep their canonical order and unique logical identities; one +prior result digest cannot describe multiple result documents. + +The evaluator never reads proof bytes. Matching references do not prove a claim +or qualify a workflow, and equal proof digests may belong to different logical +references. The package stays inactive, stores nothing, grants no authority, and +performs no candidate, credential, network, adapter, publish, deploy, or external +write. + ## The current default team You talk **only** to yshifu, in a Claude Code session. yshifu orchestrates the other roles diff --git a/RESTORE.md b/RESTORE.md index cfb1a91..3ddf9d6 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -394,6 +394,19 @@ does not read credential material or credential-like environment values, qualify claim, grant authority, activate a profile, run a candidate or adapter, or perform a network or external-write action. +Restore the five paths in the manifest's inactive evidence-integrity block, then +run: + +```sh +bash scripts/test/control-evidence-integrity.test.sh +``` + +This checks exact policy-set and public-core closure, stage and qualification +identity binding, canonical evidence/prior sets, stale or aliased references, and +deterministic observation output. It does not read proof bytes, establish proof +truth, qualify a workflow, store evidence, grant authority, activate a profile, +run a candidate or adapter, or perform a network or external-write action. + --- ## 5. Smoke test — prove the rebuilt team is alive diff --git a/ci/required-files.txt b/ci/required-files.txt index 24cc45c..c7df484 100644 --- a/ci/required-files.txt +++ b/ci/required-files.txt @@ -206,3 +206,10 @@ control/v1/credential-policy-decision.json control/v1/credential-policy.jq control/v1/evaluate-credential-policy.sh scripts/test/control-credential-policy.test.sh + +# Inactive evidence integrity policy and evaluator +control/v1/evidence-integrity-policy.json +control/v1/evidence-integrity-decision.json +control/v1/evidence-integrity.jq +control/v1/evaluate-evidence-integrity.sh +scripts/test/control-evidence-integrity.test.sh diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index 5985f0a..e463946 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"45862a20bb4f46f816ad74f0c2eb26a5a27d6af739c6aafffcb49ca98cdf8194"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"71b470e4f38132936e34a7e8b92f03c7438b534d40f924a55708b3a387dcc5bd"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"45862a20bb4f46f816ad74f0c2eb26a5a27d6af739c6aafffcb49ca98cdf8194"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"d8e67f60b2328ac18cb5cbe8022f600e15d718a461e6a48a99088f6ced7e1212"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/control/v1/evidence-integrity.jq b/control/v1/evidence-integrity.jq index 5e91942..c2c0ef2 100644 --- a/control/v1/evidence-integrity.jq +++ b/control/v1/evidence-integrity.jq @@ -64,7 +64,7 @@ def policy_ok: proof_binding:"content-ref-sha256", qualification_binding:"exact-scope-ref-or-absent"}); -def presentation_shape_ok: +def presentation_structure_ok: exact(["body","id","kind","schema_version"]) and .schema_version == 1 and .kind == "evidence_integrity_presentation" and (.id | id_ok) and (.body | @@ -78,6 +78,19 @@ def presentation_shape_ok: type == "array" and length <= 256 and all(.[];evidence_ref_ok)) and (.qualification_ref | presence_ok(scope_ref_ok("qualification")))); +def canonical_presentation_sets: + (.body.evidence == (.body.evidence | sort_by(.evidence_id))) and + ((.body.evidence | map(.evidence_id) | length) == + (.body.evidence | map(.evidence_id) | unique | length)) and + ((.body.evidence | map(.kind) | length) == + (.body.evidence | map(.kind) | unique | length)) and + (.body.prior_evidence_refs == + (.body.prior_evidence_refs | sort_by([.stage_result_ref.sha256,.evidence_id]))) and + ((.body.prior_evidence_refs | + map([.stage_result_ref.sha256,.evidence_id]) | length) == + (.body.prior_evidence_refs | + map([.stage_result_ref.sha256,.evidence_id]) | unique | length)); + def document_ref($document;$digest): {schema_version:$document.schema_version,kind:$document.kind, id:$document.id,sha256:$digest}; @@ -88,11 +101,24 @@ def content_ref($id;$media;$digest): def normalized_array($value): if ($value | type) == "array" then $value else [] end; -def ambiguous_entries($entries;$key_filter): +def duplicate_scalar_key($entries;$field): + ($entries | normalized_array(.)) as $items | + ($items | map(.[$field])) as $keys | + ($keys | length) != ($keys | unique | length); + +def duplicate_prior_key($entries): ($entries | normalized_array(.)) as $items | - ($items | map($key_filter)) as $keys | + ($items | map([.stage_result_ref.sha256,.evidence_id])) as $keys | ($keys | length) != ($keys | unique | length); +def prior_document_alias($entries): + ($entries | normalized_array(.)) as $items | + if all($items[];evidence_ref_ok) then + any($items | group_by(.stage_result_ref.sha256)[]; + ([.[] | [.stage_result_ref.schema_version,.stage_result_ref.kind, + .stage_result_ref.id]] | unique | length) > 1) + else false end; + ($policy[0]) as $p | ($decision[0]) as $definition | ($policy_set[0]) as $set | @@ -124,7 +150,8 @@ content_ref($definition.id;"application/vnd.ystack.control-decision+json"; $evidence_sections[0].decision_ref == $decision_ref then true else error("invalid policy-set evidence binding") end) | -((if ($presented | presentation_shape_ok) then [] +((if (($presented | presentation_structure_ok) and + ($presented | canonical_presentation_sets)) then [] else ["evidence.presentation-malformed"] end) + (if $presented_body.request_ref? == $expected_request_ref then [] else ["evidence.request-moved"] end) + @@ -138,11 +165,10 @@ content_ref($definition.id;"application/vnd.ystack.control-decision+json"; else ["evidence.prior-stale"] end) + (if $presented_body.qualification_ref? == $expected_qualification then [] else ["evidence.qualification-mismatch"] end) + - (if ambiguous_entries($presented_evidence; - if type == "object" then [.evidence_id?,.kind?] else [null,null] end) or - ambiguous_entries($presented_prior; - if type == "object" then [.stage_result_ref.sha256?,.evidence_id?] - else [null,null] end) + (if duplicate_scalar_key($presented_evidence;"evidence_id") or + duplicate_scalar_key($presented_evidence;"kind") or + duplicate_prior_key($presented_prior) or + prior_document_alias($presented_prior) then ["evidence.presentation-ambiguous"] else [] end) | sort | unique) as $violations | (if ($violations | length) == 0 then diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 0f9ad1e..cbbd686 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -193,13 +193,29 @@ pure_eval() { --arg resolved_sha "$resolved_sha" --arg result_sha "$result_sha" \ --arg presentation_sha "$(sha256_path "$input")" >"$output" } +pure_eval_tuple() { + local request_input=$1 result_input=$2 presentation_input=$3 output=$4 + local request_digest result_digest + request_digest=$(sha256_path "$request_input") + result_digest=$(sha256_path "$result_input") + "$jq_bin" -S -c -n -f "$program" --slurpfile policy "$policy" \ + --slurpfile decision "$definition" --slurpfile policy_set "$policy_set" \ + --slurpfile request "$request_input" --slurpfile resolved "$resolved" \ + --slurpfile result "$result_input" --slurpfile presentation "$presentation_input" \ + --arg policy_sha "$policy_sha" --arg decision_sha "$definition_sha" \ + --arg policy_set_sha "$(sha256_path "$policy_set")" \ + --arg request_sha "$request_digest" --arg resolved_sha "$resolved_sha" \ + --arg result_sha "$result_digest" \ + --arg presentation_sha "$(sha256_path "$presentation_input")" >"$output" +} expect_pure_violation() { - local name=$1 filter=$2 reason=$3 + local name=$1 filter=$2 reason=$3 second_reason=${4:-} local input="$tmp/$name.presentation" output="$tmp/$name.pure" "$jq_bin" -S -c "$filter" "$presentation" >"$input" pure_eval "$input" "$output" - "$jq_bin" -e --arg reason "$reason" ' + "$jq_bin" -e --arg reason "$reason" --arg second "$second_reason" ' .body.verdict=="violated" and (.body.reason_ids|index($reason)!=null) and + ($second=="" or (.body.reason_ids|index($second)!=null)) and .body.authority_effect=="none" and .body.storage_effect=="none" and ((.body|has("grant_ref") or has("qualification_ref") or has("activation"))|not) ' "$output" >/dev/null || fail "$name" @@ -230,6 +246,103 @@ expect_pure_violation malformed-presentation '.body.evidence=1' \ expect_pure_violation ambiguous-presentation '.body.evidence += [.body.evidence[0]]' \ evidence.presentation-ambiguous +expect_pure_violation duplicate-evidence-id \ + '.body.evidence += [(.body.evidence[0] | .kind="runtime-alt")]' \ + evidence.presentation-ambiguous evidence.presentation-malformed +expect_pure_violation duplicate-evidence-kind \ + '.body.evidence += [(.body.evidence[0] | .evidence_id="evidence.zzz")]' \ + evidence.presentation-ambiguous evidence.presentation-malformed +expect_pure_violation reversed-evidence \ + '.body.evidence += [(.body.evidence[0] | .evidence_id="evidence.zzz" | + .kind="runtime-alt")] | .body.evidence |= reverse' \ + evidence.presentation-malformed +expect_pure_violation duplicate-prior-key \ + '.body.prior_evidence_refs += [.body.prior_evidence_refs[0]]' \ + evidence.presentation-ambiguous evidence.presentation-malformed +expect_pure_violation reversed-prior \ + '.body.prior_evidence_refs += [(.body.prior_evidence_refs[0] | + .stage_result_ref.sha256=("9"*64) | .stage_result_ref.id="result.zzz" | + .evidence_id="evidence.zzz")] | .body.prior_evidence_refs |= reverse' \ + evidence.presentation-malformed +expect_pure_violation prior-document-alias \ + '.body.prior_evidence_refs += [(.body.prior_evidence_refs[0] | + .stage_result_ref.id="result.alias" | .evidence_id="evidence.alias")] | + .body.prior_evidence_refs |= sort_by([.stage_result_ref.sha256,.evidence_id])' \ + evidence.presentation-ambiguous + +shared_result="$tmp/shared-proof.result" +shared_presentation="$tmp/shared-proof.presentation" +shared_output="$tmp/shared-proof.out" +"$jq_bin" -S -c ' + .body.evidence += [(.body.evidence[0] | + .evidence_id="evidence.zzz" | .kind="runtime-alt" | + .proof_ref.content_id="proof.logical-alt" | + .proof_ref.media_type="application/vnd.ystack.alt-proof+json")] | + .body.evidence |= sort_by(.evidence_id) +' "$result" >"$shared_result" +shared_result_sha=$(sha256_path "$shared_result") +"$jq_bin" -S -c --slurpfile result "$shared_result" \ + --arg result_sha "$shared_result_sha" ' + .body.evidence=$result[0].body.evidence | + .body.result_ref={schema_version:$result[0].schema_version,kind:$result[0].kind, + id:$result[0].id,sha256:$result_sha} +' "$presentation" >"$shared_presentation" +pure_eval_tuple "$request" "$shared_result" "$shared_presentation" "$shared_output" +"$jq_bin" -e ' + .body.verdict=="satisfied" and + .body.reason_ids==["evidence.integrity-satisfied"] and + (.body.evidence_refs|length)==2 and + (.body.evidence_refs[0].proof_ref.sha256==.body.evidence_refs[1].proof_ref.sha256) and + (.body.evidence_refs[0].proof_ref.content_id!= + .body.evidence_refs[1].proof_ref.content_id) +' "$shared_output" >/dev/null || { + /bin/cat "$shared_output" >&2 + fail 'same proof bytes distinct logical refs' +} +pass 'same proof digest under distinct logical refs remains valid' + +shared_mismatch="$tmp/shared-proof-mismatch.presentation" +"$jq_bin" -S -c \ + '.body.evidence[0].proof_ref.content_id="proof.presentation-only"' \ + "$shared_presentation" >"$shared_mismatch" +pure_eval_tuple "$request" "$shared_result" "$shared_mismatch" "$shared_output" +"$jq_bin" -e ' + .body.verdict=="violated" and + (.body.reason_ids|index("evidence.current-mismatch")!=null) +' "$shared_output" >/dev/null || fail 'logical ref identity mismatch' +pass 'changed logical proof identity with retained digest fails closed' + +absent_request="$tmp/qualification-absent.request" +absent_result="$tmp/qualification-absent.result" +absent_presentation="$tmp/qualification-absent.presentation" +absent_output="$tmp/qualification-absent.out" +"$jq_bin" -S -c 'del(.body.qualification_ref)' "$request" >"$absent_request" +absent_request_sha=$(sha256_path "$absent_request") +"$jq_bin" -S -c --arg request_sha "$absent_request_sha" \ + '.body.request_ref.sha256=$request_sha | + .body.evidence[0].verdict="inconclusive"' "$result" >"$absent_result" +absent_result_sha=$(sha256_path "$absent_result") +"$jq_bin" -S -c --slurpfile request "$absent_request" \ + --slurpfile result "$absent_result" --arg request_sha "$absent_request_sha" \ + --arg result_sha "$absent_result_sha" ' + .body.qualification_ref={state:"absent"} | + .body.request_ref={schema_version:$request[0].schema_version,kind:$request[0].kind, + id:$request[0].id,sha256:$request_sha} | + .body.result_ref={schema_version:$result[0].schema_version,kind:$result[0].kind, + id:$result[0].id,sha256:$result_sha} | + .body.evidence=$result[0].body.evidence +' "$presentation" >"$absent_presentation" +pure_eval_tuple "$absent_request" "$absent_result" "$absent_presentation" \ + "$absent_output" +"$jq_bin" -e ' + .body.verdict=="satisfied" and + .body.qualification_observation=={state:"absent"} and + .body.qualification_semantics=="identity-only-unqualified" and + .body.authority_effect=="none" and .body.storage_effect=="none" and + (.body.evidence_refs[0].verdict=="inconclusive") +' "$absent_output" >/dev/null || fail 'absent qualification identity-only' +pass 'absent qualification and inconclusive proof remain identity-only' + malformed="$tmp/malformed.full" "$jq_bin" -S -c '.body.evidence=1' "$presentation" >"$malformed" run_eval malformed-full "$malformed" @@ -359,4 +472,17 @@ wait "$signal_pid" || signal_status=$? fail 'signal cleanup' pass 'signal lifecycle is bounded and removes private scratch' +for required in control/v1/evidence-integrity-policy.json \ + control/v1/evidence-integrity-decision.json control/v1/evidence-integrity.jq \ + control/v1/evaluate-evidence-integrity.sh \ + scripts/test/control-evidence-integrity.test.sh; do + [ "$(/usr/bin/grep -Fxc "$required" "$root/ci/required-files.txt")" -eq 1 ] || + fail "manifest $required" +done +/usr/bin/grep -Fq 'Inactive evidence-integrity evaluator' "$root/README.md" || + fail 'README docs' +/usr/bin/grep -Fq 'control-evidence-integrity.test.sh' "$root/RESTORE.md" || + fail 'RESTORE docs' +pass 'restore manifest and docs' + /usr/bin/printf 'control evidence integrity: %s passed\n' "$passes" From 32cffd9c70c7cae8b680e826662752ab335957f5 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 17:51:40 -0400 Subject: [PATCH 03/16] Surface evidence evaluator failures --- scripts/test/control-evidence-integrity.test.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index cbbd686..34e205f 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -162,11 +162,16 @@ presentation="$tmp/presentation.json" ' >"$presentation" run_eval() { - local name=$1 input=${2:-$presentation} runtime=${3:-$root} + local name=$1 input=${2:-$presentation} runtime=${3:-$root} run_status=0 local out="$tmp/$name.out" err="$tmp/$name.err" PATH="$bin:/usr/bin:/bin" "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ "$policy_set" "$request" "$resolved" "$result" "$input" >"$out" 2>"$err" || + run_status=$? + if [ "$run_status" -ne 0 ]; then + /usr/bin/printf 'diagnostic %s status=%s stderr=' "$name" "$run_status" >&2 + /bin/cat "$err" >&2 fail "$name status" + fi [ ! -s "$err" ] || fail "$name stderr" "$jq_bin" -S -c . "$out" >"$tmp/$name.canonical" /usr/bin/cmp -s "$out" "$tmp/$name.canonical" || fail "$name canonical" From 1b9032fdda40cc1007df78662d7cb0ef047762c7 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 17:58:56 -0400 Subject: [PATCH 04/16] Trace Linux evidence relation failures --- scripts/test/control-evidence-integrity.test.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 34e205f..c989e9a 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -170,6 +170,11 @@ run_eval() { if [ "$run_status" -ne 0 ]; then /usr/bin/printf 'diagnostic %s status=%s stderr=' "$name" "$run_status" >&2 /bin/cat "$err" >&2 + PS4='+${LINENO}: ' PATH="$bin:/usr/bin:/bin" /bin/bash -x \ + "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$policy_set" "$request" "$resolved" "$result" "$input" \ + >"$tmp/$name.trace.out" 2>"$tmp/$name.trace.err" || : + /usr/bin/tail -120 "$tmp/$name.trace.err" >&2 fail "$name status" fi [ ! -s "$err" ] || fail "$name stderr" From a5a7c69926fc19eaf149721ed8fcb1f9576f2dc8 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 18:04:54 -0400 Subject: [PATCH 05/16] Execute evidence decision validation --- control/v1/evaluate-evidence-integrity.sh | 2 +- control/v1/evidence-integrity-decision.json | 2 +- .../test/control-evidence-integrity.test.sh | 47 +++++++++++++++++-- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index 7e6e272..befe244 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -229,7 +229,7 @@ policy_sha=$(sha256_path "$scratch/policy.json") || emit_error E_RUNTIME decision_sha=$(sha256_path "$scratch/decision.json") || emit_error E_RUNTIME validator_driver_sha=$(sha256_path "$policy_validator") || emit_error E_RUNTIME validator_program_sha=$(sha256_path "$validator_program") || emit_error E_RUNTIME -"$jq_bin" -e --arg policy_sha "$policy_sha" --arg driver_sha "$driver_sha" \ +"$jq_bin" -n -e --arg policy_sha "$policy_sha" --arg driver_sha "$driver_sha" \ --arg program_sha "$program_sha" --arg validator_driver_sha "$validator_driver_sha" \ --arg validator_program_sha "$validator_program_sha" \ --slurpfile policy "$scratch/policy.json" \ diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index e463946..0ce5286 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"45862a20bb4f46f816ad74f0c2eb26a5a27d6af739c6aafffcb49ca98cdf8194"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"d8e67f60b2328ac18cb5cbe8022f600e15d718a461e6a48a99088f6ced7e1212"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"48f10dbb16e19bef6d7cb74f93a810612573e70b2ff5de52b9ff496b0fe16d16"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"d8e67f60b2328ac18cb5cbe8022f600e15d718a461e6a48a99088f6ced7e1212"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index c989e9a..ac60f53 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -170,11 +170,6 @@ run_eval() { if [ "$run_status" -ne 0 ]; then /usr/bin/printf 'diagnostic %s status=%s stderr=' "$name" "$run_status" >&2 /bin/cat "$err" >&2 - PS4='+${LINENO}: ' PATH="$bin:/usr/bin:/bin" /bin/bash -x \ - "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ - "$policy_set" "$request" "$resolved" "$result" "$input" \ - >"$tmp/$name.trace.out" 2>"$tmp/$name.trace.err" || : - /usr/bin/tail -120 "$tmp/$name.trace.err" >&2 fail "$name status" fi [ ! -s "$err" ] || fail "$name stderr" @@ -386,6 +381,30 @@ status=0 [ "$(/bin/cat "$tmp/relative.err")" = E_RUNTIME ] || fail 'relative jq rejection' pass 'relative jq interpreter rejected' +/usr/bin/grep -Fq \ + '"$jq_bin" -n -e --arg policy_sha "$policy_sha" --arg driver_sha "$driver_sha"' \ + "$evaluator" || fail 'decision envelope null-input mode' +strict_bin="$tmp/strict-bin" +/bin/mkdir "$strict_bin" +/usr/bin/printf '%s\n' '#!/bin/bash' "real_jq='$jq_bin'" \ + 'if [ "${1:-}" = --version ]; then exec "$real_jq" "$@"; fi' \ + 'saw_definition=0; saw_null_input=0' \ + 'for arg in "$@"; do' \ + ' [ "$arg" = definition ] && saw_definition=1' \ + ' [ "$arg" = -n ] && saw_null_input=1' \ + 'done' \ + '[ "$saw_definition" -eq 0 ] || [ "$saw_null_input" -eq 1 ] || exit 97' \ + 'exec "$real_jq" "$@"' >"$strict_bin/jq" +/bin/chmod 0555 "$strict_bin/jq" +PATH="$strict_bin:/usr/bin:/bin" "$evaluator" evaluate "$policy_set" \ + "$request" "$resolved" "$result" "$presentation" >"$tmp/strict.out" \ + 2>"$tmp/strict.err" || fail 'strict decision evaluator status' +if [ -s "$tmp/strict.err" ] || + ! /usr/bin/cmp -s "$tmp/valid.out" "$tmp/strict.out"; then + fail 'strict decision evaluator output' +fi +pass 'decision envelope executes explicitly on null input' + copy_runtime() { local destination=$1 path /bin/mkdir -p "$destination/control/v1" "$destination/scripts" "$destination/core" @@ -396,6 +415,24 @@ copy_runtime() { /bin/cp "$root/scripts/core-contract.sh" "$destination/scripts/core-contract.sh" /bin/cp -R "$root/core/v2" "$destination/core/v2" } + +mutated_decision_runtime="$tmp/mutated-decision-runtime" +copy_runtime "$mutated_decision_runtime" +"$jq_bin" -S -c '.body.semantics.authority_effect="unexpected"' \ + "$mutated_decision_runtime/control/v1/evidence-integrity-decision.json" \ + >"$mutated_decision_runtime/decision.next" +/bin/mv "$mutated_decision_runtime/decision.next" \ + "$mutated_decision_runtime/control/v1/evidence-integrity-decision.json" +mutated_decision_sha=$(sha256_path \ + "$mutated_decision_runtime/control/v1/evidence-integrity-decision.json") +mutated_decision_set="$tmp/mutated-decision-policy-set.json" +"$jq_bin" -S -c --arg digest "$mutated_decision_sha" ' + .body.sections[] |= if .section_id=="evidence-integrity" + then .decision_ref.sha256=$digest else . end +' "$policy_set" >"$mutated_decision_set" +expect_error mutated-decision E_RELATION "$mutated_decision_set" "$request" \ + "$resolved" "$result" "$presentation" "$mutated_decision_runtime" + wait_marker() { local marker=$1 pid=$2 attempt=0 while [ ! -e "$marker" ] && kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 400 ]; do From 851fd0981de0d8bb09066c6402666f65071bfa23 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 19:24:35 -0400 Subject: [PATCH 06/16] Fix evidence evaluator lifecycle --- control/v1/evaluate-evidence-integrity.sh | 520 ++++++++++++++++-- control/v1/evidence-integrity-decision.json | 2 +- control/v1/evidence-integrity.jq | 13 +- .../test/control-evidence-integrity.test.sh | 321 ++++++++--- 4 files changed, 715 insertions(+), 141 deletions(-) diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index befe244..e8eaab7 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -6,7 +6,7 @@ umask 077 emit_error() { case "${1:-}" in - E_USAGE|E_RUNTIME|E_LIMIT|E_RELATION|E_POLICY_SET|E_CORE) + E_USAGE|E_RUNTIME|E_LIMIT|E_PARSE|E_CANONICAL|E_RELATION|E_POLICY_SET|E_CORE) /usr/bin/printf '%s\n' "$1" >&2 ;; *) /usr/bin/printf '%s\n' E_RUNTIME >&2 ;; @@ -14,15 +14,410 @@ emit_error() { exit 1 } +physical_regular() { + local candidate=$1 parent physical + case "$candidate" in /*) ;; *) return 1 ;; esac + [ -f "$candidate" ] && [ ! -L "$candidate" ] || return 1 + parent=${candidate%/*} + [ -n "$parent" ] || parent=/ + physical=$(CDPATH='' cd -P -- "$parent" 2>/dev/null && pwd -P) || return 1 + [ "$candidate" = "$physical/${candidate##*/}" ] +} + +path_identity() { + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' + use strict; use warnings; + my ($path,$limit)=@ARGV; + my ($parent,$name)=$path =~ m{\A(.+)/([^/]+)\z}; + exit 2 unless defined($parent) && defined($name); + my $physical=abs_path($parent); + exit 2 unless defined($physical) && $physical eq $parent; + my @parent=lstat($parent); + exit 2 unless @parent && S_ISDIR($parent[2]); + chdir($parent) or exit 2; + my @cwd=stat("."); my @leaf=lstat($name); + exit 2 unless @cwd && @leaf && S_ISREG($leaf[2]); + sysopen(my $input,$name,O_RDONLY|O_NOFOLLOW) or exit 2; + binmode($input); my @opened=stat($input); + exit 2 unless @opened && S_ISREG($opened[2]) && + $leaf[0]==$opened[0] && $leaf[1]==$opened[1] && + $leaf[7]==$opened[7] && $leaf[9]==$opened[9] && $leaf[10]==$opened[10]; + my $sha=Digest::SHA->new(256); my $total=0; + while (1) { + my $read=sysread($input,my $buffer,65536); + exit 2 unless defined $read; last if $read==0; + $total += $read; exit 3 if $total > $limit; $sha->add($buffer); + } + my @after=stat($input); my @path_after=lstat($name); + my @parent_after=lstat($parent); my $after_physical=abs_path($parent); + exit 2 unless @after && @path_after && @parent_after && + defined($after_physical) && $after_physical eq $parent && + S_ISREG($path_after[2]) && S_ISDIR($parent_after[2]) && + $opened[0]==$after[0] && $opened[1]==$after[1] && + $opened[7]==$after[7] && $opened[9]==$after[9] && + $opened[10]==$after[10] && + $after[0]==$path_after[0] && $after[1]==$path_after[1] && + $cwd[0]==$parent_after[0] && $cwd[1]==$parent_after[1]; + print $parent[0],":",$parent[1],":",$leaf[0],":",$leaf[1],":", + $leaf[7],":",$leaf[9],":",$leaf[10],":",$sha->hexdigest,"\n"; + ' "$1" "$2" +} + +path_matches_identity() { + local actual + actual=$(path_identity "$1" "$3") || return 1 + [ "$actual" = "$2" ] +} + +snapshot_nofollow() { + local source=$1 expected=$2 target=$3 limit=$4 copy_status=0 + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' + use strict; use warnings; + my ($source,$expected,$target,$limit)=@ARGV; + my ($p_dev,$p_ino,$f_dev,$f_ino,$size,$mtime,$ctime,$digest)= + split(/:/,$expected,8); + my ($parent,$name)=$source =~ m{\A(.+)/([^/]+)\z}; + exit 2 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + my @parent=lstat($parent); + exit 2 unless @parent && S_ISDIR($parent[2]) && + $parent[0]==$p_dev && $parent[1]==$p_ino; + chdir($parent) or exit 2; + my @cwd=stat("."); my @leaf=lstat($name); + exit 2 unless @cwd && @leaf && S_ISREG($leaf[2]) && + $leaf[0]==$f_dev && $leaf[1]==$f_ino && $leaf[7]==$size && + $leaf[9]==$mtime && $leaf[10]==$ctime; + sysopen(my $input,$name,O_RDONLY|O_NOFOLLOW) or exit 2; + binmode($input); my @opened=stat($input); + exit 2 unless @opened && S_ISREG($opened[2]) && + $opened[0]==$f_dev && $opened[1]==$f_ino && $opened[7]==$size && + $opened[9]==$mtime && $opened[10]==$ctime; + sysopen(my $output,$target,O_WRONLY|O_CREAT|O_EXCL,0600) or exit 2; + binmode($output); my $sha=Digest::SHA->new(256); my $total=0; + while (1) { + my $read=sysread($input,my $buffer,65536); + exit 2 unless defined $read; last if $read==0; + $total += $read; exit 3 if $total > $limit; $sha->add($buffer); + my $offset=0; + while ($offset < $read) { + my $written=syswrite($output,$buffer,$read-$offset,$offset); + exit 2 unless defined($written) && $written>0; $offset += $written; + } + } + close($output) or exit 2; + my @after=stat($input); my @path_after=lstat($name); + my @parent_after=lstat($parent); + exit 2 unless @after && @path_after && @parent_after && + S_ISREG($path_after[2]) && S_ISDIR($parent_after[2]) && + $opened[0]==$after[0] && $opened[1]==$after[1] && + $opened[7]==$after[7] && $opened[9]==$after[9] && + $opened[10]==$after[10] && $after[0]==$path_after[0] && + $after[1]==$path_after[1] && $cwd[0]==$parent_after[0] && + $cwd[1]==$parent_after[1] && $sha->hexdigest eq $digest; + ' "$source" "$expected" "$target" "$limit" || copy_status=$? + case "$copy_status" in 0) ;; 3) emit_error E_LIMIT ;; *) return 1 ;; esac +} + +capture_identity_text() { + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' + use strict; use warnings; + my ($path,$expected)=@ARGV; + my ($p_dev,$p_ino,$f_dev,$f_ino,$size,$mtime,$ctime,$digest)= + split(/:/,$expected,8); + exit 2 if $size > 1048576; + my ($parent,$name)=$path =~ m{\A(.+)/([^/]+)\z}; + exit 2 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + my @parent=lstat($parent); + exit 2 unless @parent && S_ISDIR($parent[2]) && + $parent[0]==$p_dev && $parent[1]==$p_ino; + chdir($parent) or exit 2; + my @cwd=stat("."); my @leaf=lstat($name); + exit 2 unless @cwd && @leaf && S_ISREG($leaf[2]) && + $leaf[0]==$f_dev && $leaf[1]==$f_ino && $leaf[7]==$size && + $leaf[9]==$mtime && $leaf[10]==$ctime; + sysopen(my $input,$name,O_RDONLY|O_NOFOLLOW) or exit 2; + binmode($input); my @opened=stat($input); + exit 2 unless @opened && S_ISREG($opened[2]) && + $opened[0]==$f_dev && $opened[1]==$f_ino && $opened[7]==$size && + $opened[9]==$mtime && $opened[10]==$ctime; + my $sha=Digest::SHA->new(256); my $text=""; my $total=0; + while (1) { + my $read=sysread($input,my $buffer,65536); + exit 2 unless defined $read; last if $read==0; + $total += $read; exit 2 if $total > 1048576; + $sha->add($buffer); $text .= $buffer; + } + my @after=stat($input); my @path_after=lstat($name); + my @parent_after=lstat($parent); + exit 2 unless $total==$size && $sha->hexdigest eq $digest && + @after && @path_after && @parent_after && S_ISREG($path_after[2]) && + S_ISDIR($parent_after[2]) && $opened[0]==$after[0] && + $opened[1]==$after[1] && $opened[7]==$after[7] && + $opened[9]==$after[9] && $opened[10]==$after[10] && + $after[0]==$path_after[0] && $after[1]==$path_after[1] && + $cwd[0]==$parent_after[0] && $cwd[1]==$parent_after[1]; + print $text; + ' "$1" "$2" +} + +PINNED_PATHS=() +PINNED_IDENTITIES=() +pin_path() { + local path=$1 limit=${2:-1048576} identity index=0 identity_status=0 + identity=$(path_identity "$path" "$limit") || identity_status=$? + case "$identity_status" in + 0) ;; + 3) emit_error E_LIMIT ;; + *) return 1 ;; + esac + while [ "$index" -lt "${#PINNED_PATHS[@]}" ]; do + if [ "${PINNED_PATHS[$index]}" = "$path" ]; then + [ "${PINNED_IDENTITIES[$index]}" = "$identity" ] + return + fi + index=$((index + 1)) + done + PINNED_PATHS[${#PINNED_PATHS[@]}]=$path + PINNED_IDENTITIES[${#PINNED_IDENTITIES[@]}]=$identity +} +pinned_identity() { + local path=$1 index=0 + while [ "$index" -lt "${#PINNED_PATHS[@]}" ]; do + if [ "${PINNED_PATHS[$index]}" = "$path" ]; then + /usr/bin/printf '%s\n' "${PINNED_IDENTITIES[$index]}"; return 0 + fi + index=$((index + 1)) + done + return 1 +} +verify_all_pins() { + local index=0 limit + while [ "$index" -lt "${#PINNED_PATHS[@]}" ]; do + limit=1048576 + [ "${PINNED_PATHS[$index]}" != "${YSTACK_EVIDENCE_LIVE_JQ:-}" ] || limit=16777216 + [ "${PINNED_PATHS[$index]}" != "${YSTACK_EVIDENCE_PRIVATE_JQ:-}" ] || limit=16777216 + path_matches_identity "${PINNED_PATHS[$index]}" \ + "${PINNED_IDENTITIES[$index]}" "$limit" || return 1 + index=$((index + 1)) + done +} +sha256_path() { + local identity + pin_path "$1" || return 1 + identity=$(pinned_identity "$1") || return 1 + /usr/bin/printf '%s\n' "${identity##*:}" +} + +scratch=${YSTACK_EVIDENCE_SCRATCH:-} +ACTIVE_PID= +ACTIVE_PGID= +cleanup() { + [ -z "${scratch:-}" ] && return 0 + case "$scratch" in /*/ystack-evidence.??????) ;; *) return 1 ;; esac + /bin/rm -rf -- "$scratch" >/dev/null 2>&1 && + [ ! -e "$scratch" ] && [ ! -L "$scratch" ] +} +group_live_count() { + [[ "${1:-}" =~ ^[1-9][0-9]*$ ]] || return 1 + /bin/ps -axo pgid=,state= 2>/dev/null | /usr/bin/awk -v group="$1" ' + $1==group && $2!~/^Z/ {count+=1} END {print count+0}' +} +leader_state() { + /bin/ps -o state= -p "$1" 2>/dev/null | /usr/bin/tr -d ' ' +} +terminate_active() { + local group=${ACTIVE_PGID:-} leader=${ACTIVE_PID:-} state count attempt=0 + [[ "$group" =~ ^[1-9][0-9]*$ ]] && [[ "$leader" =~ ^[1-9][0-9]*$ ]] || + return 1 + /bin/kill -TERM -- "-$group" 2>/dev/null || : + state=$(leader_state "$leader") || state= + while [ -n "$state" ] && [[ "$state" != Z* ]] && [ "$attempt" -lt 100 ]; do + attempt=$((attempt + 1)); /bin/sleep 0.01 + state=$(leader_state "$leader") || state= + done + count=$(group_live_count "$group") || return 1 + if { [ -n "$state" ] && [[ "$state" != Z* ]]; } || [ "$count" -gt 0 ]; then + /bin/kill -KILL -- "-$group" 2>/dev/null || : + fi + wait "$leader" 2>/dev/null || : + state=$(leader_state "$leader") || state= + [ -z "$state" ] || return 1 + attempt=0; count=$(group_live_count "$group") || return 1 + while [ "$count" -gt 0 ] && [ "$attempt" -lt 100 ]; do + attempt=$((attempt + 1)); /bin/sleep 0.01 + count=$(group_live_count "$group") || return 1 + done + [ "$count" -eq 0 ] || return 1 + ACTIVE_PID=; ACTIVE_PGID= +} +signal_exit() { + local status=${1:-1} + exec >/dev/null 2>&1 + if [ -n "${ACTIVE_PGID:-}" ]; then terminate_active || status=125; fi + cleanup || status=125 + trap - EXIT HUP INT TERM + exit "$status" +} +trap cleanup EXIT +trap 'signal_exit 129' HUP +trap 'signal_exit 130' INT +trap 'signal_exit 143' TERM + +run_child() { + local child pgid state attempt=0 child_status=0 count self_pgid + [ -z "${ACTIVE_PID:-}" ] && [ -z "${ACTIVE_PGID:-}" ] || return 125 + self_pgid=$(/bin/ps -o pgid= -p $$ 2>/dev/null | /usr/bin/tr -d ' ') || return 125 + set -m + /bin/bash -c 'ulimit -f 2048; exec "$@"' evidence-child "$@" & + child=$! + set +m + pgid=$(/bin/ps -o pgid= -p "$child" 2>/dev/null | /usr/bin/tr -d ' ') || pgid= + if ! [[ "$pgid" =~ ^[1-9][0-9]*$ ]] || [ "$pgid" != "$child" ] || + [ "$pgid" = "$self_pgid" ]; then + wait "$child" 2>/dev/null || :; return 125 + fi + ACTIVE_PID=$child; ACTIVE_PGID=$pgid + state=$(leader_state "$child") || state= + while [ -n "$state" ] && [[ "$state" != Z* ]] && [ "$attempt" -lt 1000 ]; do + attempt=$((attempt + 1)); /bin/sleep 0.01 + state=$(leader_state "$child") || state= + done + if [ -n "$state" ] && [[ "$state" != Z* ]]; then + terminate_active || :; return 124 + fi + wait "$child" || child_status=$? + count=$(group_live_count "$pgid") || return 125 + if [ "$count" -ne 0 ]; then + ACTIVE_PID=$child; ACTIVE_PGID=$pgid + terminate_active || :; return 125 + fi + ACTIVE_PID=; ACTIVE_PGID= + return "$child_status" +} + +emit_supervisor_failure() { + local token=${1:-E_RUNTIME} + case "$token" in + E_USAGE|E_RUNTIME|E_LIMIT|E_PARSE|E_CANONICAL|E_RELATION|E_POLICY_SET|E_CORE) ;; + *) token=E_RUNTIME ;; + esac + if cleanup; then trap - EXIT HUP INT TERM; /usr/bin/printf '%s\n' "$token" >&2; fi + exit 1 +} + [ "$#" -eq 6 ] && [ "$1" = evaluate ] || emit_error E_USAGE -shift +stage=${YSTACK_EVIDENCE_STAGE:-bootstrap} +if [ "$stage" = bootstrap ]; then + normalized_args=(evaluate) + for input in "${@:2}"; do + case "$input" in /*) ;; *) input="$(pwd -P)/$input" ;; esac + input_parent=$(CDPATH='' cd -P -- "${input%/*}" 2>/dev/null && pwd -P) || + emit_error E_RUNTIME + input="$input_parent/${input##*/}" + physical_regular "$input" || emit_error E_RUNTIME + normalized_args+=("$input") + done + origin=${BASH_SOURCE[0]} + case "$origin" in /*) ;; *) origin="$(pwd -P)/$origin" ;; esac + origin_dir=$(CDPATH='' cd -P -- "${origin%/*}" 2>/dev/null && pwd -P) || + emit_error E_RUNTIME + origin="$origin_dir/${origin##*/}" + [ "$origin" = "$origin_dir/evaluate-evidence-integrity.sh" ] || emit_error E_RUNTIME + origin_identity=$(path_identity "$origin" 1048576) || emit_error E_RUNTIME + live_jq=$(command -v jq 2>/dev/null) || emit_error E_RUNTIME + case "$live_jq" in /*) ;; *) emit_error E_RUNTIME ;; esac + live_jq_parent=$(CDPATH='' cd -P -- "${live_jq%/*}" 2>/dev/null && pwd -P) || + emit_error E_RUNTIME + live_jq="$live_jq_parent/${live_jq##*/}" + physical_regular "$live_jq" || emit_error E_RUNTIME + live_jq_identity=$(path_identity "$live_jq" 16777216) || emit_error E_RUNTIME + platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) + case "$platform" in + Darwin:*) expected_jq=5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef ;; + Linux:x86_64) expected_jq=af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 ;; + *) emit_error E_RUNTIME ;; + esac + [ "${live_jq_identity##*:}" = "$expected_jq" ] || emit_error E_RUNTIME + scratch=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence.XXXXXX" 2>/dev/null) || + emit_error E_RUNTIME + scratch=$(CDPATH='' cd -P -- "$scratch" 2>/dev/null && pwd -P) || emit_error E_RUNTIME + /bin/chmod 0700 "$scratch" || emit_error E_RUNTIME + /bin/mkdir -m 0700 "$scratch/bin" || emit_error E_RUNTIME + private_driver="$scratch/driver.sh" + private_jq="$scratch/bin/jq" + snapshot_nofollow "$origin" "$origin_identity" "$private_driver" 1048576 || + emit_error E_RUNTIME + /bin/chmod 0500 "$private_driver" || emit_error E_RUNTIME + private_driver_identity=$(path_identity "$private_driver" 1048576) || + emit_error E_RUNTIME + snapshot_nofollow "$live_jq" "$live_jq_identity" "$private_jq" 16777216 || + emit_error E_RUNTIME + /bin/chmod 0500 "$private_jq" || emit_error E_RUNTIME + private_jq_identity=$(path_identity "$private_jq" 16777216) || emit_error E_RUNTIME + exec /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ + YSTACK_EVIDENCE_STAGE=supervisor YSTACK_EVIDENCE_SCRATCH="$scratch" \ + YSTACK_EVIDENCE_ORIGIN="$origin" \ + YSTACK_EVIDENCE_ORIGIN_ID="$origin_identity" \ + YSTACK_EVIDENCE_PRIVATE_DRIVER_ID="$private_driver_identity" \ + YSTACK_EVIDENCE_LIVE_JQ="$live_jq" \ + YSTACK_EVIDENCE_LIVE_JQ_ID="$live_jq_identity" \ + YSTACK_EVIDENCE_PRIVATE_JQ="$private_jq" \ + YSTACK_EVIDENCE_PRIVATE_JQ_ID="$private_jq_identity" \ + /bin/bash "$private_driver" "${normalized_args[@]}" +fi + source_path=${BASH_SOURCE[0]} case "$source_path" in /*) ;; *) source_path="$(pwd -P)/$source_path" ;; esac -source_dir=$(CDPATH='' cd -P -- "${source_path%/*}" 2>/dev/null && pwd -P) || +origin=${YSTACK_EVIDENCE_ORIGIN:-} +jq_bin=${YSTACK_EVIDENCE_PRIVATE_JQ:-} +[ "$source_path" = "$scratch/driver.sh" ] && [ "$jq_bin" = "$scratch/bin/jq" ] || emit_error E_RUNTIME -source_path="$source_dir/${source_path##*/}" -[ "$source_path" = "$source_dir/evaluate-evidence-integrity.sh" ] || +PINNED_PATHS=("$origin" "$source_path" "${YSTACK_EVIDENCE_LIVE_JQ:-}" "$jq_bin") +PINNED_IDENTITIES=("${YSTACK_EVIDENCE_ORIGIN_ID:-}" \ + "${YSTACK_EVIDENCE_PRIVATE_DRIVER_ID:-}" "${YSTACK_EVIDENCE_LIVE_JQ_ID:-}" \ + "${YSTACK_EVIDENCE_PRIVATE_JQ_ID:-}") +verify_all_pins || emit_error E_RELATION + +if [ "$stage" = supervisor ]; then + worker_status=0 + run_child /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ + YSTACK_EVIDENCE_STAGE=worker YSTACK_EVIDENCE_SCRATCH="$scratch" \ + YSTACK_EVIDENCE_ORIGIN="$origin" \ + YSTACK_EVIDENCE_ORIGIN_ID="${YSTACK_EVIDENCE_ORIGIN_ID:-}" \ + YSTACK_EVIDENCE_PRIVATE_DRIVER_ID="${YSTACK_EVIDENCE_PRIVATE_DRIVER_ID:-}" \ + YSTACK_EVIDENCE_LIVE_JQ="${YSTACK_EVIDENCE_LIVE_JQ:-}" \ + YSTACK_EVIDENCE_LIVE_JQ_ID="${YSTACK_EVIDENCE_LIVE_JQ_ID:-}" \ + YSTACK_EVIDENCE_PRIVATE_JQ="$jq_bin" \ + YSTACK_EVIDENCE_PRIVATE_JQ_ID="${YSTACK_EVIDENCE_PRIVATE_JQ_ID:-}" \ + /bin/bash "$source_path" "$@" >"$scratch/worker.out" 2>"$scratch/worker.err" || + worker_status=$? + pin_path "$scratch/worker.err" || emit_supervisor_failure E_RUNTIME + error_identity=$(pinned_identity "$scratch/worker.err") || emit_supervisor_failure E_RUNTIME + error_text=$(capture_identity_text "$scratch/worker.err" "$error_identity") || + emit_supervisor_failure E_RUNTIME + if [ "$worker_status" -ne 0 ]; then emit_supervisor_failure "$error_text"; fi + [ -z "$error_text" ] || emit_supervisor_failure E_RUNTIME + pin_path "$scratch/worker.out" || emit_supervisor_failure E_RUNTIME + output_identity=$(pinned_identity "$scratch/worker.out") || emit_supervisor_failure E_RUNTIME + output_text=$(capture_identity_text "$scratch/worker.out" "$output_identity") || + emit_supervisor_failure E_RUNTIME + verify_all_pins || emit_supervisor_failure E_RELATION + if ! cleanup; then exit 1; fi + trap - EXIT HUP INT TERM + /usr/bin/printf '%s\n' "$output_text" || exit 1 + exit 0 +fi +[ "$stage" = worker ] || emit_error E_RUNTIME +trap - EXIT HUP INT TERM + +shift +source_dir=$(CDPATH='' cd -P -- "${origin%/*}" 2>/dev/null && pwd -P) || emit_error E_RUNTIME +[ "$origin" = "$source_dir/evaluate-evidence-integrity.sh" ] || emit_error E_RUNTIME repo=$(CDPATH='' cd -P -- "$source_dir/../.." 2>/dev/null && pwd -P) || emit_error E_RUNTIME policy="$source_dir/evidence-integrity-policy.json" @@ -31,19 +426,11 @@ program="$source_dir/evidence-integrity.jq" policy_validator="$source_dir/validate.sh" validator_program="$source_dir/policy-set.jq" core_driver="$repo/scripts/core-contract.sh" -for required in "$source_path" "$policy" "$decision" "$program" \ - "$policy_validator" "$validator_program" "$core_driver"; do - [ -f "$required" ] && [ ! -L "$required" ] || emit_error E_RUNTIME -done -for input in "$@"; do - [ -f "$input" ] && [ ! -L "$input" ] || emit_error E_RUNTIME +for required in "$source_path" "$origin" "$policy" "$decision" "$program" \ + "$policy_validator" "$validator_program" "$core_driver" "$@"; do + pin_path "$required" || emit_error E_RUNTIME done -jq_bin=$(command -v jq 2>/dev/null) || emit_error E_RUNTIME -case "$jq_bin" in /*) ;; *) emit_error E_RUNTIME ;; esac -[ -f "$jq_bin" ] && [ -x "$jq_bin" ] && [ ! -L "$jq_bin" ] && - [ "$($jq_bin --version 2>/dev/null)" = jq-1.6 ] || emit_error E_RUNTIME -sha256_path() { /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}'; } sha256_text() { /usr/bin/printf '%s' "$1" | /usr/bin/shasum -a 256 | /usr/bin/awk '{print $1}' } @@ -59,28 +446,47 @@ selected_core_generation() { /usr/bin/printf '%s\n' "$selected" } -scratch=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence.XXXXXX" 2>/dev/null) || - emit_error E_RUNTIME -scratch=$(CDPATH='' cd -P -- "$scratch" 2>/dev/null && pwd -P) || - emit_error E_RUNTIME -cleanup() { /bin/rm -rf -- "$scratch" >/dev/null 2>&1 || :; } -signal_exit() { trap - EXIT HUP INT TERM; cleanup; exit 1; } -trap cleanup EXIT -trap signal_exit HUP INT TERM - snapshot_fixed() { - local source=$1 target=$2 size - /bin/dd if="$source" of="$target" bs=1048577 count=1 2>/dev/null || - emit_error E_RUNTIME - size=$(/usr/bin/wc -c <"$target" | /usr/bin/tr -d ' ') || - emit_error E_RUNTIME - [ "$size" -le 1048576 ] || emit_error E_LIMIT + local source=$1 target=$2 expected + pin_path "$source" || emit_error E_RUNTIME + expected=$(pinned_identity "$source") || emit_error E_RUNTIME + snapshot_nofollow "$source" "$expected" "$target" 1048576 || emit_error E_RUNTIME + pin_path "$target" || emit_error E_RUNTIME +} +snapshot_executable() { + local source=$1 target=$2 expected + pin_path "$source" || emit_error E_RUNTIME + expected=$(pinned_identity "$source") || emit_error E_RUNTIME + snapshot_nofollow "$source" "$expected" "$target" 1048576 || emit_error E_RUNTIME + /bin/chmod 0500 "$target" || emit_error E_RUNTIME + pin_path "$target" || emit_error E_RUNTIME } canonical_json() { - local input=$1 canonical=$2 - "$jq_bin" -s -S -c 'if length==1 then .[0] else error("root-count") end' \ - "$input" >"$canonical" 2>/dev/null || return 1 - /usr/bin/cmp -s "$input" "$canonical" + local input=$1 canonical=$2 bom + bom=$(/usr/bin/od -An -tx1 -N3 "$input" 2>/dev/null | /usr/bin/tr -d ' \n') || + emit_error E_RUNTIME + [ "$bom" != efbbbf ] || emit_error E_PARSE + "$jq_bin" -e 'true' "$input" /dev/null 2>&1 || emit_error E_PARSE + "$jq_bin" -s -e 'length==1' "$input" /dev/null 2>&1 || + emit_error E_PARSE + "$jq_bin" -S -c . "$input" >"$canonical" 2>/dev/null || emit_error E_PARSE + /usr/bin/cmp -s "$input" "$canonical" || emit_error E_CANONICAL + "$jq_bin" -e ' + def depth: + if type=="array" then if length==0 then 1 else 1+([.[]|depth]|max) end + elif type=="object" then if length==0 then 1 else 1+([.[]|depth]|max) end + else 1 end; + def members: + if type=="array" then length+([.[]|members]|add//0) + elif type=="object" then (keys_unsorted|length)+([.[]|members]|add//0) + else 0 end; + def strings_ok: + if type=="array" then all(.[];strings_ok) + elif type=="object" then + all(keys_unsorted[];utf8bytelength<=8192) and all(.[];strings_ok) + elif type=="string" then utf8bytelength<=8192 else true end; + depth<=32 and members<=4096 and strings_ok + ' "$input" >/dev/null 2>&1 || emit_error E_LIMIT } validator_pair_ok() { local pair_dir=$1 driver=$2 validator_jq=$3 expected_driver=$4 expected_program=$5 @@ -96,15 +502,15 @@ validator_pair_ok() { [ "$(sha256_path "$validator_jq")" = "$expected_program" ] } build_validator_mirror() { - local mirror="$scratch/policy-validator/control/v1" source target size + local mirror="$scratch/policy-validator/control/v1" source target /bin/mkdir -p "$mirror" || return 1 for source in "$policy_validator" "$validator_program"; do target="$mirror/${source##*/}" - /bin/dd if="$source" of="$target" bs=1048577 count=1 2>/dev/null || return 1 - size=$(/usr/bin/wc -c <"$target" | /usr/bin/tr -d ' ') || return 1 - [ "$size" -le 1048576 ] || return 1 + case "$source" in + "$policy_validator") snapshot_executable "$source" "$target" ;; + *) snapshot_fixed "$source" "$target" ;; + esac done - /bin/chmod 0500 "$mirror/validate.sh" || return 1 /usr/bin/printf '%s\n' "$mirror" } core_closure_sha() { @@ -165,7 +571,7 @@ core_closure_sha() { sha256_text "$descriptor" } build_core_mirror() { - local selected=$1 mirror="$scratch/core-package" relative source target size + local selected=$1 mirror="$scratch/core-package" relative source target local -a paths /bin/mkdir -p "$mirror/scripts" "$mirror/core/v2/generations/$selected/modules" || return 1 @@ -183,13 +589,29 @@ build_core_mirror() { for relative in "${paths[@]}"; do source="$repo/$relative" target="$mirror/$relative" - /bin/dd if="$source" of="$target" bs=1048577 count=1 2>/dev/null || return 1 - size=$(/usr/bin/wc -c <"$target" | /usr/bin/tr -d ' ') || return 1 - [ "$size" -le 1048576 ] || return 1 + case "$relative" in + scripts/core-contract.sh) snapshot_executable "$source" "$target" ;; + *) snapshot_fixed "$source" "$target" ;; + esac done - /bin/chmod 0500 "$mirror/scripts/core-contract.sh" || return 1 /usr/bin/printf '%s\n' "$mirror" } +pin_core_package() { + local root=$1 selected=$2 relative + local -a paths + paths=( + scripts/core-contract.sh + core/v2/generation-registry.json + "core/v2/generations/$selected/contracts.jq" + "core/v2/generations/$selected/core-ingress.sh" + "core/v2/generations/$selected/modules/profile_graph.jq" + "core/v2/generations/$selected/modules/result_facts.jq" + "core/v2/generations/$selected/modules/result_truth.jq" + "core/v2/generations/$selected/modules/schema.jq" + "core/v2/generations/$selected/modules/stage_request.jq" + ) + for relative in "${paths[@]}"; do pin_path "$root/$relative" || return 1; done +} fixed_files_ok() { [ "$(sha256_path "$source_path")" = "$driver_sha" ] && [ "$(sha256_path "$program")" = "$program_sha" ] && @@ -266,6 +688,8 @@ validator_pair_ok "$source_dir" "$policy_validator" "$validator_program" \ mirror_validator_dir=$(build_validator_mirror) || emit_error E_RELATION mirror_policy_validator="$mirror_validator_dir/validate.sh" mirror_validator_program="$mirror_validator_dir/policy-set.jq" +pin_path "$mirror_policy_validator" || emit_error E_RELATION +pin_path "$mirror_validator_program" || emit_error E_RELATION validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ "$mirror_validator_program" "$validator_driver_sha" "$validator_program_sha" || emit_error E_RELATION @@ -283,10 +707,12 @@ fi selected=$(selected_core_generation "$core_driver") || emit_error E_RELATION selected_sha=$(sha256_text "$selected") || emit_error E_RUNTIME +pin_core_package "$repo" "$selected" || emit_error E_RELATION live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-pre) || emit_error E_RELATION mirror_root=$(build_core_mirror "$selected") || emit_error E_RELATION mirror_core_driver="$mirror_root/scripts/core-contract.sh" +pin_core_package "$mirror_root" "$selected" || emit_error E_RELATION mirror_core_sha=$(core_closure_sha "$mirror_root" "$mirror_core_driver" "$selected" mirror-pre) || emit_error E_RELATION [ "$mirror_core_sha" = "$live_core_sha" ] || emit_error E_RELATION @@ -407,6 +833,6 @@ canonical_json "$scratch/evaluation.json" "$scratch/evaluation.canonical" || has("credential") or has("network") or has("candidate_execution"))|not) ' "$scratch/evaluation.json" >/dev/null 2>&1 || emit_error E_RUNTIME +pin_path "$scratch/evaluation.json" || emit_error E_RUNTIME +verify_all_pins || emit_error E_RELATION /bin/cat "$scratch/evaluation.json" || emit_error E_RUNTIME -trap - EXIT HUP INT TERM -cleanup diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index 0ce5286..9d43508 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"48f10dbb16e19bef6d7cb74f93a810612573e70b2ff5de52b9ff496b0fe16d16"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"d8e67f60b2328ac18cb5cbe8022f600e15d718a461e6a48a99088f6ced7e1212"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"3e5f5e181dbb576a6b00b06f58d7afe57a057f9ad97e1d9f7b4fc2f8faf87a8c"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"19b32c19f682e800a36928758fc71f3f1747b8c15a4f19efd3f1f40ef29639bc"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/control/v1/evidence-integrity.jq b/control/v1/evidence-integrity.jq index c2c0ef2..d29abd3 100644 --- a/control/v1/evidence-integrity.jq +++ b/control/v1/evidence-integrity.jq @@ -103,13 +103,18 @@ def normalized_array($value): def duplicate_scalar_key($entries;$field): ($entries | normalized_array(.)) as $items | - ($items | map(.[$field])) as $keys | - ($keys | length) != ($keys | unique | length); + if all($items[];type=="object") then + ($items | map(.[$field])) as $keys | + ($keys | length) != ($keys | unique | length) + else false end; def duplicate_prior_key($entries): ($entries | normalized_array(.)) as $items | - ($items | map([.stage_result_ref.sha256,.evidence_id])) as $keys | - ($keys | length) != ($keys | unique | length); + if all($items[]; + type=="object" and (.stage_result_ref? | type)=="object") then + ($items | map([.stage_result_ref.sha256,.evidence_id])) as $keys | + ($keys | length) != ($keys | unique | length) + else false end; def prior_document_alias($entries): ($entries | normalized_array(.)) as $items | diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index ac60f53..1a1ec53 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -226,6 +226,18 @@ expect_pure_violation() { ' "$output" >/dev/null || fail "$name" pass "$name" } +expect_full_malformed_element() { + local name=$1 filter=$2 mismatch_reason=$3 input="$tmp/$1.presentation" + "$jq_bin" -S -c "$filter" "$presentation" >"$input" + run_eval "$name" "$input" + "$jq_bin" -e --arg mismatch "$mismatch_reason" ' + .body.verdict=="violated" and + .body.reason_ids==(["evidence.presentation-malformed",$mismatch]|sort) and + (.body.reason_ids|index("evidence.presentation-ambiguous")==null) and + .body.authority_effect=="none" and .body.storage_effect=="none" + ' "$tmp/$name.out" >/dev/null || fail "$name reasons" + pass "$name" +} run_eval valid "$jq_bin" -e '.body.verdict=="satisfied" and @@ -275,6 +287,19 @@ expect_pure_violation prior-document-alias \ .body.prior_evidence_refs |= sort_by([.stage_result_ref.sha256,.evidence_id])' \ evidence.presentation-ambiguous +expect_full_malformed_element current-scalar '.body.evidence=[1]' \ + evidence.current-mismatch +expect_full_malformed_element current-null '.body.evidence=[null]' \ + evidence.current-mismatch +expect_full_malformed_element current-array '.body.evidence=[[]]' \ + evidence.current-mismatch +expect_full_malformed_element prior-scalar '.body.prior_evidence_refs=[1]' \ + evidence.prior-stale +expect_full_malformed_element prior-null '.body.prior_evidence_refs=[null]' \ + evidence.prior-stale +expect_full_malformed_element prior-array '.body.prior_evidence_refs=[[]]' \ + evidence.prior-stale + shared_result="$tmp/shared-proof.result" shared_presentation="$tmp/shared-proof.presentation" shared_output="$tmp/shared-proof.out" @@ -370,6 +395,25 @@ link="$tmp/presentation-link.json" /bin/ln -s "$presentation" "$link" expect_error symlink-input E_RUNTIME "$policy_set" "$request" "$resolved" "$result" "$link" +/usr/bin/printf '{' >"$tmp/invalid.json" +expect_error invalid-json E_PARSE "$policy_set" "$request" "$resolved" "$result" \ + "$tmp/invalid.json" +/usr/bin/printf '{}\n{}\n' >"$tmp/multi-root.json" +expect_error multi-root E_PARSE "$policy_set" "$request" "$resolved" "$result" \ + "$tmp/multi-root.json" +"$jq_bin" . "$presentation" >"$tmp/noncanonical.json" +expect_error noncanonical E_CANONICAL "$policy_set" "$request" "$resolved" "$result" \ + "$tmp/noncanonical.json" +/usr/bin/perl -e 'print "["x33,"0","]"x33,"\n"' >"$tmp/deep.json" +expect_error depth-limit E_LIMIT "$policy_set" "$request" "$resolved" "$result" \ + "$tmp/deep.json" +"$jq_bin" -S -c '.id=("x"*8193)' "$presentation" >"$tmp/string-limit.json" +expect_error string-limit E_LIMIT "$policy_set" "$request" "$resolved" "$result" \ + "$tmp/string-limit.json" +/usr/bin/perl -e 'print "{\"x\":\"","x"x1048576,"\"}\n"' >"$tmp/oversize.json" +expect_error byte-limit E_LIMIT "$policy_set" "$request" "$resolved" "$result" \ + "$tmp/oversize.json" + relative_bin="$tmp/relative-bin" /bin/mkdir "$relative_bin" /bin/cp "$jq_bin" "$relative_bin/jq" @@ -381,29 +425,24 @@ status=0 [ "$(/bin/cat "$tmp/relative.err")" = E_RUNTIME ] || fail 'relative jq rejection' pass 'relative jq interpreter rejected' +wrapper_bin="$tmp/wrapper-bin" +/bin/mkdir "$wrapper_bin" +/usr/bin/printf '%s\n' '#!/bin/bash' \ + 'if [ "${1:-}" = --version ]; then echo jq-1.6; exit 0; fi' \ + 'exec /usr/bin/jq "$@"' >"$wrapper_bin/jq" +/bin/chmod 0555 "$wrapper_bin/jq" +unbound_status=0 +PATH="$wrapper_bin:/usr/bin:/bin" "$evaluator" evaluate "$policy_set" \ + "$request" "$resolved" "$result" "$presentation" >"$tmp/unbound-jq.out" \ + 2>"$tmp/unbound-jq.err" || unbound_status=$? +[ "$unbound_status" -ne 0 ] && [ ! -s "$tmp/unbound-jq.out" ] && + [ "$(/bin/cat "$tmp/unbound-jq.err")" = E_RUNTIME ] || fail 'unbound jq result' +pass 'only official jq 1.6 bytes are accepted' + /usr/bin/grep -Fq \ '"$jq_bin" -n -e --arg policy_sha "$policy_sha" --arg driver_sha "$driver_sha"' \ "$evaluator" || fail 'decision envelope null-input mode' -strict_bin="$tmp/strict-bin" -/bin/mkdir "$strict_bin" -/usr/bin/printf '%s\n' '#!/bin/bash' "real_jq='$jq_bin'" \ - 'if [ "${1:-}" = --version ]; then exec "$real_jq" "$@"; fi' \ - 'saw_definition=0; saw_null_input=0' \ - 'for arg in "$@"; do' \ - ' [ "$arg" = definition ] && saw_definition=1' \ - ' [ "$arg" = -n ] && saw_null_input=1' \ - 'done' \ - '[ "$saw_definition" -eq 0 ] || [ "$saw_null_input" -eq 1 ] || exit 97' \ - 'exec "$real_jq" "$@"' >"$strict_bin/jq" -/bin/chmod 0555 "$strict_bin/jq" -PATH="$strict_bin:/usr/bin:/bin" "$evaluator" evaluate "$policy_set" \ - "$request" "$resolved" "$result" "$presentation" >"$tmp/strict.out" \ - 2>"$tmp/strict.err" || fail 'strict decision evaluator status' -if [ -s "$tmp/strict.err" ] || - ! /usr/bin/cmp -s "$tmp/valid.out" "$tmp/strict.out"; then - fail 'strict decision evaluator output' -fi -pass 'decision envelope executes explicitly on null input' +pass 'decision envelope explicitly uses null input' copy_runtime() { local destination=$1 path @@ -433,91 +472,195 @@ mutated_decision_set="$tmp/mutated-decision-policy-set.json" expect_error mutated-decision E_RELATION "$mutated_decision_set" "$request" \ "$resolved" "$result" "$presentation" "$mutated_decision_runtime" -wait_marker() { - local marker=$1 pid=$2 attempt=0 - while [ ! -e "$marker" ] && kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 400 ]; do +wait_exit() { + local pid=$1 limit=${2:-500} attempt=0 + while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt "$limit" ]; do attempt=$((attempt + 1)) /bin/sleep 0.01 done - [ -e "$marker" ] + ! kill -0 "$pid" 2>/dev/null } -wait_exit() { - local pid=$1 attempt=0 - while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 500 ]; do +slice_until_path() { + local pid=$1 search_root=$2 suffix=$3 attempt=0 found + /bin/kill -STOP "$pid" 2>/dev/null || return 1 + while [ "$attempt" -lt 800 ]; do + found=$(/usr/bin/find "$search_root" -type f -path "*/$suffix" -print -quit \ + 2>/dev/null) || found= + if [ -n "$found" ]; then /usr/bin/printf '%s\n' "$found"; return 0; fi + kill -0 "$pid" 2>/dev/null || return 1 + /bin/kill -CONT "$pid" 2>/dev/null || return 1 + /bin/sleep 0.005 + /bin/kill -STOP "$pid" 2>/dev/null || return 1 attempt=$((attempt + 1)) - /bin/sleep 0.01 done - ! kill -0 "$pid" 2>/dev/null + return 1 } -make_delaying_jq() { - local destination=$1 marker=$2 delay=$3 - /usr/bin/printf '%s\n' '#!/bin/bash' "real_jq='$jq_bin'" "marker='$marker'" \ - "delay='$delay'" \ - 'if [ "${1:-}" = --version ]; then exec "$real_jq" "$@"; fi' \ - 'for arg in "$@"; do' \ - ' case "$arg" in' \ - ' */program.jq) if [ ! -e "$marker" ]; then : >"$marker"; /bin/sleep "$delay"; fi ;;' \ - ' esac' \ - 'done' \ - 'exec "$real_jq" "$@"' >"$destination" - /bin/chmod 0555 "$destination" + +replace_identity_case() { + local name=$1 target_kind=$2 expected=${3:-E_RELATION} + local runtime="$tmp/$1-runtime" live_bin="$tmp/$1-bin" + local scratch_root="$tmp/$1-scratch" process target observed suffix case_status=0 + copy_runtime "$runtime" + /bin/mkdir "$live_bin" "$scratch_root" + /bin/cp "$jq_bin" "$live_bin/jq" + /bin/chmod 0555 "$live_bin/jq" + TMPDIR="$scratch_root" PATH="$live_bin:/usr/bin:/bin" \ + "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate "$policy_set" \ + "$request" "$resolved" "$result" "$presentation" >"$tmp/$name.out" \ + 2>"$tmp/$name.err" & + process=$! + suffix=driver.sh + case "$target_kind" in private-driver|live-jq) suffix=bin/jq ;; esac + [ "$target_kind" != private-jq ] || suffix=worker.out + observed=$(slice_until_path "$process" "$scratch_root" "$suffix") || { + /bin/kill -KILL "$process" 2>/dev/null || : + wait "$process" 2>/dev/null || : + fail "$name observed path" + } + case "$target_kind" in + origin) target="$runtime/control/v1/evaluate-evidence-integrity.sh" ;; + private-driver) target="${observed%/bin/jq}/driver.sh" ;; + live-jq) target="$live_bin/jq" ;; + private-jq) target="${observed%/worker.out}/bin/jq" ;; + *) fail "$name target" ;; + esac + /bin/cp "$target" "$target.next" + /bin/chmod 0500 "$target.next" + /bin/mv "$target.next" "$target" + /bin/kill -CONT "$process" 2>/dev/null || : + wait "$process" || case_status=$? + if [ "$case_status" -eq 0 ] || [ -s "$tmp/$name.out" ] || + [ "$(/bin/cat "$tmp/$name.err")" != "$expected" ] || + [ -n "$(/usr/bin/find "$scratch_root" -mindepth 1 -print -quit)" ]; then + /usr/bin/printf 'diagnostic %s status=%s stderr=' "$name" "$case_status" >&2 + /bin/cat "$tmp/$name.err" >&2 + fail "$name result" + fi + pass "$name" } -race_runtime="$tmp/race-runtime" -copy_runtime "$race_runtime" -race_bin="$tmp/race-bin" -race_marker="$tmp/race-marker" -/bin/mkdir "$race_bin" -make_delaying_jq "$race_bin/jq" "$race_marker" 1 -( - race_status=0 - PATH="$race_bin:/usr/bin:/bin" \ - "$race_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate "$policy_set" \ - "$request" "$resolved" "$result" "$presentation" >"$tmp/race.out" \ - 2>"$tmp/race.err" || race_status=$? - /usr/bin/printf '%s\n' "$race_status" >"$tmp/race.status" -) & -race_pid=$! -wait_marker "$race_marker" "$race_pid" || { - kill -TERM "$race_pid" 2>/dev/null || : - wait "$race_pid" 2>/dev/null || : - fail 'TOCTOU marker' +replace_identity_case origin-driver-swap origin +replace_identity_case executing-driver-swap private-driver +replace_identity_case live-jq-swap live-jq +replace_identity_case private-jq-swap private-jq E_RUNTIME + +program_runtime="$tmp/program-race-runtime" +program_scratch="$tmp/program-race-scratch" +copy_runtime "$program_runtime" +/bin/mkdir "$program_scratch" +TMPDIR="$program_scratch" PATH="$bin:/usr/bin:/bin" \ + "$program_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate "$policy_set" \ + "$request" "$resolved" "$result" "$presentation" >"$tmp/program-race.out" \ + 2>"$tmp/program-race.err" & +program_pid=$! +program_snapshot= +program_attempt=0 +while [ -z "$program_snapshot" ] && [ "$program_attempt" -lt 800 ]; do + program_snapshot=$(/usr/bin/find "$program_scratch" -type f -name program.jq \ + -print -quit 2>/dev/null) || program_snapshot= + [ -z "$program_snapshot" ] || break + kill -0 "$program_pid" 2>/dev/null || break + program_attempt=$((program_attempt + 1)) + /bin/sleep 0.005 +done +[ -n "$program_snapshot" ] || fail 'program race snapshot' +/usr/bin/printf '\n' >>"$program_runtime/control/v1/evidence-integrity.jq" +program_status=0 +wait "$program_pid" || program_status=$? +[ "$program_status" -ne 0 ] && [ ! -s "$tmp/program-race.out" ] && + [ "$(/bin/cat "$tmp/program-race.err")" = E_RELATION ] || fail 'program race' +pass 'live program mutation closes after no-follow snapshot' + +find_owned_leader() { + /bin/ps -axo pid=,ppid=,pgid= 2>/dev/null | /usr/bin/awk -v parent="$1" \ + '$2==parent && $1==$3 {print $1; exit}' } -/usr/bin/printf '\n' >>"$race_runtime/control/v1/evidence-integrity.jq" -wait "$race_pid" -[ "$(/bin/cat "$tmp/race.status")" -ne 0 ] && [ ! -s "$tmp/race.out" ] && - [ "$(/bin/cat "$tmp/race.err")" = E_RELATION ] || fail 'TOCTOU closure' -pass 'program mutation closes after snapshot execution' - -signal_runtime="$tmp/signal-runtime" -copy_runtime "$signal_runtime" -signal_bin="$tmp/signal-bin" signal_scratch="$tmp/signal-scratch" -signal_marker="$tmp/signal-marker" -/bin/mkdir "$signal_bin" "$signal_scratch" -make_delaying_jq "$signal_bin/jq" "$signal_marker" 2 -TMPDIR="$signal_scratch" PATH="$signal_bin:/usr/bin:/bin" \ - "$signal_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate "$policy_set" \ - "$request" "$resolved" "$result" "$presentation" >"$tmp/signal.out" \ - 2>"$tmp/signal.err" & +/bin/mkdir "$signal_scratch" +TMPDIR="$signal_scratch" PATH="$bin:/usr/bin:/bin" "$evaluator" evaluate \ + "$policy_set" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/signal.out" 2>"$tmp/signal.err" & signal_pid=$! -wait_marker "$signal_marker" "$signal_pid" || { - kill -TERM "$signal_pid" 2>/dev/null || : - wait "$signal_pid" 2>/dev/null || : - fail 'signal marker' -} -kill -TERM "$signal_pid" -wait_exit "$signal_pid" || { - kill -KILL "$signal_pid" 2>/dev/null || : - wait "$signal_pid" 2>/dev/null || : - fail 'signal bounded exit' -} +signal_leader= +signal_attempt=0 +while [ -z "$signal_leader" ] && [ "$signal_attempt" -lt 800 ]; do + signal_leader=$(find_owned_leader "$signal_pid") || signal_leader= + [ -z "$signal_leader" ] || break + kill -0 "$signal_pid" 2>/dev/null || break + signal_attempt=$((signal_attempt + 1)) + /bin/sleep 0.005 +done +[[ "$signal_leader" =~ ^[1-9][0-9]*$ ]] || fail 'owned child leader' +/bin/kill -STOP -- "-$signal_leader" +/bin/kill -TERM "$signal_pid" +wait_exit "$signal_pid" 500 || fail 'signal bounded exit' signal_status=0 wait "$signal_pid" || signal_status=$? -[ "$signal_status" -ne 0 ] && [ ! -s "$tmp/signal.out" ] && +signal_live=$(/bin/ps -axo pgid=,state= 2>/dev/null | /usr/bin/awk \ + -v group="$signal_leader" '$1==group && $2!~/^Z/ {count++} END {print count+0}') +[ "$signal_status" -ne 0 ] && [ "$signal_live" -eq 0 ] && + [ ! -s "$tmp/signal.out" ] && [ -z "$(/usr/bin/find "$signal_scratch" -mindepth 1 -print -quit)" ] || fail 'signal cleanup' -pass 'signal lifecycle is bounded and removes private scratch' +pass 'signal kills and reaps the stopped owned child group' + +/usr/bin/grep -Fq 'ulimit -f 2048' "$evaluator" || fail 'child output cap' +/usr/bin/grep -Fq '[ "$attempt" -lt 1000 ]' "$evaluator" || fail 'child deadline' +pass 'child runtime and output are bounded' + +bind_modified_driver() { + local runtime=$1 output_set=$2 driver_digest decision_digest + driver_digest=$(sha256_path "$runtime/control/v1/evaluate-evidence-integrity.sh") + "$jq_bin" -S -c --arg digest "$driver_digest" \ + '.body.evaluator.driver_ref.sha256=$digest' \ + "$runtime/control/v1/evidence-integrity-decision.json" >"$runtime/decision.next" + /bin/mv "$runtime/decision.next" \ + "$runtime/control/v1/evidence-integrity-decision.json" + decision_digest=$(sha256_path "$runtime/control/v1/evidence-integrity-decision.json") + "$jq_bin" -S -c --arg digest "$decision_digest" ' + .body.sections[] |= if .section_id=="evidence-integrity" + then .decision_ref.sha256=$digest else . end + ' "$policy_set" >"$output_set" +} + +output_runtime="$tmp/output-swap-runtime" +output_scratch="$tmp/output-swap-scratch" +copy_runtime "$output_runtime" +/usr/bin/perl -0777 -pi -e ' + s{ pin_path "\$scratch/worker\.out" \|\| emit_supervisor_failure E_RUNTIME}{ + /bin/mv "\$scratch/worker.out" "\$scratch/worker.saved" || exit 1; + /bin/ln -s "\$scratch/worker.saved" "\$scratch/worker.out" || exit 1; + pin_path "\$scratch/worker.out" || emit_supervisor_failure E_RUNTIME + } +' "$output_runtime/control/v1/evaluate-evidence-integrity.sh" +bind_modified_driver "$output_runtime" "$tmp/output-swap-set.json" +/bin/mkdir "$output_scratch" +output_status=0 +TMPDIR="$output_scratch" PATH="$bin:/usr/bin:/bin" \ + "$output_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/output-swap-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/output-swap.out" 2>"$tmp/output-swap.err" || output_status=$? +[ "$output_status" -ne 0 ] && [ ! -s "$tmp/output-swap.out" ] && + [ "$(/bin/cat "$tmp/output-swap.err")" = E_RUNTIME ] && + [ -z "$(/usr/bin/find "$output_scratch" -mindepth 1 -print -quit)" ] || + fail 'output symlink swap' +pass 'output path replacement is rejected before cleanup and emission' + +cleanup_runtime="$tmp/cleanup-failure-runtime" +cleanup_scratch="$tmp/cleanup-failure-scratch" +copy_runtime "$cleanup_runtime" +/usr/bin/perl -0777 -pi -e 's/cleanup\(\) \{\n/cleanup() {\n return 1;\n/' \ + "$cleanup_runtime/control/v1/evaluate-evidence-integrity.sh" +bind_modified_driver "$cleanup_runtime" "$tmp/cleanup-failure-set.json" +/bin/mkdir "$cleanup_scratch" +cleanup_status=0 +TMPDIR="$cleanup_scratch" PATH="$bin:/usr/bin:/bin" \ + "$cleanup_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/cleanup-failure-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/cleanup-failure.out" 2>"$tmp/cleanup-failure.err" || cleanup_status=$? +[ "$cleanup_status" -ne 0 ] && [ ! -s "$tmp/cleanup-failure.out" ] || + fail 'cleanup failure output' +pass 'cleanup failure remains non-success and emits no stdout' for required in control/v1/evidence-integrity-policy.json \ control/v1/evidence-integrity-decision.json control/v1/evidence-integrity.jq \ From 05b0152b5ae981b05a6dfb1e694c26b899b1063f Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 19:41:32 -0400 Subject: [PATCH 07/16] Make evidence jq swap test deterministic --- scripts/test/control-evidence-integrity.test.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 1a1ec53..355e76c 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -510,8 +510,10 @@ replace_identity_case() { 2>"$tmp/$name.err" & process=$! suffix=driver.sh - case "$target_kind" in private-driver|live-jq) suffix=bin/jq ;; esac - [ "$target_kind" != private-jq ] || suffix=worker.out + case "$target_kind" in + private-driver) suffix=bin/jq ;; + live-jq|private-jq) suffix=evaluation.json ;; + esac observed=$(slice_until_path "$process" "$scratch_root" "$suffix") || { /bin/kill -KILL "$process" 2>/dev/null || : wait "$process" 2>/dev/null || : @@ -521,7 +523,7 @@ replace_identity_case() { origin) target="$runtime/control/v1/evaluate-evidence-integrity.sh" ;; private-driver) target="${observed%/bin/jq}/driver.sh" ;; live-jq) target="$live_bin/jq" ;; - private-jq) target="${observed%/worker.out}/bin/jq" ;; + private-jq) target="${observed%/evaluation.json}/bin/jq" ;; *) fail "$name target" ;; esac /bin/cp "$target" "$target.next" @@ -542,7 +544,7 @@ replace_identity_case() { replace_identity_case origin-driver-swap origin replace_identity_case executing-driver-swap private-driver replace_identity_case live-jq-swap live-jq -replace_identity_case private-jq-swap private-jq E_RUNTIME +replace_identity_case private-jq-swap private-jq program_runtime="$tmp/program-race-runtime" program_scratch="$tmp/program-race-scratch" From 43ee8d54ff338c60aa307fc13087af6b6bcfab19 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 20:36:14 -0400 Subject: [PATCH 08/16] Close evidence review findings --- control/v1/evaluate-evidence-integrity.sh | 97 ++++++-- control/v1/evidence-integrity-decision.json | 2 +- control/v1/evidence-integrity.jq | 5 +- .../test/control-evidence-integrity.test.sh | 207 ++++++++++++++---- 4 files changed, 240 insertions(+), 71 deletions(-) diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index e8eaab7..a37a38b 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -1,16 +1,25 @@ #!/bin/bash -# shellcheck disable=SC2016 +# shellcheck disable=SC2016,SC2329 set -uo pipefail export LC_ALL=C umask 077 emit_error() { - case "${1:-}" in - E_USAGE|E_RUNTIME|E_LIMIT|E_PARSE|E_CANONICAL|E_RELATION|E_POLICY_SET|E_CORE) - /usr/bin/printf '%s\n' "$1" >&2 - ;; - *) /usr/bin/printf '%s\n' E_RUNTIME >&2 ;; + local token=${1:-E_RUNTIME} current_stage=${YSTACK_EVIDENCE_STAGE:-bootstrap} + case "$token" in + E_USAGE|E_RUNTIME|E_LIMIT|E_PARSE|E_CANONICAL|E_RELATION|E_POLICY_SET|E_CORE) ;; + *) token=E_RUNTIME ;; esac + if [ "$current_stage" != worker ] && [ -n "${scratch:-}" ] && + declare -F cleanup >/dev/null 2>&1; then + if cleanup; then + trap - EXIT HUP INT TERM + else + exec >/dev/null 2>&1 + exit 125 + fi + fi + /usr/bin/printf '%s\n' "$token" >&2 exit 1 } @@ -24,6 +33,37 @@ physical_regular() { [ "$candidate" = "$physical/${candidate##*/}" ] } +expected_jq_digest() { + case "$(/usr/bin/uname -s):$(/usr/bin/uname -m)" in + Darwin:*) + /usr/bin/printf '%s\n' \ + 5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef + ;; + Linux:x86_64) + /usr/bin/printf '%s\n' \ + af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 + ;; + *) return 1 ;; + esac +} + +private_mode_ok() { + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:mode -e ' + my @st=lstat($ARGV[0]); + exit 1 unless @st && S_ISREG($st[2]) && (($st[2] & 07777) == 0500); + ' "$1" +} + +private_directory_ok() { + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:mode -MCwd=abs_path -e ' + my ($path)=@ARGV; my @st=lstat($path); my $physical=abs_path($path); + exit 1 unless @st && S_ISDIR($st[2]) && (($st[2] & 07777) == 0700) && + defined($physical) && $physical eq $path; + ' "$1" +} + path_identity() { /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' @@ -335,12 +375,7 @@ if [ "$stage" = bootstrap ]; then live_jq="$live_jq_parent/${live_jq##*/}" physical_regular "$live_jq" || emit_error E_RUNTIME live_jq_identity=$(path_identity "$live_jq" 16777216) || emit_error E_RUNTIME - platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) - case "$platform" in - Darwin:*) expected_jq=5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef ;; - Linux:x86_64) expected_jq=af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 ;; - *) emit_error E_RUNTIME ;; - esac + expected_jq=$(expected_jq_digest) || emit_error E_RUNTIME [ "${live_jq_identity##*:}" = "$expected_jq" ] || emit_error E_RUNTIME scratch=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence.XXXXXX" 2>/dev/null) || emit_error E_RUNTIME @@ -374,12 +409,29 @@ source_path=${BASH_SOURCE[0]} case "$source_path" in /*) ;; *) source_path="$(pwd -P)/$source_path" ;; esac origin=${YSTACK_EVIDENCE_ORIGIN:-} jq_bin=${YSTACK_EVIDENCE_PRIVATE_JQ:-} +[ -n "$scratch" ] || emit_error E_RUNTIME +case "$scratch" in /*/ystack-evidence.??????) ;; *) emit_error E_RUNTIME ;; esac +private_directory_ok "$scratch" || emit_error E_RUNTIME [ "$source_path" = "$scratch/driver.sh" ] && [ "$jq_bin" = "$scratch/bin/jq" ] || emit_error E_RUNTIME -PINNED_PATHS=("$origin" "$source_path" "${YSTACK_EVIDENCE_LIVE_JQ:-}" "$jq_bin") -PINNED_IDENTITIES=("${YSTACK_EVIDENCE_ORIGIN_ID:-}" \ - "${YSTACK_EVIDENCE_PRIVATE_DRIVER_ID:-}" "${YSTACK_EVIDENCE_LIVE_JQ_ID:-}" \ - "${YSTACK_EVIDENCE_PRIVATE_JQ_ID:-}") +origin_id=${YSTACK_EVIDENCE_ORIGIN_ID:-} +private_driver_id=${YSTACK_EVIDENCE_PRIVATE_DRIVER_ID:-} +live_jq_path=${YSTACK_EVIDENCE_LIVE_JQ:-} +live_jq_id=${YSTACK_EVIDENCE_LIVE_JQ_ID:-} +private_jq_id=${YSTACK_EVIDENCE_PRIVATE_JQ_ID:-} +PINNED_PATHS=("$origin" "$source_path" "$live_jq_path" "$jq_bin") +PINNED_IDENTITIES=("$origin_id" "$private_driver_id" "$live_jq_id" "$private_jq_id") +for internal_identity in "${PINNED_IDENTITIES[@]}"; do + [ -n "$internal_identity" ] || emit_error E_RUNTIME +done +expected_jq=$(expected_jq_digest) || emit_error E_RUNTIME +[ "${origin_id##*:}" = "${private_driver_id##*:}" ] || emit_error E_RUNTIME +[ "${live_jq_id##*:}" = "$expected_jq" ] && + [ "${private_jq_id##*:}" = "$expected_jq" ] || + emit_error E_RUNTIME +if ! private_mode_ok "$source_path" || ! private_mode_ok "$jq_bin"; then + emit_error E_RUNTIME +fi verify_all_pins || emit_error E_RELATION if [ "$stage" = supervisor ]; then @@ -402,8 +454,15 @@ if [ "$stage" = supervisor ]; then if [ "$worker_status" -ne 0 ]; then emit_supervisor_failure "$error_text"; fi [ -z "$error_text" ] || emit_supervisor_failure E_RUNTIME pin_path "$scratch/worker.out" || emit_supervisor_failure E_RUNTIME - output_identity=$(pinned_identity "$scratch/worker.out") || emit_supervisor_failure E_RUNTIME - output_text=$(capture_identity_text "$scratch/worker.out" "$output_identity") || + worker_output_identity=$(pinned_identity "$scratch/worker.out") || + emit_supervisor_failure E_RUNTIME + worker_output_text=$(capture_identity_text "$scratch/worker.out" \ + "$worker_output_identity") || emit_supervisor_failure E_RUNTIME + [ -z "$worker_output_text" ] || emit_supervisor_failure E_RUNTIME + pin_path "$scratch/evaluation.json" || emit_supervisor_failure E_RUNTIME + output_identity=$(pinned_identity "$scratch/evaluation.json") || + emit_supervisor_failure E_RUNTIME + output_text=$(capture_identity_text "$scratch/evaluation.json" "$output_identity") || emit_supervisor_failure E_RUNTIME verify_all_pins || emit_supervisor_failure E_RELATION if ! cleanup; then exit 1; fi @@ -835,4 +894,4 @@ canonical_json "$scratch/evaluation.json" "$scratch/evaluation.canonical" || pin_path "$scratch/evaluation.json" || emit_error E_RUNTIME verify_all_pins || emit_error E_RELATION -/bin/cat "$scratch/evaluation.json" || emit_error E_RUNTIME +exit 0 diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index 9d43508..824c3ba 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"3e5f5e181dbb576a6b00b06f58d7afe57a057f9ad97e1d9f7b4fc2f8faf87a8c"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"19b32c19f682e800a36928758fc71f3f1747b8c15a4f19efd3f1f40ef29639bc"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"4a9c8475af56bb6d4e9141d467fb46569147b56d32b6e412b20371bef99c3679"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"51d16cfe79c2092daaae3c2b14270c281b3a0f1be393c94b84796f2bbf90878b"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/control/v1/evidence-integrity.jq b/control/v1/evidence-integrity.jq index d29abd3..016f162 100644 --- a/control/v1/evidence-integrity.jq +++ b/control/v1/evidence-integrity.jq @@ -103,15 +103,14 @@ def normalized_array($value): def duplicate_scalar_key($entries;$field): ($entries | normalized_array(.)) as $items | - if all($items[];type=="object") then + if all($items[];evidence_ok) then ($items | map(.[$field])) as $keys | ($keys | length) != ($keys | unique | length) else false end; def duplicate_prior_key($entries): ($entries | normalized_array(.)) as $items | - if all($items[]; - type=="object" and (.stage_result_ref? | type)=="object") then + if all($items[];evidence_ref_ok) then ($items | map([.stage_result_ref.sha256,.evidence_id])) as $keys | ($keys | length) != ($keys | unique | length) else false end; diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 355e76c..479d124 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -21,6 +21,29 @@ fail() { /usr/bin/printf 'FAIL: %s\n' "$1" >&2; exit 1; } passes=0 pass() { passes=$((passes + 1)); /usr/bin/printf 'ok %s - %s\n' "$passes" "$1"; } sha256_path() { /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}'; } +test_path_identity() { + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' + my ($path)=@ARGV; my ($parent,$name)=$path =~ m{\A(.+)/([^/]+)\z}; + exit 2 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + my @parent=lstat($parent); chdir($parent) or exit 2; + my @cwd=stat("."); my @leaf=lstat($name); + exit 2 unless @parent && @cwd && @leaf && S_ISDIR($parent[2]) && + S_ISREG($leaf[2]); + sysopen(my $input,$name,O_RDONLY|O_NOFOLLOW) or exit 2; + binmode($input); my @opened=stat($input); my $sha=Digest::SHA->new(256); + while (1) { my $read=sysread($input,my $buffer,65536); exit 2 unless + defined($read); last if $read==0; $sha->add($buffer); } + my @after=stat($input); my @path_after=lstat($name); + exit 2 unless @opened && @after && @path_after && + $leaf[0]==$opened[0] && $leaf[1]==$opened[1] && + $opened[0]==$after[0] && $opened[1]==$after[1] && + $after[0]==$path_after[0] && $after[1]==$path_after[1]; + print $parent[0],":",$parent[1],":",$leaf[0],":",$leaf[1],":", + $leaf[7],":",$leaf[9],":",$leaf[10],":",$sha->hexdigest,"\n"; + ' "$1" +} platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) case "$platform" in @@ -176,6 +199,22 @@ run_eval() { "$jq_bin" -S -c . "$out" >"$tmp/$name.canonical" /usr/bin/cmp -s "$out" "$tmp/$name.canonical" || fail "$name canonical" } +run_eval_tuple() { + local name=$1 request_input=$2 resolved_input=$3 result_input=$4 input=$5 + local runtime=${6:-$root} run_status=0 + local out="$tmp/$name.out" err="$tmp/$name.err" + PATH="$bin:/usr/bin:/bin" "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$policy_set" "$request_input" "$resolved_input" "$result_input" "$input" \ + >"$out" 2>"$err" || run_status=$? + if [ "$run_status" -ne 0 ]; then + /usr/bin/printf 'diagnostic %s status=%s stderr=' "$name" "$run_status" >&2 + /bin/cat "$err" >&2 + fail "$name status" + fi + [ ! -s "$err" ] || fail "$name stderr" + "$jq_bin" -S -c . "$out" >"$tmp/$name.canonical" + /usr/bin/cmp -s "$out" "$tmp/$name.canonical" || fail "$name canonical" +} expect_error() { local name=$1 expected=$2 policy_input=${3:-$policy_set} request_input=${4:-$request} local resolved_input=${5:-$resolved} result_input=${6:-$result} @@ -198,21 +237,6 @@ pure_eval() { --arg resolved_sha "$resolved_sha" --arg result_sha "$result_sha" \ --arg presentation_sha "$(sha256_path "$input")" >"$output" } -pure_eval_tuple() { - local request_input=$1 result_input=$2 presentation_input=$3 output=$4 - local request_digest result_digest - request_digest=$(sha256_path "$request_input") - result_digest=$(sha256_path "$result_input") - "$jq_bin" -S -c -n -f "$program" --slurpfile policy "$policy" \ - --slurpfile decision "$definition" --slurpfile policy_set "$policy_set" \ - --slurpfile request "$request_input" --slurpfile resolved "$resolved" \ - --slurpfile result "$result_input" --slurpfile presentation "$presentation_input" \ - --arg policy_sha "$policy_sha" --arg decision_sha "$definition_sha" \ - --arg policy_set_sha "$(sha256_path "$policy_set")" \ - --arg request_sha "$request_digest" --arg resolved_sha "$resolved_sha" \ - --arg result_sha "$result_digest" \ - --arg presentation_sha "$(sha256_path "$presentation_input")" >"$output" -} expect_pure_violation() { local name=$1 filter=$2 reason=$3 second_reason=${4:-} local input="$tmp/$name.presentation" output="$tmp/$name.pure" @@ -293,40 +317,66 @@ expect_full_malformed_element current-null '.body.evidence=[null]' \ evidence.current-mismatch expect_full_malformed_element current-array '.body.evidence=[[]]' \ evidence.current-mismatch +expect_full_malformed_element current-object-missing '.body.evidence=[{},{}]' \ + evidence.current-mismatch +expect_full_malformed_element current-object-wrong-type \ + '.body.evidence=[{evidence_id:1,kind:1,proof_ref:1,verdict:1}, + {evidence_id:1,kind:1,proof_ref:1,verdict:1}]' evidence.current-mismatch expect_full_malformed_element prior-scalar '.body.prior_evidence_refs=[1]' \ evidence.prior-stale expect_full_malformed_element prior-null '.body.prior_evidence_refs=[null]' \ evidence.prior-stale expect_full_malformed_element prior-array '.body.prior_evidence_refs=[[]]' \ evidence.prior-stale +expect_full_malformed_element prior-object-missing \ + '.body.prior_evidence_refs=[{},{}]' evidence.prior-stale +expect_full_malformed_element prior-object-wrong-type \ + '.body.prior_evidence_refs=[{evidence_id:1,stage_result_ref:1}, + {evidence_id:1,stage_result_ref:1}]' evidence.prior-stale +shared_request="$tmp/shared-proof.request" +"$jq_bin" -L "$root/scripts/test" -S -c -n --arg resolved_sha "$resolved_sha" ' + import "portable-core-profile-graph-fixtures" as profile; + import "portable-core-stage-request-fixtures" as request; + request::request_doc("verifier";$resolved_sha) | + walk(if type == "object" and has("schema_version") then .schema_version=2 else . end) | + .body.qualification_ref=profile::scope("qualification";"qualification.shared";("7"*64)) +' >"$shared_request" +shared_request_sha=$(sha256_path "$shared_request") shared_result="$tmp/shared-proof.result" shared_presentation="$tmp/shared-proof.presentation" -shared_output="$tmp/shared-proof.out" -"$jq_bin" -S -c ' - .body.evidence += [(.body.evidence[0] | - .evidence_id="evidence.zzz" | .kind="runtime-alt" | - .proof_ref.content_id="proof.logical-alt" | - .proof_ref.media_type="application/vnd.ystack.alt-proof+json")] | - .body.evidence |= sort_by(.evidence_id) -' "$result" >"$shared_result" +"$jq_bin" -L "$root/scripts/test" -S -c -n \ + --slurpfile request "$shared_request" --slurpfile resolved "$resolved" \ + --arg request_sha "$shared_request_sha" --arg resolved_sha "$resolved_sha" ' + import "portable-core-result-truth-fixtures" as result; + result::completed_result_doc($request[0];$request_sha;$resolved[0];$resolved_sha) | + walk(if type == "object" and has("schema_version") then .schema_version=2 else . end) +' >"$shared_result" shared_result_sha=$(sha256_path "$shared_result") -"$jq_bin" -S -c --slurpfile result "$shared_result" \ - --arg result_sha "$shared_result_sha" ' - .body.evidence=$result[0].body.evidence | - .body.result_ref={schema_version:$result[0].schema_version,kind:$result[0].kind, - id:$result[0].id,sha256:$result_sha} -' "$presentation" >"$shared_presentation" -pure_eval_tuple "$request" "$shared_result" "$shared_presentation" "$shared_output" +"$jq_bin" -S -c -n --arg request_sha "$shared_request_sha" \ + --arg resolved_sha "$resolved_sha" --arg result_sha "$shared_result_sha" \ + --slurpfile request "$shared_request" --slurpfile resolved "$resolved" \ + --slurpfile result "$shared_result" ' + def doc($value;$sha): + {schema_version:$value.schema_version,kind:$value.kind,id:$value.id,sha256:$sha}; + {schema_version:1,kind:"evidence_integrity_presentation",id:"evidence.presentation.shared", + body:{evidence:$result[0].body.evidence, + prior_evidence_refs:$request[0].body.prior_evidence_refs, + qualification_ref:{state:"present",value:$request[0].body.qualification_ref}, + request_ref:doc($request[0];$request_sha), + resolved_profile_ref:doc($resolved[0];$resolved_sha), + result_ref:doc($result[0];$result_sha)}} +' >"$shared_presentation" +run_eval_tuple shared-proof "$shared_request" "$resolved" "$shared_result" \ + "$shared_presentation" "$jq_bin" -e ' .body.verdict=="satisfied" and .body.reason_ids==["evidence.integrity-satisfied"] and - (.body.evidence_refs|length)==2 and - (.body.evidence_refs[0].proof_ref.sha256==.body.evidence_refs[1].proof_ref.sha256) and - (.body.evidence_refs[0].proof_ref.content_id!= - .body.evidence_refs[1].proof_ref.content_id) -' "$shared_output" >/dev/null || { - /bin/cat "$shared_output" >&2 + (.body.evidence_refs|length)==3 and + (.body.evidence_refs|map(.proof_ref.sha256)|unique|length)==1 and + (.body.evidence_refs|map(.proof_ref.content_id)|unique|length)==3 +' "$tmp/shared-proof.out" >/dev/null || { + /bin/cat "$tmp/shared-proof.out" >&2 fail 'same proof bytes distinct logical refs' } pass 'same proof digest under distinct logical refs remains valid' @@ -335,11 +385,12 @@ shared_mismatch="$tmp/shared-proof-mismatch.presentation" "$jq_bin" -S -c \ '.body.evidence[0].proof_ref.content_id="proof.presentation-only"' \ "$shared_presentation" >"$shared_mismatch" -pure_eval_tuple "$request" "$shared_result" "$shared_mismatch" "$shared_output" +run_eval_tuple shared-proof-mismatch "$shared_request" "$resolved" "$shared_result" \ + "$shared_mismatch" "$jq_bin" -e ' .body.verdict=="violated" and (.body.reason_ids|index("evidence.current-mismatch")!=null) -' "$shared_output" >/dev/null || fail 'logical ref identity mismatch' +' "$tmp/shared-proof-mismatch.out" >/dev/null || fail 'logical ref identity mismatch' pass 'changed logical proof identity with retained digest fails closed' absent_request="$tmp/qualification-absent.request" @@ -348,9 +399,13 @@ absent_presentation="$tmp/qualification-absent.presentation" absent_output="$tmp/qualification-absent.out" "$jq_bin" -S -c 'del(.body.qualification_ref)' "$request" >"$absent_request" absent_request_sha=$(sha256_path "$absent_request") -"$jq_bin" -S -c --arg request_sha "$absent_request_sha" \ - '.body.request_ref.sha256=$request_sha | - .body.evidence[0].verdict="inconclusive"' "$result" >"$absent_result" +"$jq_bin" -L "$root/scripts/test" -S -c -n \ + --slurpfile request "$absent_request" --slurpfile resolved "$resolved" \ + --arg request_sha "$absent_request_sha" --arg resolved_sha "$resolved_sha" ' + import "portable-core-result-truth-fixtures" as result; + result::failed_result_doc($request[0];$request_sha;$resolved[0];$resolved_sha) | + walk(if type == "object" and has("schema_version") then .schema_version=2 else . end) +' >"$absent_result" absent_result_sha=$(sha256_path "$absent_result") "$jq_bin" -S -c --slurpfile request "$absent_request" \ --slurpfile result "$absent_result" --arg request_sha "$absent_request_sha" \ @@ -362,14 +417,14 @@ absent_result_sha=$(sha256_path "$absent_result") id:$result[0].id,sha256:$result_sha} | .body.evidence=$result[0].body.evidence ' "$presentation" >"$absent_presentation" -pure_eval_tuple "$absent_request" "$absent_result" "$absent_presentation" \ - "$absent_output" +run_eval_tuple qualification-absent "$absent_request" "$resolved" "$absent_result" \ + "$absent_presentation" "$jq_bin" -e ' .body.verdict=="satisfied" and .body.qualification_observation=={state:"absent"} and .body.qualification_semantics=="identity-only-unqualified" and .body.authority_effect=="none" and .body.storage_effect=="none" and - (.body.evidence_refs[0].verdict=="inconclusive") + (.body.evidence_refs[0].verdict=="failed") ' "$absent_output" >/dev/null || fail 'absent qualification identity-only' pass 'absent qualification and inconclusive proof remain identity-only' @@ -455,6 +510,50 @@ copy_runtime() { /bin/cp -R "$root/core/v2" "$destination/core/v2" } +forged_stage_case() { + local stage=$1 suffix runtime="$tmp/forged-$1-runtime" scratch + local sentinel="$tmp/forged-$1-sentinel" physical_tmp origin live driver_id live_id + local private_driver_id + local status=0 + case "$stage" in supervisor) suffix=SUPERV ;; worker) suffix=WORKER ;; esac + scratch="$tmp/ystack-evidence.$suffix" + copy_runtime "$runtime" + runtime=$(CDPATH='' cd -P -- "$runtime" && pwd -P) + /bin/mkdir -m 0700 "$scratch" "$scratch/bin" + scratch=$(CDPATH='' cd -P -- "$scratch" && pwd -P) + origin="$runtime/control/v1/evaluate-evidence-integrity.sh" + /bin/cp "$origin" "$scratch/driver.sh" + /bin/chmod 0500 "$scratch/driver.sh" + /usr/bin/printf '%s\n' '#!/bin/bash' \ + "sentinel='$sentinel'" \ + 'if [ "${1:-}" = --version ]; then echo jq-1.6; exit 0; fi' \ + ': >"$sentinel"' \ + 'exec /usr/bin/jq "$@"' >"$scratch/bin/jq" + /bin/chmod 0500 "$scratch/bin/jq" + live="$scratch/bin/jq" + driver_id=$(test_path_identity "$origin") + private_driver_id=$(test_path_identity "$scratch/driver.sh") + live_id=$(test_path_identity "$live") + physical_tmp=$(CDPATH='' cd -P -- "$tmp" && pwd -P) + /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ + YSTACK_EVIDENCE_STAGE="$stage" YSTACK_EVIDENCE_SCRATCH="$scratch" \ + YSTACK_EVIDENCE_ORIGIN="$origin" YSTACK_EVIDENCE_ORIGIN_ID="$driver_id" \ + YSTACK_EVIDENCE_PRIVATE_DRIVER_ID="$private_driver_id" \ + YSTACK_EVIDENCE_LIVE_JQ="$live" YSTACK_EVIDENCE_LIVE_JQ_ID="$live_id" \ + YSTACK_EVIDENCE_PRIVATE_JQ="$live" YSTACK_EVIDENCE_PRIVATE_JQ_ID="$live_id" \ + /bin/bash "$scratch/driver.sh" evaluate "$physical_tmp/policy-set.json" \ + "$physical_tmp/request.json" "$physical_tmp/resolved.json" \ + "$physical_tmp/result.json" "$physical_tmp/presentation.json" \ + >"$tmp/forged-$stage.out" 2>"$tmp/forged-$stage.err" || status=$? + [ "$status" -ne 0 ] && [ ! -s "$tmp/forged-$stage.out" ] && + [ "$(/bin/cat "$tmp/forged-$stage.err")" = E_RUNTIME ] && + [ ! -e "$sentinel" ] || fail "forged $stage stage" + pass "forged $stage stage cannot bypass official jq binding" +} + +forged_stage_case supervisor +forged_stage_case worker + mutated_decision_runtime="$tmp/mutated-decision-runtime" copy_runtime "$mutated_decision_runtime" "$jq_bin" -S -c '.body.semantics.authority_effect="unexpected"' \ @@ -629,10 +728,10 @@ output_runtime="$tmp/output-swap-runtime" output_scratch="$tmp/output-swap-scratch" copy_runtime "$output_runtime" /usr/bin/perl -0777 -pi -e ' - s{ pin_path "\$scratch/worker\.out" \|\| emit_supervisor_failure E_RUNTIME}{ - /bin/mv "\$scratch/worker.out" "\$scratch/worker.saved" || exit 1; - /bin/ln -s "\$scratch/worker.saved" "\$scratch/worker.out" || exit 1; - pin_path "\$scratch/worker.out" || emit_supervisor_failure E_RUNTIME + s{ pin_path "\$scratch/evaluation\.json" \|\| emit_supervisor_failure E_RUNTIME}{ + /bin/mv "\$scratch/evaluation.json" "\$scratch/evaluation.saved" || exit 1; + /bin/ln -s "\$scratch/evaluation.saved" "\$scratch/evaluation.json" || exit 1; + pin_path "\$scratch/evaluation.json" || emit_supervisor_failure E_RUNTIME } ' "$output_runtime/control/v1/evaluate-evidence-integrity.sh" bind_modified_driver "$output_runtime" "$tmp/output-swap-set.json" @@ -664,6 +763,18 @@ TMPDIR="$cleanup_scratch" PATH="$bin:/usr/bin:/bin" \ fail 'cleanup failure output' pass 'cleanup failure remains non-success and emits no stdout' +cleanup_error_scratch="$tmp/cleanup-error-scratch" +/bin/mkdir "$cleanup_error_scratch" +cleanup_error_status=0 +TMPDIR="$cleanup_error_scratch" PATH="$bin:/usr/bin:/bin" \ + "$cleanup_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/cleanup-failure-set.json" "$request" "$resolved" "$result" \ + "$tmp/invalid.json" >"$tmp/cleanup-error.out" 2>"$tmp/cleanup-error.err" || + cleanup_error_status=$? +[ "$cleanup_error_status" -ne 0 ] && [ ! -s "$tmp/cleanup-error.out" ] && + [ ! -s "$tmp/cleanup-error.err" ] || fail 'cleanup failure error-path output' +pass 'error paths emit nothing when scratch cleanup fails' + for required in control/v1/evidence-integrity-policy.json \ control/v1/evidence-integrity-decision.json control/v1/evidence-integrity.jq \ control/v1/evaluate-evidence-integrity.sh \ From b3bc1c7238354540ff4f640ea67315676cdbc078 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 21:20:41 -0400 Subject: [PATCH 09/16] Close final evidence review findings --- control/v1/evaluate-evidence-integrity.sh | 37 ++++++++-- control/v1/evidence-integrity-decision.json | 2 +- control/v1/evidence-integrity.jq | 66 +++++++++++++++-- .../test/control-evidence-integrity.test.sh | 71 ++++++++++++++++++- 4 files changed, 161 insertions(+), 15 deletions(-) diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index a37a38b..c7b9de8 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -55,15 +55,25 @@ private_mode_ok() { ' "$1" } -private_directory_ok() { +directory_identity() { /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:mode -MCwd=abs_path -e ' - my ($path)=@ARGV; my @st=lstat($path); my $physical=abs_path($path); - exit 1 unless @st && S_ISDIR($st[2]) && (($st[2] & 07777) == 0700) && - defined($physical) && $physical eq $path; + my ($path)=@ARGV; my ($parent,$name)=$path =~ m{\A(.+)/([^/]+)\z}; + exit 1 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + my @parent=lstat($parent); my @dir=lstat($path); my $physical=abs_path($path); + exit 1 unless @parent && @dir && S_ISDIR($parent[2]) && S_ISDIR($dir[2]) && + (($dir[2] & 07777) == 0700) && defined($physical) && $physical eq $path; + print $parent[0],":",$parent[1],":",$dir[0],":",$dir[1],"\n"; ' "$1" } +directory_matches_identity() { + local actual + actual=$(directory_identity "$1") || return 1 + [ "$actual" = "$2" ] +} + path_identity() { /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' @@ -252,12 +262,17 @@ sha256_path() { /usr/bin/printf '%s\n' "${identity##*:}" } -scratch=${YSTACK_EVIDENCE_SCRATCH:-} +stage_hint=${YSTACK_EVIDENCE_STAGE:-bootstrap} +if [ "$stage_hint" = bootstrap ]; then scratch=; else scratch=${YSTACK_EVIDENCE_SCRATCH:-}; fi +SCRATCH_ID=${YSTACK_EVIDENCE_SCRATCH_ID:-} +SCRATCH_OWNED=0 ACTIVE_PID= ACTIVE_PGID= cleanup() { [ -z "${scratch:-}" ] && return 0 case "$scratch" in /*/ystack-evidence.??????) ;; *) return 1 ;; esac + [ "$SCRATCH_OWNED" -eq 1 ] && [ -n "$SCRATCH_ID" ] && + directory_matches_identity "$scratch" "$SCRATCH_ID" || return 1 /bin/rm -rf -- "$scratch" >/dev/null 2>&1 && [ ! -e "$scratch" ] && [ ! -L "$scratch" ] } @@ -350,7 +365,7 @@ emit_supervisor_failure() { } [ "$#" -eq 6 ] && [ "$1" = evaluate ] || emit_error E_USAGE -stage=${YSTACK_EVIDENCE_STAGE:-bootstrap} +stage=$stage_hint if [ "$stage" = bootstrap ]; then normalized_args=(evaluate) for input in "${@:2}"; do @@ -381,6 +396,8 @@ if [ "$stage" = bootstrap ]; then emit_error E_RUNTIME scratch=$(CDPATH='' cd -P -- "$scratch" 2>/dev/null && pwd -P) || emit_error E_RUNTIME /bin/chmod 0700 "$scratch" || emit_error E_RUNTIME + SCRATCH_ID=$(directory_identity "$scratch") || emit_error E_RUNTIME + SCRATCH_OWNED=1 /bin/mkdir -m 0700 "$scratch/bin" || emit_error E_RUNTIME private_driver="$scratch/driver.sh" private_jq="$scratch/bin/jq" @@ -395,6 +412,7 @@ if [ "$stage" = bootstrap ]; then private_jq_identity=$(path_identity "$private_jq" 16777216) || emit_error E_RUNTIME exec /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ YSTACK_EVIDENCE_STAGE=supervisor YSTACK_EVIDENCE_SCRATCH="$scratch" \ + YSTACK_EVIDENCE_SCRATCH_ID="$SCRATCH_ID" \ YSTACK_EVIDENCE_ORIGIN="$origin" \ YSTACK_EVIDENCE_ORIGIN_ID="$origin_identity" \ YSTACK_EVIDENCE_PRIVATE_DRIVER_ID="$private_driver_identity" \ @@ -411,7 +429,11 @@ origin=${YSTACK_EVIDENCE_ORIGIN:-} jq_bin=${YSTACK_EVIDENCE_PRIVATE_JQ:-} [ -n "$scratch" ] || emit_error E_RUNTIME case "$scratch" in /*/ystack-evidence.??????) ;; *) emit_error E_RUNTIME ;; esac -private_directory_ok "$scratch" || emit_error E_RUNTIME +if [ -z "$SCRATCH_ID" ] || + ! directory_matches_identity "$scratch" "$SCRATCH_ID"; then + emit_error E_RUNTIME +fi +SCRATCH_OWNED=1 [ "$source_path" = "$scratch/driver.sh" ] && [ "$jq_bin" = "$scratch/bin/jq" ] || emit_error E_RUNTIME origin_id=${YSTACK_EVIDENCE_ORIGIN_ID:-} @@ -438,6 +460,7 @@ if [ "$stage" = supervisor ]; then worker_status=0 run_child /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ YSTACK_EVIDENCE_STAGE=worker YSTACK_EVIDENCE_SCRATCH="$scratch" \ + YSTACK_EVIDENCE_SCRATCH_ID="$SCRATCH_ID" \ YSTACK_EVIDENCE_ORIGIN="$origin" \ YSTACK_EVIDENCE_ORIGIN_ID="${YSTACK_EVIDENCE_ORIGIN_ID:-}" \ YSTACK_EVIDENCE_PRIVATE_DRIVER_ID="${YSTACK_EVIDENCE_PRIVATE_DRIVER_ID:-}" \ diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index 824c3ba..f9f31f3 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"4a9c8475af56bb6d4e9141d467fb46569147b56d32b6e412b20371bef99c3679"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"51d16cfe79c2092daaae3c2b14270c281b3a0f1be393c94b84796f2bbf90878b"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"2b8f8d6ab1b5c6cb8606da55f8515ad17c604d0dc2daa5cef083512c03c2dd91"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/control/v1/evidence-integrity.jq b/control/v1/evidence-integrity.jq index 016f162..0acbd8b 100644 --- a/control/v1/evidence-integrity.jq +++ b/control/v1/evidence-integrity.jq @@ -7,20 +7,76 @@ def id_ok: def sha256_ok: type == "string" and test("\\A[0-9a-f]{64}\\z"); +def media_type_ok: + type == "string" and length <= 127 and + test("\\A[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*\\z"); + +def evidence_kind_ok: + type == "string" and + (. as $kind | + ["architecture","behavioral","deterministic","independent-review"] | + index($kind) != null); + def content_ref_ok: exact(["content_id","media_type","sha256"]) and - (.content_id | id_ok) and (.media_type | type == "string") and + (.content_id | id_ok) and (.content_id | contains(":") | not) and + (.content_id | contains("/") | not) and (.media_type | media_type_ok) and (.sha256 | sha256_ok); -def document_ref_ok($kind): +def document_ref_any_ok: exact(["id","kind","schema_version","sha256"]) and - .schema_version == 2 and .kind == $kind and (.id | id_ok) and + .schema_version == 2 and + (.kind as $kind | + ["adapter_manifest","profile","resolved_profile","stage_request","stage_result"] | + index($kind) != null) and (.id | id_ok) and (.sha256 | sha256_ok); +def document_ref_ok($kind): + document_ref_any_ok and .kind == $kind; + +def repo_path_ok: + type == "string" and length > 0 and + (test("[\\x{0000}-\\x{001f}\\x{007f}-\\x{009f}]") | not) and + (contains("\\") | not) and + (split("/") | all(.[];. != "" and . != "." and . != "..")); + +def git_revision_ref_ok: + exact(["repository_id","hash_algorithm","commit_id"]) and + (.repository_id | id_ok) and + (.hash_algorithm == "sha1" or .hash_algorithm == "sha256") and + (if .hash_algorithm == "sha1" then + (.commit_id | type == "string" and test("\\A[0-9a-f]{40}\\z")) + else (.commit_id | type == "string" and test("\\A[0-9a-f]{64}\\z")) end); + +def git_location_ok: + (exact(["kind"]) and .kind == "root") or + (exact(["kind","value"]) and .kind == "path" and (.value | repo_path_ok)); + +def git_object_ref_ok: + exact(["revision","location","object_type","object_id","mode"]) and + (.revision | git_revision_ref_ok) and (.location | git_location_ok) and + (.object_type == "blob" or .object_type == "tree") and + (if .revision.hash_algorithm == "sha1" then + (.object_id | type == "string" and test("\\A[0-9a-f]{40}\\z")) + else (.object_id | type == "string" and test("\\A[0-9a-f]{64}\\z")) end) and + (if .location.kind == "root" then .object_type == "tree" else true end) and + (if .object_type == "tree" then .mode == "040000" + else (.mode == "100644" or .mode == "100755") end); + +def artifact_ref_ok: + exact(["type","value"]) and + ((.type == "git-object" and (.value | git_object_ref_ok)) or + (.type == "content" and (.value | content_ref_ok))); + +def scope_subject_ok: + exact(["type","value"]) and + ((.type == "artifact" and (.value | artifact_ref_ok)) or + (.type == "document" and (.value | document_ref_any_ok))); + def scope_ref_ok($purpose): exact(["decision_record_ref","purpose","scope_sha256","subject_ref"]) and .purpose == $purpose and (.decision_record_ref | content_ref_ok) and - (.scope_sha256 | sha256_ok) and (.subject_ref | type == "object"); + (.scope_sha256 | sha256_ok) and (.subject_ref | scope_subject_ok); def presence_ok(value_ok): type == "object" and @@ -29,7 +85,7 @@ def presence_ok(value_ok): def evidence_ok: exact(["evidence_id","kind","proof_ref","verdict"]) and - (.evidence_id | id_ok) and (.kind | id_ok) and + (.evidence_id | id_ok) and (.kind | evidence_kind_ok) and (.verdict == "passed" or .verdict == "failed" or .verdict == "inconclusive") and (.proof_ref | content_ref_ok); diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 479d124..dd35491 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -44,6 +44,18 @@ test_path_identity() { $leaf[7],":",$leaf[9],":",$leaf[10],":",$sha->hexdigest,"\n"; ' "$1" } +test_directory_identity() { + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:mode -MCwd=abs_path -e ' + my ($path)=@ARGV; my ($parent,$name)=$path =~ m{\A(.+)/([^/]+)\z}; + exit 1 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + my @parent=lstat($parent); my @dir=lstat($path); my $physical=abs_path($path); + exit 1 unless @parent && @dir && S_ISDIR($parent[2]) && S_ISDIR($dir[2]) && + (($dir[2] & 07777) == 0700) && defined($physical) && $physical eq $path; + print $parent[0],":",$parent[1],":",$dir[0],":",$dir[1],"\n"; + ' "$1" +} platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) case "$platform" in @@ -288,14 +300,14 @@ expect_pure_violation ambiguous-presentation '.body.evidence += [.body.evidence[ evidence.presentation-ambiguous expect_pure_violation duplicate-evidence-id \ - '.body.evidence += [(.body.evidence[0] | .kind="runtime-alt")]' \ + '.body.evidence += [(.body.evidence[0] | .kind="behavioral")]' \ evidence.presentation-ambiguous evidence.presentation-malformed expect_pure_violation duplicate-evidence-kind \ '.body.evidence += [(.body.evidence[0] | .evidence_id="evidence.zzz")]' \ evidence.presentation-ambiguous evidence.presentation-malformed expect_pure_violation reversed-evidence \ '.body.evidence += [(.body.evidence[0] | .evidence_id="evidence.zzz" | - .kind="runtime-alt")] | .body.evidence |= reverse' \ + .kind="behavioral")] | .body.evidence |= reverse' \ evidence.presentation-malformed expect_pure_violation duplicate-prior-key \ '.body.prior_evidence_refs += [.body.prior_evidence_refs[0]]' \ @@ -494,6 +506,18 @@ PATH="$wrapper_bin:/usr/bin:/bin" "$evaluator" evaluate "$policy_set" \ [ "$(/bin/cat "$tmp/unbound-jq.err")" = E_RUNTIME ] || fail 'unbound jq result' pass 'only official jq 1.6 bytes are accepted' +inherited_scratch="$tmp/ystack-evidence.KEEPIT" +/bin/mkdir -m 0700 "$inherited_scratch" +: >"$inherited_scratch/owned-by-caller" +inherited_status=0 +YSTACK_EVIDENCE_SCRATCH="$inherited_scratch" PATH="$bin:/usr/bin:/bin" \ + "$evaluator" invalid >"$tmp/inherited.out" 2>"$tmp/inherited.err" || + inherited_status=$? +[ "$inherited_status" -ne 0 ] && [ ! -s "$tmp/inherited.out" ] && + [ "$(/bin/cat "$tmp/inherited.err")" = E_USAGE ] && + [ -f "$inherited_scratch/owned-by-caller" ] || fail 'inherited scratch ownership' +pass 'bootstrap ignores inherited scratch cleanup authority' + /usr/bin/grep -Fq \ '"$jq_bin" -n -e --arg policy_sha "$policy_sha" --arg driver_sha "$driver_sha"' \ "$evaluator" || fail 'decision envelope null-input mode' @@ -513,6 +537,7 @@ copy_runtime() { forged_stage_case() { local stage=$1 suffix runtime="$tmp/forged-$1-runtime" scratch local sentinel="$tmp/forged-$1-sentinel" physical_tmp origin live driver_id live_id + local scratch_id local private_driver_id local status=0 case "$stage" in supervisor) suffix=SUPERV ;; worker) suffix=WORKER ;; esac @@ -531,12 +556,14 @@ forged_stage_case() { 'exec /usr/bin/jq "$@"' >"$scratch/bin/jq" /bin/chmod 0500 "$scratch/bin/jq" live="$scratch/bin/jq" + scratch_id=$(test_directory_identity "$scratch") driver_id=$(test_path_identity "$origin") private_driver_id=$(test_path_identity "$scratch/driver.sh") live_id=$(test_path_identity "$live") physical_tmp=$(CDPATH='' cd -P -- "$tmp" && pwd -P) /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ YSTACK_EVIDENCE_STAGE="$stage" YSTACK_EVIDENCE_SCRATCH="$scratch" \ + YSTACK_EVIDENCE_SCRATCH_ID="$scratch_id" \ YSTACK_EVIDENCE_ORIGIN="$origin" YSTACK_EVIDENCE_ORIGIN_ID="$driver_id" \ YSTACK_EVIDENCE_PRIVATE_DRIVER_ID="$private_driver_id" \ YSTACK_EVIDENCE_LIVE_JQ="$live" YSTACK_EVIDENCE_LIVE_JQ_ID="$live_id" \ @@ -775,6 +802,46 @@ TMPDIR="$cleanup_error_scratch" PATH="$bin:/usr/bin:/bin" \ [ ! -s "$tmp/cleanup-error.err" ] || fail 'cleanup failure error-path output' pass 'error paths emit nothing when scratch cleanup fails' +scratch_swap_runtime="$tmp/scratch-swap-runtime" +scratch_swap_parent="$tmp/scratch-swap-parent" +scratch_swap_ready="$tmp/scratch-swap.ready" +scratch_swap_go="$tmp/scratch-swap.go" +copy_runtime "$scratch_swap_runtime" +/bin/mkdir "$scratch_swap_parent" +SCRATCH_READY="$scratch_swap_ready" SCRATCH_GO="$scratch_swap_go" \ + /usr/bin/perl -0777 -pi -e ' + my $ready=$ENV{"SCRATCH_READY"}; my $go=$ENV{"SCRATCH_GO"}; + my $replacement = qq{ /usr/bin/printf "ready\\n" >"$ready" || exit 1;\n} . + qq{ while [ ! -e "$go" ]; do /bin/sleep 0.01; done\n} . + qq{ if ! cleanup; then exit 1; fi}; + s{ if ! cleanup; then exit 1; fi}{$replacement} or exit 2; + ' "$scratch_swap_runtime/control/v1/evaluate-evidence-integrity.sh" +bind_modified_driver "$scratch_swap_runtime" "$tmp/scratch-swap-set.json" +TMPDIR="$scratch_swap_parent" PATH="$bin:/usr/bin:/bin" \ + "$scratch_swap_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/scratch-swap-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/scratch-swap.out" 2>"$tmp/scratch-swap.err" & +scratch_swap_pid=$! +scratch_swap_attempt=0 +while [ ! -e "$scratch_swap_ready" ] && kill -0 "$scratch_swap_pid" 2>/dev/null && + [ "$scratch_swap_attempt" -lt 1200 ]; do + scratch_swap_attempt=$((scratch_swap_attempt + 1)); /bin/sleep 0.005 +done +[ -e "$scratch_swap_ready" ] || fail 'scratch swap ready' +scratch_original=$(/usr/bin/find "$scratch_swap_parent" -mindepth 1 -maxdepth 1 \ + -type d -name 'ystack-evidence.??????' -print -quit) +[ -n "$scratch_original" ] || fail 'scratch swap original' +/bin/mv "$scratch_original" "$scratch_original.saved" +/bin/mkdir -m 0700 "$scratch_original" +: >"$scratch_original/replacement" +: >"$scratch_swap_go" +scratch_swap_status=0 +wait "$scratch_swap_pid" || scratch_swap_status=$? +[ "$scratch_swap_status" -ne 0 ] && [ ! -s "$tmp/scratch-swap.out" ] && + [ ! -s "$tmp/scratch-swap.err" ] && [ -d "$scratch_original.saved" ] && + [ -f "$scratch_original/replacement" ] || fail 'scratch swap cleanup identity' +pass 'scratch path replacement cannot authorize cleanup or output' + for required in control/v1/evidence-integrity-policy.json \ control/v1/evidence-integrity-decision.json control/v1/evidence-integrity.jq \ control/v1/evaluate-evidence-integrity.sh \ From ecb4f05d2fb85732b00ae879733ff39c18e979b4 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 22:11:36 -0400 Subject: [PATCH 10/16] Close evidence cleanup and signal races --- control/v1/evaluate-evidence-integrity.sh | 146 ++++++++++++++++-- control/v1/evidence-integrity-decision.json | 2 +- .../test/control-evidence-integrity.test.sh | 50 ++++++ 3 files changed, 182 insertions(+), 16 deletions(-) diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index c7b9de8..1fb9223 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -271,10 +271,58 @@ ACTIVE_PGID= cleanup() { [ -z "${scratch:-}" ] && return 0 case "$scratch" in /*/ystack-evidence.??????) ;; *) return 1 ;; esac - [ "$SCRATCH_OWNED" -eq 1 ] && [ -n "$SCRATCH_ID" ] && - directory_matches_identity "$scratch" "$SCRATCH_ID" || return 1 - /bin/rm -rf -- "$scratch" >/dev/null 2>&1 && - [ ! -e "$scratch" ] && [ ! -L "$scratch" ] + [ "$SCRATCH_OWNED" -eq 1 ] && [ -n "$SCRATCH_ID" ] || return 1 + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:mode -MCwd=abs_path -e ' + use strict; use warnings; + my ($path,$expected)=@ARGV; + my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); + my ($parent,$name)=$path =~ m{\A(.+)/([^/]+)\z}; + exit 1 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + opendir(my $parent_dh,$parent) or exit 1; + my @parent_st=stat($parent_dh); + exit 1 unless @parent_st && S_ISDIR($parent_st[2]) && + $parent_st[0]==$p_dev && $parent_st[1]==$p_ino; + chdir($parent_dh) or exit 1; + my @named=lstat($name); + exit 1 unless @named && S_ISDIR($named[2]) && + $named[0]==$d_dev && $named[1]==$d_ino; + opendir(my $root_dh,$name) or exit 1; + my @opened=stat($root_dh); + exit 1 unless @opened && S_ISDIR($opened[2]) && + $opened[0]==$d_dev && $opened[1]==$d_ino; + + sub empty_dir { + my ($dh)=@_; chdir($dh) or return 0; rewinddir($dh); + while (defined(my $entry=readdir($dh))) { + next if $entry eq "." or $entry eq ".."; + my @before=lstat($entry); return 0 unless @before; + if (S_ISDIR($before[2])) { + opendir(my $child,$entry) or return 0; + my @child_st=stat($child); + return 0 unless @child_st && $child_st[0]==$before[0] && + $child_st[1]==$before[1] && empty_dir($child); + chdir($dh) or return 0; + my @after=lstat($entry); + return 0 unless @after && S_ISDIR($after[2]) && + $after[0]==$before[0] && $after[1]==$before[1] && rmdir($entry); + } else { + return 0 unless unlink($entry); + } + chdir($dh) or return 0; + } + return 1; + } + + exit 1 unless empty_dir($root_dh); + chdir($parent_dh) or exit 1; + my @final=lstat($name); + exit 1 unless @final && S_ISDIR($final[2]) && + $final[0]==$d_dev && $final[1]==$d_ino && rmdir($name); + exit 1 if lstat($name); + ' "$scratch" "$SCRATCH_ID" >/dev/null 2>&1 || return 1 + [ ! -e "$scratch" ] && [ ! -L "$scratch" ] } group_live_count() { [[ "${1:-}" =~ ^[1-9][0-9]*$ ]] || return 1 @@ -317,38 +365,103 @@ signal_exit() { trap - EXIT HUP INT TERM exit "$status" } -trap cleanup EXIT -trap 'signal_exit 129' HUP -trap 'signal_exit 130' INT -trap 'signal_exit 143' TERM +PENDING_SIGNAL=0 +arm_signal_traps() { + trap cleanup EXIT + trap 'signal_exit 129' HUP + trap 'signal_exit 130' INT + trap 'signal_exit 143' TERM +} +defer_signal_traps() { + PENDING_SIGNAL=0 + trap 'PENDING_SIGNAL=129' HUP + trap 'PENDING_SIGNAL=130' INT + trap 'PENDING_SIGNAL=143' TERM +} +kill_unregistered_child() { + local child=$1 state attempt=0 + /bin/kill -TERM "$child" 2>/dev/null || : + state=$(leader_state "$child") || state= + while [ -n "$state" ] && [[ "$state" != Z* ]] && [ "$attempt" -lt 100 ]; do + attempt=$((attempt + 1)); /bin/sleep 0.01 + state=$(leader_state "$child") || state= + done + if [ -n "$state" ] && [[ "$state" != Z* ]]; then + /bin/kill -KILL "$child" 2>/dev/null || : + fi + wait "$child" 2>/dev/null || : + state=$(leader_state "$child") || state= + [ -z "$state" ] +} +arm_signal_traps run_child() { local child pgid state attempt=0 child_status=0 count self_pgid + local gate="$scratch/worker.gate" [ -z "${ACTIVE_PID:-}" ] && [ -z "${ACTIVE_PGID:-}" ] || return 125 - self_pgid=$(/bin/ps -o pgid= -p $$ 2>/dev/null | /usr/bin/tr -d ' ') || return 125 + defer_signal_traps + self_pgid=$(/bin/ps -o pgid= -p $$ 2>/dev/null | /usr/bin/tr -d ' ') || { + arm_signal_traps + [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" + return 125 + } + if [ -e "$gate" ] || [ -L "$gate" ] || + ! /usr/bin/mkfifo -m 0600 "$gate"; then + arm_signal_traps + [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" + return 125 + fi set -m - /bin/bash -c 'ulimit -f 2048; exec "$@"' evidence-child "$@" & + /bin/bash -c ' + gate=$1; shift + IFS= read -r gate_value <"$gate" || exit 125 + [ "$gate_value" = go ] || exit 125 + ulimit -f 2048 + exec "$@" + ' evidence-child "$gate" "$@" & child=$! set +m - pgid=$(/bin/ps -o pgid= -p "$child" 2>/dev/null | /usr/bin/tr -d ' ') || pgid= + while [ "$attempt" -lt 100 ]; do + pgid=$(/bin/ps -o pgid= -p "$child" 2>/dev/null | /usr/bin/tr -d ' ') || pgid= + if [[ "$pgid" =~ ^[1-9][0-9]*$ ]] && [ "$pgid" = "$child" ] && + [ "$pgid" != "$self_pgid" ]; then break; fi + state=$(leader_state "$child") || state= + [ -n "$state" ] && [[ "$state" != Z* ]] || break + attempt=$((attempt + 1)); /bin/sleep 0.01 + done if ! [[ "$pgid" =~ ^[1-9][0-9]*$ ]] || [ "$pgid" != "$child" ] || [ "$pgid" = "$self_pgid" ]; then - wait "$child" 2>/dev/null || :; return 125 + kill_unregistered_child "$child" || : + arm_signal_traps + [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" + return 125 fi ACTIVE_PID=$child; ACTIVE_PGID=$pgid + arm_signal_traps + [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" + /usr/bin/printf 'go\n' >"$gate" || { + terminate_active || return 125 + return 125 + } + attempt=0 state=$(leader_state "$child") || state= while [ -n "$state" ] && [[ "$state" != Z* ]] && [ "$attempt" -lt 1000 ]; do attempt=$((attempt + 1)); /bin/sleep 0.01 state=$(leader_state "$child") || state= done if [ -n "$state" ] && [[ "$state" != Z* ]]; then - terminate_active || :; return 124 + terminate_active || return 125 + return 124 fi wait "$child" || child_status=$? - count=$(group_live_count "$pgid") || return 125 + count=$(group_live_count "$pgid") || { + terminate_active || return 125 + return 125 + } if [ "$count" -ne 0 ]; then ACTIVE_PID=$child; ACTIVE_PGID=$pgid - terminate_active || :; return 125 + terminate_active || return 125 + return 125 fi ACTIVE_PID=; ACTIVE_PGID= return "$child_status" @@ -470,6 +583,9 @@ if [ "$stage" = supervisor ]; then YSTACK_EVIDENCE_PRIVATE_JQ_ID="${YSTACK_EVIDENCE_PRIVATE_JQ_ID:-}" \ /bin/bash "$source_path" "$@" >"$scratch/worker.out" 2>"$scratch/worker.err" || worker_status=$? + if [ -n "${ACTIVE_PGID:-}" ] || [ -n "${ACTIVE_PID:-}" ]; then + signal_exit 125 + fi pin_path "$scratch/worker.err" || emit_supervisor_failure E_RUNTIME error_identity=$(pinned_identity "$scratch/worker.err") || emit_supervisor_failure E_RUNTIME error_text=$(capture_identity_text "$scratch/worker.err" "$error_identity") || diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index f9f31f3..0675431 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"2b8f8d6ab1b5c6cb8606da55f8515ad17c604d0dc2daa5cef083512c03c2dd91"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"2ccb8903ae327e7aa13a7fcce17714dfc61f0db3ff9e3b44034d3701d106a60b"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index dd35491..62e622f 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -345,6 +345,14 @@ expect_full_malformed_element prior-object-missing \ expect_full_malformed_element prior-object-wrong-type \ '.body.prior_evidence_refs=[{evidence_id:1,stage_result_ref:1}, {evidence_id:1,stage_result_ref:1}]' evidence.prior-stale +expect_full_malformed_element invalid-evidence-kind \ + '.body.evidence[0].kind="runtime-alt"' evidence.current-mismatch +expect_full_malformed_element invalid-proof-content-id \ + '.body.evidence[0].proof_ref.content_id="proof:invalid"' evidence.current-mismatch +expect_full_malformed_element invalid-proof-media-type \ + '.body.evidence[0].proof_ref.media_type=""' evidence.current-mismatch +expect_full_malformed_element invalid-qualification-subject \ + '.body.qualification_ref.value.subject_ref={}' evidence.qualification-mismatch shared_request="$tmp/shared-proof.request" "$jq_bin" -L "$root/scripts/test" -S -c -n --arg resolved_sha "$resolved_sha" ' @@ -751,6 +759,48 @@ bind_modified_driver() { ' "$policy_set" >"$output_set" } +launch_runtime="$tmp/launch-signal-runtime" +launch_scratch="$tmp/launch-signal-scratch" +launch_ready="$tmp/launch-signal.ready" +launch_go="$tmp/launch-signal.go" +copy_runtime "$launch_runtime" +/bin/mkdir "$launch_scratch" +LAUNCH_READY="$launch_ready" LAUNCH_GO="$launch_go" \ + /usr/bin/perl -0777 -pi -e ' + my $ready=$ENV{"LAUNCH_READY"}; my $go=$ENV{"LAUNCH_GO"}; + my $replacement = qq{ child=\$!\n} . + qq{ /usr/bin/printf "%s\\n" "\$child" >"$ready" || exit 125\n} . + qq{ while [ ! -e "$go" ]; do /bin/sleep 0.01; done}; + s{ child=\$!}{$replacement} or exit 2; + ' "$launch_runtime/control/v1/evaluate-evidence-integrity.sh" +bind_modified_driver "$launch_runtime" "$tmp/launch-signal-set.json" +TMPDIR="$launch_scratch" PATH="$bin:/usr/bin:/bin" \ + "$launch_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/launch-signal-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/launch-signal.out" 2>"$tmp/launch-signal.err" & +launch_pid=$! +launch_attempt=0 +while [ ! -s "$launch_ready" ] && kill -0 "$launch_pid" 2>/dev/null && + [ "$launch_attempt" -lt 1200 ]; do + launch_attempt=$((launch_attempt + 1)); /bin/sleep 0.005 +done +[ -s "$launch_ready" ] || fail 'launch signal ready' +launch_child=$(/bin/cat "$launch_ready") +[[ "$launch_child" =~ ^[1-9][0-9]*$ ]] || fail 'launch signal child' +/bin/kill -TERM "$launch_pid" +: >"$launch_go" +wait_exit "$launch_pid" 500 || fail 'launch signal bounded exit' +launch_status=0 +wait "$launch_pid" || launch_status=$? +launch_live=$(/bin/ps -axo pgid=,state= 2>/dev/null | /usr/bin/awk \ + -v group="$launch_child" '$1==group && $2!~/^Z/ {count++} END {print count+0}') +[ "$launch_status" -ne 0 ] && ! kill -0 "$launch_child" 2>/dev/null && + [ "$launch_live" -eq 0 ] && [ ! -s "$tmp/launch-signal.out" ] && + [ ! -s "$tmp/launch-signal.err" ] && + [ -z "$(/usr/bin/find "$launch_scratch" -mindepth 1 -print -quit)" ] || + fail 'launch signal cleanup' +pass 'launch-window signals wait for owned child registration and reap' + output_runtime="$tmp/output-swap-runtime" output_scratch="$tmp/output-swap-scratch" copy_runtime "$output_runtime" From 7a503a6f3f97f3a4274bdc3cf779d9ecad41c7a8 Mon Sep 17 00:00:00 2001 From: ci Date: Tue, 1 Sep 2026 23:09:39 -0400 Subject: [PATCH 11/16] Authenticate evidence worker stages --- control/v1/evaluate-evidence-integrity.sh | 68 +++++++++++++++---- control/v1/evidence-integrity-decision.json | 2 +- .../test/control-evidence-integrity.test.sh | 46 +++++++++---- 3 files changed, 86 insertions(+), 30 deletions(-) diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index 1fb9223..1d9b143 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -23,6 +23,17 @@ emit_error() { exit 1 } +stage_auth_fail() { + exec >/dev/null 2>&1 + exit 125 +} + +terminal_teardown_fail() { + exec >/dev/null 2>&1 + trap - EXIT HUP INT TERM + exit 125 +} + physical_regular() { local candidate=$1 parent physical case "$candidate" in /*) ;; *) return 1 ;; esac @@ -360,7 +371,9 @@ terminate_active() { signal_exit() { local status=${1:-1} exec >/dev/null 2>&1 - if [ -n "${ACTIVE_PGID:-}" ]; then terminate_active || status=125; fi + if [ -n "${ACTIVE_PGID:-}" ] && ! terminate_active; then + terminal_teardown_fail + fi cleanup || status=125 trap - EXIT HUP INT TERM exit "$status" @@ -397,7 +410,7 @@ arm_signal_traps run_child() { local child pgid state attempt=0 child_status=0 count self_pgid - local gate="$scratch/worker.gate" + local worker_cap="$scratch/worker.cap" [ -z "${ACTIVE_PID:-}" ] && [ -z "${ACTIVE_PGID:-}" ] || return 125 defer_signal_traps self_pgid=$(/bin/ps -o pgid= -p $$ 2>/dev/null | /usr/bin/tr -d ' ') || { @@ -405,20 +418,25 @@ run_child() { [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" return 125 } - if [ -e "$gate" ] || [ -L "$gate" ] || - ! /usr/bin/mkfifo -m 0600 "$gate"; then + if [ -e "$worker_cap" ] || [ -L "$worker_cap" ] || + ! /usr/bin/mkfifo -m 0600 "$worker_cap"; then arm_signal_traps [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" return 125 fi + exec 9<>"$worker_cap" || { + arm_signal_traps + [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" + return 125 + } + /bin/rm -f -- "$worker_cap" || { + exec 9>&- + arm_signal_traps + [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" + return 125 + } set -m - /bin/bash -c ' - gate=$1; shift - IFS= read -r gate_value <"$gate" || exit 125 - [ "$gate_value" = go ] || exit 125 - ulimit -f 2048 - exec "$@" - ' evidence-child "$gate" "$@" & + "$@" & child=$! set +m while [ "$attempt" -lt 100 ]; do @@ -431,7 +449,8 @@ run_child() { done if ! [[ "$pgid" =~ ^[1-9][0-9]*$ ]] || [ "$pgid" != "$child" ] || [ "$pgid" = "$self_pgid" ]; then - kill_unregistered_child "$child" || : + kill_unregistered_child "$child" || terminal_teardown_fail + exec 9>&- arm_signal_traps [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" return 125 @@ -439,10 +458,12 @@ run_child() { ACTIVE_PID=$child; ACTIVE_PGID=$pgid arm_signal_traps [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" - /usr/bin/printf 'go\n' >"$gate" || { + /usr/bin/printf 'worker\n' >&9 || { + exec 9>&- terminate_active || return 125 return 125 } + exec 9>&- attempt=0 state=$(leader_state "$child") || state= while [ -n "$state" ] && [[ "$state" != Z* ]] && [ "$attempt" -lt 1000 ]; do @@ -479,6 +500,18 @@ emit_supervisor_failure() { [ "$#" -eq 6 ] && [ "$1" = evaluate ] || emit_error E_USAGE stage=$stage_hint +if [ "$stage" = supervisor ]; then + [ -p /dev/fd/8 ] || stage_auth_fail + IFS= read -r stage_capability <&8 || stage_auth_fail + exec 8<&- + [ "$stage_capability" = supervisor ] || stage_auth_fail +elif [ "$stage" = worker ]; then + [ -p /dev/fd/9 ] || stage_auth_fail + IFS= read -r stage_capability <&9 || stage_auth_fail + exec 9<&- + [ "$stage_capability" = worker ] || stage_auth_fail + ulimit -f 2048 || stage_auth_fail +fi if [ "$stage" = bootstrap ]; then normalized_args=(evaluate) for input in "${@:2}"; do @@ -523,7 +556,12 @@ if [ "$stage" = bootstrap ]; then emit_error E_RUNTIME /bin/chmod 0500 "$private_jq" || emit_error E_RUNTIME private_jq_identity=$(path_identity "$private_jq" 16777216) || emit_error E_RUNTIME - exec /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ + supervisor_cap="$scratch/supervisor.cap" + /usr/bin/mkfifo -m 0600 "$supervisor_cap" || emit_error E_RUNTIME + exec 8<>"$supervisor_cap" || emit_error E_RUNTIME + /bin/rm -f -- "$supervisor_cap" || emit_error E_RUNTIME + /usr/bin/printf 'supervisor\n' >&8 || emit_error E_RUNTIME + exec /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" TMPDIR="$scratch" \ YSTACK_EVIDENCE_STAGE=supervisor YSTACK_EVIDENCE_SCRATCH="$scratch" \ YSTACK_EVIDENCE_SCRATCH_ID="$SCRATCH_ID" \ YSTACK_EVIDENCE_ORIGIN="$origin" \ @@ -571,7 +609,7 @@ verify_all_pins || emit_error E_RELATION if [ "$stage" = supervisor ]; then worker_status=0 - run_child /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ + run_child /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" TMPDIR="$scratch" \ YSTACK_EVIDENCE_STAGE=worker YSTACK_EVIDENCE_SCRATCH="$scratch" \ YSTACK_EVIDENCE_SCRATCH_ID="$SCRATCH_ID" \ YSTACK_EVIDENCE_ORIGIN="$origin" \ diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index 0675431..ade213b 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"2ccb8903ae327e7aa13a7fcce17714dfc61f0db3ff9e3b44034d3701d106a60b"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"976abc8498d9f03457ae369e7aebb78b4a8feea474bbb728b273abe7612a3424"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 62e622f..5ea49f7 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -234,8 +234,12 @@ expect_error() { PATH="$bin:/usr/bin:/bin" "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ "$policy_input" "$request_input" "$resolved_input" "$result_input" \ "$presentation_input" >"$tmp/$name.out" 2>"$tmp/$name.err" || status=$? - [ "$status" -ne 0 ] && [ ! -s "$tmp/$name.out" ] && - [ "$(/bin/cat "$tmp/$name.err")" = "$expected" ] || fail "$name error" + if [ "$status" -eq 0 ] || [ -s "$tmp/$name.out" ] || + [ "$(/bin/cat "$tmp/$name.err")" != "$expected" ]; then + /usr/bin/printf 'diagnostic %s status=%s stderr=' "$name" "$status" >&2 + /bin/cat "$tmp/$name.err" >&2 + fail "$name error" + fi pass "$name" } pure_eval() { @@ -543,12 +547,19 @@ copy_runtime() { } forged_stage_case() { - local stage=$1 suffix runtime="$tmp/forged-$1-runtime" scratch - local sentinel="$tmp/forged-$1-sentinel" physical_tmp origin live driver_id live_id + local stage=$1 jq_mode=${2:-fake} + local suffix runtime="$tmp/forged-$stage-$jq_mode-runtime" scratch + local sentinel="$tmp/forged-$1-$jq_mode-sentinel" physical_tmp origin live + local driver_id live_id local scratch_id local private_driver_id local status=0 - case "$stage" in supervisor) suffix=SUPERV ;; worker) suffix=WORKER ;; esac + case "$stage:$jq_mode" in + supervisor:fake) suffix=FAKESV ;; + worker:fake) suffix=FAKEWK ;; + supervisor:official) suffix=OFFISV ;; + worker:official) suffix=OFFIWK ;; + esac scratch="$tmp/ystack-evidence.$suffix" copy_runtime "$runtime" runtime=$(CDPATH='' cd -P -- "$runtime" && pwd -P) @@ -557,11 +568,15 @@ forged_stage_case() { origin="$runtime/control/v1/evaluate-evidence-integrity.sh" /bin/cp "$origin" "$scratch/driver.sh" /bin/chmod 0500 "$scratch/driver.sh" - /usr/bin/printf '%s\n' '#!/bin/bash' \ - "sentinel='$sentinel'" \ - 'if [ "${1:-}" = --version ]; then echo jq-1.6; exit 0; fi' \ - ': >"$sentinel"' \ - 'exec /usr/bin/jq "$@"' >"$scratch/bin/jq" + if [ "$jq_mode" = official ]; then + /bin/cp "$jq_bin" "$scratch/bin/jq" + else + /usr/bin/printf '%s\n' '#!/bin/bash' \ + "sentinel='$sentinel'" \ + 'if [ "${1:-}" = --version ]; then echo jq-1.6; exit 0; fi' \ + ': >"$sentinel"' \ + 'exec /usr/bin/jq "$@"' >"$scratch/bin/jq" + fi /bin/chmod 0500 "$scratch/bin/jq" live="$scratch/bin/jq" scratch_id=$(test_directory_identity "$scratch") @@ -579,15 +594,18 @@ forged_stage_case() { /bin/bash "$scratch/driver.sh" evaluate "$physical_tmp/policy-set.json" \ "$physical_tmp/request.json" "$physical_tmp/resolved.json" \ "$physical_tmp/result.json" "$physical_tmp/presentation.json" \ - >"$tmp/forged-$stage.out" 2>"$tmp/forged-$stage.err" || status=$? - [ "$status" -ne 0 ] && [ ! -s "$tmp/forged-$stage.out" ] && - [ "$(/bin/cat "$tmp/forged-$stage.err")" = E_RUNTIME ] && + >"$tmp/forged-$stage-$jq_mode.out" \ + 2>"$tmp/forged-$stage-$jq_mode.err" || status=$? + [ "$status" -ne 0 ] && [ ! -s "$tmp/forged-$stage-$jq_mode.out" ] && + [ ! -s "$tmp/forged-$stage-$jq_mode.err" ] && [ ! -e "$sentinel" ] || fail "forged $stage stage" - pass "forged $stage stage cannot bypass official jq binding" + pass "forged $stage stage with $jq_mode jq cannot bypass parent capability" } forged_stage_case supervisor forged_stage_case worker +forged_stage_case supervisor official +forged_stage_case worker official mutated_decision_runtime="$tmp/mutated-decision-runtime" copy_runtime "$mutated_decision_runtime" From 3dc61d9a38c6a7b20c9ae6e3eb886135f7f6ef91 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 2 Sep 2026 00:00:56 -0400 Subject: [PATCH 12/16] fix(control): close evidence worker entry races --- control/v1/evaluate-evidence-integrity.sh | 304 ++++++++++-------- control/v1/evidence-integrity-decision.json | 2 +- .../test/control-evidence-integrity.test.sh | 62 +++- 3 files changed, 228 insertions(+), 140 deletions(-) diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index 1d9b143..aa54723 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -5,12 +5,12 @@ export LC_ALL=C umask 077 emit_error() { - local token=${1:-E_RUNTIME} current_stage=${YSTACK_EVIDENCE_STAGE:-bootstrap} + local token=${1:-E_RUNTIME} case "$token" in E_USAGE|E_RUNTIME|E_LIMIT|E_PARSE|E_CANONICAL|E_RELATION|E_POLICY_SET|E_CORE) ;; *) token=E_RUNTIME ;; esac - if [ "$current_stage" != worker ] && [ -n "${scratch:-}" ] && + if [ "${INTERNAL_WORKER:-0}" -ne 1 ] && [ -n "${scratch:-}" ] && declare -F cleanup >/dev/null 2>&1; then if cleanup; then trap - EXIT HUP INT TERM @@ -23,7 +23,7 @@ emit_error() { exit 1 } -stage_auth_fail() { +silent_fail() { exec >/dev/null 2>&1 exit 125 } @@ -132,11 +132,16 @@ path_matches_identity() { } snapshot_nofollow() { - local source=$1 expected=$2 target=$3 limit=$4 copy_status=0 + local source=$1 expected=$2 target=$3 limit=$4 mode=${5:-0600} + local copy_status=0 relative + case "$target" in + "$scratch"/*) relative=${target#"$scratch/"} ;; + *) return 1 ;; + esac /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' use strict; use warnings; - my ($source,$expected,$target,$limit)=@ARGV; + my ($source,$expected,$root,$root_expected,$target,$limit,$mode)=@ARGV; my ($p_dev,$p_ino,$f_dev,$f_ino,$size,$mtime,$ctime,$digest)= split(/:/,$expected,8); my ($parent,$name)=$source =~ m{\A(.+)/([^/]+)\z}; @@ -145,8 +150,13 @@ snapshot_nofollow() { my @parent=lstat($parent); exit 2 unless @parent && S_ISDIR($parent[2]) && $parent[0]==$p_dev && $parent[1]==$p_ino; - chdir($parent) or exit 2; - my @cwd=stat("."); my @leaf=lstat($name); + opendir(my $source_parent_dh,$parent) or exit 2; + my @source_parent_opened=stat($source_parent_dh); + exit 2 unless @source_parent_opened && S_ISDIR($source_parent_opened[2]) && + $source_parent_opened[0]==$parent[0] && + $source_parent_opened[1]==$parent[1]; + chdir($source_parent_dh) or exit 2; + my @cwd=stat($source_parent_dh); my @leaf=lstat($name); exit 2 unless @cwd && @leaf && S_ISREG($leaf[2]) && $leaf[0]==$f_dev && $leaf[1]==$f_ino && $leaf[7]==$size && $leaf[9]==$mtime && $leaf[10]==$ctime; @@ -155,7 +165,38 @@ snapshot_nofollow() { exit 2 unless @opened && S_ISREG($opened[2]) && $opened[0]==$f_dev && $opened[1]==$f_ino && $opened[7]==$size && $opened[9]==$mtime && $opened[10]==$ctime; - sysopen(my $output,$target,O_WRONLY|O_CREAT|O_EXCL,0600) or exit 2; + + my ($rp_dev,$rp_ino,$rd_dev,$rd_ino)=split(/:/,$root_expected,4); + my ($root_parent,$root_name)=$root =~ m{\A(.+)/([^/]+)\z}; + exit 2 unless defined($root_parent) && defined($root_name) && + defined(abs_path($root_parent)) && abs_path($root_parent) eq $root_parent && + $target =~ m{\A[^/]+(?:/[^/]+)*\z}; + opendir(my $root_parent_dh,$root_parent) or exit 2; + my @root_parent_st=stat($root_parent_dh); + exit 2 unless @root_parent_st && S_ISDIR($root_parent_st[2]) && + $root_parent_st[0]==$rp_dev && $root_parent_st[1]==$rp_ino; + chdir($root_parent_dh) or exit 2; + my @root_named=lstat($root_name); + exit 2 unless @root_named && S_ISDIR($root_named[2]) && + $root_named[0]==$rd_dev && $root_named[1]==$rd_ino; + opendir(my $root_dh,$root_name) or exit 2; + my @root_opened=stat($root_dh); + exit 2 unless @root_opened && S_ISDIR($root_opened[2]) && + $root_opened[0]==$rd_dev && $root_opened[1]==$rd_ino; + chdir($root_dh) or exit 2; + my @parts=split(m{/},$target); my $leaf=pop @parts; + for my $component (@parts) { + exit 2 if $component eq "." or $component eq ".."; + my @named=lstat($component); + exit 2 unless @named && S_ISDIR($named[2]); + opendir(my $next,$component) or exit 2; + my @next_opened=stat($next); + exit 2 unless @next_opened && S_ISDIR($next_opened[2]) && + $next_opened[0]==$named[0] && $next_opened[1]==$named[1]; + chdir($next) or exit 2; + } + exit 2 if $leaf eq "." or $leaf eq ".."; + sysopen(my $output,$leaf,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600) or exit 2; binmode($output); my $sha=Digest::SHA->new(256); my $total=0; while (1) { my $read=sysread($input,my $buffer,65536); @@ -167,7 +208,9 @@ snapshot_nofollow() { exit 2 unless defined($written) && $written>0; $offset += $written; } } + chmod(oct($mode),$output) or exit 2; close($output) or exit 2; + chdir($source_parent_dh) or exit 2; my @after=stat($input); my @path_after=lstat($name); my @parent_after=lstat($parent); exit 2 unless @after && @path_after && @parent_after && @@ -177,10 +220,54 @@ snapshot_nofollow() { $opened[10]==$after[10] && $after[0]==$path_after[0] && $after[1]==$path_after[1] && $cwd[0]==$parent_after[0] && $cwd[1]==$parent_after[1] && $sha->hexdigest eq $digest; - ' "$source" "$expected" "$target" "$limit" || copy_status=$? + ' "$source" "$expected" "$scratch" "$SCRATCH_ID" "$relative" "$limit" \ + "$mode" || copy_status=$? case "$copy_status" in 0) ;; 3) emit_error E_LIMIT ;; *) return 1 ;; esac } +scratch_mkdirs() { + [ "$#" -gt 0 ] || return 1 + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:mode -MCwd=abs_path -e ' + use strict; use warnings; + my ($root,$expected,@paths)=@ARGV; + my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); + my ($parent,$name)=$root =~ m{\A(.+)/([^/]+)\z}; + exit 1 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + opendir(my $parent_dh,$parent) or exit 1; + my @parent_st=stat($parent_dh); + exit 1 unless @parent_st && S_ISDIR($parent_st[2]) && + $parent_st[0]==$p_dev && $parent_st[1]==$p_ino; + chdir($parent_dh) or exit 1; + my @root_named=lstat($name); + exit 1 unless @root_named && S_ISDIR($root_named[2]) && + $root_named[0]==$d_dev && $root_named[1]==$d_ino; + opendir(my $root_dh,$name) or exit 1; + my @root_opened=stat($root_dh); + exit 1 unless @root_opened && S_ISDIR($root_opened[2]) && + $root_opened[0]==$d_dev && $root_opened[1]==$d_ino; + for my $path (@paths) { + exit 1 unless $path =~ m{\A[^/]+(?:/[^/]+)*\z}; + chdir($root_dh) or exit 1; + for my $component (split(m{/},$path)) { + exit 1 if $component eq "." or $component eq ".."; + my @named=lstat($component); + if (!@named) { + mkdir($component,0700) or exit 1; + @named=lstat($component); + } + exit 1 unless @named && S_ISDIR($named[2]); + opendir(my $next,$component) or exit 1; + my @opened=stat($next); + exit 1 unless @opened && S_ISDIR($opened[2]) && + $opened[0]==$named[0] && $opened[1]==$named[1]; + chdir($next) or exit 1; + } + } + ' "$scratch" "$SCRATCH_ID" "$@" +} + capture_identity_text() { /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' @@ -259,8 +346,8 @@ verify_all_pins() { local index=0 limit while [ "$index" -lt "${#PINNED_PATHS[@]}" ]; do limit=1048576 - [ "${PINNED_PATHS[$index]}" != "${YSTACK_EVIDENCE_LIVE_JQ:-}" ] || limit=16777216 - [ "${PINNED_PATHS[$index]}" != "${YSTACK_EVIDENCE_PRIVATE_JQ:-}" ] || limit=16777216 + [ "${PINNED_PATHS[$index]}" != "${live_jq_path:-}" ] || limit=16777216 + [ "${PINNED_PATHS[$index]}" != "${jq_bin:-}" ] || limit=16777216 path_matches_identity "${PINNED_PATHS[$index]}" \ "${PINNED_IDENTITIES[$index]}" "$limit" || return 1 index=$((index + 1)) @@ -273,10 +360,11 @@ sha256_path() { /usr/bin/printf '%s\n' "${identity##*:}" } -stage_hint=${YSTACK_EVIDENCE_STAGE:-bootstrap} -if [ "$stage_hint" = bootstrap ]; then scratch=; else scratch=${YSTACK_EVIDENCE_SCRATCH:-}; fi -SCRATCH_ID=${YSTACK_EVIDENCE_SCRATCH_ID:-} +if [ -n "${YSTACK_EVIDENCE_STAGE+x}" ]; then silent_fail; fi +scratch= +SCRATCH_ID= SCRATCH_OWNED=0 +INTERNAL_WORKER=0 ACTIVE_PID= ACTIVE_PGID= cleanup() { @@ -410,31 +498,18 @@ arm_signal_traps run_child() { local child pgid state attempt=0 child_status=0 count self_pgid - local worker_cap="$scratch/worker.cap" [ -z "${ACTIVE_PID:-}" ] && [ -z "${ACTIVE_PGID:-}" ] || return 125 defer_signal_traps - self_pgid=$(/bin/ps -o pgid= -p $$ 2>/dev/null | /usr/bin/tr -d ' ') || { + self_pgid=$(/bin/ps -o pgid= -p "$$" 2>/dev/null | /usr/bin/tr -d ' ') || { arm_signal_traps [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" return 125 } - if [ -e "$worker_cap" ] || [ -L "$worker_cap" ] || - ! /usr/bin/mkfifo -m 0600 "$worker_cap"; then + if ! [[ "$self_pgid" =~ ^[1-9][0-9]*$ ]]; then arm_signal_traps [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" return 125 fi - exec 9<>"$worker_cap" || { - arm_signal_traps - [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" - return 125 - } - /bin/rm -f -- "$worker_cap" || { - exec 9>&- - arm_signal_traps - [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" - return 125 - } set -m "$@" & child=$! @@ -450,7 +525,6 @@ run_child() { if ! [[ "$pgid" =~ ^[1-9][0-9]*$ ]] || [ "$pgid" != "$child" ] || [ "$pgid" = "$self_pgid" ]; then kill_unregistered_child "$child" || terminal_teardown_fail - exec 9>&- arm_signal_traps [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" return 125 @@ -458,12 +532,6 @@ run_child() { ACTIVE_PID=$child; ACTIVE_PGID=$pgid arm_signal_traps [ "$PENDING_SIGNAL" -eq 0 ] || signal_exit "$PENDING_SIGNAL" - /usr/bin/printf 'worker\n' >&9 || { - exec 9>&- - terminate_active || return 125 - return 125 - } - exec 9>&- attempt=0 state=$(leader_state "$child") || state= while [ -n "$state" ] && [[ "$state" != Z* ]] && [ "$attempt" -lt 1000 ]; do @@ -499,85 +567,47 @@ emit_supervisor_failure() { } [ "$#" -eq 6 ] && [ "$1" = evaluate ] || emit_error E_USAGE -stage=$stage_hint -if [ "$stage" = supervisor ]; then - [ -p /dev/fd/8 ] || stage_auth_fail - IFS= read -r stage_capability <&8 || stage_auth_fail - exec 8<&- - [ "$stage_capability" = supervisor ] || stage_auth_fail -elif [ "$stage" = worker ]; then - [ -p /dev/fd/9 ] || stage_auth_fail - IFS= read -r stage_capability <&9 || stage_auth_fail - exec 9<&- - [ "$stage_capability" = worker ] || stage_auth_fail - ulimit -f 2048 || stage_auth_fail -fi -if [ "$stage" = bootstrap ]; then - normalized_args=(evaluate) - for input in "${@:2}"; do - case "$input" in /*) ;; *) input="$(pwd -P)/$input" ;; esac - input_parent=$(CDPATH='' cd -P -- "${input%/*}" 2>/dev/null && pwd -P) || - emit_error E_RUNTIME - input="$input_parent/${input##*/}" - physical_regular "$input" || emit_error E_RUNTIME - normalized_args+=("$input") - done - origin=${BASH_SOURCE[0]} - case "$origin" in /*) ;; *) origin="$(pwd -P)/$origin" ;; esac - origin_dir=$(CDPATH='' cd -P -- "${origin%/*}" 2>/dev/null && pwd -P) || - emit_error E_RUNTIME - origin="$origin_dir/${origin##*/}" - [ "$origin" = "$origin_dir/evaluate-evidence-integrity.sh" ] || emit_error E_RUNTIME - origin_identity=$(path_identity "$origin" 1048576) || emit_error E_RUNTIME - live_jq=$(command -v jq 2>/dev/null) || emit_error E_RUNTIME - case "$live_jq" in /*) ;; *) emit_error E_RUNTIME ;; esac - live_jq_parent=$(CDPATH='' cd -P -- "${live_jq%/*}" 2>/dev/null && pwd -P) || +normalized_args=(evaluate) +for input in "${@:2}"; do + case "$input" in /*) ;; *) input="$(pwd -P)/$input" ;; esac + input_parent=$(CDPATH='' cd -P -- "${input%/*}" 2>/dev/null && pwd -P) || emit_error E_RUNTIME - live_jq="$live_jq_parent/${live_jq##*/}" - physical_regular "$live_jq" || emit_error E_RUNTIME - live_jq_identity=$(path_identity "$live_jq" 16777216) || emit_error E_RUNTIME - expected_jq=$(expected_jq_digest) || emit_error E_RUNTIME - [ "${live_jq_identity##*:}" = "$expected_jq" ] || emit_error E_RUNTIME - scratch=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence.XXXXXX" 2>/dev/null) || - emit_error E_RUNTIME - scratch=$(CDPATH='' cd -P -- "$scratch" 2>/dev/null && pwd -P) || emit_error E_RUNTIME - /bin/chmod 0700 "$scratch" || emit_error E_RUNTIME - SCRATCH_ID=$(directory_identity "$scratch") || emit_error E_RUNTIME - SCRATCH_OWNED=1 - /bin/mkdir -m 0700 "$scratch/bin" || emit_error E_RUNTIME - private_driver="$scratch/driver.sh" - private_jq="$scratch/bin/jq" - snapshot_nofollow "$origin" "$origin_identity" "$private_driver" 1048576 || - emit_error E_RUNTIME - /bin/chmod 0500 "$private_driver" || emit_error E_RUNTIME - private_driver_identity=$(path_identity "$private_driver" 1048576) || - emit_error E_RUNTIME - snapshot_nofollow "$live_jq" "$live_jq_identity" "$private_jq" 16777216 || - emit_error E_RUNTIME - /bin/chmod 0500 "$private_jq" || emit_error E_RUNTIME - private_jq_identity=$(path_identity "$private_jq" 16777216) || emit_error E_RUNTIME - supervisor_cap="$scratch/supervisor.cap" - /usr/bin/mkfifo -m 0600 "$supervisor_cap" || emit_error E_RUNTIME - exec 8<>"$supervisor_cap" || emit_error E_RUNTIME - /bin/rm -f -- "$supervisor_cap" || emit_error E_RUNTIME - /usr/bin/printf 'supervisor\n' >&8 || emit_error E_RUNTIME - exec /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" TMPDIR="$scratch" \ - YSTACK_EVIDENCE_STAGE=supervisor YSTACK_EVIDENCE_SCRATCH="$scratch" \ - YSTACK_EVIDENCE_SCRATCH_ID="$SCRATCH_ID" \ - YSTACK_EVIDENCE_ORIGIN="$origin" \ - YSTACK_EVIDENCE_ORIGIN_ID="$origin_identity" \ - YSTACK_EVIDENCE_PRIVATE_DRIVER_ID="$private_driver_identity" \ - YSTACK_EVIDENCE_LIVE_JQ="$live_jq" \ - YSTACK_EVIDENCE_LIVE_JQ_ID="$live_jq_identity" \ - YSTACK_EVIDENCE_PRIVATE_JQ="$private_jq" \ - YSTACK_EVIDENCE_PRIVATE_JQ_ID="$private_jq_identity" \ - /bin/bash "$private_driver" "${normalized_args[@]}" -fi + input="$input_parent/${input##*/}" + physical_regular "$input" || emit_error E_RUNTIME + normalized_args+=("$input") +done +origin=${BASH_SOURCE[0]} +case "$origin" in /*) ;; *) origin="$(pwd -P)/$origin" ;; esac +origin_dir=$(CDPATH='' cd -P -- "${origin%/*}" 2>/dev/null && pwd -P) || + emit_error E_RUNTIME +origin="$origin_dir/${origin##*/}" +[ "$origin" = "$origin_dir/evaluate-evidence-integrity.sh" ] || emit_error E_RUNTIME +origin_identity=$(path_identity "$origin" 1048576) || emit_error E_RUNTIME +live_jq=$(command -v jq 2>/dev/null) || emit_error E_RUNTIME +case "$live_jq" in /*) ;; *) emit_error E_RUNTIME ;; esac +live_jq_parent=$(CDPATH='' cd -P -- "${live_jq%/*}" 2>/dev/null && pwd -P) || + emit_error E_RUNTIME +live_jq="$live_jq_parent/${live_jq##*/}" +physical_regular "$live_jq" || emit_error E_RUNTIME +live_jq_identity=$(path_identity "$live_jq" 16777216) || emit_error E_RUNTIME +expected_jq=$(expected_jq_digest) || emit_error E_RUNTIME +[ "${live_jq_identity##*:}" = "$expected_jq" ] || emit_error E_RUNTIME +scratch=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence.XXXXXX" 2>/dev/null) || + emit_error E_RUNTIME +scratch=$(CDPATH='' cd -P -- "$scratch" 2>/dev/null && pwd -P) || emit_error E_RUNTIME +SCRATCH_ID=$(directory_identity "$scratch") || emit_error E_RUNTIME +SCRATCH_OWNED=1 +scratch_mkdirs bin || emit_error E_RUNTIME +source_path="$scratch/driver.sh" +jq_bin="$scratch/bin/jq" +snapshot_nofollow "$origin" "$origin_identity" "$source_path" 1048576 0500 || + emit_error E_RUNTIME +private_driver_identity=$(path_identity "$source_path" 1048576) || + emit_error E_RUNTIME +snapshot_nofollow "$live_jq" "$live_jq_identity" "$jq_bin" 16777216 0500 || + emit_error E_RUNTIME +private_jq_identity=$(path_identity "$jq_bin" 16777216) || emit_error E_RUNTIME -source_path=${BASH_SOURCE[0]} -case "$source_path" in /*) ;; *) source_path="$(pwd -P)/$source_path" ;; esac -origin=${YSTACK_EVIDENCE_ORIGIN:-} -jq_bin=${YSTACK_EVIDENCE_PRIVATE_JQ:-} [ -n "$scratch" ] || emit_error E_RUNTIME case "$scratch" in /*/ystack-evidence.??????) ;; *) emit_error E_RUNTIME ;; esac if [ -z "$SCRATCH_ID" ] || @@ -587,11 +617,11 @@ fi SCRATCH_OWNED=1 [ "$source_path" = "$scratch/driver.sh" ] && [ "$jq_bin" = "$scratch/bin/jq" ] || emit_error E_RUNTIME -origin_id=${YSTACK_EVIDENCE_ORIGIN_ID:-} -private_driver_id=${YSTACK_EVIDENCE_PRIVATE_DRIVER_ID:-} -live_jq_path=${YSTACK_EVIDENCE_LIVE_JQ:-} -live_jq_id=${YSTACK_EVIDENCE_LIVE_JQ_ID:-} -private_jq_id=${YSTACK_EVIDENCE_PRIVATE_JQ_ID:-} +origin_id=$origin_identity +private_driver_id=$private_driver_identity +live_jq_path=$live_jq +live_jq_id=$live_jq_identity +private_jq_id=$private_jq_identity PINNED_PATHS=("$origin" "$source_path" "$live_jq_path" "$jq_bin") PINNED_IDENTITIES=("$origin_id" "$private_driver_id" "$live_jq_id" "$private_jq_id") for internal_identity in "${PINNED_IDENTITIES[@]}"; do @@ -607,19 +637,9 @@ if ! private_mode_ok "$source_path" || ! private_mode_ok "$jq_bin"; then fi verify_all_pins || emit_error E_RELATION -if [ "$stage" = supervisor ]; then +supervisor_main() { worker_status=0 - run_child /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" TMPDIR="$scratch" \ - YSTACK_EVIDENCE_STAGE=worker YSTACK_EVIDENCE_SCRATCH="$scratch" \ - YSTACK_EVIDENCE_SCRATCH_ID="$SCRATCH_ID" \ - YSTACK_EVIDENCE_ORIGIN="$origin" \ - YSTACK_EVIDENCE_ORIGIN_ID="${YSTACK_EVIDENCE_ORIGIN_ID:-}" \ - YSTACK_EVIDENCE_PRIVATE_DRIVER_ID="${YSTACK_EVIDENCE_PRIVATE_DRIVER_ID:-}" \ - YSTACK_EVIDENCE_LIVE_JQ="${YSTACK_EVIDENCE_LIVE_JQ:-}" \ - YSTACK_EVIDENCE_LIVE_JQ_ID="${YSTACK_EVIDENCE_LIVE_JQ_ID:-}" \ - YSTACK_EVIDENCE_PRIVATE_JQ="$jq_bin" \ - YSTACK_EVIDENCE_PRIVATE_JQ_ID="${YSTACK_EVIDENCE_PRIVATE_JQ_ID:-}" \ - /bin/bash "$source_path" "$@" >"$scratch/worker.out" 2>"$scratch/worker.err" || + run_child worker_main "$@" >"$scratch/worker.out" 2>"$scratch/worker.err" || worker_status=$? if [ -n "${ACTIVE_PGID:-}" ] || [ -n "${ACTIVE_PID:-}" ]; then signal_exit 125 @@ -646,9 +666,12 @@ if [ "$stage" = supervisor ]; then trap - EXIT HUP INT TERM /usr/bin/printf '%s\n' "$output_text" || exit 1 exit 0 -fi -[ "$stage" = worker ] || emit_error E_RUNTIME +} + +worker_main() { +INTERNAL_WORKER=1 trap - EXIT HUP INT TERM +ulimit -f 2048 || silent_fail shift source_dir=$(CDPATH='' cd -P -- "${origin%/*}" 2>/dev/null && pwd -P) || @@ -686,15 +709,16 @@ snapshot_fixed() { local source=$1 target=$2 expected pin_path "$source" || emit_error E_RUNTIME expected=$(pinned_identity "$source") || emit_error E_RUNTIME - snapshot_nofollow "$source" "$expected" "$target" 1048576 || emit_error E_RUNTIME + snapshot_nofollow "$source" "$expected" "$target" 1048576 0600 || + emit_error E_RUNTIME pin_path "$target" || emit_error E_RUNTIME } snapshot_executable() { local source=$1 target=$2 expected pin_path "$source" || emit_error E_RUNTIME expected=$(pinned_identity "$source") || emit_error E_RUNTIME - snapshot_nofollow "$source" "$expected" "$target" 1048576 || emit_error E_RUNTIME - /bin/chmod 0500 "$target" || emit_error E_RUNTIME + snapshot_nofollow "$source" "$expected" "$target" 1048576 0500 || + emit_error E_RUNTIME pin_path "$target" || emit_error E_RUNTIME } canonical_json() { @@ -739,7 +763,7 @@ validator_pair_ok() { } build_validator_mirror() { local mirror="$scratch/policy-validator/control/v1" source target - /bin/mkdir -p "$mirror" || return 1 + scratch_mkdirs policy-validator/control/v1 || return 1 for source in "$policy_validator" "$validator_program"; do target="$mirror/${source##*/}" case "$source" in @@ -809,7 +833,8 @@ core_closure_sha() { build_core_mirror() { local selected=$1 mirror="$scratch/core-package" relative source target local -a paths - /bin/mkdir -p "$mirror/scripts" "$mirror/core/v2/generations/$selected/modules" || + scratch_mkdirs core-package/scripts \ + "core-package/core/v2/generations/$selected/modules" || return 1 paths=( scripts/core-contract.sh @@ -1071,4 +1096,7 @@ canonical_json "$scratch/evaluation.json" "$scratch/evaluation.canonical" || pin_path "$scratch/evaluation.json" || emit_error E_RUNTIME verify_all_pins || emit_error E_RELATION -exit 0 +return 0 +} + +supervisor_main "${normalized_args[@]}" diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index ade213b..7910c11 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"976abc8498d9f03457ae369e7aebb78b4a8feea474bbb728b273abe7612a3424"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"671c7d9d152b9c056e1223b2cde2d553acef2c26abe884bd69d783c7130909d6"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 5ea49f7..07ca96e 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -285,6 +285,10 @@ run_eval valid .body.qualification_semantics=="identity-only-unqualified" and (.body.evidence_refs|length)==1' "$tmp/valid.out" >/dev/null || fail 'valid output' pass 'valid exact immutable evidence presentation' +run_eval portable-valid +/usr/bin/cmp -s "$tmp/valid.out" "$tmp/portable-valid.out" || + fail 'portable valid output' +pass "portable valid evaluation on $platform" expect_pure_violation result-moved '.body.result_ref.sha256=("0"*64)' \ evidence.result-moved @@ -550,7 +554,7 @@ forged_stage_case() { local stage=$1 jq_mode=${2:-fake} local suffix runtime="$tmp/forged-$stage-$jq_mode-runtime" scratch local sentinel="$tmp/forged-$1-$jq_mode-sentinel" physical_tmp origin live - local driver_id live_id + local driver_id live_id capability local scratch_id local private_driver_id local status=0 @@ -584,6 +588,17 @@ forged_stage_case() { private_driver_id=$(test_path_identity "$scratch/driver.sh") live_id=$(test_path_identity "$live") physical_tmp=$(CDPATH='' cd -P -- "$tmp" && pwd -P) + capability="$scratch/caller.cap" + /usr/bin/mkfifo -m 0600 "$capability" + if [ "$stage" = supervisor ]; then + exec 8<>"$capability" + /bin/rm -f "$capability" + /usr/bin/printf 'supervisor\n' >&8 + else + exec 9<>"$capability" + /bin/rm -f "$capability" + /usr/bin/printf 'worker\n' >&9 + fi /usr/bin/env -i LC_ALL=C PATH="$scratch/bin:/usr/bin:/bin" \ YSTACK_EVIDENCE_STAGE="$stage" YSTACK_EVIDENCE_SCRATCH="$scratch" \ YSTACK_EVIDENCE_SCRATCH_ID="$scratch_id" \ @@ -596,6 +611,7 @@ forged_stage_case() { "$physical_tmp/result.json" "$physical_tmp/presentation.json" \ >"$tmp/forged-$stage-$jq_mode.out" \ 2>"$tmp/forged-$stage-$jq_mode.err" || status=$? + if [ "$stage" = supervisor ]; then exec 8>&-; else exec 9>&-; fi [ "$status" -ne 0 ] && [ ! -s "$tmp/forged-$stage-$jq_mode.out" ] && [ ! -s "$tmp/forged-$stage-$jq_mode.err" ] && [ ! -e "$sentinel" ] || fail "forged $stage stage" @@ -870,6 +886,50 @@ TMPDIR="$cleanup_error_scratch" PATH="$bin:/usr/bin:/bin" \ [ ! -s "$tmp/cleanup-error.err" ] || fail 'cleanup failure error-path output' pass 'error paths emit nothing when scratch cleanup fails' +bin_swap_runtime="$tmp/bin-swap-runtime" +bin_swap_parent="$tmp/bin-swap-parent" +bin_swap_outside="$tmp/bin-swap-outside" +bin_swap_ready="$tmp/bin-swap.ready" +bin_swap_go="$tmp/bin-swap.go" +copy_runtime "$bin_swap_runtime" +/bin/mkdir "$bin_swap_parent" "$bin_swap_outside" +/usr/bin/printf 'outside-unchanged\n' >"$bin_swap_outside/sentinel" +BIN_SWAP_READY="$bin_swap_ready" BIN_SWAP_GO="$bin_swap_go" \ + /usr/bin/perl -0777 -pi -e ' + my $ready=$ENV{"BIN_SWAP_READY"}; my $go=$ENV{"BIN_SWAP_GO"}; + my $replacement = qq{scratch_mkdirs bin || emit_error E_RUNTIME\n} . + qq{/usr/bin/printf "ready\\n" >"$ready" || exit 1\n} . + qq{while [ ! -e "$go" ]; do /bin/sleep 0.01; done}; + s{scratch_mkdirs bin \|\| emit_error E_RUNTIME}{$replacement} or exit 2; + ' "$bin_swap_runtime/control/v1/evaluate-evidence-integrity.sh" +bind_modified_driver "$bin_swap_runtime" "$tmp/bin-swap-set.json" +TMPDIR="$bin_swap_parent" PATH="$bin:/usr/bin:/bin" \ + "$bin_swap_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/bin-swap-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/bin-swap.out" 2>"$tmp/bin-swap.err" & +bin_swap_pid=$! +bin_swap_attempt=0 +while [ ! -e "$bin_swap_ready" ] && kill -0 "$bin_swap_pid" 2>/dev/null && + [ "$bin_swap_attempt" -lt 1200 ]; do + bin_swap_attempt=$((bin_swap_attempt + 1)); /bin/sleep 0.005 +done +[ -e "$bin_swap_ready" ] || fail 'bin ancestor swap ready' +bin_swap_scratch=$(/usr/bin/find "$bin_swap_parent" -mindepth 1 -maxdepth 1 \ + -type d -name 'ystack-evidence.??????' -print -quit) +[ -n "$bin_swap_scratch" ] || fail 'bin ancestor swap scratch' +/bin/mv "$bin_swap_scratch/bin" "$bin_swap_scratch/bin.saved" +/bin/ln -s "$bin_swap_outside" "$bin_swap_scratch/bin" +: >"$bin_swap_go" +bin_swap_status=0 +wait "$bin_swap_pid" || bin_swap_status=$? +[ "$bin_swap_status" -ne 0 ] && [ ! -s "$tmp/bin-swap.out" ] && + [ "$(/bin/cat "$tmp/bin-swap.err")" = E_RUNTIME ] && + [ "$(/bin/cat "$bin_swap_outside/sentinel")" = outside-unchanged ] && + [ ! -e "$bin_swap_outside/jq" ] && + [ -z "$(/usr/bin/find "$bin_swap_parent" -mindepth 1 -print -quit)" ] || + fail 'bin ancestor swap result' +pass 'scratch bin ancestor swap cannot create or chmod outside files' + scratch_swap_runtime="$tmp/scratch-swap-runtime" scratch_swap_parent="$tmp/scratch-swap-parent" scratch_swap_ready="$tmp/scratch-swap.ready" From 1bc1a6663f0adf2daed39db749457558e0b68383 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 2 Sep 2026 01:28:01 -0400 Subject: [PATCH 13/16] fix(control): bind evidence lifecycle effects --- control/v1/evaluate-evidence-integrity.sh | 320 +++++++++++++++--- control/v1/evidence-integrity-decision.json | 2 +- .../test/control-evidence-integrity.test.sh | 127 ++++++- 3 files changed, 404 insertions(+), 45 deletions(-) diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index aa54723..3f852c4 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -10,8 +10,7 @@ emit_error() { E_USAGE|E_RUNTIME|E_LIMIT|E_PARSE|E_CANONICAL|E_RELATION|E_POLICY_SET|E_CORE) ;; *) token=E_RUNTIME ;; esac - if [ "${INTERNAL_WORKER:-0}" -ne 1 ] && [ -n "${scratch:-}" ] && - declare -F cleanup >/dev/null 2>&1; then + if [ -n "${scratch:-}" ] && declare -F cleanup >/dev/null 2>&1; then if cleanup; then trap - EXIT HUP INT TERM else @@ -268,6 +267,188 @@ scratch_mkdirs() { ' "$scratch" "$SCRATCH_ID" "$@" } +scratch_relative() { + case "$1" in + "$scratch"/*) /usr/bin/printf '%s\n' "${1#"$scratch/"}" ;; + *) return 1 ;; + esac +} + +scratch_capture() { + local output=$1 error=$2 input=$3 input_identity=$4 + shift 4 + [ "$#" -gt 0 ] && [ "$output" != "$error" ] || return 125 + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path \ + -MPOSIX=_exit -e ' + use strict; use warnings; + my ($root,$expected,$stdout_name,$stderr_name,$stdin_name, + $stdin_identity,@command)=@ARGV; + exit 125 unless @command; + my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); + my ($parent,$name)=$root =~ m{\A(.+)/([^/]+)\z}; + exit 125 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + opendir(my $parent_dh,$parent) or exit 125; + my @parent_st=stat($parent_dh); + exit 125 unless @parent_st && S_ISDIR($parent_st[2]) && + $parent_st[0]==$p_dev && $parent_st[1]==$p_ino; + chdir($parent_dh) or exit 125; + my @root_named=lstat($name); + exit 125 unless @root_named && S_ISDIR($root_named[2]) && + $root_named[0]==$d_dev && $root_named[1]==$d_ino; + opendir(my $root_dh,$name) or exit 125; + my @root_opened=stat($root_dh); + exit 125 unless @root_opened && S_ISDIR($root_opened[2]) && + $root_opened[0]==$d_dev && $root_opened[1]==$d_ino; + + sub output_handle { + my ($root_handle,$relative)=@_; + if ($relative eq "-") { + sysopen(my $null,"/dev/null",O_WRONLY|O_NOFOLLOW) or exit 125; + return $null; + } + exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z}; + chdir($root_handle) or exit 125; + my @parts=split(m{/},$relative); my $leaf=pop @parts; + for my $component (@parts) { + exit 125 if $component eq "." or $component eq ".."; + my @named=lstat($component); + exit 125 unless @named && S_ISDIR($named[2]); + opendir(my $next,$component) or exit 125; + my @opened=stat($next); + exit 125 unless @opened && S_ISDIR($opened[2]) && + $opened[0]==$named[0] && $opened[1]==$named[1]; + chdir($next) or exit 125; + } + exit 125 if $leaf eq "." or $leaf eq ".."; + sysopen(my $file,$leaf,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600) or + exit 125; + my @opened=stat($file); + exit 125 unless @opened && S_ISREG($opened[2]); + return $file; + } + + sub input_bytes { + my ($root_handle,$relative,$identity)=@_; + return undef if $relative eq "-"; + exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z}; + my (undef,undef,$f_dev,$f_ino,$size,$mtime,$ctime,$digest)= + split(/:/,$identity,8); + chdir($root_handle) or exit 125; + my @parts=split(m{/},$relative); my $leaf=pop @parts; + for my $component (@parts) { + exit 125 if $component eq "." or $component eq ".."; + my @named=lstat($component); + exit 125 unless @named && S_ISDIR($named[2]); + opendir(my $next,$component) or exit 125; + my @opened=stat($next); + exit 125 unless @opened && S_ISDIR($opened[2]) && + $opened[0]==$named[0] && $opened[1]==$named[1]; + chdir($next) or exit 125; + } + exit 125 if $leaf eq "." or $leaf eq ".."; + my @named=lstat($leaf); + exit 125 unless @named && S_ISREG($named[2]) && + $named[0]==$f_dev && $named[1]==$f_ino && $named[7]==$size && + $named[9]==$mtime && $named[10]==$ctime; + sysopen(my $file,$leaf,O_RDONLY|O_NOFOLLOW) or exit 125; + my @opened=stat($file); + exit 125 unless @opened && S_ISREG($opened[2]) && + $opened[0]==$f_dev && $opened[1]==$f_ino && $opened[7]==$size && + $opened[9]==$mtime && $opened[10]==$ctime; + my $sha=Digest::SHA->new(256); my $total=0; my $text=""; + while (1) { + my $read=sysread($file,my $buffer,65536); + exit 125 unless defined $read; last if $read==0; + $total += $read; exit 125 if $total > 1048576; + $sha->add($buffer); $text .= $buffer; + } + my @after=stat($file); my @path_after=lstat($leaf); + exit 125 unless $total==$size && $sha->hexdigest eq $digest && + @after && @path_after && S_ISREG($path_after[2]) && + $after[0]==$f_dev && $after[1]==$f_ino && + $path_after[0]==$f_dev && $path_after[1]==$f_ino; + return $text; + } + + my $stdout=output_handle($root_dh,$stdout_name); + my $stderr=output_handle($root_dh,$stderr_name); + my $stdin_text=input_bytes($root_dh,$stdin_name,$stdin_identity); + if (defined($stdin_text)) { + pipe(my $reader,my $writer) or exit 125; + my $writer_pid=fork(); exit 125 unless defined($writer_pid); + if ($writer_pid==0) { + close($reader); + my $offset=0; my $length=length($stdin_text); + while ($offset < $length) { + my $written=syswrite($writer,$stdin_text,$length-$offset,$offset); + _exit(125) unless defined($written) && $written>0; + $offset += $written; + } + close($writer); _exit(0); + } + close($writer); + open(STDIN,"<&",$reader) or exit 125; + close($reader); + } else { + sysopen(my $null,"/dev/null",O_RDONLY|O_NOFOLLOW) or exit 125; + open(STDIN,"<&",$null) or exit 125; + close($null); + } + open(STDOUT,">&",$stdout) or exit 125; + open(STDERR,">&",$stderr) or exit 125; + close($stdout); close($stderr); close($root_dh); + close($parent_dh); + exec {$command[0]} @command; + exit 126; + ' "$scratch" "$SCRATCH_ID" "$output" "$error" "$input" \ + "$input_identity" "$@" +} + +scratch_write_lines() { + local target=$1 + shift + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:DEFAULT,:mode -MCwd=abs_path -e ' + use strict; use warnings; + my ($root,$expected,$target,@lines)=@ARGV; + my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); + my ($parent,$name)=$root =~ m{\A(.+)/([^/]+)\z}; + exit 1 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent && + $target =~ m{\A[^/]+(?:/[^/]+)*\z}; + opendir(my $parent_dh,$parent) or exit 1; + my @parent_st=stat($parent_dh); + exit 1 unless @parent_st && S_ISDIR($parent_st[2]) && + $parent_st[0]==$p_dev && $parent_st[1]==$p_ino; + chdir($parent_dh) or exit 1; + my @root_named=lstat($name); + exit 1 unless @root_named && S_ISDIR($root_named[2]) && + $root_named[0]==$d_dev && $root_named[1]==$d_ino; + opendir(my $root_dh,$name) or exit 1; + my @root_opened=stat($root_dh); + exit 1 unless @root_opened && S_ISDIR($root_opened[2]) && + $root_opened[0]==$d_dev && $root_opened[1]==$d_ino; + chdir($root_dh) or exit 1; + my @parts=split(m{/},$target); my $leaf=pop @parts; + for my $component (@parts) { + exit 1 if $component eq "." or $component eq ".."; + my @named=lstat($component); + exit 1 unless @named && S_ISDIR($named[2]); + opendir(my $next,$component) or exit 1; + my @opened=stat($next); + exit 1 unless @opened && S_ISDIR($opened[2]) && + $opened[0]==$named[0] && $opened[1]==$named[1]; + chdir($next) or exit 1; + } + exit 1 if $leaf eq "." or $leaf eq ".."; + sysopen(my $file,$leaf,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600) or exit 1; + for my $line (@lines) { print {$file} $line,"\n" or exit 1; } + close($file) or exit 1; + ' "$scratch" "$SCRATCH_ID" "$target" "$@" +} + capture_identity_text() { /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path -e ' @@ -364,12 +545,14 @@ if [ -n "${YSTACK_EVIDENCE_STAGE+x}" ]; then silent_fail; fi scratch= SCRATCH_ID= SCRATCH_OWNED=0 -INTERNAL_WORKER=0 ACTIVE_PID= ACTIVE_PGID= cleanup() { [ -z "${scratch:-}" ] && return 0 - case "$scratch" in /*/ystack-evidence.??????) ;; *) return 1 ;; esac + case "$scratch" in + /*/ystack-evidence.??????|/*/ystack-evidence.??????/worker) ;; + *) return 1 ;; + esac [ "$SCRATCH_OWNED" -eq 1 ] && [ -n "$SCRATCH_ID" ] || return 1 /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:mode -MCwd=abs_path -e ' @@ -566,6 +749,7 @@ emit_supervisor_failure() { exit 1 } +bootstrap_prepare() { [ "$#" -eq 6 ] && [ "$1" = evaluate ] || emit_error E_USAGE normalized_args=(evaluate) for input in "${@:2}"; do @@ -597,7 +781,9 @@ scratch=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence.XXXXXX" 2>/dev/nul scratch=$(CDPATH='' cd -P -- "$scratch" 2>/dev/null && pwd -P) || emit_error E_RUNTIME SCRATCH_ID=$(directory_identity "$scratch") || emit_error E_RUNTIME SCRATCH_OWNED=1 -scratch_mkdirs bin || emit_error E_RUNTIME +scratch_mkdirs bin io worker || emit_error E_RUNTIME +worker_scratch="$scratch/worker" +worker_scratch_id=$(directory_identity "$worker_scratch") || emit_error E_RUNTIME source_path="$scratch/driver.sh" jq_bin="$scratch/bin/jq" snapshot_nofollow "$origin" "$origin_identity" "$source_path" 1048576 0500 || @@ -636,31 +822,35 @@ if ! private_mode_ok "$source_path" || ! private_mode_ok "$jq_bin"; then emit_error E_RUNTIME fi verify_all_pins || emit_error E_RELATION +} supervisor_main() { worker_status=0 - run_child worker_main "$@" >"$scratch/worker.out" 2>"$scratch/worker.err" || + run_child scratch_capture io/worker.out io/worker.err driver.sh \ + "$private_driver_id" /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + TMPDIR="$scratch" HOME=/nonexistent /bin/bash -c \ + 'source /dev/stdin || exit 125; worker_entry "$@"' ystack-evidence-worker \ + "$worker_scratch" "$worker_scratch_id" "$origin" "$origin_id" "$source_path" \ + "$private_driver_id" "$live_jq_path" "$live_jq_id" "$jq_bin" \ + "$private_jq_id" "$@" || worker_status=$? if [ -n "${ACTIVE_PGID:-}" ] || [ -n "${ACTIVE_PID:-}" ]; then signal_exit 125 fi - pin_path "$scratch/worker.err" || emit_supervisor_failure E_RUNTIME - error_identity=$(pinned_identity "$scratch/worker.err") || emit_supervisor_failure E_RUNTIME - error_text=$(capture_identity_text "$scratch/worker.err" "$error_identity") || + pin_path "$scratch/io/worker.err" || emit_supervisor_failure E_RUNTIME + error_identity=$(pinned_identity "$scratch/io/worker.err") || + emit_supervisor_failure E_RUNTIME + error_text=$(capture_identity_text "$scratch/io/worker.err" "$error_identity") || emit_supervisor_failure E_RUNTIME if [ "$worker_status" -ne 0 ]; then emit_supervisor_failure "$error_text"; fi [ -z "$error_text" ] || emit_supervisor_failure E_RUNTIME - pin_path "$scratch/worker.out" || emit_supervisor_failure E_RUNTIME - worker_output_identity=$(pinned_identity "$scratch/worker.out") || + pin_path "$scratch/io/worker.out" || emit_supervisor_failure E_RUNTIME + worker_output_identity=$(pinned_identity "$scratch/io/worker.out") || emit_supervisor_failure E_RUNTIME - worker_output_text=$(capture_identity_text "$scratch/worker.out" \ + worker_output_text=$(capture_identity_text "$scratch/io/worker.out" \ "$worker_output_identity") || emit_supervisor_failure E_RUNTIME - [ -z "$worker_output_text" ] || emit_supervisor_failure E_RUNTIME - pin_path "$scratch/evaluation.json" || emit_supervisor_failure E_RUNTIME - output_identity=$(pinned_identity "$scratch/evaluation.json") || - emit_supervisor_failure E_RUNTIME - output_text=$(capture_identity_text "$scratch/evaluation.json" "$output_identity") || - emit_supervisor_failure E_RUNTIME + [ -n "$worker_output_text" ] || emit_supervisor_failure E_RUNTIME + output_text=$worker_output_text verify_all_pins || emit_supervisor_failure E_RELATION if ! cleanup; then exit 1; fi trap - EXIT HUP INT TERM @@ -668,10 +858,42 @@ supervisor_main() { exit 0 } -worker_main() { -INTERNAL_WORKER=1 +worker_entry() { + [ "$#" -eq 16 ] || silent_fail + trap - EXIT HUP INT TERM + scratch=$1 + SCRATCH_ID=$2 + origin=$3 + origin_id=$4 + source_path=$5 + private_driver_id=$6 + live_jq_path=$7 + live_jq_id=$8 + jq_bin=$9 + private_jq_id=${10} + shift 10 + SCRATCH_OWNED=0 + ACTIVE_PID= + ACTIVE_PGID= + [ "$#" -eq 6 ] && [ "$1" = evaluate ] || silent_fail + case "$scratch" in /*/ystack-evidence.??????/worker) ;; *) silent_fail ;; esac + directory_matches_identity "$scratch" "$SCRATCH_ID" || silent_fail + SCRATCH_OWNED=1 + outer_scratch=${scratch%/worker} + [ "$source_path" = "$outer_scratch/driver.sh" ] && + [ "$jq_bin" = "$outer_scratch/bin/jq" ] || emit_error E_RUNTIME + PINNED_PATHS=("$origin" "$source_path" "$live_jq_path" "$jq_bin") + PINNED_IDENTITIES=("$origin_id" "$private_driver_id" "$live_jq_id" "$private_jq_id") + expected_jq=$(expected_jq_digest) || emit_error E_RUNTIME + [ "${origin_id##*:}" = "${private_driver_id##*:}" ] && + [ "${live_jq_id##*:}" = "$expected_jq" ] && + [ "${private_jq_id##*:}" = "$expected_jq" ] || emit_error E_RUNTIME + if ! private_mode_ok "$source_path" || ! private_mode_ok "$jq_bin"; then + emit_error E_RUNTIME + fi + verify_all_pins || emit_error E_RUNTIME trap - EXIT HUP INT TERM -ulimit -f 2048 || silent_fail +ulimit -f 2048 || emit_error E_RUNTIME shift source_dir=$(CDPATH='' cd -P -- "${origin%/*}" 2>/dev/null && pwd -P) || @@ -722,14 +944,16 @@ snapshot_executable() { pin_path "$target" || emit_error E_RUNTIME } canonical_json() { - local input=$1 canonical=$2 bom + local input=$1 canonical=$2 canonical_relative bom bom=$(/usr/bin/od -An -tx1 -N3 "$input" 2>/dev/null | /usr/bin/tr -d ' \n') || emit_error E_RUNTIME [ "$bom" != efbbbf ] || emit_error E_PARSE "$jq_bin" -e 'true' "$input" /dev/null 2>&1 || emit_error E_PARSE "$jq_bin" -s -e 'length==1' "$input" /dev/null 2>&1 || emit_error E_PARSE - "$jq_bin" -S -c . "$input" >"$canonical" 2>/dev/null || emit_error E_PARSE + canonical_relative=$(scratch_relative "$canonical") || emit_error E_RUNTIME + scratch_capture "$canonical_relative" - - - "$jq_bin" -S -c . "$input" || + emit_error E_PARSE /usr/bin/cmp -s "$input" "$canonical" || emit_error E_CANONICAL "$jq_bin" -e ' def depth: @@ -776,7 +1000,8 @@ build_validator_mirror() { core_closure_sha() { local root=$1 wrapper=$2 selected=$3 tag=$4 registry generation_root canonical local relative file digest members descriptor physical selected_sha count modules - local -a paths + local members_identity members_text + local -a paths member_lines registry="$root/core/v2/generation-registry.json" generation_root="$root/core/v2/generations/$selected" for required_dir in "$root" "$root/scripts" "$root/core" "$root/core/v2" \ @@ -794,8 +1019,8 @@ core_closure_sha() { [ "$count" -eq 3 ] && [ "$modules" -eq 5 ] || return 1 [ -f "$registry" ] && [ ! -L "$registry" ] || return 1 canonical="$scratch/registry-$tag.json" - "$jq_bin" -s -S -c 'if length==1 then .[0] else error("root-count") end' \ - "$registry" >"$canonical" 2>/dev/null || return 1 + scratch_capture "registry-$tag.json" - - - "$jq_bin" -s -S -c \ + 'if length==1 then .[0] else error("root-count") end' "$registry" || return 1 /usr/bin/cmp -s "$registry" "$canonical" || return 1 "$jq_bin" -e --arg selected "$selected" ' type=="array" and length>=1 and @@ -814,20 +1039,26 @@ core_closure_sha() { "core/v2/generations/$selected/modules/stage_request.jq" ) members="$scratch/core-members-$tag.tsv" - : >"$members" || return 1 + member_lines=() for relative in "${paths[@]}"; do file="$root/$relative" [ -f "$file" ] && [ ! -L "$file" ] || return 1 digest=$(sha256_path "$file") || return 1 - /usr/bin/printf '%s\t%s\n' "$relative" "$digest" >>"$members" || return 1 + member_lines[${#member_lines[@]}]="$relative"$'\t'"$digest" done + scratch_write_lines "core-members-$tag.tsv" "${member_lines[@]}" || return 1 + pin_path "$members" || return 1 + members_identity=$(pinned_identity "$members") || return 1 + members_text=$(capture_identity_text "$members" "$members_identity") || return 1 selected_sha=$(sha256_text "$selected") || return 1 - descriptor=$("$jq_bin" -Rn -S -c --arg selected_sha "$selected_sha" ' - [inputs|split("\t")|{path:.[0],sha256:.[1]}] as $members | + descriptor=$("$jq_bin" -Rn -S -c --arg selected_sha "$selected_sha" \ + --arg members "$members_text" ' + ($members|split("\n")|map(select(length>0)|split("\t")| + {path:.[0],sha256:.[1]})) as $members | {schema_version:1,kind:"core_contract_package_closure", semantic_identity:"core.contracts.v2", selected_generation_id_sha256:$selected_sha,members:$members} - ' <"$members") || return 1 + ') || return 1 sha256_text "$descriptor" } build_core_mirror() { @@ -955,8 +1186,9 @@ validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ "$mirror_validator_program" "$validator_driver_sha" "$validator_program_sha" || emit_error E_RELATION policy_status=0 -PATH="${jq_bin%/*}:/usr/bin:/bin" "$mirror_policy_validator" validate \ - "$scratch/policy-set.json" >"$scratch/policy.out" 2>"$scratch/policy.err" || +scratch_capture policy.out policy.err - - /usr/bin/env -i LC_ALL=C \ + PATH="${jq_bin%/*}:/usr/bin:/bin" TMPDIR="$scratch" HOME=/nonexistent \ + "$mirror_policy_validator" validate "$scratch/policy-set.json" || policy_status=$? if ! validator_pair_ok "$source_dir" "$policy_validator" "$validator_program" \ "$validator_driver_sha" "$validator_program_sha" || @@ -995,9 +1227,11 @@ mirror_core_sha=$(core_closure_sha "$mirror_root" "$mirror_core_driver" "$select ' "$scratch/policy-set.json" >/dev/null 2>&1 || emit_error E_RELATION core_status=0 -PATH="${jq_bin%/*}:/usr/bin:/bin" "$mirror_core_driver" validate-stage-run \ +scratch_capture core.out core.err - - /usr/bin/env -i LC_ALL=C \ + PATH="${jq_bin%/*}:/usr/bin:/bin" TMPDIR="$scratch" HOME=/nonexistent \ + "$mirror_core_driver" validate-stage-run \ "$scratch/request.json" "$scratch/resolved.json" "$scratch/result.json" \ - >"$scratch/core.out" 2>"$scratch/core.err" || core_status=$? + || core_status=$? post_live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-post) || emit_error E_RELATION post_mirror_core_sha=$(core_closure_sha \ @@ -1017,7 +1251,8 @@ request_sha=$(sha256_path "$scratch/request.json") || emit_error E_RUNTIME resolved_sha=$(sha256_path "$scratch/resolved.json") || emit_error E_RUNTIME result_sha=$(sha256_path "$scratch/result.json") || emit_error E_RUNTIME presentation_sha=$(sha256_path "$scratch/presentation.json") || emit_error E_RUNTIME -"$jq_bin" -S -c -n -f "$scratch/program.jq" \ +scratch_capture evaluation.json - - - "$jq_bin" -S -c -n \ + -f "$scratch/program.jq" \ --slurpfile policy "$scratch/policy.json" \ --slurpfile decision "$scratch/decision.json" \ --slurpfile policy_set "$scratch/policy-set.json" \ @@ -1028,7 +1263,7 @@ presentation_sha=$(sha256_path "$scratch/presentation.json") || emit_error E_RUN --arg policy_sha "$policy_sha" --arg decision_sha "$decision_sha" \ --arg policy_set_sha "$policy_set_sha" --arg request_sha "$request_sha" \ --arg resolved_sha "$resolved_sha" --arg result_sha "$result_sha" \ - --arg presentation_sha "$presentation_sha" >"$scratch/evaluation.json" 2>/dev/null || + --arg presentation_sha "$presentation_sha" || emit_error E_RUNTIME fixed_files_ok || emit_error E_RELATION final_live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-final) || @@ -1096,7 +1331,16 @@ canonical_json "$scratch/evaluation.json" "$scratch/evaluation.canonical" || pin_path "$scratch/evaluation.json" || emit_error E_RUNTIME verify_all_pins || emit_error E_RELATION +output_identity=$(pinned_identity "$scratch/evaluation.json") || emit_error E_RUNTIME +output_text=$(capture_identity_text "$scratch/evaluation.json" "$output_identity") || + emit_error E_RUNTIME +if ! cleanup; then silent_fail; fi +trap - EXIT HUP INT TERM +/usr/bin/printf '%s\n' "$output_text" || exit 1 return 0 } -supervisor_main "${normalized_args[@]}" +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + bootstrap_prepare "$@" + supervisor_main "${normalized_args[@]}" +fi diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index 7910c11..e6625c6 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"671c7d9d152b9c056e1223b2cde2d553acef2c26abe884bd69d783c7130909d6"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"08593d21a695f4a81c87ed3d2bb7ff44fd14eb68d770c6c5876eb5a729c8e868"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 07ca96e..76a9507 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -290,6 +290,27 @@ run_eval portable-valid fail 'portable valid output' pass "portable valid evaluation on $platform" +bash_env_hook="$tmp/conditional-bash-env.sh" +bash_env_sentinel="$tmp/conditional-bash-env.sentinel" +/usr/bin/printf '%s\n' \ + 'case "$0" in' \ + ' */validate.sh|*/core-contract.sh)' \ + ' /usr/bin/printf "%s\\n" "${AWS_SECRET_ACCESS_KEY:-missing}" >'\ +"\"$bash_env_sentinel\"" \ + ' ;;' \ + 'esac' >"$bash_env_hook" +bash_env_status=0 +BASH_ENV="$bash_env_hook" AWS_SECRET_ACCESS_KEY=must-not-reach-nested-bash \ + PATH="$bin:/usr/bin:/bin" "$evaluator" evaluate "$policy_set" "$request" \ + "$resolved" "$result" "$presentation" >"$tmp/bash-env.out" \ + 2>"$tmp/bash-env.err" || bash_env_status=$? +if ! { [ "$bash_env_status" -eq 0 ] && [ -s "$tmp/bash-env.out" ] && + [ ! -s "$tmp/bash-env.err" ] && [ ! -e "$bash_env_sentinel" ] && + /usr/bin/cmp -s "$tmp/valid.out" "$tmp/bash-env.out"; }; then + fail 'nested bash environment isolation' +fi +pass 'nested bash ignores conditional BASH_ENV and credential-like values' + expect_pure_violation result-moved '.body.result_ref.sha256=("0"*64)' \ evidence.result-moved expect_pure_violation request-moved '.body.request_ref.sha256=("0"*64)' \ @@ -623,6 +644,47 @@ forged_stage_case worker forged_stage_case supervisor official forged_stage_case worker official +direct_runtime="$tmp/direct-worker-runtime" +direct_scratch="$tmp/ystack-evidence.DIRECT" +copy_runtime "$direct_runtime" +/bin/mkdir -m 0700 "$direct_scratch" "$direct_scratch/bin" "$direct_scratch/worker" +direct_runtime=$(CDPATH='' cd -P -- "$direct_runtime" && pwd -P) +direct_scratch=$(CDPATH='' cd -P -- "$direct_scratch" && pwd -P) +direct_worker="$direct_scratch/worker" +direct_live_parent=$(CDPATH='' cd -P -- "${jq_bin%/*}" && pwd -P) +direct_live="$direct_live_parent/${jq_bin##*/}" +direct_input_parent=$(CDPATH='' cd -P -- "$tmp" && pwd -P) +direct_origin="$direct_runtime/control/v1/evaluate-evidence-integrity.sh" +/bin/cp "$direct_origin" "$direct_scratch/driver.sh" +/bin/chmod 0500 "$direct_scratch/driver.sh" +/bin/cp "$jq_bin" "$direct_scratch/bin/jq" +/bin/chmod 0500 "$direct_scratch/bin/jq" +direct_origin_id=$(test_path_identity "$direct_origin") +direct_driver_id=$(test_path_identity "$direct_scratch/driver.sh") +direct_live_id=$(test_path_identity "$direct_live") +direct_jq_id=$(test_path_identity "$direct_scratch/bin/jq") +direct_worker_id=$(test_directory_identity "$direct_worker") +direct_status=0 +/usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin TMPDIR="$direct_scratch" \ + HOME=/nonexistent /bin/bash -c \ + 'source "$1" || exit 125; shift; declare -F worker_main >/dev/null && exit 124; + worker_entry "$@"' direct-worker \ + "$direct_origin" "$direct_worker" "$direct_worker_id" "$direct_origin" \ + "$direct_origin_id" "$direct_scratch/driver.sh" "$direct_driver_id" \ + "$direct_live" "$direct_live_id" "$direct_scratch/bin/jq" "$direct_jq_id" \ + evaluate "$direct_input_parent/policy-set.json" "$direct_input_parent/request.json" \ + "$direct_input_parent/resolved.json" "$direct_input_parent/result.json" \ + "$direct_input_parent/presentation.json" \ + >"$tmp/direct-worker.out" 2>"$tmp/direct-worker.err" || direct_status=$? +if ! { [ "$direct_status" -eq 0 ] && [ -s "$tmp/direct-worker.out" ] && + [ ! -s "$tmp/direct-worker.err" ] && [ ! -e "$direct_worker" ] && + [ -z "$(/usr/bin/find "$direct_scratch" -name evaluation.json -print -quit)" ] && + "$jq_bin" -e '.kind=="evidence_integrity_evaluation"' \ + "$tmp/direct-worker.out" >/dev/null; }; then + fail 'direct sourced worker cleanup' +fi +pass 'direct sourced worker has observation output but no retained worker effects' + mutated_decision_runtime="$tmp/mutated-decision-runtime" copy_runtime "$mutated_decision_runtime" "$jq_bin" -S -c '.body.semantics.authority_effect="unexpected"' \ @@ -691,7 +753,7 @@ replace_identity_case() { origin) target="$runtime/control/v1/evaluate-evidence-integrity.sh" ;; private-driver) target="${observed%/bin/jq}/driver.sh" ;; live-jq) target="$live_bin/jq" ;; - private-jq) target="${observed%/evaluation.json}/bin/jq" ;; + private-jq) target="${observed%/worker/evaluation.json}/bin/jq" ;; *) fail "$name target" ;; esac /bin/cp "$target" "$target.next" @@ -839,11 +901,11 @@ output_runtime="$tmp/output-swap-runtime" output_scratch="$tmp/output-swap-scratch" copy_runtime "$output_runtime" /usr/bin/perl -0777 -pi -e ' - s{ pin_path "\$scratch/evaluation\.json" \|\| emit_supervisor_failure E_RUNTIME}{ + s{pin_path "\$scratch/evaluation\.json" \|\| emit_error E_RUNTIME}{ /bin/mv "\$scratch/evaluation.json" "\$scratch/evaluation.saved" || exit 1; /bin/ln -s "\$scratch/evaluation.saved" "\$scratch/evaluation.json" || exit 1; - pin_path "\$scratch/evaluation.json" || emit_supervisor_failure E_RUNTIME - } + pin_path "\$scratch/evaluation.json" || emit_error E_RUNTIME + } or exit 2 ' "$output_runtime/control/v1/evaluate-evidence-integrity.sh" bind_modified_driver "$output_runtime" "$tmp/output-swap-set.json" /bin/mkdir "$output_scratch" @@ -886,6 +948,59 @@ TMPDIR="$cleanup_error_scratch" PATH="$bin:/usr/bin:/bin" \ [ ! -s "$tmp/cleanup-error.err" ] || fail 'cleanup failure error-path output' pass 'error paths emit nothing when scratch cleanup fails' +capture_swap_case() { + local mode=$1 runtime="$tmp/capture-$1-runtime" + local parent="$tmp/capture-$1-parent" outside="$tmp/capture-$1-outside" + local ready="$tmp/capture-$1.ready" go="$tmp/capture-$1.go" + local process scratch_path status=0 attempt=0 + copy_runtime "$runtime" + /bin/mkdir "$parent" "$outside" + /usr/bin/printf 'outside-unchanged\n' >"$outside/sentinel" + CAPTURE_READY="$ready" CAPTURE_GO="$go" /usr/bin/perl -0777 -pi -e ' + my $ready=$ENV{"CAPTURE_READY"}; my $go=$ENV{"CAPTURE_GO"}; + my $replacement = qq{ /usr/bin/printf "ready\\n" >"$ready" || exit 1\n} . + qq{ while [ ! -e "$go" ]; do /bin/sleep 0.01; done\n} . + qq{ run_child scratch_capture}; + s{ run_child scratch_capture}{$replacement} or exit 2; + ' "$runtime/control/v1/evaluate-evidence-integrity.sh" + bind_modified_driver "$runtime" "$tmp/capture-$1-set.json" + TMPDIR="$parent" PATH="$bin:/usr/bin:/bin" \ + "$runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/capture-$1-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/capture-$1.out" 2>"$tmp/capture-$1.err" & + process=$! + while [ ! -e "$ready" ] && kill -0 "$process" 2>/dev/null && + [ "$attempt" -lt 1200 ]; do + attempt=$((attempt + 1)); /bin/sleep 0.005 + done + [ -e "$ready" ] || fail "capture $mode ready" + scratch_path=$(/usr/bin/find "$parent" -mindepth 1 -maxdepth 1 -type d \ + -name 'ystack-evidence.??????' -print -quit) + [ -n "$scratch_path" ] || fail "capture $mode scratch" + case "$mode" in + ancestor) + /bin/mv "$scratch_path/io" "$scratch_path/io.saved" + /bin/ln -s "$outside" "$scratch_path/io" + ;; + leaf) + /bin/ln -s "$outside/sentinel" "$scratch_path/io/worker.out" + ;; + *) fail "capture $mode mode" ;; + esac + : >"$go" + wait "$process" || status=$? + [ "$status" -ne 0 ] && [ ! -s "$tmp/capture-$1.out" ] && + [ "$(/bin/cat "$tmp/capture-$1.err")" = E_RUNTIME ] && + [ "$(/bin/cat "$outside/sentinel")" = outside-unchanged ] && + [ ! -e "$outside/worker.out" ] && [ ! -e "$outside/worker.err" ] && + [ -z "$(/usr/bin/find "$parent" -mindepth 1 -print -quit)" ] || + fail "capture $mode result" + pass "anchored scratch capture rejects $mode replacement" +} + +capture_swap_case leaf +capture_swap_case ancestor + bin_swap_runtime="$tmp/bin-swap-runtime" bin_swap_parent="$tmp/bin-swap-parent" bin_swap_outside="$tmp/bin-swap-outside" @@ -897,10 +1012,10 @@ copy_runtime "$bin_swap_runtime" BIN_SWAP_READY="$bin_swap_ready" BIN_SWAP_GO="$bin_swap_go" \ /usr/bin/perl -0777 -pi -e ' my $ready=$ENV{"BIN_SWAP_READY"}; my $go=$ENV{"BIN_SWAP_GO"}; - my $replacement = qq{scratch_mkdirs bin || emit_error E_RUNTIME\n} . + my $replacement = qq{scratch_mkdirs bin io worker || emit_error E_RUNTIME\n} . qq{/usr/bin/printf "ready\\n" >"$ready" || exit 1\n} . qq{while [ ! -e "$go" ]; do /bin/sleep 0.01; done}; - s{scratch_mkdirs bin \|\| emit_error E_RUNTIME}{$replacement} or exit 2; + s{scratch_mkdirs bin io worker \|\| emit_error E_RUNTIME}{$replacement} or exit 2; ' "$bin_swap_runtime/control/v1/evaluate-evidence-integrity.sh" bind_modified_driver "$bin_swap_runtime" "$tmp/bin-swap-set.json" TMPDIR="$bin_swap_parent" PATH="$bin:/usr/bin:/bin" \ From ceff678d5ede9cf6e72db83f61e018898961ed8b Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 2 Sep 2026 02:38:51 -0400 Subject: [PATCH 14/16] fix(control): attest evidence payload execution --- README.md | 8 + RESTORE.md | 5 +- control/v1/evaluate-evidence-integrity.sh | 409 ++++++++++++++---- control/v1/evidence-integrity-decision.json | 2 +- .../test/control-evidence-integrity.test.sh | 145 ++++++- 5 files changed, 480 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index d04e78d..bf4c3e8 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,14 @@ canonical `satisfied` or `violated` identity observation. Evidence and prior references must keep their canonical order and unique logical identities; one prior result digest cannot describe multiple result documents. +The launcher is an explicit trusted shell boundary; it does not claim to +self-attest bytes that Bash already loaded. The decision separately binds the +exact marked evaluation payload. The launcher extracts those fragments from a +private no-follow snapshot, verifies their content identity, and gives only the +verified bytes to the worker. Scratch output descriptors keep their creation +identities through consumption. Public-core validation uses its accounted mode +with a fixed budget, an anchored receipt descriptor, and a worker-owned root. + The evaluator never reads proof bytes. Matching references do not prove a claim or qualify a workflow, and equal proof digests may belong to different logical references. The package stays inactive, stores nothing, grants no authority, and diff --git a/RESTORE.md b/RESTORE.md index 3ddf9d6..eddfa56 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -403,7 +403,10 @@ bash scripts/test/control-evidence-integrity.test.sh This checks exact policy-set and public-core closure, stage and qualification identity binding, canonical evidence/prior sets, stale or aliased references, and -deterministic observation output. It does not read proof bytes, establish proof +deterministic observation output. It also checks the explicit trusted-launcher +boundary, exact marked-payload identity, retained scratch descriptor identities, +accounted core receipt and cleanup, and nested Bash environment isolation. The +launcher is not self-attested. It does not read proof bytes, establish proof truth, qualify a workflow, store evidence, grant authority, activate a profile, run a candidate or adapter, or perform a network or external-write action. diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index 3f852c4..d6b7c0b 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -1,4 +1,5 @@ #!/bin/bash +# YSTACK_EVIDENCE_PAYLOAD_SHARED_BEGIN # shellcheck disable=SC2016,SC2329 set -uo pipefail export LC_ALL=C @@ -274,16 +275,86 @@ scratch_relative() { esac } -scratch_capture() { - local output=$1 error=$2 input=$3 input_identity=$4 - shift 4 - [ "$#" -gt 0 ] && [ "$output" != "$error" ] || return 125 +scratch_prepare_capture() { + local output=$1 error=$2 receipt=$3 + if { [ "$output" != - ] && + { [ "$output" = "$error" ] || [ "$output" = "$receipt" ]; }; } || + { [ "$error" != - ] && [ "$error" = "$receipt" ]; }; then + return 125 + fi + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:DEFAULT,:mode -MCwd=abs_path -e ' + use strict; use warnings; + my ($root,$expected,@names)=@ARGV; + my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); + my ($parent,$name)=$root =~ m{\A(.+)/([^/]+)\z}; + exit 125 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + opendir(my $parent_dh,$parent) or exit 125; + my @parent_st=stat($parent_dh); + exit 125 unless @parent_st && S_ISDIR($parent_st[2]) && + $parent_st[0]==$p_dev && $parent_st[1]==$p_ino; + chdir($parent_dh) or exit 125; + my @root_named=lstat($name); + exit 125 unless @root_named && S_ISDIR($root_named[2]) && + $root_named[0]==$d_dev && $root_named[1]==$d_ino; + opendir(my $root_dh,$name) or exit 125; + my @root_opened=stat($root_dh); + exit 125 unless @root_opened && S_ISDIR($root_opened[2]) && + $root_opened[0]==$d_dev && $root_opened[1]==$d_ino; + sub create_output { + my ($root_handle,$relative)=@_; + return "-" if $relative eq "-"; + exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z}; + chdir($root_handle) or exit 125; + my @parts=split(m{/},$relative); my $leaf=pop @parts; + for my $component (@parts) { + exit 125 if $component eq "." or $component eq ".."; + my @named=lstat($component); + exit 125 unless @named && S_ISDIR($named[2]); + opendir(my $next,$component) or exit 125; + my @opened=stat($next); + exit 125 unless @opened && S_ISDIR($opened[2]) && + $opened[0]==$named[0] && $opened[1]==$named[1]; + chdir($next) or exit 125; + } + exit 125 if $leaf eq "." or $leaf eq ".."; + sysopen(my $file,$leaf,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600) or + exit 125; + my @opened=stat($file); + exit 125 unless @opened && S_ISREG($opened[2]) && $opened[7]==0; + close($file) or exit 125; + return $opened[0].":".$opened[1]; + } + print join(" ",map {create_output($root_dh,$_)} @names),"\n"; + ' "$scratch" "$SCRATCH_ID" "$output" "$error" "$receipt" +} + +scratch_prepare_capture_keys() { + local prepared remainder + prepared=$(scratch_prepare_capture "$1" "$2" "$3") || return 125 + case "$prepared" in *' '*' '*) ;; *) return 125 ;; esac + CAPTURE_OUTPUT_KEY=${prepared%% *} + remainder=${prepared#* } + CAPTURE_ERROR_KEY=${remainder%% *} + CAPTURE_RECEIPT_KEY=${remainder#* } + case "$CAPTURE_RECEIPT_KEY" in *' '*) return 125 ;; esac + [ -n "$CAPTURE_OUTPUT_KEY" ] && [ -n "$CAPTURE_ERROR_KEY" ] && + [ -n "$CAPTURE_RECEIPT_KEY" ] +} + +scratch_capture_prepared() { + local output=$1 output_key=$2 error=$3 error_key=$4 + local receipt=$5 receipt_key=$6 input=$7 input_identity=$8 payload_sha=$9 + shift 9 + [ "$#" -gt 0 ] || return 125 /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path \ - -MPOSIX=_exit -e ' + -MPOSIX=_exit,dup2 -e ' use strict; use warnings; - my ($root,$expected,$stdout_name,$stderr_name,$stdin_name, - $stdin_identity,@command)=@ARGV; + my ($root,$expected,$stdout_name,$stdout_key,$stderr_name,$stderr_key, + $receipt_name,$receipt_key,$stdin_name,$stdin_identity,$payload_sha, + @command)=@ARGV; exit 125 unless @command; my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); my ($parent,$name)=$root =~ m{\A(.+)/([^/]+)\z}; @@ -301,14 +372,16 @@ scratch_capture() { my @root_opened=stat($root_dh); exit 125 unless @root_opened && S_ISDIR($root_opened[2]) && $root_opened[0]==$d_dev && $root_opened[1]==$d_ino; - - sub output_handle { - my ($root_handle,$relative)=@_; + sub open_output { + my ($root_handle,$relative,$key)=@_; if ($relative eq "-") { + exit 125 unless $key eq "-"; sysopen(my $null,"/dev/null",O_WRONLY|O_NOFOLLOW) or exit 125; return $null; } - exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z}; + exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z} && + $key =~ m{\A[0-9]+:[0-9]+\z}; + my ($dev,$ino)=split(/:/,$key,2); chdir($root_handle) or exit 125; my @parts=split(m{/},$relative); my $leaf=pop @parts; for my $component (@parts) { @@ -321,24 +394,47 @@ scratch_capture() { $opened[0]==$named[0] && $opened[1]==$named[1]; chdir($next) or exit 125; } - exit 125 if $leaf eq "." or $leaf eq ".."; - sysopen(my $file,$leaf,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600) or - exit 125; + my @named=lstat($leaf); + exit 125 unless @named && S_ISREG($named[2]) && + $named[0]==$dev && $named[1]==$ino && $named[7]==0; + sysopen(my $file,$leaf,O_WRONLY|O_NOFOLLOW) or exit 125; my @opened=stat($file); - exit 125 unless @opened && S_ISREG($opened[2]); + exit 125 unless @opened && S_ISREG($opened[2]) && + $opened[0]==$dev && $opened[1]==$ino && $opened[7]==0; return $file; } - + sub extract_payload { + my ($text)=@_; my @spec=( + ["# YSTACK_EVIDENCE_PAYLOAD_SHARED_BEGIN\n", + "# YSTACK_EVIDENCE_PAYLOAD_SHARED_END\n"], + ["# YSTACK_EVIDENCE_PAYLOAD_CLEANUP_BEGIN\n", + "# YSTACK_EVIDENCE_PAYLOAD_CLEANUP_END\n"], + ["# YSTACK_EVIDENCE_PAYLOAD_WORKER_BEGIN\n", + "# YSTACK_EVIDENCE_PAYLOAD_WORKER_END\n"]); + my @lines=split(/(?<=\n)/,$text); my $payload=""; my $cursor=0; + for my $pair (@spec) { + my ($begin,$end)=@$pair; + exit 125 unless grep({$_ eq $begin} @lines)==1 && + grep({$_ eq $end} @lines)==1; + $cursor++ while $cursor<@lines && $lines[$cursor] ne $begin; + exit 125 if $cursor>=@lines; $cursor++; + while ($cursor<@lines && $lines[$cursor] ne $end) { + $payload .= $lines[$cursor]; $cursor++; + } + exit 125 if $cursor>=@lines; $cursor++; + } + return $payload; + } sub input_bytes { - my ($root_handle,$relative,$identity)=@_; - return undef if $relative eq "-"; - exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z}; + my ($root_handle,$relative,$identity,$expected_payload_sha)=@_; + return undef if $relative eq "-" && $expected_payload_sha eq "-"; + exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z} && + $expected_payload_sha =~ m{\A[0-9a-f]{64}\z}; my (undef,undef,$f_dev,$f_ino,$size,$mtime,$ctime,$digest)= split(/:/,$identity,8); chdir($root_handle) or exit 125; my @parts=split(m{/},$relative); my $leaf=pop @parts; for my $component (@parts) { - exit 125 if $component eq "." or $component eq ".."; my @named=lstat($component); exit 125 unless @named && S_ISDIR($named[2]); opendir(my $next,$component) or exit 125; @@ -347,7 +443,6 @@ scratch_capture() { $opened[0]==$named[0] && $opened[1]==$named[1]; chdir($next) or exit 125; } - exit 125 if $leaf eq "." or $leaf eq ".."; my @named=lstat($leaf); exit 125 unless @named && S_ISREG($named[2]) && $named[0]==$f_dev && $named[1]==$f_ino && $named[7]==$size && @@ -366,21 +461,21 @@ scratch_capture() { } my @after=stat($file); my @path_after=lstat($leaf); exit 125 unless $total==$size && $sha->hexdigest eq $digest && - @after && @path_after && S_ISREG($path_after[2]) && - $after[0]==$f_dev && $after[1]==$f_ino && + @after && @path_after && $after[0]==$f_dev && $after[1]==$f_ino && $path_after[0]==$f_dev && $path_after[1]==$f_ino; - return $text; + my $payload=extract_payload($text); + exit 125 unless Digest::SHA::sha256_hex($payload) eq $expected_payload_sha; + return $payload; } - - my $stdout=output_handle($root_dh,$stdout_name); - my $stderr=output_handle($root_dh,$stderr_name); - my $stdin_text=input_bytes($root_dh,$stdin_name,$stdin_identity); + my $stdout=open_output($root_dh,$stdout_name,$stdout_key); + my $stderr=open_output($root_dh,$stderr_name,$stderr_key); + my $receipt=open_output($root_dh,$receipt_name,$receipt_key); + my $stdin_text=input_bytes($root_dh,$stdin_name,$stdin_identity,$payload_sha); if (defined($stdin_text)) { pipe(my $reader,my $writer) or exit 125; my $writer_pid=fork(); exit 125 unless defined($writer_pid); if ($writer_pid==0) { - close($reader); - my $offset=0; my $length=length($stdin_text); + close($reader); my $offset=0; my $length=length($stdin_text); while ($offset < $length) { my $written=syswrite($writer,$stdin_text,$length-$offset,$offset); _exit(125) unless defined($written) && $written>0; @@ -388,22 +483,44 @@ scratch_capture() { } close($writer); _exit(0); } - close($writer); - open(STDIN,"<&",$reader) or exit 125; - close($reader); + close($writer); open(STDIN,"<&",$reader) or exit 125; close($reader); } else { sysopen(my $null,"/dev/null",O_RDONLY|O_NOFOLLOW) or exit 125; - open(STDIN,"<&",$null) or exit 125; - close($null); + open(STDIN,"<&",$null) or exit 125; close($null); } open(STDOUT,">&",$stdout) or exit 125; open(STDERR,">&",$stderr) or exit 125; - close($stdout); close($stderr); close($root_dh); - close($parent_dh); - exec {$command[0]} @command; - exit 126; - ' "$scratch" "$SCRATCH_ID" "$output" "$error" "$input" \ - "$input_identity" "$@" + my $receipt_fd=fileno($receipt); + close($stdout); close($stderr); close($root_dh); close($parent_dh); + dup2($receipt_fd,3)>=0 or exit 125; + close($receipt) if $receipt_fd!=3; + exec {$command[0]} @command; exit 126; + ' "$scratch" "$SCRATCH_ID" "$output" "$output_key" "$error" \ + "$error_key" "$receipt" "$receipt_key" "$input" "$input_identity" \ + "$payload_sha" "$@" +} + +scratch_capture() { + local output=$1 error=$2 receipt=$3 input=$4 input_identity=$5 payload_sha=$6 + shift 6 + scratch_prepare_capture_keys "$output" "$error" "$receipt" || return 125 + scratch_capture_prepared "$output" "$CAPTURE_OUTPUT_KEY" \ + "$error" "$CAPTURE_ERROR_KEY" "$receipt" "$CAPTURE_RECEIPT_KEY" \ + "$input" "$input_identity" "$payload_sha" "$@" +} + +capture_identity_for_key() { + local path=$1 key=$2 limit=${3:-1048576} identity file_key + [ "$key" != - ] || return 1 + identity=$(path_identity "$path" "$limit") || return 1 + file_key=$(/usr/bin/printf '%s\n' "$identity" | /usr/bin/awk -F: \ + 'NF==8 {print $3":"$4}') || return 1 + [ "$file_key" = "$key" ] || return 1 + /usr/bin/printf '%s\n' "$identity" +} + +capture_path_matches() { + capture_identity_for_key "$@" >/dev/null } scratch_write_lines() { @@ -541,12 +658,48 @@ sha256_path() { /usr/bin/printf '%s\n' "${identity##*:}" } +payload_sha_from_identity() { + local wrapped text sentinel=YSTACK_EVIDENCE_PAYLOAD_SENTINEL_9f41c2 + wrapped=$({ + capture_identity_text "$1" "$2" || exit 1 + /usr/bin/printf '%s' "$sentinel" + }) || return 1 + case "$wrapped" in *"$sentinel") ;; *) return 1 ;; esac + text=${wrapped%"$sentinel"} + /usr/bin/printf '%s' "$text" | /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MDigest::SHA -e ' + use strict; use warnings; local $/; my $text=; + my @spec=( + ["# YSTACK_EVIDENCE_PAYLOAD_SHARED_BEGIN\n", + "# YSTACK_EVIDENCE_PAYLOAD_SHARED_END\n"], + ["# YSTACK_EVIDENCE_PAYLOAD_CLEANUP_BEGIN\n", + "# YSTACK_EVIDENCE_PAYLOAD_CLEANUP_END\n"], + ["# YSTACK_EVIDENCE_PAYLOAD_WORKER_BEGIN\n", + "# YSTACK_EVIDENCE_PAYLOAD_WORKER_END\n"]); + my @lines=split(/(?<=\n)/,$text); my $payload=""; my $cursor=0; + for my $pair (@spec) { + my ($begin,$end)=@$pair; + exit 1 unless grep({$_ eq $begin} @lines)==1 && + grep({$_ eq $end} @lines)==1; + $cursor++ while $cursor<@lines && $lines[$cursor] ne $begin; + exit 1 if $cursor>=@lines; $cursor++; + while ($cursor<@lines && $lines[$cursor] ne $end) { + $payload .= $lines[$cursor]; $cursor++; + } + exit 1 if $cursor>=@lines; $cursor++; + } + print Digest::SHA::sha256_hex($payload),"\n"; + ' +} +# YSTACK_EVIDENCE_PAYLOAD_SHARED_END + if [ -n "${YSTACK_EVIDENCE_STAGE+x}" ]; then silent_fail; fi scratch= SCRATCH_ID= SCRATCH_OWNED=0 ACTIVE_PID= ACTIVE_PGID= +# YSTACK_EVIDENCE_PAYLOAD_CLEANUP_BEGIN cleanup() { [ -z "${scratch:-}" ] && return 0 case "$scratch" in @@ -606,6 +759,7 @@ cleanup() { ' "$scratch" "$SCRATCH_ID" >/dev/null 2>&1 || return 1 [ ! -e "$scratch" ] && [ ! -L "$scratch" ] } +# YSTACK_EVIDENCE_PAYLOAD_CLEANUP_END group_live_count() { [[ "${1:-}" =~ ^[1-9][0-9]*$ ]] || return 1 /bin/ps -axo pgid=,state= 2>/dev/null | /usr/bin/awk -v group="$1" ' @@ -790,6 +944,8 @@ snapshot_nofollow "$origin" "$origin_identity" "$source_path" 1048576 0500 || emit_error E_RUNTIME private_driver_identity=$(path_identity "$source_path" 1048576) || emit_error E_RUNTIME +private_payload_sha=$(payload_sha_from_identity "$source_path" \ + "$private_driver_identity") || emit_error E_RUNTIME snapshot_nofollow "$live_jq" "$live_jq_identity" "$jq_bin" 16777216 0500 || emit_error E_RUNTIME private_jq_identity=$(path_identity "$jq_bin" 16777216) || emit_error E_RUNTIME @@ -810,6 +966,9 @@ live_jq_id=$live_jq_identity private_jq_id=$private_jq_identity PINNED_PATHS=("$origin" "$source_path" "$live_jq_path" "$jq_bin") PINNED_IDENTITIES=("$origin_id" "$private_driver_id" "$live_jq_id" "$private_jq_id") +for input in "${normalized_args[@]:1}"; do + pin_path "$input" || emit_error E_RUNTIME +done for internal_identity in "${PINNED_IDENTITIES[@]}"; do [ -n "$internal_identity" ] || emit_error E_RUNTIME done @@ -826,30 +985,68 @@ verify_all_pins || emit_error E_RELATION supervisor_main() { worker_status=0 - run_child scratch_capture io/worker.out io/worker.err driver.sh \ - "$private_driver_id" /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + scratch_prepare_capture_keys io/worker.out io/worker.err - || + emit_supervisor_failure E_RUNTIME + worker_output_key=$CAPTURE_OUTPUT_KEY + worker_error_key=$CAPTURE_ERROR_KEY + run_child scratch_capture_prepared io/worker.out "$worker_output_key" \ + io/worker.err "$worker_error_key" - - driver.sh "$private_driver_id" \ + "$private_payload_sha" /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ TMPDIR="$scratch" HOME=/nonexistent /bin/bash -c \ 'source /dev/stdin || exit 125; worker_entry "$@"' ystack-evidence-worker \ "$worker_scratch" "$worker_scratch_id" "$origin" "$origin_id" "$source_path" \ - "$private_driver_id" "$live_jq_path" "$live_jq_id" "$jq_bin" \ - "$private_jq_id" "$@" || + "$private_driver_id" "$private_payload_sha" "$live_jq_path" "$live_jq_id" \ + "$jq_bin" "$private_jq_id" "$@" || worker_status=$? if [ -n "${ACTIVE_PGID:-}" ] || [ -n "${ACTIVE_PID:-}" ]; then signal_exit 125 fi - pin_path "$scratch/io/worker.err" || emit_supervisor_failure E_RUNTIME - error_identity=$(pinned_identity "$scratch/io/worker.err") || + error_identity=$(capture_identity_for_key "$scratch/io/worker.err" \ + "$worker_error_key") || emit_supervisor_failure E_RUNTIME error_text=$(capture_identity_text "$scratch/io/worker.err" "$error_identity") || emit_supervisor_failure E_RUNTIME + capture_path_matches "$scratch/io/worker.err" "$worker_error_key" || + emit_supervisor_failure E_RUNTIME if [ "$worker_status" -ne 0 ]; then emit_supervisor_failure "$error_text"; fi [ -z "$error_text" ] || emit_supervisor_failure E_RUNTIME - pin_path "$scratch/io/worker.out" || emit_supervisor_failure E_RUNTIME - worker_output_identity=$(pinned_identity "$scratch/io/worker.out") || + worker_output_identity=$(capture_identity_for_key "$scratch/io/worker.out" \ + "$worker_output_key") || emit_supervisor_failure E_RUNTIME worker_output_text=$(capture_identity_text "$scratch/io/worker.out" \ "$worker_output_identity") || emit_supervisor_failure E_RUNTIME [ -n "$worker_output_text" ] || emit_supervisor_failure E_RUNTIME + capture_path_matches "$scratch/io/worker.out" "$worker_output_key" || + emit_supervisor_failure E_RUNTIME + policy_set_expected=$(pinned_identity "$2") || emit_supervisor_failure E_RUNTIME + request_expected=$(pinned_identity "$3") || emit_supervisor_failure E_RUNTIME + resolved_expected=$(pinned_identity "$4") || emit_supervisor_failure E_RUNTIME + result_expected=$(pinned_identity "$5") || emit_supervisor_failure E_RUNTIME + presentation_expected=$(pinned_identity "$6") || emit_supervisor_failure E_RUNTIME + worker_canonical=$("$jq_bin" -S -c . "$scratch/io/worker.out" 2>/dev/null) || + emit_supervisor_failure E_RUNTIME + [ "$worker_canonical" = "$worker_output_text" ] || + emit_supervisor_failure E_RUNTIME + "$jq_bin" -e --arg policy_set_sha "${policy_set_expected##*:}" \ + --arg request_sha "${request_expected##*:}" \ + --arg resolved_sha "${resolved_expected##*:}" \ + --arg result_sha "${result_expected##*:}" \ + --arg presentation_sha "${presentation_expected##*:}" ' + (keys|sort)==["body","id","kind","schema_version"] and + .schema_version==1 and .kind=="evidence_integrity_evaluation" and + .body.activation_state=="inactive" and .body.authority_effect=="none" and + .body.storage_effect=="none" and + .body.policy_set.sha256==$policy_set_sha and + .body.stage.request_ref.sha256==$request_sha and + .body.stage.resolved_profile_ref.sha256==$resolved_sha and + .body.stage.result_ref.sha256==$result_sha and + .body.presentation_ref.sha256==$presentation_sha and + (.body.verdict=="satisfied" or .body.verdict=="violated") and + ((.body|has("grant_ref") or has("qualification_ref") or has("activation") or + has("credential") or has("network") or has("candidate_execution"))|not) + ' "$scratch/io/worker.out" >/dev/null 2>&1 || emit_supervisor_failure E_RUNTIME + capture_path_matches "$scratch/io/worker.out" "$worker_output_key" || + emit_supervisor_failure E_RUNTIME output_text=$worker_output_text verify_all_pins || emit_supervisor_failure E_RELATION if ! cleanup; then exit 1; fi @@ -858,8 +1055,9 @@ supervisor_main() { exit 0 } +# YSTACK_EVIDENCE_PAYLOAD_WORKER_BEGIN worker_entry() { - [ "$#" -eq 16 ] || silent_fail + [ "$#" -eq 17 ] || silent_fail trap - EXIT HUP INT TERM scratch=$1 SCRATCH_ID=$2 @@ -867,11 +1065,12 @@ worker_entry() { origin_id=$4 source_path=$5 private_driver_id=$6 - live_jq_path=$7 - live_jq_id=$8 - jq_bin=$9 - private_jq_id=${10} - shift 10 + private_payload_sha=$7 + live_jq_path=$8 + live_jq_id=$9 + jq_bin=${10} + private_jq_id=${11} + shift 11 SCRATCH_OWNED=0 ACTIVE_PID= ACTIVE_PGID= @@ -891,6 +1090,9 @@ worker_entry() { if ! private_mode_ok "$source_path" || ! private_mode_ok "$jq_bin"; then emit_error E_RUNTIME fi + observed_payload_sha=$(payload_sha_from_identity "$source_path" \ + "$private_driver_id") || emit_error E_RUNTIME + [ "$observed_payload_sha" = "$private_payload_sha" ] || emit_error E_RUNTIME verify_all_pins || emit_error E_RUNTIME trap - EXIT HUP INT TERM ulimit -f 2048 || emit_error E_RUNTIME @@ -944,7 +1146,7 @@ snapshot_executable() { pin_path "$target" || emit_error E_RUNTIME } canonical_json() { - local input=$1 canonical=$2 canonical_relative bom + local input=$1 canonical=$2 canonical_relative bom canonical_identity input_identity bom=$(/usr/bin/od -An -tx1 -N3 "$input" 2>/dev/null | /usr/bin/tr -d ' \n') || emit_error E_RUNTIME [ "$bom" != efbbbf ] || emit_error E_PARSE @@ -952,9 +1154,14 @@ canonical_json() { "$jq_bin" -s -e 'length==1' "$input" /dev/null 2>&1 || emit_error E_PARSE canonical_relative=$(scratch_relative "$canonical") || emit_error E_RUNTIME - scratch_capture "$canonical_relative" - - - "$jq_bin" -S -c . "$input" || + scratch_capture "$canonical_relative" - - - - - "$jq_bin" -S -c . "$input" || emit_error E_PARSE - /usr/bin/cmp -s "$input" "$canonical" || emit_error E_CANONICAL + canonical_identity=$(capture_identity_for_key "$canonical" \ + "$CAPTURE_OUTPUT_KEY") || emit_error E_RUNTIME + input_identity=$(path_identity "$input" 1048576) || emit_error E_RUNTIME + [ "${canonical_identity##*:}" = "${input_identity##*:}" ] || + emit_error E_CANONICAL + capture_path_matches "$canonical" "$CAPTURE_OUTPUT_KEY" || emit_error E_RUNTIME "$jq_bin" -e ' def depth: if type=="array" then if length==0 then 1 else 1+([.[]|depth]|max) end @@ -1000,7 +1207,7 @@ build_validator_mirror() { core_closure_sha() { local root=$1 wrapper=$2 selected=$3 tag=$4 registry generation_root canonical local relative file digest members descriptor physical selected_sha count modules - local members_identity members_text + local members_identity members_text canonical_identity registry_identity local -a paths member_lines registry="$root/core/v2/generation-registry.json" generation_root="$root/core/v2/generations/$selected" @@ -1019,9 +1226,13 @@ core_closure_sha() { [ "$count" -eq 3 ] && [ "$modules" -eq 5 ] || return 1 [ -f "$registry" ] && [ ! -L "$registry" ] || return 1 canonical="$scratch/registry-$tag.json" - scratch_capture "registry-$tag.json" - - - "$jq_bin" -s -S -c \ + scratch_capture "registry-$tag.json" - - - - - "$jq_bin" -s -S -c \ 'if length==1 then .[0] else error("root-count") end' "$registry" || return 1 - /usr/bin/cmp -s "$registry" "$canonical" || return 1 + canonical_identity=$(capture_identity_for_key "$canonical" \ + "$CAPTURE_OUTPUT_KEY") || return 1 + registry_identity=$(path_identity "$registry" 1048576) || return 1 + [ "${canonical_identity##*:}" = "${registry_identity##*:}" ] || return 1 + capture_path_matches "$canonical" "$CAPTURE_OUTPUT_KEY" || return 1 "$jq_bin" -e --arg selected "$selected" ' type=="array" and length>=1 and ([.[]|select(.generation_id==$selected and .semantic_identity=="core.contracts.v2")] @@ -1105,7 +1316,9 @@ pin_core_package() { for relative in "${paths[@]}"; do pin_path "$root/$relative" || return 1; done } fixed_files_ok() { - [ "$(sha256_path "$source_path")" = "$driver_sha" ] && + [ "$(sha256_path "$source_path")" = "$launcher_sha" ] && + [ "$(payload_sha_from_identity "$source_path" "$private_driver_id")" = \ + "$payload_sha" ] && [ "$(sha256_path "$program")" = "$program_sha" ] && [ "$(sha256_path "$policy")" = "$policy_sha" ] && [ "$(sha256_path "$decision")" = "$decision_sha" ] @@ -1137,13 +1350,17 @@ for control_dir in "$repo/control" "$source_dir"; do done [ "$source_dir" = "$repo/control/v1" ] || emit_error E_RELATION -driver_sha=$(sha256_path "$source_path") || emit_error E_RUNTIME +launcher_sha=$(sha256_path "$source_path") || emit_error E_RUNTIME +payload_sha=$(payload_sha_from_identity "$source_path" "$private_driver_id") || + emit_error E_RUNTIME +[ "$payload_sha" = "$private_payload_sha" ] || emit_error E_RELATION program_sha=$(sha256_path "$scratch/program.jq") || emit_error E_RUNTIME policy_sha=$(sha256_path "$scratch/policy.json") || emit_error E_RUNTIME decision_sha=$(sha256_path "$scratch/decision.json") || emit_error E_RUNTIME validator_driver_sha=$(sha256_path "$policy_validator") || emit_error E_RUNTIME validator_program_sha=$(sha256_path "$validator_program") || emit_error E_RUNTIME -"$jq_bin" -n -e --arg policy_sha "$policy_sha" --arg driver_sha "$driver_sha" \ +"$jq_bin" -n -e --arg policy_sha "$policy_sha" --arg launcher_sha "$launcher_sha" \ + --arg payload_sha "$payload_sha" \ --arg program_sha "$program_sha" --arg validator_driver_sha "$validator_driver_sha" \ --arg validator_program_sha "$validator_program_sha" \ --slurpfile policy "$scratch/policy.json" \ @@ -1153,8 +1370,12 @@ validator_program_sha=$(sha256_path "$validator_program") || emit_error E_RUNTIM id:"control-decision.evidence-integrity", body:{activation_state:"inactive",decision:"allow-observation-only-evaluation", evaluator:{ - driver_ref:{content_id:"control-evaluator-driver.evidence-integrity.v1", - media_type:"text/x-shellscript",sha256:$driver_sha}, + trusted_launcher_ref:{ + content_id:"control-evaluator-launcher.evidence-integrity.v1", + media_type:"text/x-shellscript",sha256:$launcher_sha}, + evaluation_payload_ref:{ + content_id:"control-evaluator-payload.evidence-integrity.v1", + media_type:"text/x-shellscript-fragment",sha256:$payload_sha}, policy_set_validator:{ driver_ref:{content_id:"control-policy-set-validator-driver.v1", media_type:"text/x-shellscript",sha256:$validator_driver_sha}, @@ -1168,6 +1389,7 @@ validator_program_sha=$(sha256_path "$validator_program") || emit_error E_RUNTIM semantics:{authority_effect:"none",candidate_execution:"none", credential_access:"none", input_contract:"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1", + launcher_attestation:"trusted-boundary-not-self-attested", network_access:"none",output_kind:"evidence_integrity_evaluation", output_schema_version:1,qualification_effect:"none", reference_semantics:"identity-only",storage_effect:"none", @@ -1186,10 +1408,14 @@ validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ "$mirror_validator_program" "$validator_driver_sha" "$validator_program_sha" || emit_error E_RELATION policy_status=0 -scratch_capture policy.out policy.err - - /usr/bin/env -i LC_ALL=C \ +scratch_capture policy.out policy.err - - - - /usr/bin/env -i LC_ALL=C \ PATH="${jq_bin%/*}:/usr/bin:/bin" TMPDIR="$scratch" HOME=/nonexistent \ "$mirror_policy_validator" validate "$scratch/policy-set.json" || policy_status=$? +capture_path_matches "$scratch/policy.out" "$CAPTURE_OUTPUT_KEY" || + emit_error E_RUNTIME +capture_path_matches "$scratch/policy.err" "$CAPTURE_ERROR_KEY" || + emit_error E_RUNTIME if ! validator_pair_ok "$source_dir" "$policy_validator" "$validator_program" \ "$validator_driver_sha" "$validator_program_sha" || ! validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ @@ -1227,11 +1453,41 @@ mirror_core_sha=$(core_closure_sha "$mirror_root" "$mirror_core_driver" "$select ' "$scratch/policy-set.json" >/dev/null 2>&1 || emit_error E_RELATION core_status=0 -scratch_capture core.out core.err - - /usr/bin/env -i LC_ALL=C \ +scratch_mkdirs core-accounted || emit_error E_RUNTIME +core_accounted_root="$scratch/core-accounted" +core_accounted_id=$(directory_identity "$core_accounted_root") || + emit_error E_RUNTIME +core_byte_budget=16777216 +scratch_capture core.out core.err core.receipt - - - /usr/bin/env -i LC_ALL=C \ PATH="${jq_bin%/*}:/usr/bin:/bin" TMPDIR="$scratch" HOME=/nonexistent \ - "$mirror_core_driver" validate-stage-run \ + "$mirror_core_driver" --accounted-validation "$core_accounted_root" \ + "$core_byte_budget" validate-stage-run \ "$scratch/request.json" "$scratch/resolved.json" "$scratch/result.json" \ || core_status=$? +core_output_key=$CAPTURE_OUTPUT_KEY +core_error_key=$CAPTURE_ERROR_KEY +core_receipt_key=$CAPTURE_RECEIPT_KEY +capture_path_matches "$scratch/core.out" "$core_output_key" || emit_error E_RUNTIME +capture_path_matches "$scratch/core.err" "$core_error_key" || emit_error E_RUNTIME +capture_path_matches "$scratch/core.receipt" "$core_receipt_key" || + emit_error E_RUNTIME +core_receipt_identity=$(capture_identity_for_key "$scratch/core.receipt" \ + "$core_receipt_key") || emit_error E_RUNTIME +core_receipt_text=$(capture_identity_text "$scratch/core.receipt" \ + "$core_receipt_identity") || emit_error E_RUNTIME +capture_path_matches "$scratch/core.receipt" "$core_receipt_key" || + emit_error E_RUNTIME +case "$core_receipt_text" in written-bytes:*) ;; *) emit_error E_RUNTIME ;; esac +core_written_bytes=${core_receipt_text#written-bytes:} +[[ "$core_written_bytes" =~ ^(0|[1-9][0-9]*)$ ]] || emit_error E_RUNTIME +[ "$core_written_bytes" -gt 0 ] && + [ "$core_written_bytes" -le "$core_byte_budget" ] || emit_error E_RUNTIME +directory_matches_identity "$core_accounted_root" "$core_accounted_id" || + emit_error E_RUNTIME +[ -z "$(/usr/bin/find "$core_accounted_root" -mindepth 1 -print -quit \ + 2>/dev/null)" ] || emit_error E_RUNTIME +directory_matches_identity "$core_accounted_root" "$core_accounted_id" || + emit_error E_RUNTIME post_live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-post) || emit_error E_RELATION post_mirror_core_sha=$(core_closure_sha \ @@ -1251,7 +1507,7 @@ request_sha=$(sha256_path "$scratch/request.json") || emit_error E_RUNTIME resolved_sha=$(sha256_path "$scratch/resolved.json") || emit_error E_RUNTIME result_sha=$(sha256_path "$scratch/result.json") || emit_error E_RUNTIME presentation_sha=$(sha256_path "$scratch/presentation.json") || emit_error E_RUNTIME -scratch_capture evaluation.json - - - "$jq_bin" -S -c -n \ +scratch_capture evaluation.json - - - - - "$jq_bin" -S -c -n \ -f "$scratch/program.jq" \ --slurpfile policy "$scratch/policy.json" \ --slurpfile decision "$scratch/decision.json" \ @@ -1265,6 +1521,9 @@ scratch_capture evaluation.json - - - "$jq_bin" -S -c -n \ --arg resolved_sha "$resolved_sha" --arg result_sha "$result_sha" \ --arg presentation_sha "$presentation_sha" || emit_error E_RUNTIME +evaluation_capture_key=$CAPTURE_OUTPUT_KEY +evaluation_identity=$(capture_identity_for_key "$scratch/evaluation.json" \ + "$evaluation_capture_key") || emit_error E_RUNTIME fixed_files_ok || emit_error E_RELATION final_live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-final) || emit_error E_RELATION @@ -1329,16 +1588,20 @@ canonical_json "$scratch/evaluation.json" "$scratch/evaluation.canonical" || has("credential") or has("network") or has("candidate_execution"))|not) ' "$scratch/evaluation.json" >/dev/null 2>&1 || emit_error E_RUNTIME -pin_path "$scratch/evaluation.json" || emit_error E_RUNTIME +capture_path_matches "$scratch/evaluation.json" "$evaluation_capture_key" || + emit_error E_RUNTIME verify_all_pins || emit_error E_RELATION -output_identity=$(pinned_identity "$scratch/evaluation.json") || emit_error E_RUNTIME -output_text=$(capture_identity_text "$scratch/evaluation.json" "$output_identity") || +output_text=$(capture_identity_text "$scratch/evaluation.json" \ + "$evaluation_identity") || + emit_error E_RUNTIME +capture_path_matches "$scratch/evaluation.json" "$evaluation_capture_key" || emit_error E_RUNTIME if ! cleanup; then silent_fail; fi trap - EXIT HUP INT TERM /usr/bin/printf '%s\n' "$output_text" || exit 1 return 0 } +# YSTACK_EVIDENCE_PAYLOAD_WORKER_END if [ "${BASH_SOURCE[0]}" = "$0" ]; then bootstrap_prepare "$@" diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index e6625c6..52ed770 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"driver_ref":{"content_id":"control-evaluator-driver.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"08593d21a695f4a81c87ed3d2bb7ff44fd14eb68d770c6c5876eb5a729c8e868"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"evaluation_payload_ref":{"content_id":"control-evaluator-payload.evidence-integrity.v1","media_type":"text/x-shellscript-fragment","sha256":"61c2d561e95969f0e6ada15b205f0012ec271bd3c5fa6e11ea198c09f1a8e227"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"},"trusted_launcher_ref":{"content_id":"control-evaluator-launcher.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"2553e1b0eeaf4cfb1d08548d6212d8089edde3e201f1c6b9e77de894c6311770"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","launcher_attestation":"trusted-boundary-not-self-attested","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 76a9507..41d4c94 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -5,7 +5,7 @@ export LC_ALL=C umask 077 if [ "${YSTACK_EVIDENCE_TEST_BOUNDED:-0}" != 1 ]; then - YSTACK_EVIDENCE_TEST_BOUNDED=1 exec /usr/bin/perl -e 'alarm 240; exec @ARGV' "$0" + YSTACK_EVIDENCE_TEST_BOUNDED=1 exec /usr/bin/perl -e 'alarm 360; exec @ARGV' "$0" fi root=$(CDPATH='' cd -P -- "${BASH_SOURCE[0]%/*}/../.." && pwd -P) @@ -56,6 +56,15 @@ test_directory_identity() { print $parent[0],":",$parent[1],":",$dir[0],":",$dir[1],"\n"; ' "$1" } +test_payload_sha() { + local identity=${2:-} + [ -n "$identity" ] || identity=$(test_path_identity "$1") + [ -n "$identity" ] || return 1 + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin /bin/bash -c ' + source "$1" || exit 1 + payload_sha_from_identity "$1" "$2" + ' payload-hash "$1" "$identity" +} platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) case "$platform" in @@ -93,6 +102,9 @@ generation=$(/usr/bin/sed -n \ [[ "$generation" =~ ^g-[0-9a-f]{64}$ ]] || fail 'selected generation shape' policy_sha=$(sha256_path "$policy") definition_sha=$(sha256_path "$definition") +launcher_sha=$(sha256_path "$evaluator") +launcher_identity=$(test_path_identity "$evaluator") +payload_sha=$(test_payload_sha "$evaluator" "$launcher_identity") core_package_sha=$("$jq_bin" -er '.body.core_contract.package_ref.sha256' "$policy") for canonical_source in "$policy" "$definition"; do "$jq_bin" -S -c . "$canonical_source" >"$tmp/canonical" @@ -106,6 +118,20 @@ for source_path in control/v1/evidence-integrity-policy.json \ fail "raw generation $source_path" done pass 'canonical definitions and opaque core generation' +"$jq_bin" -e --arg launcher "$launcher_sha" --arg payload "$payload_sha" ' + .body.evaluator.trusted_launcher_ref=={ + content_id:"control-evaluator-launcher.evidence-integrity.v1", + media_type:"text/x-shellscript",sha256:$launcher} and + .body.evaluator.evaluation_payload_ref=={ + content_id:"control-evaluator-payload.evidence-integrity.v1", + media_type:"text/x-shellscript-fragment",sha256:$payload} and + .body.semantics.launcher_attestation=="trusted-boundary-not-self-attested" +' "$definition" >/dev/null || fail 'launcher and payload identity contract' +if ! /usr/bin/grep -Fq 'self-attest bytes that Bash already loaded' "$root/README.md" || + ! /usr/bin/grep -Fq 'launcher is not self-attested.' "$root/RESTORE.md"; then + fail 'launcher boundary docs' +fi +pass 'trusted launcher and exact evaluation payload are distinct identities' policy_set="$tmp/policy-set.json" "$jq_bin" -S -c -n --arg policy_sha "$policy_sha" \ @@ -556,7 +582,7 @@ YSTACK_EVIDENCE_SCRATCH="$inherited_scratch" PATH="$bin:/usr/bin:/bin" \ pass 'bootstrap ignores inherited scratch cleanup authority' /usr/bin/grep -Fq \ - '"$jq_bin" -n -e --arg policy_sha "$policy_sha" --arg driver_sha "$driver_sha"' \ + '"$jq_bin" -n -e --arg policy_sha "$policy_sha" --arg launcher_sha "$launcher_sha"' \ "$evaluator" || fail 'decision envelope null-input mode' pass 'decision envelope explicitly uses null input' @@ -661,6 +687,7 @@ direct_origin="$direct_runtime/control/v1/evaluate-evidence-integrity.sh" /bin/chmod 0500 "$direct_scratch/bin/jq" direct_origin_id=$(test_path_identity "$direct_origin") direct_driver_id=$(test_path_identity "$direct_scratch/driver.sh") +direct_payload_sha=$(test_payload_sha "$direct_scratch/driver.sh" "$direct_driver_id") direct_live_id=$(test_path_identity "$direct_live") direct_jq_id=$(test_path_identity "$direct_scratch/bin/jq") direct_worker_id=$(test_directory_identity "$direct_worker") @@ -671,7 +698,8 @@ direct_status=0 worker_entry "$@"' direct-worker \ "$direct_origin" "$direct_worker" "$direct_worker_id" "$direct_origin" \ "$direct_origin_id" "$direct_scratch/driver.sh" "$direct_driver_id" \ - "$direct_live" "$direct_live_id" "$direct_scratch/bin/jq" "$direct_jq_id" \ + "$direct_payload_sha" "$direct_live" "$direct_live_id" \ + "$direct_scratch/bin/jq" "$direct_jq_id" \ evaluate "$direct_input_parent/policy-set.json" "$direct_input_parent/request.json" \ "$direct_input_parent/resolved.json" "$direct_input_parent/result.json" \ "$direct_input_parent/presentation.json" \ @@ -836,15 +864,16 @@ signal_live=$(/bin/ps -axo pgid=,state= 2>/dev/null | /usr/bin/awk \ fail 'signal cleanup' pass 'signal kills and reaps the stopped owned child group' -/usr/bin/grep -Fq 'ulimit -f 2048' "$evaluator" || fail 'child output cap' -/usr/bin/grep -Fq '[ "$attempt" -lt 1000 ]' "$evaluator" || fail 'child deadline' -pass 'child runtime and output are bounded' - bind_modified_driver() { - local runtime=$1 output_set=$2 driver_digest decision_digest + local runtime=$1 output_set=$2 driver_digest payload_digest decision_digest + runtime=$(CDPATH='' cd -P -- "$runtime" && pwd -P) driver_digest=$(sha256_path "$runtime/control/v1/evaluate-evidence-integrity.sh") - "$jq_bin" -S -c --arg digest "$driver_digest" \ - '.body.evaluator.driver_ref.sha256=$digest' \ + payload_digest=$(test_payload_sha \ + "$runtime/control/v1/evaluate-evidence-integrity.sh") + "$jq_bin" -S -c --arg launcher "$driver_digest" --arg payload "$payload_digest" ' + .body.evaluator.trusted_launcher_ref.sha256=$launcher | + .body.evaluator.evaluation_payload_ref.sha256=$payload + ' \ "$runtime/control/v1/evidence-integrity-decision.json" >"$runtime/decision.next" /bin/mv "$runtime/decision.next" \ "$runtime/control/v1/evidence-integrity-decision.json" @@ -855,6 +884,52 @@ bind_modified_driver() { ' "$policy_set" >"$output_set" } +core_stall_runtime="$tmp/core-stall-runtime" +core_stall_scratch="$tmp/core-stall-scratch" +core_stall_helper="$tmp/core-stall-helper.sh" +core_stall_marker="$tmp/core-stall.pid" +copy_runtime "$core_stall_runtime" +/usr/bin/printf '%s\n' '#!/bin/bash' 'set -u' \ + 'root=$2' \ + '/bin/mkdir "$root/stalled-core" || exit 1' \ + "/usr/bin/printf '%s\\n' \"\$\$\" >\"$core_stall_marker\"" \ + '/bin/kill -STOP "$$"' \ + 'while :; do /bin/sleep 1; done' >"$core_stall_helper" +/bin/chmod 0500 "$core_stall_helper" +CORE_STALL_HELPER="$core_stall_helper" /usr/bin/perl -0777 -pi -e ' + my $helper=$ENV{"CORE_STALL_HELPER"}; + s{"\$mirror_core_driver" --accounted-validation}{"$helper" --accounted-validation} + or exit 2; +' "$core_stall_runtime/control/v1/evaluate-evidence-integrity.sh" +bind_modified_driver "$core_stall_runtime" "$tmp/core-stall-set.json" +/bin/mkdir "$core_stall_scratch" +TMPDIR="$core_stall_scratch" PATH="$bin:/usr/bin:/bin" \ + "$core_stall_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/core-stall-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/core-stall.out" 2>"$tmp/core-stall.err" & +core_stall_parent=$! +core_stall_attempt=0 +while [ ! -s "$core_stall_marker" ] && kill -0 "$core_stall_parent" 2>/dev/null && + [ "$core_stall_attempt" -lt 1200 ]; do + core_stall_attempt=$((core_stall_attempt + 1)); /bin/sleep 0.005 +done +[ -s "$core_stall_marker" ] || fail 'nested core stall marker' +core_stall_pid=$(/bin/cat "$core_stall_marker") +[[ "$core_stall_pid" =~ ^[1-9][0-9]*$ ]] || fail 'nested core stall pid' +/bin/kill -TERM "$core_stall_parent" +wait_exit "$core_stall_parent" 500 || fail 'nested core bounded exit' +core_stall_status=0 +wait "$core_stall_parent" || core_stall_status=$? +[ "$core_stall_status" -ne 0 ] && ! kill -0 "$core_stall_pid" 2>/dev/null && + [ ! -s "$tmp/core-stall.out" ] && [ ! -s "$tmp/core-stall.err" ] && + [ -z "$(/usr/bin/find "$core_stall_scratch" -mindepth 1 -print -quit)" ] || + fail 'nested core signal cleanup' +pass 'stopped nested core is killed and worker-owned scratch is removed' + +/usr/bin/grep -Fq 'ulimit -f 2048' "$evaluator" || fail 'child output cap' +/usr/bin/grep -Fq '[ "$attempt" -lt 1000 ]' "$evaluator" || fail 'child deadline' +pass 'child runtime and output are bounded' + launch_runtime="$tmp/launch-signal-runtime" launch_scratch="$tmp/launch-signal-scratch" launch_ready="$tmp/launch-signal.ready" @@ -901,10 +976,10 @@ output_runtime="$tmp/output-swap-runtime" output_scratch="$tmp/output-swap-scratch" copy_runtime "$output_runtime" /usr/bin/perl -0777 -pi -e ' - s{pin_path "\$scratch/evaluation\.json" \|\| emit_error E_RUNTIME}{ + s{capture_path_matches "\$scratch/evaluation\.json" "\$evaluation_capture_key" \|\|}{ /bin/mv "\$scratch/evaluation.json" "\$scratch/evaluation.saved" || exit 1; /bin/ln -s "\$scratch/evaluation.saved" "\$scratch/evaluation.json" || exit 1; - pin_path "\$scratch/evaluation.json" || emit_error E_RUNTIME + capture_path_matches "\$scratch/evaluation.json" "\$evaluation_capture_key" || } or exit 2 ' "$output_runtime/control/v1/evaluate-evidence-integrity.sh" bind_modified_driver "$output_runtime" "$tmp/output-swap-set.json" @@ -920,6 +995,47 @@ TMPDIR="$output_scratch" PATH="$bin:/usr/bin:/bin" \ fail 'output symlink swap' pass 'output path replacement is rejected before cleanup and emission' +post_capture_runtime="$tmp/post-capture-runtime" +post_capture_scratch="$tmp/post-capture-scratch" +post_capture_ready="$tmp/post-capture.ready" +post_capture_go="$tmp/post-capture.go" +copy_runtime "$post_capture_runtime" +POST_CAPTURE_READY="$post_capture_ready" POST_CAPTURE_GO="$post_capture_go" \ + /usr/bin/perl -0777 -pi -e ' + my $ready=$ENV{"POST_CAPTURE_READY"}; my $go=$ENV{"POST_CAPTURE_GO"}; + my $replacement = qq{ worker_status=\$?\n} . + qq{ /usr/bin/printf "ready\\n" >"$ready" || exit 1\n} . + qq{ while [ ! -e "$go" ]; do /bin/sleep 0.01; done\n} . + qq{ if [ -n}; + s{ worker_status=\$\?\n if \[ -n}{$replacement} or exit 2; + ' "$post_capture_runtime/control/v1/evaluate-evidence-integrity.sh" +bind_modified_driver "$post_capture_runtime" "$tmp/post-capture-set.json" +/bin/mkdir "$post_capture_scratch" +TMPDIR="$post_capture_scratch" PATH="$bin:/usr/bin:/bin" \ + "$post_capture_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/post-capture-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/post-capture.out" 2>"$tmp/post-capture.err" & +post_capture_pid=$! +post_capture_attempt=0 +while [ ! -e "$post_capture_ready" ] && kill -0 "$post_capture_pid" 2>/dev/null && + [ "$post_capture_attempt" -lt 1200 ]; do + post_capture_attempt=$((post_capture_attempt + 1)); /bin/sleep 0.005 +done +[ -e "$post_capture_ready" ] || fail 'post-capture replacement ready' +post_capture_root=$(/usr/bin/find "$post_capture_scratch" -mindepth 1 -maxdepth 1 \ + -type d -name 'ystack-evidence.??????' -print -quit) +[ -n "$post_capture_root" ] || fail 'post-capture replacement root' +/bin/mv "$post_capture_root/io/worker.out" "$post_capture_root/io/worker.saved" +/usr/bin/printf '{"forged":true}\n' >"$post_capture_root/io/worker.out" +: >"$post_capture_go" +post_capture_status=0 +wait "$post_capture_pid" || post_capture_status=$? +[ "$post_capture_status" -ne 0 ] && [ ! -s "$tmp/post-capture.out" ] && + [ "$(/bin/cat "$tmp/post-capture.err")" = E_RUNTIME ] && + [ -z "$(/usr/bin/find "$post_capture_scratch" -mindepth 1 -print -quit)" ] || + fail 'post-capture replacement result' +pass 'post-creation output replacement is rejected before consumption' + cleanup_runtime="$tmp/cleanup-failure-runtime" cleanup_scratch="$tmp/cleanup-failure-scratch" copy_runtime "$cleanup_runtime" @@ -960,8 +1076,8 @@ capture_swap_case() { my $ready=$ENV{"CAPTURE_READY"}; my $go=$ENV{"CAPTURE_GO"}; my $replacement = qq{ /usr/bin/printf "ready\\n" >"$ready" || exit 1\n} . qq{ while [ ! -e "$go" ]; do /bin/sleep 0.01; done\n} . - qq{ run_child scratch_capture}; - s{ run_child scratch_capture}{$replacement} or exit 2; + qq{ run_child scratch_capture_prepared}; + s{ run_child scratch_capture_prepared}{$replacement} or exit 2; ' "$runtime/control/v1/evaluate-evidence-integrity.sh" bind_modified_driver "$runtime" "$tmp/capture-$1-set.json" TMPDIR="$parent" PATH="$bin:/usr/bin:/bin" \ @@ -983,6 +1099,7 @@ capture_swap_case() { /bin/ln -s "$outside" "$scratch_path/io" ;; leaf) + /bin/mv "$scratch_path/io/worker.out" "$scratch_path/io/worker.out.saved" /bin/ln -s "$outside/sentinel" "$scratch_path/io/worker.out" ;; *) fail "capture $mode mode" ;; From e95ca6ead590d68bb6603be94a24471f7d41543a Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 2 Sep 2026 02:55:34 -0400 Subject: [PATCH 15/16] test(control): allow accounted marker margin --- scripts/test/control-evidence-integrity.test.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 41d4c94..7279c8b 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -17,6 +17,9 @@ core_wrapper="$root/scripts/core-contract.sh" tmp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-evidence-test.XXXXXX") cleanup() { /bin/rm -rf -- "$tmp"; } trap cleanup EXIT HUP INT TERM +# Accounted Linux paths can exceed 7s; 4000 x 5ms leaves a 20s CI margin +# inside the suite's 360s hard cap for markers reached after that work. +late_marker_attempts=4000 fail() { /usr/bin/printf 'FAIL: %s\n' "$1" >&2; exit 1; } passes=0 pass() { passes=$((passes + 1)); /usr/bin/printf 'ok %s - %s\n' "$passes" "$1"; } @@ -910,7 +913,7 @@ TMPDIR="$core_stall_scratch" PATH="$bin:/usr/bin:/bin" \ core_stall_parent=$! core_stall_attempt=0 while [ ! -s "$core_stall_marker" ] && kill -0 "$core_stall_parent" 2>/dev/null && - [ "$core_stall_attempt" -lt 1200 ]; do + [ "$core_stall_attempt" -lt "$late_marker_attempts" ]; do core_stall_attempt=$((core_stall_attempt + 1)); /bin/sleep 0.005 done [ -s "$core_stall_marker" ] || fail 'nested core stall marker' @@ -1018,7 +1021,7 @@ TMPDIR="$post_capture_scratch" PATH="$bin:/usr/bin:/bin" \ post_capture_pid=$! post_capture_attempt=0 while [ ! -e "$post_capture_ready" ] && kill -0 "$post_capture_pid" 2>/dev/null && - [ "$post_capture_attempt" -lt 1200 ]; do + [ "$post_capture_attempt" -lt "$late_marker_attempts" ]; do post_capture_attempt=$((post_capture_attempt + 1)); /bin/sleep 0.005 done [ -e "$post_capture_ready" ] || fail 'post-capture replacement ready' @@ -1184,7 +1187,7 @@ TMPDIR="$scratch_swap_parent" PATH="$bin:/usr/bin:/bin" \ scratch_swap_pid=$! scratch_swap_attempt=0 while [ ! -e "$scratch_swap_ready" ] && kill -0 "$scratch_swap_pid" 2>/dev/null && - [ "$scratch_swap_attempt" -lt 1200 ]; do + [ "$scratch_swap_attempt" -lt "$late_marker_attempts" ]; do scratch_swap_attempt=$((scratch_swap_attempt + 1)); /bin/sleep 0.005 done [ -e "$scratch_swap_ready" ] || fail 'scratch swap ready' From f2cb6b7458ed6e16804c77a522469d7c5ffe0f1b Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 2 Sep 2026 04:40:22 -0400 Subject: [PATCH 16/16] fix(control): bind producer-final capture identity --- README.md | 5 +- RESTORE.md | 2 +- control/v1/evaluate-evidence-integrity.sh | 332 ++++++++++++++---- control/v1/evidence-integrity-decision.json | 2 +- .../test/control-evidence-integrity.test.sh | 103 ++++-- 5 files changed, 347 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index bf4c3e8..697a502 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,9 @@ The launcher is an explicit trusted shell boundary; it does not claim to self-attest bytes that Bash already loaded. The decision separately binds the exact marked evaluation payload. The launcher extracts those fragments from a private no-follow snapshot, verifies their content identity, and gives only the -verified bytes to the worker. Scratch output descriptors keep their creation -identities through consumption. Public-core validation uses its accounted mode +verified bytes to the worker. Scratch producers return full final descriptor +identities over an inherited, unlinked channel; consumers bind every read to +those identities. Public-core validation uses its accounted mode with a fixed budget, an anchored receipt descriptor, and a worker-owned root. The evaluator never reads proof bytes. Matching references do not prove a claim diff --git a/RESTORE.md b/RESTORE.md index eddfa56..df3dd4b 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -404,7 +404,7 @@ bash scripts/test/control-evidence-integrity.test.sh This checks exact policy-set and public-core closure, stage and qualification identity binding, canonical evidence/prior sets, stale or aliased references, and deterministic observation output. It also checks the explicit trusted-launcher -boundary, exact marked-payload identity, retained scratch descriptor identities, +boundary, exact marked-payload identity, producer-final scratch identities, accounted core receipt and cleanup, and nested Bash environment isolation. The launcher is not self-attested. It does not read proof bytes, establish proof truth, qualify a workflow, store evidence, grant authority, activate a profile, diff --git a/control/v1/evaluate-evidence-integrity.sh b/control/v1/evaluate-evidence-integrity.sh index d6b7c0b..de51f71 100755 --- a/control/v1/evaluate-evidence-integrity.sh +++ b/control/v1/evaluate-evidence-integrity.sh @@ -276,14 +276,16 @@ scratch_relative() { } scratch_prepare_capture() { - local output=$1 error=$2 receipt=$3 + local output=$1 error=$2 receipt=$3 record=$4 if { [ "$output" != - ] && { [ "$output" = "$error" ] || [ "$output" = "$receipt" ]; }; } || { [ "$error" != - ] && [ "$error" = "$receipt" ]; }; then return 125 fi + [ "$record" != - ] && [ "$record" != "$output" ] && + [ "$record" != "$error" ] && [ "$record" != "$receipt" ] || return 125 /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ - /usr/bin/perl -MFcntl=:DEFAULT,:mode -MCwd=abs_path -e ' + /usr/bin/perl -MFcntl=:DEFAULT,:mode -MCwd=abs_path -MPOSIX=mkfifo -e ' use strict; use warnings; my ($root,$expected,@names)=@ARGV; my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); @@ -302,8 +304,8 @@ scratch_prepare_capture() { my @root_opened=stat($root_dh); exit 125 unless @root_opened && S_ISDIR($root_opened[2]) && $root_opened[0]==$d_dev && $root_opened[1]==$d_ino; - sub create_output { - my ($root_handle,$relative)=@_; + sub create_node { + my ($root_handle,$relative,$fifo)=@_; return "-" if $relative eq "-"; exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z}; chdir($root_handle) or exit 125; @@ -319,6 +321,12 @@ scratch_prepare_capture() { chdir($next) or exit 125; } exit 125 if $leaf eq "." or $leaf eq ".."; + if ($fifo) { + mkfifo($leaf,0600) or exit 125; + my @created=lstat($leaf); + exit 125 unless @created && S_ISFIFO($created[2]); + return $created[0].":".$created[1]; + } sysopen(my $file,$leaf,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW,0600) or exit 125; my @opened=stat($file); @@ -326,34 +334,88 @@ scratch_prepare_capture() { close($file) or exit 125; return $opened[0].":".$opened[1]; } - print join(" ",map {create_output($root_dh,$_)} @names),"\n"; - ' "$scratch" "$SCRATCH_ID" "$output" "$error" "$receipt" + my $record=pop @names; + print join(" ",(map {create_node($root_dh,$_,0)} @names), + create_node($root_dh,$record,1)),"\n"; + ' "$scratch" "$SCRATCH_ID" "$output" "$error" "$receipt" "$record" +} + +capture_channel_seal() { + local relative=$1 key=$2 + /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ + /usr/bin/perl -MFcntl=:mode -MCwd=abs_path -e ' + use strict; use warnings; + my ($root,$expected,$relative,$key)=@ARGV; + my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); + my ($f_dev,$f_ino)=split(/:/,$key,2); + exit 1 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z}; + open(my $channel,"<&=6") or exit 1; + my @channel_st=stat($channel); + exit 1 unless @channel_st && S_ISFIFO($channel_st[2]) && + $channel_st[0]==$f_dev && $channel_st[1]==$f_ino; + my ($parent,$name)=$root =~ m{\A(.+)/([^/]+)\z}; + exit 1 unless defined($parent) && defined($name) && + defined(abs_path($parent)) && abs_path($parent) eq $parent; + opendir(my $parent_dh,$parent) or exit 1; + my @parent_st=stat($parent_dh); + exit 1 unless @parent_st && $parent_st[0]==$p_dev && $parent_st[1]==$p_ino; + chdir($parent_dh) or exit 1; + my @root_named=lstat($name); + exit 1 unless @root_named && S_ISDIR($root_named[2]) && + $root_named[0]==$d_dev && $root_named[1]==$d_ino; + opendir(my $root_dh,$name) or exit 1; + my @parts=split(m{/},$relative); my $leaf=pop @parts; + chdir($root_dh) or exit 1; + for my $component (@parts) { + my @named=lstat($component); + exit 1 unless @named && S_ISDIR($named[2]); + opendir(my $next,$component) or exit 1; + my @opened=stat($next); + exit 1 unless @opened && $opened[0]==$named[0] && $opened[1]==$named[1]; + chdir($next) or exit 1; + } + my @named=lstat($leaf); + exit 1 unless @named && S_ISFIFO($named[2]) && + $named[0]==$f_dev && $named[1]==$f_ino && unlink($leaf); + exit 1 if lstat($leaf); + ' "$scratch" "$SCRATCH_ID" "$relative" "$key" || return 1 + [ ! -e "$scratch/$relative" ] && [ ! -L "$scratch/$relative" ] } scratch_prepare_capture_keys() { local prepared remainder - prepared=$(scratch_prepare_capture "$1" "$2" "$3") || return 125 - case "$prepared" in *' '*' '*) ;; *) return 125 ;; esac + prepared=$(scratch_prepare_capture "$1" "$2" "$3" "$4") || return 125 + case "$prepared" in *' '*' '*' '*) ;; *) return 125 ;; esac CAPTURE_OUTPUT_KEY=${prepared%% *} remainder=${prepared#* } CAPTURE_ERROR_KEY=${remainder%% *} - CAPTURE_RECEIPT_KEY=${remainder#* } - case "$CAPTURE_RECEIPT_KEY" in *' '*) return 125 ;; esac + remainder=${remainder#* } + CAPTURE_RECEIPT_KEY=${remainder%% *} + CAPTURE_RECORD_KEY=${remainder#* } + case "$CAPTURE_RECORD_KEY" in *' '*) return 125 ;; esac [ -n "$CAPTURE_OUTPUT_KEY" ] && [ -n "$CAPTURE_ERROR_KEY" ] && - [ -n "$CAPTURE_RECEIPT_KEY" ] + [ -n "$CAPTURE_RECEIPT_KEY" ] && [ -n "$CAPTURE_RECORD_KEY" ] || return 125 + exec 6>&- + exec 6<>"$scratch/$4" || return 125 + if ! capture_channel_seal "$4" "$CAPTURE_RECORD_KEY"; then + exec 6>&- + return 125 + fi } scratch_capture_prepared() { local output=$1 output_key=$2 error=$3 error_key=$4 - local receipt=$5 receipt_key=$6 input=$7 input_identity=$8 payload_sha=$9 - shift 9 + local receipt=$5 receipt_key=$6 record=$7 record_key=$8 + local input=$9 input_identity=${10} payload_sha=${11} + shift 11 [ "$#" -gt 0 ] || return 125 /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ /usr/bin/perl -MFcntl=:DEFAULT,:mode -MDigest::SHA -MCwd=abs_path \ -MPOSIX=_exit,dup2 -e ' use strict; use warnings; my ($root,$expected,$stdout_name,$stdout_key,$stderr_name,$stderr_key, - $receipt_name,$receipt_key,$stdin_name,$stdin_identity,$payload_sha, + $receipt_name,$receipt_key,$record_name,$record_key,$stdin_name, + $stdin_identity,$payload_sha, @command)=@ARGV; exit 125 unless @command; my ($p_dev,$p_ino,$d_dev,$d_ino)=split(/:/,$expected,4); @@ -376,7 +438,7 @@ scratch_capture_prepared() { my ($root_handle,$relative,$key)=@_; if ($relative eq "-") { exit 125 unless $key eq "-"; - sysopen(my $null,"/dev/null",O_WRONLY|O_NOFOLLOW) or exit 125; + sysopen(my $null,"/dev/null",O_RDWR|O_NOFOLLOW) or exit 125; return $null; } exit 125 unless $relative =~ m{\A[^/]+(?:/[^/]+)*\z} && @@ -397,7 +459,7 @@ scratch_capture_prepared() { my @named=lstat($leaf); exit 125 unless @named && S_ISREG($named[2]) && $named[0]==$dev && $named[1]==$ino && $named[7]==0; - sysopen(my $file,$leaf,O_WRONLY|O_NOFOLLOW) or exit 125; + sysopen(my $file,$leaf,O_RDWR|O_NOFOLLOW) or exit 125; my @opened=stat($file); exit 125 unless @opened && S_ISREG($opened[2]) && $opened[0]==$dev && $opened[1]==$ino && $opened[7]==0; @@ -470,10 +532,17 @@ scratch_capture_prepared() { my $stdout=open_output($root_dh,$stdout_name,$stdout_key); my $stderr=open_output($root_dh,$stderr_name,$stderr_key); my $receipt=open_output($root_dh,$receipt_name,$receipt_key); + my ($record_dev,$record_ino)=split(/:/,$record_key,2); + open(my $channel,">&=6") or exit 125; + my @channel_st=stat($channel); + exit 125 unless @channel_st && S_ISFIFO($channel_st[2]) && + $channel_st[0]==$record_dev && $channel_st[1]==$record_ino; + fcntl($channel,F_SETFD,FD_CLOEXEC) or exit 125; my $stdin_text=input_bytes($root_dh,$stdin_name,$stdin_identity,$payload_sha); + my $writer_pid=-1; if (defined($stdin_text)) { pipe(my $reader,my $writer) or exit 125; - my $writer_pid=fork(); exit 125 unless defined($writer_pid); + $writer_pid=fork(); exit 125 unless defined($writer_pid); if ($writer_pid==0) { close($reader); my $offset=0; my $length=length($stdin_text); while ($offset < $length) { @@ -488,25 +557,80 @@ scratch_capture_prepared() { sysopen(my $null,"/dev/null",O_RDONLY|O_NOFOLLOW) or exit 125; open(STDIN,"<&",$null) or exit 125; close($null); } - open(STDOUT,">&",$stdout) or exit 125; - open(STDERR,">&",$stderr) or exit 125; - my $receipt_fd=fileno($receipt); - close($stdout); close($stderr); close($root_dh); close($parent_dh); - dup2($receipt_fd,3)>=0 or exit 125; - close($receipt) if $receipt_fd!=3; - exec {$command[0]} @command; exit 126; + my $command_pid=fork(); exit 125 unless defined($command_pid); + if ($command_pid==0) { + open(STDOUT,">&",$stdout) or _exit(125); + open(STDERR,">&",$stderr) or _exit(125); + my $receipt_fd=fileno($receipt); + close($channel); close($root_dh); close($parent_dh); + dup2($receipt_fd,3)>=0 or _exit(125); + close($receipt) if $receipt_fd!=3; + close($stdout); close($stderr); + exec {$command[0]} @command or _exit(126); + } + close(STDIN); close($root_dh); close($parent_dh); + waitpid($command_pid,0)==$command_pid or exit 125; + my $raw_status=$?; my $status; + if (($raw_status & 127)==0) { $status=($raw_status >> 8) & 255; } + else { $status=128+($raw_status & 127); } + if ($writer_pid>0) { + waitpid($writer_pid,0)==$writer_pid or exit 125; + $status=125 unless (($? & 127)==0 && (($? >> 8) & 255)==0); + } + sub final_identity { + my ($handle,$name)=@_; return "-" if $name eq "-"; + seek($handle,0,0) or exit 125; + my @before=stat($handle); my $sha=Digest::SHA->new(256); my $total=0; + exit 125 unless @before && S_ISREG($before[2]); + while (1) { + my $read=sysread($handle,my $buffer,65536); + exit 125 unless defined $read; last if $read==0; + $total += $read; exit 125 if $total > 2097152; $sha->add($buffer); + } + my @after=stat($handle); + exit 125 unless @after && $total==$after[7] && + $before[0]==$after[0] && $before[1]==$after[1] && + $before[7]==$after[7] && $before[9]==$after[9] && + $before[10]==$after[10]; + return join(":",$after[0],$after[1],$after[7],$after[9],$after[10], + $sha->hexdigest); + } + my $stdout_final=final_identity($stdout,$stdout_name); + my $stderr_final=final_identity($stderr,$stderr_name); + my $receipt_final=final_identity($receipt,$receipt_name); + my $record_text=join(" ","status:".$status,"output:".$stdout_final, + "error:".$stderr_final,"receipt:".$receipt_final)."\n"; + my $offset=0; + while ($offset0; $offset += $written; + } + close($stdout); close($stderr); close($receipt); close($channel) or exit 125; + exit $status; ' "$scratch" "$SCRATCH_ID" "$output" "$output_key" "$error" \ - "$error_key" "$receipt" "$receipt_key" "$input" "$input_identity" \ - "$payload_sha" "$@" + "$error_key" "$receipt" "$receipt_key" "$record" "$record_key" \ + "$input" "$input_identity" "$payload_sha" "$@" } scratch_capture() { - local output=$1 error=$2 receipt=$3 input=$4 input_identity=$5 payload_sha=$6 - shift 6 - scratch_prepare_capture_keys "$output" "$error" "$receipt" || return 125 + local output=$1 error=$2 receipt=$3 record=$4 input=$5 input_identity=$6 + local payload_sha=$7 + shift 7 + local status=0 record_text + scratch_prepare_capture_keys "$output" "$error" "$receipt" "$record" || + return 125 scratch_capture_prepared "$output" "$CAPTURE_OUTPUT_KEY" \ "$error" "$CAPTURE_ERROR_KEY" "$receipt" "$CAPTURE_RECEIPT_KEY" \ - "$input" "$input_identity" "$payload_sha" "$@" + "$record" "$CAPTURE_RECORD_KEY" "$input" "$input_identity" \ + "$payload_sha" "$@" || status=$? + IFS= read -r -t 5 record_text <&6 || { + exec 6>&- + return 125 + } + exec 6>&- + capture_record_parse "$record_text" || return 125 + [ "$CAPTURE_FINAL_STATUS" -eq "$status" ] || return 125 + return "$status" } capture_identity_for_key() { @@ -523,6 +647,34 @@ capture_path_matches() { capture_identity_for_key "$@" >/dev/null } +capture_record_parse() { + local text=$1 atom + atom='(-|[0-9]+:[0-9]+:[0-9]+:[0-9]+:[0-9]+:[0-9a-f]{64})' + if [[ "$text" =~ ^status:([0-9]+)[[:space:]]output:${atom}[[:space:]]error:${atom}[[:space:]]receipt:${atom}$ ]]; then + CAPTURE_FINAL_STATUS=${BASH_REMATCH[1]} + CAPTURE_OUTPUT_FINAL=${BASH_REMATCH[2]} + CAPTURE_ERROR_FINAL=${BASH_REMATCH[3]} + CAPTURE_RECEIPT_FINAL=${BASH_REMATCH[4]} + else + return 1 + fi + [ "$CAPTURE_FINAL_STATUS" -le 255 ] +} + +capture_identity_for_final() { + local path=$1 final=$2 limit=${3:-2097152} identity observed + [ "$final" != - ] || return 1 + identity=$(path_identity "$path" "$limit") || return 1 + observed=$(/usr/bin/printf '%s\n' "$identity" | /usr/bin/awk -F: \ + 'NF==8 {print $3":"$4":"$5":"$6":"$7":"$8}') || return 1 + [ "$observed" = "$final" ] || return 1 + /usr/bin/printf '%s\n' "$identity" +} + +capture_full_identity_matches() { + capture_identity_for_final "$@" >/dev/null +} + scratch_write_lines() { local target=$1 shift @@ -985,12 +1137,14 @@ verify_all_pins || emit_error E_RELATION supervisor_main() { worker_status=0 - scratch_prepare_capture_keys io/worker.out io/worker.err - || + scratch_prepare_capture_keys io/worker.out io/worker.err - io/worker.identity || emit_supervisor_failure E_RUNTIME worker_output_key=$CAPTURE_OUTPUT_KEY worker_error_key=$CAPTURE_ERROR_KEY + worker_record_key=$CAPTURE_RECORD_KEY run_child scratch_capture_prepared io/worker.out "$worker_output_key" \ - io/worker.err "$worker_error_key" - - driver.sh "$private_driver_id" \ + io/worker.err "$worker_error_key" - - io/worker.identity "$worker_record_key" \ + driver.sh "$private_driver_id" \ "$private_payload_sha" /usr/bin/env -i LC_ALL=C PATH=/usr/bin:/bin \ TMPDIR="$scratch" HOME=/nonexistent /bin/bash -c \ 'source /dev/stdin || exit 125; worker_entry "$@"' ystack-evidence-worker \ @@ -1001,22 +1155,32 @@ supervisor_main() { if [ -n "${ACTIVE_PGID:-}" ] || [ -n "${ACTIVE_PID:-}" ]; then signal_exit 125 fi - error_identity=$(capture_identity_for_key "$scratch/io/worker.err" \ - "$worker_error_key") || + IFS= read -r -t 5 worker_record_text <&6 || { + exec 6>&- + emit_supervisor_failure E_RUNTIME + } + exec 6>&- + capture_record_parse "$worker_record_text" || emit_supervisor_failure E_RUNTIME + [ "$CAPTURE_FINAL_STATUS" -eq "$worker_status" ] || + emit_supervisor_failure E_RUNTIME + worker_output_final=$CAPTURE_OUTPUT_FINAL + worker_error_final=$CAPTURE_ERROR_FINAL + error_identity=$(capture_identity_for_final "$scratch/io/worker.err" \ + "$worker_error_final") || emit_supervisor_failure E_RUNTIME error_text=$(capture_identity_text "$scratch/io/worker.err" "$error_identity") || emit_supervisor_failure E_RUNTIME - capture_path_matches "$scratch/io/worker.err" "$worker_error_key" || + capture_full_identity_matches "$scratch/io/worker.err" "$worker_error_final" || emit_supervisor_failure E_RUNTIME if [ "$worker_status" -ne 0 ]; then emit_supervisor_failure "$error_text"; fi [ -z "$error_text" ] || emit_supervisor_failure E_RUNTIME - worker_output_identity=$(capture_identity_for_key "$scratch/io/worker.out" \ - "$worker_output_key") || + worker_output_identity=$(capture_identity_for_final "$scratch/io/worker.out" \ + "$worker_output_final") || emit_supervisor_failure E_RUNTIME worker_output_text=$(capture_identity_text "$scratch/io/worker.out" \ "$worker_output_identity") || emit_supervisor_failure E_RUNTIME [ -n "$worker_output_text" ] || emit_supervisor_failure E_RUNTIME - capture_path_matches "$scratch/io/worker.out" "$worker_output_key" || + capture_full_identity_matches "$scratch/io/worker.out" "$worker_output_final" || emit_supervisor_failure E_RUNTIME policy_set_expected=$(pinned_identity "$2") || emit_supervisor_failure E_RUNTIME request_expected=$(pinned_identity "$3") || emit_supervisor_failure E_RUNTIME @@ -1031,21 +1195,43 @@ supervisor_main() { --arg request_sha "${request_expected##*:}" \ --arg resolved_sha "${resolved_expected##*:}" \ --arg result_sha "${result_expected##*:}" \ - --arg presentation_sha "${presentation_expected##*:}" ' + --arg presentation_sha "${presentation_expected##*:}" \ + --slurpfile policy_set "$2" --slurpfile request "$3" \ + --slurpfile result "$5" --slurpfile presentation "$6" ' (keys|sort)==["body","id","kind","schema_version"] and .schema_version==1 and .kind=="evidence_integrity_evaluation" and + .id==$result[0].id and + (.body|keys|sort)==["activation_state","authority_effect","core_contract", + "decision_ref","evaluation_mode","evidence_refs","policy_ref","policy_set", + "presentation_ref","prior_evidence_refs","qualification_observation", + "qualification_semantics","reason_ids","reference_semantics","stage", + "storage_effect","verdict"] and .body.activation_state=="inactive" and .body.authority_effect=="none" and - .body.storage_effect=="none" and - .body.policy_set.sha256==$policy_set_sha and + .body.storage_effect=="none" and .body.evaluation_mode=="observation-only" and + .body.reference_semantics=="identity-only" and + .body.qualification_semantics=="identity-only-unqualified" and + .body.core_contract==$policy_set[0].body.core_contract and + .body.policy_set=={id:$policy_set[0].id,sha256:$policy_set_sha} and .body.stage.request_ref.sha256==$request_sha and .body.stage.resolved_profile_ref.sha256==$resolved_sha and .body.stage.result_ref.sha256==$result_sha and .body.presentation_ref.sha256==$presentation_sha and + .body.evidence_refs==($result[0].body.evidence| + map({evidence_id,kind,proof_ref,verdict})) and + .body.prior_evidence_refs==$request[0].body.prior_evidence_refs and + .body.qualification_observation== + (if $request[0].body|has("qualification_ref") then + {state:"present",value:$request[0].body.qualification_ref} + else {state:"absent"} end) and (.body.verdict=="satisfied" or .body.verdict=="violated") and + (.body.reason_ids|type=="array" and length>=1 and .==(sort|unique)) and + (if .body.verdict=="satisfied" then + .body.reason_ids==["evidence.integrity-satisfied"] + else (.body.reason_ids|index("evidence.integrity-satisfied")==null) end) and ((.body|has("grant_ref") or has("qualification_ref") or has("activation") or has("credential") or has("network") or has("candidate_execution"))|not) ' "$scratch/io/worker.out" >/dev/null 2>&1 || emit_supervisor_failure E_RUNTIME - capture_path_matches "$scratch/io/worker.out" "$worker_output_key" || + capture_full_identity_matches "$scratch/io/worker.out" "$worker_output_final" || emit_supervisor_failure E_RUNTIME output_text=$worker_output_text verify_all_pins || emit_supervisor_failure E_RELATION @@ -1154,14 +1340,16 @@ canonical_json() { "$jq_bin" -s -e 'length==1' "$input" /dev/null 2>&1 || emit_error E_PARSE canonical_relative=$(scratch_relative "$canonical") || emit_error E_RUNTIME - scratch_capture "$canonical_relative" - - - - - "$jq_bin" -S -c . "$input" || + scratch_capture "$canonical_relative" - - "$canonical_relative.identity" \ + - - - "$jq_bin" -S -c . "$input" || emit_error E_PARSE - canonical_identity=$(capture_identity_for_key "$canonical" \ - "$CAPTURE_OUTPUT_KEY") || emit_error E_RUNTIME + canonical_identity=$(capture_identity_for_final "$canonical" \ + "$CAPTURE_OUTPUT_FINAL") || emit_error E_RUNTIME input_identity=$(path_identity "$input" 1048576) || emit_error E_RUNTIME [ "${canonical_identity##*:}" = "${input_identity##*:}" ] || emit_error E_CANONICAL - capture_path_matches "$canonical" "$CAPTURE_OUTPUT_KEY" || emit_error E_RUNTIME + capture_full_identity_matches "$canonical" "$CAPTURE_OUTPUT_FINAL" || + emit_error E_RUNTIME "$jq_bin" -e ' def depth: if type=="array" then if length==0 then 1 else 1+([.[]|depth]|max) end @@ -1226,13 +1414,14 @@ core_closure_sha() { [ "$count" -eq 3 ] && [ "$modules" -eq 5 ] || return 1 [ -f "$registry" ] && [ ! -L "$registry" ] || return 1 canonical="$scratch/registry-$tag.json" - scratch_capture "registry-$tag.json" - - - - - "$jq_bin" -s -S -c \ + scratch_capture "registry-$tag.json" - - "registry-$tag.identity" \ + - - - "$jq_bin" -s -S -c \ 'if length==1 then .[0] else error("root-count") end' "$registry" || return 1 - canonical_identity=$(capture_identity_for_key "$canonical" \ - "$CAPTURE_OUTPUT_KEY") || return 1 + canonical_identity=$(capture_identity_for_final "$canonical" \ + "$CAPTURE_OUTPUT_FINAL") || return 1 registry_identity=$(path_identity "$registry" 1048576) || return 1 [ "${canonical_identity##*:}" = "${registry_identity##*:}" ] || return 1 - capture_path_matches "$canonical" "$CAPTURE_OUTPUT_KEY" || return 1 + capture_full_identity_matches "$canonical" "$CAPTURE_OUTPUT_FINAL" || return 1 "$jq_bin" -e --arg selected "$selected" ' type=="array" and length>=1 and ([.[]|select(.generation_id==$selected and .semantic_identity=="core.contracts.v2")] @@ -1408,13 +1597,14 @@ validator_pair_ok "$mirror_validator_dir" "$mirror_policy_validator" \ "$mirror_validator_program" "$validator_driver_sha" "$validator_program_sha" || emit_error E_RELATION policy_status=0 -scratch_capture policy.out policy.err - - - - /usr/bin/env -i LC_ALL=C \ +scratch_capture policy.out policy.err - policy.identity - - - \ + /usr/bin/env -i LC_ALL=C \ PATH="${jq_bin%/*}:/usr/bin:/bin" TMPDIR="$scratch" HOME=/nonexistent \ "$mirror_policy_validator" validate "$scratch/policy-set.json" || policy_status=$? -capture_path_matches "$scratch/policy.out" "$CAPTURE_OUTPUT_KEY" || +capture_full_identity_matches "$scratch/policy.out" "$CAPTURE_OUTPUT_FINAL" || emit_error E_RUNTIME -capture_path_matches "$scratch/policy.err" "$CAPTURE_ERROR_KEY" || +capture_full_identity_matches "$scratch/policy.err" "$CAPTURE_ERROR_FINAL" || emit_error E_RUNTIME if ! validator_pair_ok "$source_dir" "$policy_validator" "$validator_program" \ "$validator_driver_sha" "$validator_program_sha" || @@ -1458,24 +1648,27 @@ core_accounted_root="$scratch/core-accounted" core_accounted_id=$(directory_identity "$core_accounted_root") || emit_error E_RUNTIME core_byte_budget=16777216 -scratch_capture core.out core.err core.receipt - - - /usr/bin/env -i LC_ALL=C \ +scratch_capture core.out core.err core.receipt core.identity - - - \ + /usr/bin/env -i LC_ALL=C \ PATH="${jq_bin%/*}:/usr/bin:/bin" TMPDIR="$scratch" HOME=/nonexistent \ "$mirror_core_driver" --accounted-validation "$core_accounted_root" \ "$core_byte_budget" validate-stage-run \ "$scratch/request.json" "$scratch/resolved.json" "$scratch/result.json" \ || core_status=$? -core_output_key=$CAPTURE_OUTPUT_KEY -core_error_key=$CAPTURE_ERROR_KEY -core_receipt_key=$CAPTURE_RECEIPT_KEY -capture_path_matches "$scratch/core.out" "$core_output_key" || emit_error E_RUNTIME -capture_path_matches "$scratch/core.err" "$core_error_key" || emit_error E_RUNTIME -capture_path_matches "$scratch/core.receipt" "$core_receipt_key" || +core_output_final=$CAPTURE_OUTPUT_FINAL +core_error_final=$CAPTURE_ERROR_FINAL +core_receipt_final=$CAPTURE_RECEIPT_FINAL +capture_full_identity_matches "$scratch/core.out" "$core_output_final" || + emit_error E_RUNTIME +capture_full_identity_matches "$scratch/core.err" "$core_error_final" || + emit_error E_RUNTIME +capture_full_identity_matches "$scratch/core.receipt" "$core_receipt_final" || emit_error E_RUNTIME -core_receipt_identity=$(capture_identity_for_key "$scratch/core.receipt" \ - "$core_receipt_key") || emit_error E_RUNTIME +core_receipt_identity=$(capture_identity_for_final "$scratch/core.receipt" \ + "$core_receipt_final") || emit_error E_RUNTIME core_receipt_text=$(capture_identity_text "$scratch/core.receipt" \ "$core_receipt_identity") || emit_error E_RUNTIME -capture_path_matches "$scratch/core.receipt" "$core_receipt_key" || +capture_full_identity_matches "$scratch/core.receipt" "$core_receipt_final" || emit_error E_RUNTIME case "$core_receipt_text" in written-bytes:*) ;; *) emit_error E_RUNTIME ;; esac core_written_bytes=${core_receipt_text#written-bytes:} @@ -1507,7 +1700,8 @@ request_sha=$(sha256_path "$scratch/request.json") || emit_error E_RUNTIME resolved_sha=$(sha256_path "$scratch/resolved.json") || emit_error E_RUNTIME result_sha=$(sha256_path "$scratch/result.json") || emit_error E_RUNTIME presentation_sha=$(sha256_path "$scratch/presentation.json") || emit_error E_RUNTIME -scratch_capture evaluation.json - - - - - "$jq_bin" -S -c -n \ +scratch_capture evaluation.json - - evaluation.identity - - - \ + "$jq_bin" -S -c -n \ -f "$scratch/program.jq" \ --slurpfile policy "$scratch/policy.json" \ --slurpfile decision "$scratch/decision.json" \ @@ -1521,9 +1715,9 @@ scratch_capture evaluation.json - - - - - "$jq_bin" -S -c -n \ --arg resolved_sha "$resolved_sha" --arg result_sha "$result_sha" \ --arg presentation_sha "$presentation_sha" || emit_error E_RUNTIME -evaluation_capture_key=$CAPTURE_OUTPUT_KEY -evaluation_identity=$(capture_identity_for_key "$scratch/evaluation.json" \ - "$evaluation_capture_key") || emit_error E_RUNTIME +evaluation_final=$CAPTURE_OUTPUT_FINAL +evaluation_identity=$(capture_identity_for_final "$scratch/evaluation.json" \ + "$evaluation_final") || emit_error E_RUNTIME fixed_files_ok || emit_error E_RELATION final_live_core_sha=$(core_closure_sha "$repo" "$core_driver" "$selected" live-final) || emit_error E_RELATION @@ -1588,13 +1782,13 @@ canonical_json "$scratch/evaluation.json" "$scratch/evaluation.canonical" || has("credential") or has("network") or has("candidate_execution"))|not) ' "$scratch/evaluation.json" >/dev/null 2>&1 || emit_error E_RUNTIME -capture_path_matches "$scratch/evaluation.json" "$evaluation_capture_key" || +capture_full_identity_matches "$scratch/evaluation.json" "$evaluation_final" || emit_error E_RUNTIME verify_all_pins || emit_error E_RELATION output_text=$(capture_identity_text "$scratch/evaluation.json" \ "$evaluation_identity") || emit_error E_RUNTIME -capture_path_matches "$scratch/evaluation.json" "$evaluation_capture_key" || +capture_full_identity_matches "$scratch/evaluation.json" "$evaluation_final" || emit_error E_RUNTIME if ! cleanup; then silent_fail; fi trap - EXIT HUP INT TERM diff --git a/control/v1/evidence-integrity-decision.json b/control/v1/evidence-integrity-decision.json index 52ed770..526d9bb 100644 --- a/control/v1/evidence-integrity-decision.json +++ b/control/v1/evidence-integrity-decision.json @@ -1 +1 @@ -{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"evaluation_payload_ref":{"content_id":"control-evaluator-payload.evidence-integrity.v1","media_type":"text/x-shellscript-fragment","sha256":"61c2d561e95969f0e6ada15b205f0012ec271bd3c5fa6e11ea198c09f1a8e227"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"},"trusted_launcher_ref":{"content_id":"control-evaluator-launcher.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"2553e1b0eeaf4cfb1d08548d6212d8089edde3e201f1c6b9e77de894c6311770"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","launcher_attestation":"trusted-boundary-not-self-attested","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} +{"body":{"activation_state":"inactive","decision":"allow-observation-only-evaluation","evaluator":{"evaluation_payload_ref":{"content_id":"control-evaluator-payload.evidence-integrity.v1","media_type":"text/x-shellscript-fragment","sha256":"79e2a3c8817e19f1e3fc9d5571b68cb1eb15d55b69eb10f5090bb943220d1afc"},"policy_set_validator":{"driver_ref":{"content_id":"control-policy-set-validator-driver.v1","media_type":"text/x-shellscript","sha256":"cf173ad0eaa08244bf636e3937845e894b21f14291fc5e66753e8673bdd2bd2a"},"program_ref":{"content_id":"control-policy-set-validator-program.v1","media_type":"text/x-jq","sha256":"2be97550574ee4522fc0bd14780c92dee3c1b455f2c04b7763b0e437665a8d58"}},"program_ref":{"content_id":"control-evaluator-program.evidence-integrity.v1","media_type":"text/x-jq","sha256":"5b61b900b71e9485072a2d65fe52a221c25d9e270c942d8d3b00ef53aedf117f"},"trusted_launcher_ref":{"content_id":"control-evaluator-launcher.evidence-integrity.v1","media_type":"text/x-shellscript","sha256":"f0ecbe036601102db082d0a94c860ea868614ff79d06f2b37ec8dcd672c367a2"}},"fail_mode":"closed","policy_ref":{"content_id":"control-policy.evidence-integrity","media_type":"application/vnd.ystack.control-policy+json","sha256":"171b89c49c7dd6a58e4c5aa6ca13e8c95d109acf7f67429ecb33fcf1dae7582a"},"semantics":{"authority_effect":"none","candidate_execution":"none","credential_access":"none","input_contract":"control-policy-set+public-core-stage-run+evidence-integrity-presentation.v1","launcher_attestation":"trusted-boundary-not-self-attested","network_access":"none","output_kind":"evidence_integrity_evaluation","output_schema_version":1,"qualification_effect":"none","reference_semantics":"identity-only","storage_effect":"none","verdicts":["satisfied","violated"]}},"id":"control-decision.evidence-integrity","kind":"evidence_integrity_decision","schema_version":1} diff --git a/scripts/test/control-evidence-integrity.test.sh b/scripts/test/control-evidence-integrity.test.sh index 7279c8b..501b884 100755 --- a/scripts/test/control-evidence-integrity.test.sh +++ b/scripts/test/control-evidence-integrity.test.sh @@ -887,37 +887,39 @@ bind_modified_driver() { ' "$policy_set" >"$output_set" } -core_stall_runtime="$tmp/core-stall-runtime" core_stall_scratch="$tmp/core-stall-scratch" -core_stall_helper="$tmp/core-stall-helper.sh" -core_stall_marker="$tmp/core-stall.pid" -copy_runtime "$core_stall_runtime" -/usr/bin/printf '%s\n' '#!/bin/bash' 'set -u' \ - 'root=$2' \ - '/bin/mkdir "$root/stalled-core" || exit 1' \ - "/usr/bin/printf '%s\\n' \"\$\$\" >\"$core_stall_marker\"" \ - '/bin/kill -STOP "$$"' \ - 'while :; do /bin/sleep 1; done' >"$core_stall_helper" -/bin/chmod 0500 "$core_stall_helper" -CORE_STALL_HELPER="$core_stall_helper" /usr/bin/perl -0777 -pi -e ' - my $helper=$ENV{"CORE_STALL_HELPER"}; - s{"\$mirror_core_driver" --accounted-validation}{"$helper" --accounted-validation} - or exit 2; -' "$core_stall_runtime/control/v1/evaluate-evidence-integrity.sh" -bind_modified_driver "$core_stall_runtime" "$tmp/core-stall-set.json" /bin/mkdir "$core_stall_scratch" +core_stall_scratch=$(CDPATH='' cd -P -- "$core_stall_scratch" && pwd -P) TMPDIR="$core_stall_scratch" PATH="$bin:/usr/bin:/bin" \ - "$core_stall_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ - "$tmp/core-stall-set.json" "$request" "$resolved" "$result" "$presentation" \ + "$evaluator" evaluate "$policy_set" "$request" "$resolved" "$result" "$presentation" \ >"$tmp/core-stall.out" 2>"$tmp/core-stall.err" & core_stall_parent=$! core_stall_attempt=0 -while [ ! -s "$core_stall_marker" ] && kill -0 "$core_stall_parent" 2>/dev/null && +core_stall_pid= +while [ -z "$core_stall_pid" ] && kill -0 "$core_stall_parent" 2>/dev/null && [ "$core_stall_attempt" -lt "$late_marker_attempts" ]; do + core_stall_temp=$(/usr/bin/find "$core_stall_scratch" -type d \ + -path '*/worker/core-accounted/portable-core-accounted-v2.*' \ + -print -quit 2>/dev/null) || core_stall_temp= + if [ -n "$core_stall_temp" ]; then + core_stall_candidate=$(find_owned_leader "$core_stall_parent") || + core_stall_candidate= + if [[ "$core_stall_candidate" =~ ^[1-9][0-9]*$ ]] && + /bin/kill -STOP -- "-$core_stall_candidate" 2>/dev/null; then + core_stall_pid=$core_stall_candidate + break + fi + fi core_stall_attempt=$((core_stall_attempt + 1)); /bin/sleep 0.005 done -[ -s "$core_stall_marker" ] || fail 'nested core stall marker' -core_stall_pid=$(/bin/cat "$core_stall_marker") +if [ -z "$core_stall_pid" ]; then + core_stall_early_status=0 + wait "$core_stall_parent" || core_stall_early_status=$? + /usr/bin/printf 'diagnostic nested-core status=%s stderr=' \ + "$core_stall_early_status" >&2 + /bin/cat "$tmp/core-stall.err" >&2 + fail 'nested core stall marker' +fi [[ "$core_stall_pid" =~ ^[1-9][0-9]*$ ]] || fail 'nested core stall pid' /bin/kill -TERM "$core_stall_parent" wait_exit "$core_stall_parent" 500 || fail 'nested core bounded exit' @@ -979,10 +981,10 @@ output_runtime="$tmp/output-swap-runtime" output_scratch="$tmp/output-swap-scratch" copy_runtime "$output_runtime" /usr/bin/perl -0777 -pi -e ' - s{capture_path_matches "\$scratch/evaluation\.json" "\$evaluation_capture_key" \|\|}{ + s{capture_full_identity_matches "\$scratch/evaluation\.json" "\$evaluation_final" \|\|}{ /bin/mv "\$scratch/evaluation.json" "\$scratch/evaluation.saved" || exit 1; /bin/ln -s "\$scratch/evaluation.saved" "\$scratch/evaluation.json" || exit 1; - capture_path_matches "\$scratch/evaluation.json" "\$evaluation_capture_key" || + capture_full_identity_matches "\$scratch/evaluation.json" "\$evaluation_final" || } or exit 2 ' "$output_runtime/control/v1/evaluate-evidence-integrity.sh" bind_modified_driver "$output_runtime" "$tmp/output-swap-set.json" @@ -1028,6 +1030,8 @@ done post_capture_root=$(/usr/bin/find "$post_capture_scratch" -mindepth 1 -maxdepth 1 \ -type d -name 'ystack-evidence.??????' -print -quit) [ -n "$post_capture_root" ] || fail 'post-capture replacement root' +[ ! -e "$post_capture_root/io/worker.identity" ] || + fail 'post-capture metadata channel remained path-backed' /bin/mv "$post_capture_root/io/worker.out" "$post_capture_root/io/worker.saved" /usr/bin/printf '{"forged":true}\n' >"$post_capture_root/io/worker.out" : >"$post_capture_go" @@ -1039,6 +1043,57 @@ wait "$post_capture_pid" || post_capture_status=$? fail 'post-capture replacement result' pass 'post-creation output replacement is rejected before consumption' +post_inplace_runtime="$tmp/post-inplace-runtime" +post_inplace_scratch="$tmp/post-inplace-scratch" +post_inplace_ready="$tmp/post-inplace.ready" +post_inplace_go="$tmp/post-inplace.go" +copy_runtime "$post_inplace_runtime" +POST_INPLACE_READY="$post_inplace_ready" POST_INPLACE_GO="$post_inplace_go" \ + /usr/bin/perl -0777 -pi -e ' + my $ready=$ENV{"POST_INPLACE_READY"}; my $go=$ENV{"POST_INPLACE_GO"}; + my $replacement = qq{ worker_status=\$?\n} . + qq{ /usr/bin/printf "ready\\n" >"$ready" || exit 1\n} . + qq{ while [ ! -e "$go" ]; do /bin/sleep 0.01; done\n} . + qq{ if [ -n}; + s{ worker_status=\$\?\n if \[ -n}{$replacement} or exit 2; + ' "$post_inplace_runtime/control/v1/evaluate-evidence-integrity.sh" +bind_modified_driver "$post_inplace_runtime" "$tmp/post-inplace-set.json" +/bin/mkdir "$post_inplace_scratch" +TMPDIR="$post_inplace_scratch" PATH="$bin:/usr/bin:/bin" \ + "$post_inplace_runtime/control/v1/evaluate-evidence-integrity.sh" evaluate \ + "$tmp/post-inplace-set.json" "$request" "$resolved" "$result" "$presentation" \ + >"$tmp/post-inplace.out" 2>"$tmp/post-inplace.err" & +post_inplace_pid=$! +post_inplace_attempt=0 +while [ ! -e "$post_inplace_ready" ] && kill -0 "$post_inplace_pid" 2>/dev/null && + [ "$post_inplace_attempt" -lt "$late_marker_attempts" ]; do + post_inplace_attempt=$((post_inplace_attempt + 1)); /bin/sleep 0.005 +done +[ -e "$post_inplace_ready" ] || fail 'post-producer in-place ready' +post_inplace_root=$(/usr/bin/find "$post_inplace_scratch" -mindepth 1 -maxdepth 1 \ + -type d -name 'ystack-evidence.??????' -print -quit) +[ -n "$post_inplace_root" ] || fail 'post-producer in-place root' +post_inplace_root=$(CDPATH='' cd -P -- "$post_inplace_root" && pwd -P) +[ ! -e "$post_inplace_root/io/worker.identity" ] || + fail 'post-producer metadata channel remained path-backed' +post_inplace_identity_before=$(test_path_identity "$post_inplace_root/io/worker.out") +post_inplace_inode_before=$(/usr/bin/printf '%s\n' "$post_inplace_identity_before" | + /usr/bin/awk -F: '{print $3":"$4}') +/usr/bin/printf '{"forged":true}\n' >"$post_inplace_root/io/worker.out" +post_inplace_identity_after=$(test_path_identity "$post_inplace_root/io/worker.out") +post_inplace_inode_after=$(/usr/bin/printf '%s\n' "$post_inplace_identity_after" | + /usr/bin/awk -F: '{print $3":"$4}') +[ "$post_inplace_inode_before" = "$post_inplace_inode_after" ] || + fail 'post-producer mutation changed inode' +: >"$post_inplace_go" +post_inplace_status=0 +wait "$post_inplace_pid" || post_inplace_status=$? +[ "$post_inplace_status" -ne 0 ] && [ ! -s "$tmp/post-inplace.out" ] && + [ "$(/bin/cat "$tmp/post-inplace.err")" = E_RUNTIME ] && + [ -z "$(/usr/bin/find "$post_inplace_scratch" -mindepth 1 -print -quit)" ] || + fail 'post-producer in-place result' +pass 'producer-final full identity rejects same-inode output mutation' + cleanup_runtime="$tmp/cleanup-failure-runtime" cleanup_scratch="$tmp/cleanup-failure-scratch" copy_runtime "$cleanup_runtime"