diff --git a/agent-governance/README.md b/agent-governance/README.md index d234bee..b6eab47 100644 --- a/agent-governance/README.md +++ b/agent-governance/README.md @@ -91,7 +91,7 @@ scripts/render.sh --agent cursor --target-os windows \ **Behavior settings** go through `--env KEY=VALUE` (repeatable) and land in the right place per tool — Claude's `env` block, and inlined into Cursor's session hook / every Codex hook command (Codex has no managed env block). Response caching is on by default; monitor-only mode is just `--env ENDOR_AI_AUDIT_NO_BLOCKING=true`. -**`--skip-endorctl-update`** makes the session hook use an already-installed `endorctl` instead of checking for a newer one every session — useful once the fleet is provisioned. It passes through the runner too. +**`--skip-endorctl-update`** makes the session hook use an already-installed `endorctl` instead of ever checking for a newer one — useful once the fleet is provisioned. It still installs when the binary is missing, and it passes through the runner too. (Without it the check is throttled to once every 24 h and runs in the background, so it costs a session nothing either way.) `render.sh` also takes `-o/--output` (`-` for stdout). `render-plist.sh` is agent-agnostic — `--style plist` (default) with `--payload-type` (default `com.anthropic.claudecode`) selects a custom-settings app, or `--style mcx` with `--pref-domain`/`--pref-key` (defaults `com.openai.codex` / `requirements_toml_base64`) forces a managed preference; `--identifier`/`--organization` are required, with `--name`, `--profile-identifier`, and the UUID flags optional. Run either script with `--help` for the full list. @@ -106,11 +106,19 @@ A good rollout starts in monitor-only, watches the Endor audit log over a repres **`endorctl` installs and updates itself.** It isn't shipped per tool — the generated session hook runs [`download_endorctl.sh`](scripts/download_endorctl.sh) (or [`download_endorctl.ps1`](scripts/download_endorctl.ps1) on Windows), which installs the binary on first run and refreshes it when a new version ships, verifying a SHA-256 each time. So the only things that change after setup are the config (when you regenerate it) and the governance rules (server-side at Endor, fetched at run time). +**Downloading never blocks a session (POSIX).** `endorctl` is a ~300 MB binary, so on a slow link a foreground download would stall agent startup for minutes. The POSIX bootstrap instead decides what's needed and hands the work to a detached background job: + +- **Steady state** — a binary is installed and was checked within the TTL: no network at all, so the hook costs two file tests. +- **Update available** — the session audits immediately using the binary already on disk; the new one is fetched in the background and swaps in for the next session. +- **Nothing installed yet** — that one session is **not audited**; the download runs in the background and later sessions are covered. + +Downloads resume across sessions rather than restarting, and a lock keeps concurrent agents (Claude, Cursor, Codex, or several windows) from each pulling their own copy. The version check is throttled to once every 24 h — override with `--env ENDORCTL_UPDATE_TTL_MINUTES=`. Windows still downloads in the foreground; see [`download_endorctl.ps1`](scripts/download_endorctl.ps1). + **What needs re-delivery when it changes:** | What changes | How it updates | Your action | | --- | --- | --- | -| `endorctl` binary | Self-updates on session start (SHA-256 verified); `--skip-endorctl-update` pins it | None | +| `endorctl` binary | Self-updates in the background, at most once every 24 h (SHA-256 verified); `--skip-endorctl-update` pins it | None | | Governance rules | Server-side at Endor, fetched at run time | None | | Claude / Codex profile config (macOS) | Regenerate the `.mobileconfig`, re-upload to the MDM | Re-upload | | Cursor / Codex runner config (macOS/Linux) | Runner re-fetches `REF` and re-renders on each scheduled run | None after setup | @@ -119,6 +127,7 @@ A good rollout starts in monitor-only, watches the Endor audit log over a repres **Security properties:** - **Tamper-resistance.** A profile-delivered config (Claude and Codex on macOS) is an OS-enforced managed setting — hard for a developer to override, and Codex additionally marks managed-source hooks trusted-by-policy so a user can't disable them. A script-delivered file (Cursor, and the file-based Linux/Windows paths) is not OS-enforced; a determined developer could override it. Cursor has no profile mechanism today. +- **One unaudited session per machine (POSIX).** Because the first install runs in the background rather than blocking startup, the session that triggers it isn't audited — nor is any other session started before the download lands. Coverage is complete from then on. To close that window, pre-provision `endorctl` (an MDM package, or your config management) so the binary is already present the first time an agent runs. - **Least-privilege credentials.** A generated profile (or Codex `requirements.toml`) carries the API key and secret to every laptop — scope it to an **audit-only** credential. - **Pin the revision.** The runner executes this repo's code as root, so it fetches a specific revision: set `REF` (at the top of `runner.sh`) to a reviewed tag, branch, or commit and each device runs only that, not the moving branch tip. Bump `REF` to roll out a change; the default (`main`) tracks the latest. - **Credential isolation (Claude).** The `env` block exports into every subprocess Claude spawns, including any `endorctl` the agent itself runs. To keep audit credentials out of the agent's process tree, hook-scoped variables use an `AGENT_HOOK_ENDOR_*` prefix that `endorctl` doesn't read natively, and the hook passes them through as `--api-key …` flags. Codex has no managed env block, so its credentials are passed as `--api-key …` flags directly on each hook command (never exported), which keeps them out of the agent's environment the same way. @@ -130,7 +139,7 @@ Each script needs only what's standard to where it runs; the laptop paths stay l | Script | Runs on | Needs | | --- | --- | --- | -| [`download_endorctl.sh`](scripts/download_endorctl.sh) | developer laptop (inlined into the session hook) | POSIX `sh` + `curl` (plus `awk`/`sed`/`uname`/`mktemp`/`tr` and `sha256sum` or `shasum` — all standard on macOS & Linux) | +| [`download_endorctl.sh`](scripts/download_endorctl.sh) | developer laptop (inlined into the session hook) | POSIX `sh` + `curl` (plus `awk`/`sed`/`find`/`wc`/`uname`/`tr` and `sha256sum` or `shasum` — all standard on macOS & Linux) | | [`download_endorctl.ps1`](scripts/download_endorctl.ps1) | Windows laptop (encoded into the session hook) | Windows PowerShell 5.1 (built in) | | [`scripts/render.sh`](scripts/render.sh) | admin machine (macOS/Linux, or Windows via Git Bash/WSL), or laptop via the runner | POSIX `sh` + `awk` + `sed`; for `--target-os windows` also `iconv` + `base64` | | [`scripts/render-plist.sh`](scripts/render-plist.sh) | admin machine (macOS) | `plutil` (native to macOS); `base64` for `--style mcx` | @@ -146,9 +155,24 @@ scripts/ render-plist.sh wrap a config (stdin) into a .mobileconfig profile runner.sh MDM runner: clone → render → swap-if-changed examples/ checked-in samples (demo creds, placeholder UUIDs) +tests/run-tests.sh test suite (offline by default) docs/ deployment runbooks + the support matrix ``` +## Tests + +```sh +tests/run-tests.sh # offline; seconds +tests/run-tests.sh --network # + assert the download endpoint's contract +tests/run-tests.sh --network-full # + a real resume and install (~300 MB) +``` + +The offline suite drives `download_endorctl.sh` under a throwaway `HOME` with a stubbed `curl`, so branches that only occur on a bad network — a dead endpoint, a half-finished download, a corrupt one, two agents racing, a signal mid-transfer — are all reachable without waiting on a transfer. It also **regenerates every `examples/` artifact and fails if the checked-in copy differs**, which is the check that keeps the samples honest after a script change, and syntax-checks all 36 hook commands embedded across those artifacts to confirm they survived JSON/TOML escaping. + +`--network` is worth running when the download endpoint might have changed: it pins the behavior resume depends on — a closed `bytes=A-B` range returns `206`, while an open-ended `bytes=A-` returns the whole body. That second one is why the bootstrap builds an explicit closed range instead of using `curl -C -`; if it ever starts returning `206`, the code can be simplified. + +Everything needs only what ships with macOS/Linux; `plutil` (for the profile comparisons) and `python3` (for the escaping check) are used when present and skipped when not. + ## Examples `examples/` holds one checked-in sample per output shape, generated with demo credentials (`PEPE` / `PAPA` / namespace `spiderman`) and placeholder profile UUIDs: @@ -164,7 +188,7 @@ docs/ deployment runbooks + the support matrix | MDM profile (plist, mcx) | Codex | `examples/codex/com.openai.codex.mobileconfig` | | TOML (encoded PowerShell hook) | Codex | `examples/codex/requirements.windows.toml` | -There's no separate Linux example: `settings.json` is exactly what Claude reads as the Linux `/etc/claude-code/managed-settings.json` and as the inner payload of the macOS profile, `requirements.toml` is what Codex reads at `/etc/codex/`, and JumpCloud reuses these same files. Only the Windows samples differ (the encoded `powershell` hook). After changing a script, regenerate the affected examples with the commands above so they stay in sync. +There's no separate Linux example: `settings.json` is exactly what Claude reads as the Linux `/etc/claude-code/managed-settings.json` and as the inner payload of the macOS profile, `requirements.toml` is what Codex reads at `/etc/codex/`, and JumpCloud reuses these same files. Only the Windows samples differ (the encoded `powershell` hook). After changing a script, regenerate the affected examples with the commands above so they stay in sync — `tests/run-tests.sh` fails if you forget. ## Extending diff --git a/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig b/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig index 2557afc..6790def 100644 --- a/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig +++ b/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig @@ -90,36 +90,87 @@ command BIN="${HOME}/.endorctl/endorctl" -skip= -[ -n "${ENDORCTL_SKIP_UPDATE:-}" ] && [ -x "$BIN" ] && skip=1 -if [ -z "$skip" ]; then - case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 1 ;; esac - case "$(uname -m)" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) exit 1 ;; esac - URL="https://api.endorlabs.com/download/latest/endorctl_${os}_${arch}" - ARCH_KEY="ARCH_TYPE_$(echo "${os}_${arch}" | tr '[:lower:]' '[:upper:]')" - current=$([ -x "$BIN" ] && "$BIN" --version 2>/dev/null | awk '/version/ {print $NF; exit}') - meta=$(curl -fsSL --retry 5 --retry-connrefused --retry-all-errors https://api.endorlabs.com/meta/version) - latest=$(echo "$meta" | sed -n 's/.*"ClientVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') - expected_sha=$(echo "$meta" | sed -n "s/.*\"${ARCH_KEY}\"[[:space:]]*:[[:space:]]*\"\([a-f0-9]*\)\".*/\1/p") - uptodate= - [ -n "$current" ] && { [ -z "$latest" ] || [ "$current" = "$latest" ]; } && uptodate=1 - if [ -z "$uptodate" ]; then - DIR=$(dirname "$BIN") - mkdir -p "$DIR" - # Sweep leftovers from interrupted past runs. Age-gated so a concurrent - # session's in-flight download is never deleted; the name cannot match the - # installed binary ("endorctl"). +DIR="${HOME}/.endorctl" +STAMP="$DIR/.update-check" +TTL="${ENDORCTL_UPDATE_TTL_MINUTES:-1440}" +case "$TTL" in ''|*[!0-9]*) TTL=1440 ;; esac +need= +if [ ! -x "$BIN" ]; then + need=1 +elif [ -z "${ENDORCTL_SKIP_UPDATE:-}" ]; then + [ -f "$STAMP" ] && [ -z "$(find "$STAMP" -mmin +"$TTL" 2>/dev/null)" ] || need=1 +fi +if [ -n "$need" ]; then + ( + trap '' HUP + trap - EXIT + LOCK="$DIR/.update.lock" + PART="$DIR/.endorctl.part" + SHAF="$DIR/.endorctl.sha" + sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi + } + mkdir -p "$DIR" || exit 0 + if [ -d "$LOCK" ]; then + ref="$PART"; [ -f "$PART" ] || ref="$LOCK" + [ -n "$(find "$ref" -mmin +30 2>/dev/null)" ] || exit 0 + mv "$LOCK" "$LOCK.stale.$$" 2>/dev/null && rm -rf "$LOCK.stale.$$" + fi + mkdir "$LOCK" 2>/dev/null || exit 0 + echo "$$" > "$LOCK/owner" 2>/dev/null || { rmdir "$LOCK" 2>/dev/null; exit 0; } + [ "$(cat "$LOCK/owner" 2>/dev/null)" = "$$" ] || exit 0 + trap 'rm -rf "$LOCK"' EXIT + trap 'exit 1' INT TERM find "$DIR" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null - TMP=$(mktemp "$DIR/endorctl-download-XXXXXX") || exit 1 - curl -fsSL --retry 5 --retry-connrefused --retry-all-errors -o "$TMP" "$URL" || { rm -f "$TMP"; exit 1; } - [ ${#expected_sha} -eq 64 ] || { rm -f "$TMP"; exit 1; } - case "$expected_sha" in *[!0-9a-f]*) rm -f "$TMP"; exit 1 ;; esac - if command -v sha256sum >/dev/null 2>&1; then sum=$(sha256sum "$TMP" | awk '{print $1}'); else sum=$(shasum -a 256 "$TMP" | awk '{print $1}'); fi - [ "$sum" = "$expected_sha" ] || { rm -f "$TMP"; exit 1; } - chmod +x "$TMP" || { rm -f "$TMP"; exit 1; } - mv "$TMP" "$BIN" - fi + case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 0 ;; esac + case "$(uname -m)" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) exit 0 ;; esac + URL="https://api.endorlabs.com/download/latest/endorctl_${os}_${arch}" + ARCH_KEY="ARCH_TYPE_$(echo "${os}_${arch}" | tr '[:lower:]' '[:upper:]')" + current=$([ -x "$BIN" ] && "$BIN" --version 2>/dev/null | awk '/version/ {print $NF; exit}') + meta=$(curl -fsSL --connect-timeout 5 --max-time 30 https://api.endorlabs.com/meta/version) || exit 0 + latest=$(echo "$meta" | sed -n 's/.*"ClientVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + expected_sha=$(echo "$meta" | sed -n "s/.*\"${ARCH_KEY}\"[[:space:]]*:[[:space:]]*\"\([a-f0-9]*\)\".*/\1/p") + [ -n "$latest" ] || exit 0 + [ ${#expected_sha} -eq 64 ] || exit 0 + case "$expected_sha" in *[!0-9a-f]*) exit 0 ;; esac + if [ -n "$current" ] && [ "$current" = "$latest" ]; then + rm -f "$PART" "$SHAF" + : > "$STAMP" + exit 0 + fi + if [ ! -f "$SHAF" ] || [ "$(cat "$SHAF" 2>/dev/null)" != "$expected_sha" ]; then + rm -f "$PART" + printf '%s\n' "$expected_sha" > "$SHAF" || exit 0 + fi + if [ ! -f "$PART" ] || [ "$(sha256 "$PART")" != "$expected_sha" ]; then + total=$(curl -fsSLI --connect-timeout 5 --max-time 30 "$URL" 2>/dev/null \ + | tr -d '\r' | sed -n 's/^[Cc]ontent-[Ll]ength: *//p' | tail -1) + case "$total" in ''|*[!0-9]*) total= ;; esac + size=$(wc -c < "$PART" 2>/dev/null | tr -d ' ') + case "$size" in ''|*[!0-9]*) size=0 ;; esac + if [ -z "$total" ] || [ "$size" -ge "$total" ]; then rm -f "$PART"; fi + n=0; ok= + while [ "$n" -lt 3 ]; do + n=$((n + 1)) + size=$(wc -c < "$PART" 2>/dev/null | tr -d ' ') + case "$size" in ''|*[!0-9]*) size=0 ;; esac + rng= + [ -n "$total" ] && [ "$size" -gt 0 ] && rng="-r $size-$((total - 1))" + if curl -fsSL --connect-timeout 10 --speed-limit 10240 --speed-time 60 \ + $rng "$URL" >> "$PART"; then ok=1; break; fi + sleep 5 + done + [ -n "$ok" ] || exit 0 + [ "$(sha256 "$PART")" = "$expected_sha" ] || { rm -f "$PART" "$SHAF"; exit 0; } + fi + chmod +x "$PART" || exit 0 + mv "$PART" "$BIN" || exit 0 + rm -f "$SHAF" + : > "$STAMP" + ) >/dev/null 2>&1 </dev/null & fi +[ -x "$BIN" ] || exit 0 "$HOME/.endorctl/endorctl" --api "$AGENT_HOOK_ENDOR_API" --namespace "$AGENT_HOOK_ENDOR_NAMESPACE" --api-key "$AGENT_HOOK_ENDOR_API_CREDENTIALS_KEY" --api-secret "$AGENT_HOOK_ENDOR_API_CREDENTIALS_SECRET" ai-audit claudecode type command diff --git a/agent-governance/examples/claude/settings.json b/agent-governance/examples/claude/settings.json index 90f85ff..6a7850a 100644 --- a/agent-governance/examples/claude/settings.json +++ b/agent-governance/examples/claude/settings.json @@ -12,7 +12,7 @@ "hooks": [ { "type": "command", - "command": "BIN=\"${HOME}/.endorctl/endorctl\"\nskip=\n[ -n \"${ENDORCTL_SKIP_UPDATE:-}\" ] && [ -x \"$BIN\" ] && skip=1\nif [ -z \"$skip\" ]; then\n case \"$(uname -s)\" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 1 ;; esac\n case \"$(uname -m)\" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) exit 1 ;; esac\n URL=\"https://api.endorlabs.com/download/latest/endorctl_${os}_${arch}\"\n ARCH_KEY=\"ARCH_TYPE_$(echo \"${os}_${arch}\" | tr '[:lower:]' '[:upper:]')\"\n current=$([ -x \"$BIN\" ] && \"$BIN\" --version 2>/dev/null | awk '/version/ {print $NF; exit}')\n meta=$(curl -fsSL --retry 5 --retry-connrefused --retry-all-errors https://api.endorlabs.com/meta/version)\n latest=$(echo \"$meta\" | sed -n 's/.*\"ClientVersion\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p')\n expected_sha=$(echo \"$meta\" | sed -n \"s/.*\\\"${ARCH_KEY}\\\"[[:space:]]*:[[:space:]]*\\\"\\([a-f0-9]*\\)\\\".*/\\1/p\")\n uptodate=\n [ -n \"$current\" ] && { [ -z \"$latest\" ] || [ \"$current\" = \"$latest\" ]; } && uptodate=1\n if [ -z \"$uptodate\" ]; then\n DIR=$(dirname \"$BIN\")\n mkdir -p \"$DIR\"\n # Sweep leftovers from interrupted past runs. Age-gated so a concurrent\n # session's in-flight download is never deleted; the name cannot match the\n # installed binary (\"endorctl\").\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n TMP=$(mktemp \"$DIR/endorctl-download-XXXXXX\") || exit 1\n curl -fsSL --retry 5 --retry-connrefused --retry-all-errors -o \"$TMP\" \"$URL\" || { rm -f \"$TMP\"; exit 1; }\n [ ${#expected_sha} -eq 64 ] || { rm -f \"$TMP\"; exit 1; }\n case \"$expected_sha\" in *[!0-9a-f]*) rm -f \"$TMP\"; exit 1 ;; esac\n if command -v sha256sum >/dev/null 2>&1; then sum=$(sha256sum \"$TMP\" | awk '{print $1}'); else sum=$(shasum -a 256 \"$TMP\" | awk '{print $1}'); fi\n [ \"$sum\" = \"$expected_sha\" ] || { rm -f \"$TMP\"; exit 1; }\n chmod +x \"$TMP\" || { rm -f \"$TMP\"; exit 1; }\n mv \"$TMP\" \"$BIN\"\n fi\nfi\n\"$HOME/.endorctl/endorctl\" --api \"$AGENT_HOOK_ENDOR_API\" --namespace \"$AGENT_HOOK_ENDOR_NAMESPACE\" --api-key \"$AGENT_HOOK_ENDOR_API_CREDENTIALS_KEY\" --api-secret \"$AGENT_HOOK_ENDOR_API_CREDENTIALS_SECRET\" ai-audit claudecode" + "command": "BIN=\"${HOME}/.endorctl/endorctl\"\nDIR=\"${HOME}/.endorctl\"\nSTAMP=\"$DIR/.update-check\"\nTTL=\"${ENDORCTL_UPDATE_TTL_MINUTES:-1440}\"\ncase \"$TTL\" in ''|*[!0-9]*) TTL=1440 ;; esac\nneed=\nif [ ! -x \"$BIN\" ]; then\n need=1\nelif [ -z \"${ENDORCTL_SKIP_UPDATE:-}\" ]; then\n [ -f \"$STAMP\" ] && [ -z \"$(find \"$STAMP\" -mmin +\"$TTL\" 2>/dev/null)\" ] || need=1\nfi\nif [ -n \"$need\" ]; then\n (\n trap '' HUP\n trap - EXIT\n LOCK=\"$DIR/.update.lock\"\n PART=\"$DIR/.endorctl.part\"\n SHAF=\"$DIR/.endorctl.sha\"\n sha256() {\n if command -v sha256sum >/dev/null 2>&1; then sha256sum \"$1\" | awk '{print $1}'\n else shasum -a 256 \"$1\" | awk '{print $1}'; fi\n }\n mkdir -p \"$DIR\" || exit 0\n if [ -d \"$LOCK\" ]; then\n ref=\"$PART\"; [ -f \"$PART\" ] || ref=\"$LOCK\"\n [ -n \"$(find \"$ref\" -mmin +30 2>/dev/null)\" ] || exit 0\n mv \"$LOCK\" \"$LOCK.stale.$$\" 2>/dev/null && rm -rf \"$LOCK.stale.$$\"\n fi\n mkdir \"$LOCK\" 2>/dev/null || exit 0\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n case \"$(uname -s)\" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 0 ;; esac\n case \"$(uname -m)\" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) exit 0 ;; esac\n URL=\"https://api.endorlabs.com/download/latest/endorctl_${os}_${arch}\"\n ARCH_KEY=\"ARCH_TYPE_$(echo \"${os}_${arch}\" | tr '[:lower:]' '[:upper:]')\"\n current=$([ -x \"$BIN\" ] && \"$BIN\" --version 2>/dev/null | awk '/version/ {print $NF; exit}')\n meta=$(curl -fsSL --connect-timeout 5 --max-time 30 https://api.endorlabs.com/meta/version) || exit 0\n latest=$(echo \"$meta\" | sed -n 's/.*\"ClientVersion\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p')\n expected_sha=$(echo \"$meta\" | sed -n \"s/.*\\\"${ARCH_KEY}\\\"[[:space:]]*:[[:space:]]*\\\"\\([a-f0-9]*\\)\\\".*/\\1/p\")\n [ -n \"$latest\" ] || exit 0\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n if [ ! -f \"$SHAF\" ] || [ \"$(cat \"$SHAF\" 2>/dev/null)\" != \"$expected_sha\" ]; then\n rm -f \"$PART\"\n printf '%s\\n' \"$expected_sha\" > \"$SHAF\" || exit 0\n fi\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n total=$(curl -fsSLI --connect-timeout 5 --max-time 30 \"$URL\" 2>/dev/null \\\n | tr -d '\\r' | sed -n 's/^[Cc]ontent-[Ll]ength: *//p' | tail -1)\n case \"$total\" in ''|*[!0-9]*) total= ;; esac\n size=$(wc -c < \"$PART\" 2>/dev/null | tr -d ' ')\n case \"$size\" in ''|*[!0-9]*) size=0 ;; esac\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n size=$(wc -c < \"$PART\" 2>/dev/null | tr -d ' ')\n case \"$size\" in ''|*[!0-9]*) size=0 ;; esac\n rng=\n [ -n \"$total\" ] && [ \"$size\" -gt 0 ] && rng=\"-r $size-$((total - 1))\"\n if curl -fsSL --connect-timeout 10 --speed-limit 10240 --speed-time 60 \\\n $rng \"$URL\" >> \"$PART\"; then ok=1; break; fi\n sleep 5\n done\n [ -n \"$ok\" ] || exit 0\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n chmod +x \"$PART\" || exit 0\n mv \"$PART\" \"$BIN\" || exit 0\n rm -f \"$SHAF\"\n : > \"$STAMP\"\n ) >/dev/null 2>&1 /dev/null | awk '/version/ {print $NF; exit}')\n meta=$(curl -fsSL --retry 5 --retry-connrefused --retry-all-errors https://api.endorlabs.com/meta/version)\n latest=$(echo \"$meta\" | sed -n 's/.*\"ClientVersion\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p')\n expected_sha=$(echo \"$meta\" | sed -n \"s/.*\\\"${ARCH_KEY}\\\"[[:space:]]*:[[:space:]]*\\\"\\([a-f0-9]*\\)\\\".*/\\1/p\")\n uptodate=\n [ -n \"$current\" ] && { [ -z \"$latest\" ] || [ \"$current\" = \"$latest\" ]; } && uptodate=1\n if [ -z \"$uptodate\" ]; then\n DIR=$(dirname \"$BIN\")\n mkdir -p \"$DIR\"\n # Sweep leftovers from interrupted past runs. Age-gated so a concurrent\n # session's in-flight download is never deleted; the name cannot match the\n # installed binary (\"endorctl\").\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n TMP=$(mktemp \"$DIR/endorctl-download-XXXXXX\") || exit 1\n curl -fsSL --retry 5 --retry-connrefused --retry-all-errors -o \"$TMP\" \"$URL\" || { rm -f \"$TMP\"; exit 1; }\n [ ${#expected_sha} -eq 64 ] || { rm -f \"$TMP\"; exit 1; }\n case \"$expected_sha\" in *[!0-9a-f]*) rm -f \"$TMP\"; exit 1 ;; esac\n if command -v sha256sum >/dev/null 2>&1; then sum=$(sha256sum \"$TMP\" | awk '{print $1}'); else sum=$(shasum -a 256 \"$TMP\" | awk '{print $1}'); fi\n [ \"$sum\" = \"$expected_sha\" ] || { rm -f \"$TMP\"; exit 1; }\n chmod +x \"$TMP\" || { rm -f \"$TMP\"; exit 1; }\n mv \"$TMP\" \"$BIN\"\n fi\nfi\nENDOR_AI_AUDIT_CACHE_ENABLED='true' \"$HOME/.endorctl/endorctl\" --api 'https://api.endorlabs.com' --namespace 'spiderman' --api-key 'PEPE' --api-secret 'PAPA' ai-audit codex" +command = "BIN=\"${HOME}/.endorctl/endorctl\"\nDIR=\"${HOME}/.endorctl\"\nSTAMP=\"$DIR/.update-check\"\nTTL=\"${ENDORCTL_UPDATE_TTL_MINUTES:-1440}\"\ncase \"$TTL\" in ''|*[!0-9]*) TTL=1440 ;; esac\nneed=\nif [ ! -x \"$BIN\" ]; then\n need=1\nelif [ -z \"${ENDORCTL_SKIP_UPDATE:-}\" ]; then\n [ -f \"$STAMP\" ] && [ -z \"$(find \"$STAMP\" -mmin +\"$TTL\" 2>/dev/null)\" ] || need=1\nfi\nif [ -n \"$need\" ]; then\n (\n trap '' HUP\n trap - EXIT\n LOCK=\"$DIR/.update.lock\"\n PART=\"$DIR/.endorctl.part\"\n SHAF=\"$DIR/.endorctl.sha\"\n sha256() {\n if command -v sha256sum >/dev/null 2>&1; then sha256sum \"$1\" | awk '{print $1}'\n else shasum -a 256 \"$1\" | awk '{print $1}'; fi\n }\n mkdir -p \"$DIR\" || exit 0\n if [ -d \"$LOCK\" ]; then\n ref=\"$PART\"; [ -f \"$PART\" ] || ref=\"$LOCK\"\n [ -n \"$(find \"$ref\" -mmin +30 2>/dev/null)\" ] || exit 0\n mv \"$LOCK\" \"$LOCK.stale.$$\" 2>/dev/null && rm -rf \"$LOCK.stale.$$\"\n fi\n mkdir \"$LOCK\" 2>/dev/null || exit 0\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n case \"$(uname -s)\" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 0 ;; esac\n case \"$(uname -m)\" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) exit 0 ;; esac\n URL=\"https://api.endorlabs.com/download/latest/endorctl_${os}_${arch}\"\n ARCH_KEY=\"ARCH_TYPE_$(echo \"${os}_${arch}\" | tr '[:lower:]' '[:upper:]')\"\n current=$([ -x \"$BIN\" ] && \"$BIN\" --version 2>/dev/null | awk '/version/ {print $NF; exit}')\n meta=$(curl -fsSL --connect-timeout 5 --max-time 30 https://api.endorlabs.com/meta/version) || exit 0\n latest=$(echo \"$meta\" | sed -n 's/.*\"ClientVersion\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p')\n expected_sha=$(echo \"$meta\" | sed -n \"s/.*\\\"${ARCH_KEY}\\\"[[:space:]]*:[[:space:]]*\\\"\\([a-f0-9]*\\)\\\".*/\\1/p\")\n [ -n \"$latest\" ] || exit 0\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n if [ ! -f \"$SHAF\" ] || [ \"$(cat \"$SHAF\" 2>/dev/null)\" != \"$expected_sha\" ]; then\n rm -f \"$PART\"\n printf '%s\\n' \"$expected_sha\" > \"$SHAF\" || exit 0\n fi\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n total=$(curl -fsSLI --connect-timeout 5 --max-time 30 \"$URL\" 2>/dev/null \\\n | tr -d '\\r' | sed -n 's/^[Cc]ontent-[Ll]ength: *//p' | tail -1)\n case \"$total\" in ''|*[!0-9]*) total= ;; esac\n size=$(wc -c < \"$PART\" 2>/dev/null | tr -d ' ')\n case \"$size\" in ''|*[!0-9]*) size=0 ;; esac\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n size=$(wc -c < \"$PART\" 2>/dev/null | tr -d ' ')\n case \"$size\" in ''|*[!0-9]*) size=0 ;; esac\n rng=\n [ -n \"$total\" ] && [ \"$size\" -gt 0 ] && rng=\"-r $size-$((total - 1))\"\n if curl -fsSL --connect-timeout 10 --speed-limit 10240 --speed-time 60 \\\n $rng \"$URL\" >> \"$PART\"; then ok=1; break; fi\n sleep 5\n done\n [ -n \"$ok\" ] || exit 0\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n chmod +x \"$PART\" || exit 0\n mv \"$PART\" \"$BIN\" || exit 0\n rm -f \"$SHAF\"\n : > \"$STAMP\"\n ) >/dev/null 2>&1 \"$T\"\nchmod 600 \"$T\"\ntrap 'rm -f \"$T\"' EXIT\nBIN=\"${HOME}/.endorctl/endorctl\"\nskip=\n[ -n \"${ENDORCTL_SKIP_UPDATE:-}\" ] && [ -x \"$BIN\" ] && skip=1\nif [ -z \"$skip\" ]; then\n case \"$(uname -s)\" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 1 ;; esac\n case \"$(uname -m)\" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) exit 1 ;; esac\n URL=\"https://api.endorlabs.com/download/latest/endorctl_${os}_${arch}\"\n ARCH_KEY=\"ARCH_TYPE_$(echo \"${os}_${arch}\" | tr '[:lower:]' '[:upper:]')\"\n current=$([ -x \"$BIN\" ] && \"$BIN\" --version 2>/dev/null | awk '/version/ {print $NF; exit}')\n meta=$(curl -fsSL --retry 5 --retry-connrefused --retry-all-errors https://api.endorlabs.com/meta/version)\n latest=$(echo \"$meta\" | sed -n 's/.*\"ClientVersion\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p')\n expected_sha=$(echo \"$meta\" | sed -n \"s/.*\\\"${ARCH_KEY}\\\"[[:space:]]*:[[:space:]]*\\\"\\([a-f0-9]*\\)\\\".*/\\1/p\")\n uptodate=\n [ -n \"$current\" ] && { [ -z \"$latest\" ] || [ \"$current\" = \"$latest\" ]; } && uptodate=1\n if [ -z \"$uptodate\" ]; then\n DIR=$(dirname \"$BIN\")\n mkdir -p \"$DIR\"\n # Sweep leftovers from interrupted past runs. Age-gated so a concurrent\n # session's in-flight download is never deleted; the name cannot match the\n # installed binary (\"endorctl\").\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n TMP=$(mktemp \"$DIR/endorctl-download-XXXXXX\") || exit 1\n curl -fsSL --retry 5 --retry-connrefused --retry-all-errors -o \"$TMP\" \"$URL\" || { rm -f \"$TMP\"; exit 1; }\n [ ${#expected_sha} -eq 64 ] || { rm -f \"$TMP\"; exit 1; }\n case \"$expected_sha\" in *[!0-9a-f]*) rm -f \"$TMP\"; exit 1 ;; esac\n if command -v sha256sum >/dev/null 2>&1; then sum=$(sha256sum \"$TMP\" | awk '{print $1}'); else sum=$(shasum -a 256 \"$TMP\" | awk '{print $1}'); fi\n [ \"$sum\" = \"$expected_sha\" ] || { rm -f \"$TMP\"; exit 1; }\n chmod +x \"$TMP\" || { rm -f \"$TMP\"; exit 1; }\n mv \"$TMP\" \"$BIN\"\n fi\nfi\nENDOR_AI_AUDIT_CACHE_ENABLED='true' \"$HOME/.endorctl/endorctl\" --api 'https://api.endorlabs.com' --namespace 'spiderman' --api-key 'PEPE' --api-secret 'PAPA' ai-audit cursor < \"$T\"" + "command": "umask 077\nT=\"$HOME/.endorctl-cursor-stdin.$$\"\ncat > \"$T\"\nchmod 600 \"$T\"\ntrap 'rm -f \"$T\"' EXIT\nBIN=\"${HOME}/.endorctl/endorctl\"\nDIR=\"${HOME}/.endorctl\"\nSTAMP=\"$DIR/.update-check\"\nTTL=\"${ENDORCTL_UPDATE_TTL_MINUTES:-1440}\"\ncase \"$TTL\" in ''|*[!0-9]*) TTL=1440 ;; esac\nneed=\nif [ ! -x \"$BIN\" ]; then\n need=1\nelif [ -z \"${ENDORCTL_SKIP_UPDATE:-}\" ]; then\n [ -f \"$STAMP\" ] && [ -z \"$(find \"$STAMP\" -mmin +\"$TTL\" 2>/dev/null)\" ] || need=1\nfi\nif [ -n \"$need\" ]; then\n (\n trap '' HUP\n trap - EXIT\n LOCK=\"$DIR/.update.lock\"\n PART=\"$DIR/.endorctl.part\"\n SHAF=\"$DIR/.endorctl.sha\"\n sha256() {\n if command -v sha256sum >/dev/null 2>&1; then sha256sum \"$1\" | awk '{print $1}'\n else shasum -a 256 \"$1\" | awk '{print $1}'; fi\n }\n mkdir -p \"$DIR\" || exit 0\n if [ -d \"$LOCK\" ]; then\n ref=\"$PART\"; [ -f \"$PART\" ] || ref=\"$LOCK\"\n [ -n \"$(find \"$ref\" -mmin +30 2>/dev/null)\" ] || exit 0\n mv \"$LOCK\" \"$LOCK.stale.$$\" 2>/dev/null && rm -rf \"$LOCK.stale.$$\"\n fi\n mkdir \"$LOCK\" 2>/dev/null || exit 0\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n case \"$(uname -s)\" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 0 ;; esac\n case \"$(uname -m)\" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) exit 0 ;; esac\n URL=\"https://api.endorlabs.com/download/latest/endorctl_${os}_${arch}\"\n ARCH_KEY=\"ARCH_TYPE_$(echo \"${os}_${arch}\" | tr '[:lower:]' '[:upper:]')\"\n current=$([ -x \"$BIN\" ] && \"$BIN\" --version 2>/dev/null | awk '/version/ {print $NF; exit}')\n meta=$(curl -fsSL --connect-timeout 5 --max-time 30 https://api.endorlabs.com/meta/version) || exit 0\n latest=$(echo \"$meta\" | sed -n 's/.*\"ClientVersion\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p')\n expected_sha=$(echo \"$meta\" | sed -n \"s/.*\\\"${ARCH_KEY}\\\"[[:space:]]*:[[:space:]]*\\\"\\([a-f0-9]*\\)\\\".*/\\1/p\")\n [ -n \"$latest\" ] || exit 0\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n if [ ! -f \"$SHAF\" ] || [ \"$(cat \"$SHAF\" 2>/dev/null)\" != \"$expected_sha\" ]; then\n rm -f \"$PART\"\n printf '%s\\n' \"$expected_sha\" > \"$SHAF\" || exit 0\n fi\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n total=$(curl -fsSLI --connect-timeout 5 --max-time 30 \"$URL\" 2>/dev/null \\\n | tr -d '\\r' | sed -n 's/^[Cc]ontent-[Ll]ength: *//p' | tail -1)\n case \"$total\" in ''|*[!0-9]*) total= ;; esac\n size=$(wc -c < \"$PART\" 2>/dev/null | tr -d ' ')\n case \"$size\" in ''|*[!0-9]*) size=0 ;; esac\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n size=$(wc -c < \"$PART\" 2>/dev/null | tr -d ' ')\n case \"$size\" in ''|*[!0-9]*) size=0 ;; esac\n rng=\n [ -n \"$total\" ] && [ \"$size\" -gt 0 ] && rng=\"-r $size-$((total - 1))\"\n if curl -fsSL --connect-timeout 10 --speed-limit 10240 --speed-time 60 \\\n $rng \"$URL\" >> \"$PART\"; then ok=1; break; fi\n sleep 5\n done\n [ -n \"$ok\" ] || exit 0\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n chmod +x \"$PART\" || exit 0\n mv \"$PART\" \"$BIN\" || exit 0\n rm -f \"$SHAF\"\n : > \"$STAMP\"\n ) >/dev/null 2>&1 /dev/null | awk '/version/ {print $NF; exit}') - meta=$(curl -fsSL --retry 5 --retry-connrefused --retry-all-errors https://api.endorlabs.com/meta/version) - latest=$(echo "$meta" | sed -n 's/.*"ClientVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') - expected_sha=$(echo "$meta" | sed -n "s/.*\"${ARCH_KEY}\"[[:space:]]*:[[:space:]]*\"\([a-f0-9]*\)\".*/\1/p") - uptodate= - [ -n "$current" ] && { [ -z "$latest" ] || [ "$current" = "$latest" ]; } && uptodate=1 - if [ -z "$uptodate" ]; then - DIR=$(dirname "$BIN") - mkdir -p "$DIR" - # Sweep leftovers from interrupted past runs. Age-gated so a concurrent - # session's in-flight download is never deleted; the name cannot match the - # installed binary ("endorctl"). +DIR="${HOME}/.endorctl" +STAMP="$DIR/.update-check" +TTL="${ENDORCTL_UPDATE_TTL_MINUTES:-1440}" +case "$TTL" in ''|*[!0-9]*) TTL=1440 ;; esac + +need= +if [ ! -x "$BIN" ]; then + need=1 +elif [ -z "${ENDORCTL_SKIP_UPDATE:-}" ]; then + # A stamp newer than the TTL means no network I/O at all this session. + [ -f "$STAMP" ] && [ -z "$(find "$STAMP" -mmin +"$TTL" 2>/dev/null)" ] || need=1 +fi + +if [ -n "$need" ]; then + ( + # The redirections release the hook's stdout pipe, which the agent waits on. + # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one). + trap '' HUP + trap - EXIT + + LOCK="$DIR/.update.lock" + PART="$DIR/.endorctl.part" + SHAF="$DIR/.endorctl.sha" + sha256() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + else shasum -a 256 "$1" | awk '{print $1}'; fi + } + mkdir -p "$DIR" || exit 0 + + # One downloader per machine. Staleness is judged by the partial's mtime, + # which curl advances as it writes, so a live slow download is never broken. + if [ -d "$LOCK" ]; then + ref="$PART"; [ -f "$PART" ] || ref="$LOCK" + [ -n "$(find "$ref" -mmin +30 2>/dev/null)" ] || exit 0 + mv "$LOCK" "$LOCK.stale.$$" 2>/dev/null && rm -rf "$LOCK.stale.$$" + fi + mkdir "$LOCK" 2>/dev/null || exit 0 + # Stand down unless the lock is ours, before arming the trap that removes it. + echo "$$" > "$LOCK/owner" 2>/dev/null || { rmdir "$LOCK" 2>/dev/null; exit 0; } + [ "$(cat "$LOCK/owner" 2>/dev/null)" = "$$" ] || exit 0 + # INT/TERM route through exit so the EXIT trap does the one cleanup. + trap 'rm -rf "$LOCK"' EXIT + trap 'exit 1' INT TERM + + # Leftovers from the previous mktemp-based scheme, and from killed runs. find "$DIR" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null - TMP=$(mktemp "$DIR/endorctl-download-XXXXXX") || exit 1 - curl -fsSL --retry 5 --retry-connrefused --retry-all-errors -o "$TMP" "$URL" || { rm -f "$TMP"; exit 1; } - [ ${#expected_sha} -eq 64 ] || { rm -f "$TMP"; exit 1; } - case "$expected_sha" in *[!0-9a-f]*) rm -f "$TMP"; exit 1 ;; esac - if command -v sha256sum >/dev/null 2>&1; then sum=$(sha256sum "$TMP" | awk '{print $1}'); else sum=$(shasum -a 256 "$TMP" | awk '{print $1}'); fi - [ "$sum" = "$expected_sha" ] || { rm -f "$TMP"; exit 1; } - chmod +x "$TMP" || { rm -f "$TMP"; exit 1; } - mv "$TMP" "$BIN" - fi + + case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 0 ;; esac + case "$(uname -m)" in arm64|aarch64) arch=arm64 ;; x86_64|amd64) arch=amd64 ;; *) exit 0 ;; esac + URL="https://api.endorlabs.com/download/latest/endorctl_${os}_${arch}" + ARCH_KEY="ARCH_TYPE_$(echo "${os}_${arch}" | tr '[:lower:]' '[:upper:]')" + current=$([ -x "$BIN" ] && "$BIN" --version 2>/dev/null | awk '/version/ {print $NF; exit}') + meta=$(curl -fsSL --connect-timeout 5 --max-time 30 https://api.endorlabs.com/meta/version) || exit 0 + latest=$(echo "$meta" | sed -n 's/.*"ClientVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + expected_sha=$(echo "$meta" | sed -n "s/.*\"${ARCH_KEY}\"[[:space:]]*:[[:space:]]*\"\([a-f0-9]*\)\".*/\1/p") + [ -n "$latest" ] || exit 0 + [ ${#expected_sha} -eq 64 ] || exit 0 + case "$expected_sha" in *[!0-9a-f]*) exit 0 ;; esac + + if [ -n "$current" ] && [ "$current" = "$latest" ]; then + rm -f "$PART" "$SHAF" + : > "$STAMP" + exit 0 + fi + + # Pin the partial to the digest it is being built for: endorctl is rebuilt + # roughly daily, and a partial spanning two builds could never verify. + if [ ! -f "$SHAF" ] || [ "$(cat "$SHAF" 2>/dev/null)" != "$expected_sha" ]; then + rm -f "$PART" + printf '%s\n' "$expected_sha" > "$SHAF" || exit 0 + fi + + if [ ! -f "$PART" ] || [ "$(sha256 "$PART")" != "$expected_sha" ]; then + # Resume with an explicit closed range, not `curl -C -`: this endpoint + # answers a closed bytes=A-B with a 206 but an open-ended bytes=A- with the + # whole file, and the open form is what -C - sends. Hence the HEAD probe. + total=$(curl -fsSLI --connect-timeout 5 --max-time 30 "$URL" 2>/dev/null \ + | tr -d '\r' | sed -n 's/^[Cc]ontent-[Ll]ength: *//p' | tail -1) + case "$total" in ''|*[!0-9]*) total= ;; esac + size=$(wc -c < "$PART" 2>/dev/null | tr -d ' ') + case "$size" in ''|*[!0-9]*) size=0 ;; esac + # No length to resume against, or a full-length partial that failed its + # digest (racing downloaders can make one): either way, start over. + if [ -z "$total" ] || [ "$size" -ge "$total" ]; then rm -f "$PART"; fi + + n=0; ok= + while [ "$n" -lt 3 ]; do + n=$((n + 1)) + # Recomputed per attempt: one that dies midway leaves a correct prefix. + size=$(wc -c < "$PART" 2>/dev/null | tr -d ' ') + case "$size" in ''|*[!0-9]*) size=0 ;; esac + rng= + [ -n "$total" ] && [ "$size" -gt 0 ] && rng="-r $size-$((total - 1))" + # No --max-time: off the critical path, a slow link should finish. + # $rng is deliberately unquoted - it is either empty or two words. + if curl -fsSL --connect-timeout 10 --speed-limit 10240 --speed-time 60 \ + $rng "$URL" >> "$PART"; then ok=1; break; fi + sleep 5 + done + [ -n "$ok" ] || exit 0 + [ "$(sha256 "$PART")" = "$expected_sha" ] || { rm -f "$PART" "$SHAF"; exit 0; } + fi + + chmod +x "$PART" || exit 0 + mv "$PART" "$BIN" || exit 0 + rm -f "$SHAF" + : > "$STAMP" + ) >/dev/null 2>&1 . Windows still fetches inline. # # Example: # render.sh --agent cursor --api-key K --api-secret S --namespace NS -o hooks.json @@ -96,7 +102,7 @@ while [ $# -gt 0 ]; do --api-key) api_key="$2"; shift 2 ;; --api-secret) api_secret="$2"; shift 2 ;; --namespace) namespace="$2"; shift 2 ;; - -h|--help) sed -n '2,37p' "$0"; exit 0 ;; + -h|--help) sed -n '2,43p' "$0"; exit 0 ;; *) die "unknown argument: $1" ;; esac done @@ -111,12 +117,17 @@ case "$target_os" in macos|linux|windows) ;; *) die "unknown --target-os: $target_os (macos|linux|windows)" ;; esac +# The bootstrap is embedded in every session hook (and base64'd into the Windows +# form), so its comments and blank lines are dropped on the way in - they would +# otherwise bloat every generated profile. Only whole-line comments are removed; +# read scripts/download_endorctl.sh for why it does what it does. +strip_src() { sed -e '/^[[:space:]]*#/d' -e '/^[[:space:]]*$/d' "$1"; } if [ "$target_os" = windows ]; then command -v iconv >/dev/null || die "iconv is required for --target-os windows" command -v base64 >/dev/null || die "base64 is required for --target-os windows" - boot=$(cat "$SCRIPT_DIR/download_endorctl.ps1") + boot=$(strip_src "$SCRIPT_DIR/download_endorctl.ps1") else - boot=$(cat "$SCRIPT_DIR/download_endorctl.sh") + boot=$(strip_src "$SCRIPT_DIR/download_endorctl.sh") fi # Prompt only when interactive; unattended runs must supply creds up front. diff --git a/agent-governance/tests/run-tests.sh b/agent-governance/tests/run-tests.sh new file mode 100755 index 0000000..930d4b1 --- /dev/null +++ b/agent-governance/tests/run-tests.sh @@ -0,0 +1,394 @@ +#!/bin/bash +# Tests for the agent-governance scripts. +# +# tests/run-tests.sh offline: bootstrap behavior + example sync +# tests/run-tests.sh --network also assert the download endpoint's contract +# tests/run-tests.sh --network-full also do a real resume + install (~300 MB) +# +# The offline suite is the one to run in CI: it needs no network and finishes in +# seconds. It drives download_endorctl.sh under a throwaway HOME with a stubbed +# curl, so every branch - including ones that only happen on a bad network - is +# reachable without waiting on a ~300 MB transfer. +# +# bash rather than POSIX sh: this runs on a maintainer's machine, not on a +# managed endpoint, so the portability rules the shipped scripts follow (see +# README "Prerequisites") do not apply here. The scripts under test are still +# checked with sh -n and dash -n. +set -u + +AG=$(cd "$(dirname "$0")/.." && pwd) +BOOT="$AG/scripts/download_endorctl.sh" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/agov-tests.XXXXXX") +STUB="$WORK/stub" +trap 'rm -rf "$WORK"' EXIT + +want_network=0; want_full=0 +for a in "$@"; do + case "$a" in + --network) want_network=1 ;; + --network-full) want_network=1; want_full=1 ;; + -h|--help) sed -n '2,7p' "$0"; exit 0 ;; + *) echo "unknown argument: $a" >&2; exit 2 ;; + esac +done + +pass=0; fail=0 +ok() { pass=$((pass+1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; } +bad() { fail=$((fail+1)); printf ' \033[31mFAIL\033[0m %s\n' "$1"; } +chk() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (want '$3', got '$2')"; fi; } +sec() { printf '\n\033[1m== %s\033[0m\n' "$1"; } + +# --- fixtures --------------------------------------------------------------- +# Stand-in "endorctl" binaries that answer --version the way the real one does. +mkdir -p "$STUB" +mkfake() { printf '#!/bin/sh\necho "endorctl version %s"\n' "$1" > "$2"; chmod +x "$2"; } +mkfake v1.7.1085 "$WORK/new-bin"; NEWSHA=$(shasum -a 256 "$WORK/new-bin" | awk '{print $1}') +mkfake v1.7.1000 "$WORK/old-bin" + +# Stub curl. Reproduces the endpoint's actual range behavior, which the resume +# logic depends on: a closed range (bytes=A-B) is honored with a 206, but an +# open-ended one (bytes=A-) is answered with the whole body - which is why +# `curl -C -` cannot be used here. Body goes to stdout; the caller appends. +cat > "$STUB/curl" <<'STUBEOF' +#!/bin/bash +echo "curl $*" >> "$CURL_LOG" +url=""; head=0; cont=0; range="" +while [ $# -gt 0 ]; do + case "$1" in + -o) shift 2 ;; + -r) range="$2"; shift 2 ;; + -C) cont=1; shift 2 ;; + --connect-timeout|--max-time|--speed-limit|--speed-time) shift 2 ;; + -*I|-I) head=1; shift ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +case "$url" in + */meta/version) + [ "${STUB_META_FAIL:-}" = 1 ] && exit 7 + printf '{"ClientVersion":"%s","ClientChecksums":{"ARCH_TYPE_MACOS_ARM64":"%s","ARCH_TYPE_MACOS_AMD64":"%s","ARCH_TYPE_LINUX_ARM64":"%s","ARCH_TYPE_LINUX_AMD64":"%s"}}\n' \ + "$STUB_LATEST" "$STUB_SHA" "$STUB_SHA" "$STUB_SHA" "$STUB_SHA" + ;; + */download/latest/*) + total=$(wc -c < "$STUB_DL_BODY" | tr -d ' ') + if [ "$head" = 1 ]; then printf 'HTTP/1.1 200 OK\r\nContent-Length: %s\r\n\r\n' "$total"; exit 0; fi + [ "${STUB_DL_FAIL:-}" = 1 ] && exit 28 + [ -n "${STUB_DL_SLEEP:-}" ] && sleep "$STUB_DL_SLEEP" + [ "$cont" = 1 ] && exit 33 # open-ended range: what -C - sends, unusable here + if [ -n "$range" ]; then + start=${range%-*}; end=${range#*-} + if [ -z "$end" ]; then cat "$STUB_DL_BODY" + else dd if="$STUB_DL_BODY" bs=1 skip="$start" count=$((end - start + 1)) 2>/dev/null; fi + else + cat "$STUB_DL_BODY" + fi + ;; +esac +exit 0 +STUBEOF +chmod +x "$STUB/curl" + +# Compose bootstrap + audit exactly as render.sh does, then run it. +run() { + CURL_LOG="$H/curl.log"; export CURL_LOG + : > "$CURL_LOG" + { cat "$BOOT"; echo 'echo AUDIT-RAN'; } > "$H/composed.sh" + ( export HOME="$H" PATH="$STUB:$PATH"; /bin/sh "$H/composed.sh" ) 2>"$H/stderr" +} +newhome() { + H=$(mktemp -d "$WORK/home.XXXXXX") + export STUB_LATEST=v1.7.1085 STUB_SHA="$NEWSHA" STUB_DL_BODY="$WORK/new-bin" + unset STUB_META_FAIL STUB_DL_FAIL STUB_DL_SLEEP +} +settle() { for _ in $(seq 1 60); do [ -d "$H/.endorctl/.update.lock" ] || break; sleep 0.2; done; sleep 0.4; } +have() { [ -e "$1" ] && echo yes || echo no; } +# A `case` inside $(...) confuses bash's parser, so wrap it. +isnum() { case "${1:-}" in ''|*[!0-9]*) echo no ;; *) echo yes ;; esac; } +ver() { "$H/.endorctl/endorctl" --version 2>/dev/null; } +calls() { c=$(grep -c "$1" "$H/curl.log" 2>/dev/null); echo "${c:-0}"; } +# Body GETs only - the length probe uses -I against the same URL. +gets() { c=$(grep 'download/latest' "$H/curl.log" 2>/dev/null | grep -vc 'fsSLI'); echo "${c:-0}"; } + +sec "syntax" +for s in sh bash dash; do + command -v "$s" >/dev/null || { echo " (no $s, skipped)"; continue; } + for f in "$BOOT" "$AG/scripts/render.sh" "$AG/scripts/render-plist.sh" "$AG/scripts/runner.sh"; do + if $s -n "$f" 2>/dev/null; then ok "$s -n $(basename "$f")"; else bad "$s -n $(basename "$f")"; fi + done +done + +sec "cold machine: no binary, so the session is not audited and the install is backgrounded" +newhome +out=$(run) +chk "audit did not run this session" "$(echo "$out" | grep -c AUDIT-RAN)" "0" +chk "hook returned before the binary existed" "$(have "$H/.endorctl/endorctl")" "no" +settle +chk "background job installed it" "$(ver)" "endorctl version v1.7.1085" +chk "check stamp written" "$(have "$H/.endorctl/.update-check")" "yes" +chk "lock released" "$(have "$H/.endorctl/.update.lock")" "no" +chk "partial cleaned up" "$(have "$H/.endorctl/.endorctl.part")" "no" +chk "digest pin cleaned up" "$(have "$H/.endorctl/.endorctl.sha")" "no" + +sec "warm machine: a fresh stamp means no network at all" +out=$(run); settle +chk "audit ran" "$(echo "$out" | grep -c AUDIT-RAN)" "1" +chk "zero curl invocations" "$(wc -l < "$H/curl.log" | tr -d ' ')" "0" + +sec "stamp expired but already current: metadata only" +touch -t 202001010000 "$H/.endorctl/.update-check" +out=$(run); settle +chk "audit ran" "$(echo "$out" | grep -c AUDIT-RAN)" "1" +chk "metadata fetched once" "$(calls meta/version)" "1" +chk "no download" "$(gets)" "0" +chk "stamp refreshed" "$(find "$H/.endorctl/.update-check" -mmin +1 | wc -l | tr -d ' ')" "0" + +sec "update available: the session audits now, the upgrade lands after" +cp "$WORK/old-bin" "$H/.endorctl/endorctl" +touch -t 202001010000 "$H/.endorctl/.update-check" +out=$(run) +chk "audit ran without waiting" "$(echo "$out" | grep -c AUDIT-RAN)" "1" +chk "old binary still in place when the hook returned" "$(ver)" "endorctl version v1.7.1000" +settle +chk "upgraded in the background" "$(ver)" "endorctl version v1.7.1085" + +sec "ENDORCTL_SKIP_UPDATE: no check even with an expired stamp" +cp "$WORK/old-bin" "$H/.endorctl/endorctl" +touch -t 202001010000 "$H/.endorctl/.update-check" +out=$(ENDORCTL_SKIP_UPDATE=1 run); settle +chk "audit ran" "$(echo "$out" | grep -c AUDIT-RAN)" "1" +chk "zero curl invocations" "$(wc -l < "$H/curl.log" | tr -d ' ')" "0" +chk "binary untouched" "$(ver)" "endorctl version v1.7.1000" + +sec "custom TTL is honored" +newhome +mkdir -p "$H/.endorctl"; cp "$WORK/old-bin" "$H/.endorctl/endorctl" +touch -t 202001010000 "$H/.endorctl/.update-check" +( export ENDORCTL_UPDATE_TTL_MINUTES=999999999; run >/dev/null ); settle +chk "a TTL longer than the stamp's age suppresses the check" "$(wc -l < "$H/curl.log" | tr -d ' ')" "0" +run >/dev/null; settle +chk "the default TTL does not" "$(calls meta/version)" "1" + +sec "digest mismatch: nothing is installed" +newhome +export STUB_DL_BODY="$WORK/old-bin" # body that does not match the advertised digest +run; settle +chk "binary not installed" "$(have "$H/.endorctl/endorctl")" "no" +chk "bad partial discarded" "$(have "$H/.endorctl/.endorctl.part")" "no" +chk "stamp not written, so the next session retries" "$(have "$H/.endorctl/.update-check")" "no" +chk "lock released" "$(have "$H/.endorctl/.update.lock")" "no" + +sec "complete partial from a killed run installs without refetching" +newhome +mkdir -p "$H/.endorctl" +cp "$WORK/new-bin" "$H/.endorctl/.endorctl.part" +printf '%s\n' "$NEWSHA" > "$H/.endorctl/.endorctl.sha" +run; settle +chk "installed" "$(ver)" "endorctl version v1.7.1085" +chk "no download issued" "$(gets)" "0" + +sec "short partial is resumed with a closed range, not restarted" +newhome +mkdir -p "$H/.endorctl" +head -c 20 "$WORK/new-bin" > "$H/.endorctl/.endorctl.part" +printf '%s\n' "$NEWSHA" > "$H/.endorctl/.endorctl.sha" +run; settle +chk "one GET" "$(gets)" "1" +chk "it carried a closed range starting at the partial's length" "$(calls ' -r 20-')" "1" +chk "assembled binary is correct" "$(ver)" "endorctl version v1.7.1085" + +sec "digest moved while the partial sat on disk: start over" +newhome +mkdir -p "$H/.endorctl" +cp "$WORK/old-bin" "$H/.endorctl/.endorctl.part" +printf '%s\n' "0000000000000000000000000000000000000000000000000000000000000000" > "$H/.endorctl/.endorctl.sha" +run; settle +chk "refetched from scratch" "$(gets)" "1" +chk "correct binary installed" "$(ver)" "endorctl version v1.7.1085" + +sec "full-length corrupt partial self-heals instead of wedging" +# Regression: a full-length partial that fails its digest (two racing downloaders +# can produce one) would otherwise re-request a range past the end every session, +# fail, keep the partial, and never install. +newhome +mkdir -p "$H/.endorctl" +tr 'a-z' 'A-Z' < "$WORK/new-bin" > "$H/.endorctl/.endorctl.part" +printf '%s\n' "$NEWSHA" > "$H/.endorctl/.endorctl.sha" +chk "fixture is exactly full length" \ + "$(wc -c < "$H/.endorctl/.endorctl.part" | tr -d ' ')" "$(wc -c < "$WORK/new-bin" | tr -d ' ')" +run; settle +chk "recovered and installed" "$(ver)" "endorctl version v1.7.1085" +chk "lock released" "$(have "$H/.endorctl/.update.lock")" "no" + +sec "metadata unreachable: the session still audits, nothing is installed" +newhome +mkdir -p "$H/.endorctl"; cp "$WORK/old-bin" "$H/.endorctl/endorctl" +export STUB_META_FAIL=1 +out=$(run); settle +chk "audit ran" "$(echo "$out" | grep -c AUDIT-RAN)" "1" +chk "binary untouched" "$(ver)" "endorctl version v1.7.1000" +chk "stamp not written, so the next session retries" "$(have "$H/.endorctl/.update-check")" "no" + +sec "a live lock makes a concurrent session stand down" +newhome +mkdir -p "$H/.endorctl/.update.lock"; echo 99999 > "$H/.endorctl/.update.lock/owner" +run; settle +chk "stood down without touching the network" "$(wc -l < "$H/curl.log" | tr -d ' ')" "0" +chk "the other session's lock survived" "$(have "$H/.endorctl/.update.lock")" "yes" + +sec "a slow download does not block the hook" +newhome +export STUB_DL_SLEEP=6 +s=$(date +%s); run >/dev/null; e=$(date +%s) +chk "hook returned immediately" "$([ $((e-s)) -le 2 ] && echo fast || echo "blocked $((e-s))s")" "fast" +settle +chk "binary arrived afterwards" "$(have "$H/.endorctl/endorctl")" "yes" + +sec "SIGTERM mid-download drains cleanly and the next session finishes" +# A trap does not fire until the running command returns, so a signalled job +# holds its lock until curl drains - correct, curl still owns the partial. What +# matters is that it then cleans up and leaves resumable state behind. +newhome +export STUB_DL_SLEEP=5 +run >/dev/null +sleep 1 +pid=$(pgrep -f "$H/composed.sh" | head -1) +chk "background job is running" "$([ -n "$pid" ] && echo yes || echo no)" "yes" +chk "lock held while downloading" "$(have "$H/.endorctl/.update.lock")" "yes" +[ -n "$pid" ] && kill -TERM "$pid" 2>/dev/null +for _ in $(seq 1 40); do [ -d "$H/.endorctl/.update.lock" ] || break; sleep 0.5; done +chk "lock released once curl drained" "$(have "$H/.endorctl/.update.lock")" "no" +chk "install aborted by the signal" "$(have "$H/.endorctl/endorctl")" "no" +chk "digest pin kept" "$(have "$H/.endorctl/.endorctl.sha")" "yes" +chk "downloaded bytes kept" "$(have "$H/.endorctl/.endorctl.part")" "yes" +unset STUB_DL_SLEEP +run >/dev/null; settle +chk "next session completes the install" "$(ver)" "endorctl version v1.7.1085" +chk "without re-downloading" "$(gets)" "0" + +# --- examples --------------------------------------------------------------- +# The checked-in examples/ are generated output. Any change to a script must be +# reflected there, or the samples in the README silently drift from reality. +sec "checked-in examples are in sync with the scripts" +GEN="$WORK/gen"; mkdir -p "$GEN"/cursor "$GEN"/claude "$GEN"/codex +K=PEPE; S=PAPA; NS=spiderman # demo credentials the samples were built with +r() { "$AG/scripts/render.sh" --api-key $K --api-secret $S --namespace $NS "$@"; } +{ + r --agent cursor -o "$GEN/cursor/hooks.json" + r --agent claude -o "$GEN/claude/settings.json" + r --agent codex -o "$GEN/codex/requirements.toml" + r --agent cursor --target-os windows -o "$GEN/cursor/hooks.windows.json" + r --agent claude --target-os windows -o "$GEN/claude/settings.windows.json" + r --agent codex --target-os windows -o "$GEN/codex/requirements.windows.toml" +} >/dev/null 2>&1 +if command -v plutil >/dev/null 2>&1; then + # Placeholder UUIDs, so a regenerated profile stays byte-identical. + r --agent claude -o - 2>/dev/null | "$AG/scripts/render-plist.sh" \ + --identifier com.endorlabs.ai-governance.claudecode --organization "Endor Labs" \ + --name "Claude Code - Endor AI Governance" \ + --profile-uuid 00000000-0000-0000-0000-AAAAAAAAAAAA \ + --content-uuid 00000000-0000-0000-0000-BBBBBBBBBBBB \ + -o "$GEN/claude/com.anthropic.claudecode.mobileconfig" 2>/dev/null + r --agent codex -o - 2>/dev/null | "$AG/scripts/render-plist.sh" --style mcx \ + --identifier com.endorlabs.ai-governance.codex --organization "Endor Labs" \ + --name "Codex - Endor AI Governance" \ + --profile-uuid 00000000-0000-0000-0000-CCCCCCCCCCCC \ + --content-uuid 00000000-0000-0000-0000-DDDDDDDDDDDD \ + -o "$GEN/codex/com.openai.codex.mobileconfig" 2>/dev/null +fi +for rel in cursor/hooks.json cursor/hooks.windows.json \ + claude/settings.json claude/settings.windows.json \ + claude/com.anthropic.claudecode.mobileconfig \ + codex/requirements.toml codex/requirements.windows.toml \ + codex/com.openai.codex.mobileconfig; do + if [ ! -f "$GEN/$rel" ]; then echo " (skipped $rel, needs plutil)"; continue; fi + if cmp -s "$GEN/$rel" "$AG/examples/$rel"; then ok "examples/$rel" + else bad "examples/$rel is stale - regenerate it (see README \"Examples\")"; fi +done + +sec "every embedded hook command is valid shell after JSON/TOML escaping" +if command -v python3 >/dev/null 2>&1; then + python3 - "$AG" "$WORK" <<'PY' +import json, subprocess, sys, os, plistlib, base64 +ag, work = sys.argv[1], sys.argv[2] +try: import tomllib +except ImportError: tomllib = None +n = 0; bad = [] +def check(label, cmd): + global n; n += 1 + p = os.path.join(work, 'cmd.sh') + open(p, 'w').write(cmd) + r = subprocess.run(['sh', '-n', p], capture_output=True, text=True) + if r.returncode: bad.append((label, r.stderr.strip())) +d = json.load(open(f'{ag}/examples/claude/settings.json')) +for ev, a in d['hooks'].items(): check(f'claude:{ev}', a[0]['hooks'][0]['command']) +d = json.load(open(f'{ag}/examples/cursor/hooks.json')) +for ev, a in d['hooks'].items(): check(f'cursor:{ev}', a[0]['command']) +if tomllib: + d = tomllib.load(open(f'{ag}/examples/codex/requirements.toml', 'rb')) + for ev, arr in d['hooks'].items(): + for e in arr: check(f'codex:{ev}', e['hooks'][0]['command']) +pf = f'{ag}/examples/claude/com.anthropic.claudecode.mobileconfig' +if os.path.exists(pf): + pl = plistlib.load(open(pf, 'rb')) + for ev, a in pl['PayloadContent'][0]['hooks'].items(): + check(f'profile:{ev}', a[0]['hooks'][0]['command']) +pf = f'{ag}/examples/codex/com.openai.codex.mobileconfig' +if os.path.exists(pf) and tomllib: + pl = plistlib.load(open(pf, 'rb')) + b = base64.b64decode(pl['PayloadContent'][0]['PayloadContent']['com.openai.codex'] + ['Forced'][0]['mcx_preference_settings']['requirements_toml_base64']).decode() + for ev, arr in tomllib.loads(b)['hooks'].items(): + for e in arr: check(f'mcx:{ev}', e['hooks'][0]['command']) + same = b.rstrip() == open(f'{ag}/examples/codex/requirements.toml').read().rstrip() + if not same: bad.append(('mcx payload', 'does not match requirements.toml')) +for label, err in bad: print(f' \033[31mFAIL\033[0m {label}: {err}') +print(f' \033[32mPASS\033[0m {n} embedded hook commands parsed and syntax-checked') +sys.exit(1 if bad else 0) +PY + if [ $? -eq 0 ]; then pass=$((pass+1)); else fail=$((fail+1)); fi +else + echo " (skipped, needs python3 to parse JSON/TOML/plist)" +fi + +# --- network ---------------------------------------------------------------- +if [ "$want_network" = 1 ]; then + sec "download endpoint contract (network)" + U=https://api.endorlabs.com/download/latest/endorctl_macos_arm64 + meta=$(curl -fsSL --connect-timeout 5 --max-time 30 https://api.endorlabs.com/meta/version) + latest=$(echo "$meta" | sed -n 's/.*"ClientVersion"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + sha=$(echo "$meta" | sed -n 's/.*"ARCH_TYPE_MACOS_ARM64"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*/\1/p') + chk "meta advertises a version" "$([ -n "$latest" ] && echo yes || echo no)" "yes" + chk "meta advertises a 64-hex digest" "${#sha}" "64" + total=$(curl -fsSLI --connect-timeout 5 --max-time 30 "$U" 2>/dev/null \ + | tr -d '\r' | sed -n 's/^[Cc]ontent-[Ll]ength: *//p' | tail -1) + chk "HEAD yields a numeric length" "$(isnum "$total")" "yes" + code=$(curl -sS -r 0-99 -o /dev/null -w '%{http_code}' --max-time 30 "$U") + chk "closed range is honored (resume depends on this)" "$code" "206" + # If this ever becomes 206, `curl -C -` would work and the closed-range dance + # in download_endorctl.sh could be simplified. + code=$(curl -sS -r "$((total - 1000))-" -o /dev/null -w '%{http_code}' --max-time 60 "$U") + chk "open-ended range still returns the whole body, so -C - stays unusable" "$code" "200" +fi + +if [ "$want_full" = 1 ]; then + sec "real resume and install (network, ~300 MB)" + H="$WORK/real"; mkdir -p "$H/.endorctl" + U=https://api.endorlabs.com/download/latest/endorctl_macos_arm64 + meta=$(curl -fsSL --connect-timeout 5 --max-time 30 https://api.endorlabs.com/meta/version) + sha=$(echo "$meta" | sed -n 's/.*"ARCH_TYPE_MACOS_ARM64"[[:space:]]*:[[:space:]]*"\([a-f0-9]*\)".*/\1/p') + curl -fsSL -r 0-104857599 -o "$H/.endorctl/.endorctl.part" "$U" + printf '%s\n' "$sha" > "$H/.endorctl/.endorctl.sha" + chk "seeded a 100 MB partial" "$(wc -c < "$H/.endorctl/.endorctl.part" | tr -d ' ')" "104857600" + { cat "$BOOT"; echo 'echo AUDIT-RAN'; } > "$H/composed.sh" + s=$(date +%s); ( export HOME="$H"; /bin/sh "$H/composed.sh" ) >/dev/null; e=$(date +%s) + chk "hook returned immediately" "$([ $((e-s)) -le 2 ] && echo fast || echo "blocked $((e-s))s")" "fast" + for _ in $(seq 1 900); do [ -d "$H/.endorctl/.update.lock" ] || break; sleep 1; done + got=$(shasum -a 256 "$H/.endorctl/endorctl" 2>/dev/null | awk '{print $1}') + chk "resumed and installed a digest-matching binary" "$got" "$sha" + chk "partial cleaned up" "$(have "$H/.endorctl/.endorctl.part")" "no" + chk "lock released" "$(have "$H/.endorctl/.update.lock")" "no" +fi + +printf '\n%s passed, %s failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ]