From d2b81584ef5f553de877fe97a5cb193affcb66a8 Mon Sep 17 00:00:00 2001 From: George Apostolopoulos Date: Thu, 30 Jul 2026 20:57:46 -0700 Subject: [PATCH 1/3] agent-governance: stop the endorctl download from blocking session start endorctl is a ~300 MB binary served uncompressed, and download_endorctl.sh fetched it inline in the session hook with no timeout of any kind. On a slow link that stalled agent startup for minutes; on a stalled connection it could hang indefinitely, since curl has no default transfer timeout and --retry 5 multiplied it. The binary is rebuilt roughly daily, so this was not a first-run-only cost. Split the bootstrap into a foreground decision and a detached background worker. The foreground now does two file tests and returns: steady state costs no network I/O and no binary spawn, down from ~0.8s plus an uncapped round trip. An available update is fetched in the background while the session audits with the binary already on disk. A machine with no endorctl yet skips that one audit (exit 0, so the appended audit call does not run against a missing binary) rather than blocking on the install. Also: - Bound every request. --connect-timeout/--max-time on metadata; --connect-timeout plus --speed-limit/--speed-time on the body. No --max-time on the body: off the critical path, a slow link should finish. - Resume across sessions instead of restarting from byte 0. curl -C - cannot be used here - it sends an open-ended "Range: bytes=A-", which this endpoint answers with a 200 and the whole body, so curl aborts with "server doesn't seem to support byte ranges". A closed bytes=A-B gets a proper 206, so probe the length with HEAD and request an explicit range. - Serialize with an mkdir lock, so concurrent agents perform one download rather than N x 300 MB competing for the same scarce bandwidth. - Pin the partial to the digest it is being built for, and discard a full-length partial that fails verification. Without that, a corrupt full-length partial would re-request a range past the end every session and never recover. - Throttle the version check to once every 24h (ENDORCTL_UPDATE_TTL_MINUTES), stamped on success only so failures retry. Windows (download_endorctl.ps1) is unchanged and still fetches inline; it needs a different detach primitive and is tracked as a follow-up in the design note. Adds tests/run-tests.sh, the repo's first test suite: 71 offline assertions driving the bootstrap under a throwaway HOME with a stubbed curl, an examples/ sync check that fails when generated output drifts from the scripts, and opt-in --network checks that pin the endpoint's range contract. Co-Authored-By: Claude Opus 5 (1M context) --- agent-governance/README.md | 34 +- ...26-07-30-nonblocking-endorctl-bootstrap.md | 169 ++++++++ .../com.anthropic.claudecode.mobileconfig | 184 ++++++-- .../examples/claude/settings.json | 2 +- .../codex/com.openai.codex.mobileconfig | 2 +- .../examples/codex/requirements.toml | 2 +- agent-governance/examples/cursor/hooks.json | 2 +- agent-governance/scripts/download_endorctl.sh | 182 ++++++-- agent-governance/scripts/render.sh | 10 +- agent-governance/tests/run-tests.sh | 394 ++++++++++++++++++ 10 files changed, 913 insertions(+), 68 deletions(-) create mode 100644 agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md create mode 100755 agent-governance/tests/run-tests.sh diff --git a/agent-governance/README.md b/agent-governance/README.md index d234bee..852886c 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) -docs/ deployment runbooks + the support matrix +tests/run-tests.sh test suite (offline by default) +docs/ deployment runbooks, design notes, 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/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md b/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md new file mode 100644 index 0000000..44b8540 --- /dev/null +++ b/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md @@ -0,0 +1,169 @@ +# Design: non-blocking `endorctl` bootstrap + +Status: implemented +Date: 2026-07-30 +Scope: `agent-governance/scripts/download_endorctl.sh` (POSIX only this pass) + +## Problem + +Every SessionStart hook runs `download_endorctl.sh` inline and the agent blocks +until it returns. On slow networks this stalls agent startup, sometimes for +many minutes. + +Measured against `api.endorlabs.com` on 2026-07-30: + +| | | +| --- | --- | +| `endorctl_macos_arm64` size | 305,440,226 bytes (291 MiB) | +| Compression on the wire | none — server ignores `Accept-Encoding: gzip` | +| Range requests | closed (`bytes=A-B`) honored with `206`; open-ended (`bytes=A-`) answered with the **whole body**. `Accept-Ranges` is not advertised | +| Version / `Last-Modified` at the time | `v1.7.1085` / previous day | +| `endorctl --version` locally | ~0.77 s | + +291 MiB is roughly 24 s at 100 Mbps, 4 min at 10 Mbps, 20 min at 2 Mbps. The +binary appears to be rebuilt about daily, so this is not a first-run-only cost — +developers pay it on the first session of most days. + +Five defects in the current script: + +1. **No timeout ceiling.** Both curls (`download_endorctl.sh:10,23`) use + `-fsSL --retry 5 --retry-connrefused --retry-all-errors` with no + `--connect-timeout`, `--max-time`, or `--speed-limit`. curl has no default + transfer timeout, so a slow-but-alive link or a captive portal hangs the hook + indefinitely, and `--retry 5` multiplies it by up to 6. This is the direct + cause of the startup-blocking complaints. (`download_endorctl.ps1` does set + `-TimeoutSec 30`/`120` — the two paths disagree.) +2. **No resume.** A failure at 90% restarts from byte 0, up to five more times. + The server does support ranges, but only the closed form — see the Resume + note below for why the obvious `curl -C -` cannot be used here. +3. **Updates are on the critical path.** Even when a working binary is already + installed, the session waits for a newer one. +4. **Nothing serializes concurrent sessions.** Claude Code, Cursor, and Codex — + or three Claude windows — each download their own 291 MiB copy into their own + `mktemp` file, competing for the same scarce bandwidth. There is no lock. +5. **Failure is fail-closed and loud.** Every error path is `exit 1`, and + `render.sh:190` composes the session hook as `bootstrap \n audit`, so a + network hiccup yields both no audit event and a hook error shown to the + developer. The version check also runs every session even when there is + nothing to do; there is no "checked recently" stamp. + +## Decisions + +- **Scope: this repo only.** Server-side fixes (gzip the download, ship a + smaller binary) are the biggest single lever but belong to another team. +- **Background download is acceptable**, including on first run. One session + (plus any starting during the download window) runs un-audited on a fresh + machine. +- **Keep the updater inline** in the generated hook command rather than caching + it as `$HOME/.endorctl/update.sh`. Inlining roughly doubles the bootstrap's + size and makes the examples uglier, but the managed config stays the single + source of truth — which is the whole tamper-resistance story. A cached script + adds a refresh/staleness problem and hands the developer a file to neuter. +- **Update-check TTL: 24 h**, overridable by an env knob. Governance rules are + server-side and fetched at run time, so binary freshness is not urgent. +- **POSIX first; Windows is a follow-up.** `download_endorctl.ps1` needs a + different detach primitive and a different resume mechanism (see Follow-ups), + and it is less acutely broken because it already has timeouts. +- **No explicit marker for un-audited sessions** for now. Absence of events from + a device is the admin's signal. Revisit later. + +## Target design + +### Foreground (inside the hook, blocking) + +```sh +BIN=$HOME/.endorctl/endorctl +if [ ! -x "$BIN" ]; then + spawn_background_installer + exit 0 # nothing to audit with; hook succeeds, audit line never runs +fi +if check_due && [ -z "$ENDORCTL_SKIP_UPDATE" ]; then + spawn_background_updater +fi +# fall through to the audit, using the binary already on disk +``` + +Steady-state foreground cost becomes one `[ -x ]` test plus one stamp-age test: +**zero network, zero binary spawn**, down from ~0.8 s plus an uncapped RTT. + +`exit 0` — not `exit 1` — is what skips the audit cleanly. It behaves +identically in all three composed forms in `render.sh`, including Cursor's, +where the `EXIT` trap still removes `$T`. + +### Background (detached subshell) + +Today's logic, hardened: + +- **Detach:** `( trap '' HUP; … ) >/dev/null 2>&1 -`, appending to the partial. The offset + is recomputed per attempt, so an attempt that dies midway still leaves a + correct prefix for the next one. Resume is also what makes a process-group kill + survivable, so the design does not depend on bulletproof detachment. +- **SHA-pin the partial:** record the expected SHA beside it (`$DIR/.endorctl.sha`). + The binary is rebuilt about daily, so a partial spanning two builds would fail + verification forever; if the server's SHA moved, discard the partial and start + fresh rather than retry into a permanent mismatch. +- **Discard a full-length partial that fails its digest.** A failed download + keeps its partial for the next session, which is right for a genuine partial + but wedges on a corrupt full-length one (two racing downloaders can produce + it) — every session would re-request a range past the end and fail the same + way for ever. The `HEAD` length makes this detectable: at or past full length + with a bad digest, start over. The same check covers a server that ignores the + range and resends the whole body, which would leave the partial over-long. +- **Stamp on success only** (`$DIR/.update-check`), so a failed check retries on + the next session rather than being suppressed for the full TTL. + +Verify SHA → `chmod +x` → atomic `mv` is unchanged from today. + +### Preserved behavior + +- `--skip-endorctl-update` keeps its current meaning: use the installed binary + as-is, no per-session version check, install only when missing. +- The existing age-gated sweep of `endorctl-download-*` leftovers still has a + job for pre-upgrade stragglers. +- SHA-256 verification before install is non-negotiable and unchanged. + +## Out of scope + +- Server-side compression or a smaller `endorctl`. +- MDM-side pre-provisioning of the binary (Jamf package / Intune Win32 app), + which would take the download off the laptop entirely. +- Any change to `render.sh`'s composition of the hook commands, beyond + regenerating output. + +## Follow-ups + +- **Windows parity.** `download_endorctl.ps1` needs `Start-Process powershell + -EncodedCommand … -WindowStyle Hidden` (re-encoding the updater from a + here-string) because `Start-Job` dies with its parent, plus a Range-header + resume loop replacing `Invoke-WebRequest -OutFile`. Note also that + `Invoke-WebRequest -TimeoutSec` is not a whole-transfer timeout, so today's + `120` does not bound a 291 MiB download. +- Reconsider an explicit signal for un-audited sessions. + +## Regeneration checklist + +Changing `download_endorctl.sh` changes every generated artifact. Regenerate all +eight `examples/` files with the demo credentials (`PEPE` / `PAPA` / namespace +`spiderman`) and the placeholder profile UUIDs already checked in, per the +commands in `agent-governance/README.md`. diff --git a/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig b/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig index 2557afc..7f7c10d 100644 --- a/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig +++ b/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig @@ -89,37 +89,163 @@ 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"). + # Non-blocking endorctl bootstrap, inlined into each agent's session hook. +# +# The foreground path never touches the network: it decides whether work is +# needed, hands it to a detached subshell, and returns. The audit call appended +# after this snippet then runs against whatever binary is already installed, so +# a ~300 MB download never holds up a session start. Steady state costs one +# executable test and one stamp-age test - no metadata request, no binary spawn. +# +# A machine with no endorctl yet skips its first audit rather than blocking on +# the install; the download proceeds in the background and later sessions are +# audited. Downloads resume across sessions, so a killed background job wastes +# nothing, and a lock keeps concurrent agents from each pulling their own copy. +BIN="${HOME}/.endorctl/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 we checked recently, so skip even the + # metadata request - a steady-state session start does no network I/O at all. + [ -f "$STAMP" ] && [ -z "$(find "$STAMP" -mmin +"$TTL" 2>/dev/null)" ] || need=1 +fi + +if [ -n "$need" ]; then + ( + # Detached: the redirections release the hook's stdout pipe, which the agent + # waits on, and ignoring HUP is nohup's effect without depending on nohup. + # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one, + # and subshell trap inheritance varies by shell). A process-group kill is + # survivable regardless - the next session resumes the partial download. + 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: concurrent agents (Claude, Cursor, Codex, or + # several windows) would otherwise each pull their own copy and compete for + # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is + # judged by the partial's mtime, which curl advances as it writes - a fixed + # timeout would kill a live download on a very slow link. + 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 + # Another process could have broken and retaken the lock between that mv and + # this mkdir, so stand down unless the marker is ours - and do it before + # arming the trap, or we would delete a lock we do not hold. Should this + # still race, the cost is a duplicated download; the digest gate below is + # what keeps a bad binary from ever being installed. + 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 rather than cleaning up in place: a signal trap + # resumes the script when it returns, which would drop the lock while the + # download carried on. This way the EXIT trap does the one cleanup. + # A trap does not run until the command in progress returns, so a signal + # arriving mid-transfer takes effect when curl drains - correct, since curl + # still owns the partial until then. Killing with -9 skips the trap and + # leaves the lock behind; the staleness check above is what recovers that. + trap 'rm -rf "$LOCK"' EXIT + trap 'exit 1' INT TERM + + # Leftovers from the previous mktemp-based scheme, and from any run killed + # before it could clean up. Age-gated so nothing in flight is deleted. 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 + # Validate the digest up front - a download it could not gate is wasted bandwidth. + [ ${#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, so a partial spanning two builds could never verify - if the + # expected digest moved while it sat on disk, start over rather than resume + # into a mismatch that would repeat every session. + if [ ! -f "$SHAF" ] || [ "$(cat "$SHAF" 2>/dev/null)" != "$expected_sha" ]; then + rm -f "$PART" + printf '%s\n' "$expected_sha" > "$SHAF" || exit 0 + fi + + # A complete-but-uninstalled partial is possible if a previous run was killed + # between the download and the swap, so verify before refetching. + if [ ! -f "$PART" ] || [ "$(sha256 "$PART")" != "$expected_sha" ]; then + # Resume with an explicit closed range, not `curl -C -`. The download + # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended + # one (bytes=A-) with the whole file - and the open form is what -C - sends, + # so it fails outright with "server doesn't seem to support byte ranges". + # Asking for the closed form means knowing the total length up front. + 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 means nothing to resume against; a partial at or past full + # length that failed its digest is corrupt (two racing downloaders can do + # it). Either way the only way forward is to 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 still leaves a correct + # prefix on disk, so the next attempt continues from there. + 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 genuinely slow link should be + # allowed to finish. --speed-limit aborts a stalled transfer instead. + # $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 + # Also catches a server that ignored the range and resent the whole body: + # the partial ends up over-long, fails here, and is rebuilt from scratch. + [ "$(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 + +# Nothing to audit with yet: skip this session rather than block on the install. +# Exiting 0 (not 1) keeps the hook successful and stops the appended audit call +# from running against a binary that is not there. +[ -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..b7f9ce9 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": "# Non-blocking endorctl bootstrap, inlined into each agent's session hook.\n#\n# The foreground path never touches the network: it decides whether work is\n# needed, hands it to a detached subshell, and returns. The audit call appended\n# after this snippet then runs against whatever binary is already installed, so\n# a ~300 MB download never holds up a session start. Steady state costs one\n# executable test and one stamp-age test - no metadata request, no binary spawn.\n#\n# A machine with no endorctl yet skips its first audit rather than blocking on\n# the install; the download proceeds in the background and later sessions are\n# audited. Downloads resume across sessions, so a killed background job wastes\n# nothing, and a lock keeps concurrent agents from each pulling their own copy.\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\n\nneed=\nif [ ! -x \"$BIN\" ]; then\n need=1\nelif [ -z \"${ENDORCTL_SKIP_UPDATE:-}\" ]; then\n # A stamp newer than the TTL means we checked recently, so skip even the\n # metadata request - a steady-state session start does no network I/O at all.\n [ -f \"$STAMP\" ] && [ -z \"$(find \"$STAMP\" -mmin +\"$TTL\" 2>/dev/null)\" ] || need=1\nfi\n\nif [ -n \"$need\" ]; then\n (\n # Detached: the redirections release the hook's stdout pipe, which the agent\n # waits on, and ignoring HUP is nohup's effect without depending on nohup.\n # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one,\n # and subshell trap inheritance varies by shell). A process-group kill is\n # survivable regardless - the next session resumes the partial download.\n trap '' HUP\n trap - EXIT\n\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\n # One downloader per machine: concurrent agents (Claude, Cursor, Codex, or\n # several windows) would otherwise each pull their own copy and compete for\n # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is\n # judged by the partial's mtime, which curl advances as it writes - a fixed\n # timeout would kill a live download on a very slow link.\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 # Another process could have broken and retaken the lock between that mv and\n # this mkdir, so stand down unless the marker is ours - and do it before\n # arming the trap, or we would delete a lock we do not hold. Should this\n # still race, the cost is a duplicated download; the digest gate below is\n # what keeps a bad binary from ever being installed.\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n # INT/TERM route through exit rather than cleaning up in place: a signal trap\n # resumes the script when it returns, which would drop the lock while the\n # download carried on. This way the EXIT trap does the one cleanup.\n # A trap does not run until the command in progress returns, so a signal\n # arriving mid-transfer takes effect when curl drains - correct, since curl\n # still owns the partial until then. Killing with -9 skips the trap and\n # leaves the lock behind; the staleness check above is what recovers that.\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n\n # Leftovers from the previous mktemp-based scheme, and from any run killed\n # before it could clean up. Age-gated so nothing in flight is deleted.\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n\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 # Validate the digest up front - a download it could not gate is wasted bandwidth.\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n\n # Pin the partial to the digest it is being built for. endorctl is rebuilt\n # roughly daily, so a partial spanning two builds could never verify - if the\n # expected digest moved while it sat on disk, start over rather than resume\n # into a mismatch that would repeat every session.\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\n # A complete-but-uninstalled partial is possible if a previous run was killed\n # between the download and the swap, so verify before refetching.\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n # Resume with an explicit closed range, not `curl -C -`. The download\n # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended\n # one (bytes=A-) with the whole file - and the open form is what -C - sends,\n # so it fails outright with \"server doesn't seem to support byte ranges\".\n # Asking for the closed form means knowing the total length up front.\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 # No length means nothing to resume against; a partial at or past full\n # length that failed its digest is corrupt (two racing downloaders can do\n # it). Either way the only way forward is to start over.\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n # Recomputed per attempt: one that dies midway still leaves a correct\n # prefix on disk, so the next attempt continues from there.\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 # No --max-time: off the critical path, a genuinely slow link should be\n # allowed to finish. --speed-limit aborts a stalled transfer instead.\n # $rng is deliberately unquoted - it is either empty or two words.\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 # Also catches a server that ignored the range and resent the whole body:\n # the partial ends up over-long, fails here, and is rebuilt from scratch.\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n\n chmod +x \"$PART\" || exit 0\n mv \"$PART\" \"$BIN\" || exit 0\n rm -f \"$SHAF\"\n : > \"$STAMP\"\n ) >/dev/null 2>&1 mcx_preference_settings requirements_toml_base64 - IyBFbmRvciBMYWJzIEFJIGdvdmVybmFuY2UgLSBtYW5hZ2VkIENvZGV4IGhvb2tzIChnZW5lcmF0ZWQgYnkgcmVuZGVyLnNoKS4KIyBEZWxpdmVyIGFzIGFuIE1ETSBwcm9maWxlIChjb20ub3BlbmFpLmNvZGV4IHJlcXVpcmVtZW50c190b21sX2Jhc2U2NCkgdmlhCiMgInJlbmRlci1wbGlzdC5zaCAtLXN0eWxlIG1jeCIsIG9yIGFzIGEgbWFuYWdlZCAvZXRjL2NvZGV4L3JlcXVpcmVtZW50cy50b21sLgojIEhvb2tzIGZyb20gYSBtYW5hZ2VkL3JlcXVpcmVtZW50cyBzb3VyY2UgYXJlIGF1dG8tdHJ1c3RlZCBhbmQgY2Fubm90IGJlCiMgZGlzYWJsZWQgYnkgdGhlIHVzZXI7IGF1ZGl0IGNyZWRlbnRpYWxzIGFyZSBiYWtlZCBpbnRvIGVhY2ggY29tbWFuZC4gVGhlc2UKIyBydW4gYWxvbmdzaWRlIGFueSB1c2VyL3Byb2plY3QgaG9va3MgKGxpa2UgQ2xhdWRlL0N1cnNvcik7IHRvIGluc3RlYWQgcnVuCiMgb25seSBtYW5hZ2VkIGhvb2tzLCBhbiBhZG1pbiBjYW4gYWRkICJhbGxvd19tYW5hZ2VkX2hvb2tzX29ubHkgPSB0cnVlIi4KW2ZlYXR1cmVzXQpob29rcyA9IHRydWUKCltbaG9va3MuU2Vzc2lvblN0YXJ0XV0KW1tob29rcy5TZXNzaW9uU3RhcnQuaG9va3NdXQp0eXBlID0gImNvbW1hbmQiCmNvbW1hbmQgPSAiQklOPVwiJHtIT01FfS8uZW5kb3JjdGwvZW5kb3JjdGxcIlxuc2tpcD1cblsgLW4gXCIke0VORE9SQ1RMX1NLSVBfVVBEQVRFOi19XCIgXSAmJiBbIC14IFwiJEJJTlwiIF0gJiYgc2tpcD0xXG5pZiBbIC16IFwiJHNraXBcIiBdOyB0aGVuXG4gIGNhc2UgXCIkKHVuYW1lIC1zKVwiIGluIERhcndpbikgb3M9bWFjb3MgOzsgTGludXgpIG9zPWxpbnV4IDs7ICopIGV4aXQgMSA7OyBlc2FjXG4gIGNhc2UgXCIkKHVuYW1lIC1tKVwiIGluIGFybTY0fGFhcmNoNjQpIGFyY2g9YXJtNjQgOzsgeDg2XzY0fGFtZDY0KSBhcmNoPWFtZDY0IDs7ICopIGV4aXQgMSA7OyBlc2FjXG4gIFVSTD1cImh0dHBzOi8vYXBpLmVuZG9ybGFicy5jb20vZG93bmxvYWQvbGF0ZXN0L2VuZG9yY3RsXyR7b3N9XyR7YXJjaH1cIlxuICBBUkNIX0tFWT1cIkFSQ0hfVFlQRV8kKGVjaG8gXCIke29zfV8ke2FyY2h9XCIgfCB0ciAnWzpsb3dlcjpdJyAnWzp1cHBlcjpdJylcIlxuICBjdXJyZW50PSQoWyAteCBcIiRCSU5cIiBdICYmIFwiJEJJTlwiIC0tdmVyc2lvbiAyPi9kZXYvbnVsbCB8IGF3ayAnL3ZlcnNpb24vIHtwcmludCAkTkY7IGV4aXR9JylcbiAgbWV0YT0kKGN1cmwgLWZzU0wgLS1yZXRyeSA1IC0tcmV0cnktY29ubnJlZnVzZWQgLS1yZXRyeS1hbGwtZXJyb3JzIGh0dHBzOi8vYXBpLmVuZG9ybGFicy5jb20vbWV0YS92ZXJzaW9uKVxuICBsYXRlc3Q9JChlY2hvIFwiJG1ldGFcIiB8IHNlZCAtbiAncy8uKlwiQ2xpZW50VmVyc2lvblwiW1s6c3BhY2U6XV0qOltbOnNwYWNlOl1dKlwiXFwoW15cIl0qXFwpXCIuKi9cXDEvcCcpXG4gIGV4cGVjdGVkX3NoYT0kKGVjaG8gXCIkbWV0YVwiIHwgc2VkIC1uIFwicy8uKlxcXCIke0FSQ0hfS0VZfVxcXCJbWzpzcGFjZTpdXSo6W1s6c3BhY2U6XV0qXFxcIlxcKFthLWYwLTldKlxcKVxcXCIuKi9cXDEvcFwiKVxuICB1cHRvZGF0ZT1cbiAgWyAtbiBcIiRjdXJyZW50XCIgXSAmJiB7IFsgLXogXCIkbGF0ZXN0XCIgXSB8fCBbIFwiJGN1cnJlbnRcIiA9IFwiJGxhdGVzdFwiIF07IH0gJiYgdXB0b2RhdGU9MVxuICBpZiBbIC16IFwiJHVwdG9kYXRlXCIgXTsgdGhlblxuICAgIERJUj0kKGRpcm5hbWUgXCIkQklOXCIpXG4gICAgbWtkaXIgLXAgXCIkRElSXCJcbiAgICAjIFN3ZWVwIGxlZnRvdmVycyBmcm9tIGludGVycnVwdGVkIHBhc3QgcnVucy4gQWdlLWdhdGVkIHNvIGEgY29uY3VycmVudFxuICAgICMgc2Vzc2lvbidzIGluLWZsaWdodCBkb3dubG9hZCBpcyBuZXZlciBkZWxldGVkOyB0aGUgbmFtZSBjYW5ub3QgbWF0Y2ggdGhlXG4gICAgIyBpbnN0YWxsZWQgYmluYXJ5IChcImVuZG9yY3RsXCIpLlxuICAgIGZpbmQgXCIkRElSXCIgLW5hbWUgJ2VuZG9yY3RsLWRvd25sb2FkLSonIC1tbWluICs2MCAtZGVsZXRlIDI+L2Rldi9udWxsXG4gICAgVE1QPSQobWt0ZW1wIFwiJERJUi9lbmRvcmN0bC1kb3dubG9hZC1YWFhYWFhcIikgfHwgZXhpdCAxXG4gICAgY3VybCAtZnNTTCAtLXJldHJ5IDUgLS1yZXRyeS1jb25ucmVmdXNlZCAtLXJldHJ5LWFsbC1lcnJvcnMgLW8gXCIkVE1QXCIgXCIkVVJMXCIgfHwgeyBybSAtZiBcIiRUTVBcIjsgZXhpdCAxOyB9XG4gICAgWyAkeyNleHBlY3RlZF9zaGF9IC1lcSA2NCBdIHx8IHsgcm0gLWYgXCIkVE1QXCI7IGV4aXQgMTsgfVxuICAgIGNhc2UgXCIkZXhwZWN0ZWRfc2hhXCIgaW4gKlshMC05YS1mXSopIHJtIC1mIFwiJFRNUFwiOyBleGl0IDEgOzsgZXNhY1xuICAgIGlmIGNvbW1hbmQgLXYgc2hhMjU2c3VtID4vZGV2L251bGwgMj4mMTsgdGhlbiBzdW09JChzaGEyNTZzdW0gXCIkVE1QXCIgfCBhd2sgJ3twcmludCAkMX0nKTsgZWxzZSBzdW09JChzaGFzdW0gLWEgMjU2IFwiJFRNUFwiIHwgYXdrICd7cHJpbnQgJDF9Jyk7IGZpXG4gICAgWyBcIiRzdW1cIiA9IFwiJGV4cGVjdGVkX3NoYVwiIF0gfHwgeyBybSAtZiBcIiRUTVBcIjsgZXhpdCAxOyB9XG4gICAgY2htb2QgK3ggXCIkVE1QXCIgfHwgeyBybSAtZiBcIiRUTVBcIjsgZXhpdCAxOyB9XG4gICAgbXYgXCIkVE1QXCIgXCIkQklOXCJcbiAgZmlcbmZpXG5FTkRPUl9BSV9BVURJVF9DQUNIRV9FTkFCTEVEPSd0cnVlJyBcIiRIT01FLy5lbmRvcmN0bC9lbmRvcmN0bFwiIC0tYXBpICdodHRwczovL2FwaS5lbmRvcmxhYnMuY29tJyAtLW5hbWVzcGFjZSAnc3BpZGVybWFuJyAtLWFwaS1rZXkgJ1BFUEUnIC0tYXBpLXNlY3JldCAnUEFQQScgYWktYXVkaXQgY29kZXgiCgpbW2hvb2tzLlVzZXJQcm9tcHRTdWJtaXRdXQpbW2hvb2tzLlVzZXJQcm9tcHRTdWJtaXQuaG9va3NdXQp0eXBlID0gImNvbW1hbmQiCmNvbW1hbmQgPSAiRU5ET1JfQUlfQVVESVRfQ0FDSEVfRU5BQkxFRD0ndHJ1ZScgXCIkSE9NRS8uZW5kb3JjdGwvZW5kb3JjdGxcIiAtLWFwaSAnaHR0cHM6Ly9hcGkuZW5kb3JsYWJzLmNvbScgLS1uYW1lc3BhY2UgJ3NwaWRlcm1hbicgLS1hcGkta2V5ICdQRVBFJyAtLWFwaS1zZWNyZXQgJ1BBUEEnIGFpLWF1ZGl0IGNvZGV4IgoKW1tob29rcy5QcmVUb29sVXNlXV0KbWF0Y2hlciA9ICIuKiIKW1tob29rcy5QcmVUb29sVXNlLmhvb2tzXV0KdHlwZSA9ICJjb21tYW5kIgpjb21tYW5kID0gIkVORE9SX0FJX0FVRElUX0NBQ0hFX0VOQUJMRUQ9J3RydWUnIFwiJEhPTUUvLmVuZG9yY3RsL2VuZG9yY3RsXCIgLS1hcGkgJ2h0dHBzOi8vYXBpLmVuZG9ybGFicy5jb20nIC0tbmFtZXNwYWNlICdzcGlkZXJtYW4nIC0tYXBpLWtleSAnUEVQRScgLS1hcGktc2VjcmV0ICdQQVBBJyBhaS1hdWRpdCBjb2RleCIKCltbaG9va3MuUGVybWlzc2lvblJlcXVlc3RdXQptYXRjaGVyID0gIi4qIgpbW2hvb2tzLlBlcm1pc3Npb25SZXF1ZXN0Lmhvb2tzXV0KdHlwZSA9ICJjb21tYW5kIgpjb21tYW5kID0gIkVORE9SX0FJX0FVRElUX0NBQ0hFX0VOQUJMRUQ9J3RydWUnIFwiJEhPTUUvLmVuZG9yY3RsL2VuZG9yY3RsXCIgLS1hcGkgJ2h0dHBzOi8vYXBpLmVuZG9ybGFicy5jb20nIC0tbmFtZXNwYWNlICdzcGlkZXJtYW4nIC0tYXBpLWtleSAnUEVQRScgLS1hcGktc2VjcmV0ICdQQVBBJyBhaS1hdWRpdCBjb2RleCIKCltbaG9va3MuUG9zdFRvb2xVc2VdXQptYXRjaGVyID0gIi4qIgpbW2hvb2tzLlBvc3RUb29sVXNlLmhvb2tzXV0KdHlwZSA9ICJjb21tYW5kIgpjb21tYW5kID0gIkVORE9SX0FJX0FVRElUX0NBQ0hFX0VOQUJMRUQ9J3RydWUnIFwiJEhPTUUvLmVuZG9yY3RsL2VuZG9yY3RsXCIgLS1hcGkgJ2h0dHBzOi8vYXBpLmVuZG9ybGFicy5jb20nIC0tbmFtZXNwYWNlICdzcGlkZXJtYW4nIC0tYXBpLWtleSAnUEVQRScgLS1hcGktc2VjcmV0ICdQQVBBJyBhaS1hdWRpdCBjb2RleCIKCltbaG9va3MuU3RvcF1dCltbaG9va3MuU3RvcC5ob29rc11dCnR5cGUgPSAiY29tbWFuZCIKY29tbWFuZCA9ICJFTkRPUl9BSV9BVURJVF9DQUNIRV9FTkFCTEVEPSd0cnVlJyBcIiRIT01FLy5lbmRvcmN0bC9lbmRvcmN0bFwiIC0tYXBpICdodHRwczovL2FwaS5lbmRvcmxhYnMuY29tJyAtLW5hbWVzcGFjZSAnc3BpZGVybWFuJyAtLWFwaS1rZXkgJ1BFUEUnIC0tYXBpLXNlY3JldCAnUEFQQScgYWktYXVkaXQgY29kZXgi + IyBFbmRvciBMYWJzIEFJIGdvdmVybmFuY2UgLSBtYW5hZ2VkIENvZGV4IGhvb2tzIChnZW5lcmF0ZWQgYnkgcmVuZGVyLnNoKS4KIyBEZWxpdmVyIGFzIGFuIE1ETSBwcm9maWxlIChjb20ub3BlbmFpLmNvZGV4IHJlcXVpcmVtZW50c190b21sX2Jhc2U2NCkgdmlhCiMgInJlbmRlci1wbGlzdC5zaCAtLXN0eWxlIG1jeCIsIG9yIGFzIGEgbWFuYWdlZCAvZXRjL2NvZGV4L3JlcXVpcmVtZW50cy50b21sLgojIEhvb2tzIGZyb20gYSBtYW5hZ2VkL3JlcXVpcmVtZW50cyBzb3VyY2UgYXJlIGF1dG8tdHJ1c3RlZCBhbmQgY2Fubm90IGJlCiMgZGlzYWJsZWQgYnkgdGhlIHVzZXI7IGF1ZGl0IGNyZWRlbnRpYWxzIGFyZSBiYWtlZCBpbnRvIGVhY2ggY29tbWFuZC4gVGhlc2UKIyBydW4gYWxvbmdzaWRlIGFueSB1c2VyL3Byb2plY3QgaG9va3MgKGxpa2UgQ2xhdWRlL0N1cnNvcik7IHRvIGluc3RlYWQgcnVuCiMgb25seSBtYW5hZ2VkIGhvb2tzLCBhbiBhZG1pbiBjYW4gYWRkICJhbGxvd19tYW5hZ2VkX2hvb2tzX29ubHkgPSB0cnVlIi4KW2ZlYXR1cmVzXQpob29rcyA9IHRydWUKCltbaG9va3MuU2Vzc2lvblN0YXJ0XV0KW1tob29rcy5TZXNzaW9uU3RhcnQuaG9va3NdXQp0eXBlID0gImNvbW1hbmQiCmNvbW1hbmQgPSAiIyBOb24tYmxvY2tpbmcgZW5kb3JjdGwgYm9vdHN0cmFwLCBpbmxpbmVkIGludG8gZWFjaCBhZ2VudCdzIHNlc3Npb24gaG9vay5cbiNcbiMgVGhlIGZvcmVncm91bmQgcGF0aCBuZXZlciB0b3VjaGVzIHRoZSBuZXR3b3JrOiBpdCBkZWNpZGVzIHdoZXRoZXIgd29yayBpc1xuIyBuZWVkZWQsIGhhbmRzIGl0IHRvIGEgZGV0YWNoZWQgc3Vic2hlbGwsIGFuZCByZXR1cm5zLiBUaGUgYXVkaXQgY2FsbCBhcHBlbmRlZFxuIyBhZnRlciB0aGlzIHNuaXBwZXQgdGhlbiBydW5zIGFnYWluc3Qgd2hhdGV2ZXIgYmluYXJ5IGlzIGFscmVhZHkgaW5zdGFsbGVkLCBzb1xuIyBhIH4zMDAgTUIgZG93bmxvYWQgbmV2ZXIgaG9sZHMgdXAgYSBzZXNzaW9uIHN0YXJ0LiBTdGVhZHkgc3RhdGUgY29zdHMgb25lXG4jIGV4ZWN1dGFibGUgdGVzdCBhbmQgb25lIHN0YW1wLWFnZSB0ZXN0IC0gbm8gbWV0YWRhdGEgcmVxdWVzdCwgbm8gYmluYXJ5IHNwYXduLlxuI1xuIyBBIG1hY2hpbmUgd2l0aCBubyBlbmRvcmN0bCB5ZXQgc2tpcHMgaXRzIGZpcnN0IGF1ZGl0IHJhdGhlciB0aGFuIGJsb2NraW5nIG9uXG4jIHRoZSBpbnN0YWxsOyB0aGUgZG93bmxvYWQgcHJvY2VlZHMgaW4gdGhlIGJhY2tncm91bmQgYW5kIGxhdGVyIHNlc3Npb25zIGFyZVxuIyBhdWRpdGVkLiBEb3dubG9hZHMgcmVzdW1lIGFjcm9zcyBzZXNzaW9ucywgc28gYSBraWxsZWQgYmFja2dyb3VuZCBqb2Igd2FzdGVzXG4jIG5vdGhpbmcsIGFuZCBhIGxvY2sga2VlcHMgY29uY3VycmVudCBhZ2VudHMgZnJvbSBlYWNoIHB1bGxpbmcgdGhlaXIgb3duIGNvcHkuXG5CSU49XCIke0hPTUV9Ly5lbmRvcmN0bC9lbmRvcmN0bFwiXG5ESVI9XCIke0hPTUV9Ly5lbmRvcmN0bFwiXG5TVEFNUD1cIiRESVIvLnVwZGF0ZS1jaGVja1wiXG5UVEw9XCIke0VORE9SQ1RMX1VQREFURV9UVExfTUlOVVRFUzotMTQ0MH1cIlxuY2FzZSBcIiRUVExcIiBpbiAnJ3wqWyEwLTldKikgVFRMPTE0NDAgOzsgZXNhY1xuXG5uZWVkPVxuaWYgWyAhIC14IFwiJEJJTlwiIF07IHRoZW5cbiAgbmVlZD0xXG5lbGlmIFsgLXogXCIke0VORE9SQ1RMX1NLSVBfVVBEQVRFOi19XCIgXTsgdGhlblxuICAjIEEgc3RhbXAgbmV3ZXIgdGhhbiB0aGUgVFRMIG1lYW5zIHdlIGNoZWNrZWQgcmVjZW50bHksIHNvIHNraXAgZXZlbiB0aGVcbiAgIyBtZXRhZGF0YSByZXF1ZXN0IC0gYSBzdGVhZHktc3RhdGUgc2Vzc2lvbiBzdGFydCBkb2VzIG5vIG5ldHdvcmsgSS9PIGF0IGFsbC5cbiAgWyAtZiBcIiRTVEFNUFwiIF0gJiYgWyAteiBcIiQoZmluZCBcIiRTVEFNUFwiIC1tbWluICtcIiRUVExcIiAyPi9kZXYvbnVsbClcIiBdIHx8IG5lZWQ9MVxuZmlcblxuaWYgWyAtbiBcIiRuZWVkXCIgXTsgdGhlblxuICAoXG4gICAgIyBEZXRhY2hlZDogdGhlIHJlZGlyZWN0aW9ucyByZWxlYXNlIHRoZSBob29rJ3Mgc3Rkb3V0IHBpcGUsIHdoaWNoIHRoZSBhZ2VudFxuICAgICMgd2FpdHMgb24sIGFuZCBpZ25vcmluZyBIVVAgaXMgbm9odXAncyBlZmZlY3Qgd2l0aG91dCBkZXBlbmRpbmcgb24gbm9odXAuXG4gICAgIyBDbGVhciBhbnkgRVhJVCB0cmFwIGluaGVyaXRlZCBmcm9tIHRoZSBjYWxsZXIgKEN1cnNvcidzIHdyYXBwZXIgc2V0cyBvbmUsXG4gICAgIyBhbmQgc3Vic2hlbGwgdHJhcCBpbmhlcml0YW5jZSB2YXJpZXMgYnkgc2hlbGwpLiBBIHByb2Nlc3MtZ3JvdXAga2lsbCBpc1xuICAgICMgc3Vydml2YWJsZSByZWdhcmRsZXNzIC0gdGhlIG5leHQgc2Vzc2lvbiByZXN1bWVzIHRoZSBwYXJ0aWFsIGRvd25sb2FkLlxuICAgIHRyYXAgJycgSFVQXG4gICAgdHJhcCAtIEVYSVRcblxuICAgIExPQ0s9XCIkRElSLy51cGRhdGUubG9ja1wiXG4gICAgUEFSVD1cIiRESVIvLmVuZG9yY3RsLnBhcnRcIlxuICAgIFNIQUY9XCIkRElSLy5lbmRvcmN0bC5zaGFcIlxuICAgIHNoYTI1NigpIHtcbiAgICAgIGlmIGNvbW1hbmQgLXYgc2hhMjU2c3VtID4vZGV2L251bGwgMj4mMTsgdGhlbiBzaGEyNTZzdW0gXCIkMVwiIHwgYXdrICd7cHJpbnQgJDF9J1xuICAgICAgZWxzZSBzaGFzdW0gLWEgMjU2IFwiJDFcIiB8IGF3ayAne3ByaW50ICQxfSc7IGZpXG4gICAgfVxuICAgIG1rZGlyIC1wIFwiJERJUlwiIHx8IGV4aXQgMFxuXG4gICAgIyBPbmUgZG93bmxvYWRlciBwZXIgbWFjaGluZTogY29uY3VycmVudCBhZ2VudHMgKENsYXVkZSwgQ3Vyc29yLCBDb2RleCwgb3JcbiAgICAjIHNldmVyYWwgd2luZG93cykgd291bGQgb3RoZXJ3aXNlIGVhY2ggcHVsbCB0aGVpciBvd24gY29weSBhbmQgY29tcGV0ZSBmb3JcbiAgICAjIHRoZSBzYW1lIHNjYXJjZSBiYW5kd2lkdGguIG1rZGlyIGlzIHRoZSBhdG9taWMgcHJpbWl0aXZlLiBTdGFsZW5lc3MgaXNcbiAgICAjIGp1ZGdlZCBieSB0aGUgcGFydGlhbCdzIG10aW1lLCB3aGljaCBjdXJsIGFkdmFuY2VzIGFzIGl0IHdyaXRlcyAtIGEgZml4ZWRcbiAgICAjIHRpbWVvdXQgd291bGQga2lsbCBhIGxpdmUgZG93bmxvYWQgb24gYSB2ZXJ5IHNsb3cgbGluay5cbiAgICBpZiBbIC1kIFwiJExPQ0tcIiBdOyB0aGVuXG4gICAgICByZWY9XCIkUEFSVFwiOyBbIC1mIFwiJFBBUlRcIiBdIHx8IHJlZj1cIiRMT0NLXCJcbiAgICAgIFsgLW4gXCIkKGZpbmQgXCIkcmVmXCIgLW1taW4gKzMwIDI+L2Rldi9udWxsKVwiIF0gfHwgZXhpdCAwXG4gICAgICBtdiBcIiRMT0NLXCIgXCIkTE9DSy5zdGFsZS4kJFwiIDI+L2Rldi9udWxsICYmIHJtIC1yZiBcIiRMT0NLLnN0YWxlLiQkXCJcbiAgICBmaVxuICAgIG1rZGlyIFwiJExPQ0tcIiAyPi9kZXYvbnVsbCB8fCBleGl0IDBcbiAgICAjIEFub3RoZXIgcHJvY2VzcyBjb3VsZCBoYXZlIGJyb2tlbiBhbmQgcmV0YWtlbiB0aGUgbG9jayBiZXR3ZWVuIHRoYXQgbXYgYW5kXG4gICAgIyB0aGlzIG1rZGlyLCBzbyBzdGFuZCBkb3duIHVubGVzcyB0aGUgbWFya2VyIGlzIG91cnMgLSBhbmQgZG8gaXQgYmVmb3JlXG4gICAgIyBhcm1pbmcgdGhlIHRyYXAsIG9yIHdlIHdvdWxkIGRlbGV0ZSBhIGxvY2sgd2UgZG8gbm90IGhvbGQuIFNob3VsZCB0aGlzXG4gICAgIyBzdGlsbCByYWNlLCB0aGUgY29zdCBpcyBhIGR1cGxpY2F0ZWQgZG93bmxvYWQ7IHRoZSBkaWdlc3QgZ2F0ZSBiZWxvdyBpc1xuICAgICMgd2hhdCBrZWVwcyBhIGJhZCBiaW5hcnkgZnJvbSBldmVyIGJlaW5nIGluc3RhbGxlZC5cbiAgICBlY2hvIFwiJCRcIiA+IFwiJExPQ0svb3duZXJcIiAyPi9kZXYvbnVsbCB8fCB7IHJtZGlyIFwiJExPQ0tcIiAyPi9kZXYvbnVsbDsgZXhpdCAwOyB9XG4gICAgWyBcIiQoY2F0IFwiJExPQ0svb3duZXJcIiAyPi9kZXYvbnVsbClcIiA9IFwiJCRcIiBdIHx8IGV4aXQgMFxuICAgICMgSU5UL1RFUk0gcm91dGUgdGhyb3VnaCBleGl0IHJhdGhlciB0aGFuIGNsZWFuaW5nIHVwIGluIHBsYWNlOiBhIHNpZ25hbCB0cmFwXG4gICAgIyByZXN1bWVzIHRoZSBzY3JpcHQgd2hlbiBpdCByZXR1cm5zLCB3aGljaCB3b3VsZCBkcm9wIHRoZSBsb2NrIHdoaWxlIHRoZVxuICAgICMgZG93bmxvYWQgY2FycmllZCBvbi4gVGhpcyB3YXkgdGhlIEVYSVQgdHJhcCBkb2VzIHRoZSBvbmUgY2xlYW51cC5cbiAgICAjIEEgdHJhcCBkb2VzIG5vdCBydW4gdW50aWwgdGhlIGNvbW1hbmQgaW4gcHJvZ3Jlc3MgcmV0dXJucywgc28gYSBzaWduYWxcbiAgICAjIGFycml2aW5nIG1pZC10cmFuc2ZlciB0YWtlcyBlZmZlY3Qgd2hlbiBjdXJsIGRyYWlucyAtIGNvcnJlY3QsIHNpbmNlIGN1cmxcbiAgICAjIHN0aWxsIG93bnMgdGhlIHBhcnRpYWwgdW50aWwgdGhlbi4gS2lsbGluZyB3aXRoIC05IHNraXBzIHRoZSB0cmFwIGFuZFxuICAgICMgbGVhdmVzIHRoZSBsb2NrIGJlaGluZDsgdGhlIHN0YWxlbmVzcyBjaGVjayBhYm92ZSBpcyB3aGF0IHJlY292ZXJzIHRoYXQuXG4gICAgdHJhcCAncm0gLXJmIFwiJExPQ0tcIicgRVhJVFxuICAgIHRyYXAgJ2V4aXQgMScgSU5UIFRFUk1cblxuICAgICMgTGVmdG92ZXJzIGZyb20gdGhlIHByZXZpb3VzIG1rdGVtcC1iYXNlZCBzY2hlbWUsIGFuZCBmcm9tIGFueSBydW4ga2lsbGVkXG4gICAgIyBiZWZvcmUgaXQgY291bGQgY2xlYW4gdXAuIEFnZS1nYXRlZCBzbyBub3RoaW5nIGluIGZsaWdodCBpcyBkZWxldGVkLlxuICAgIGZpbmQgXCIkRElSXCIgLW5hbWUgJ2VuZG9yY3RsLWRvd25sb2FkLSonIC1tbWluICs2MCAtZGVsZXRlIDI+L2Rldi9udWxsXG5cbiAgICBjYXNlIFwiJCh1bmFtZSAtcylcIiBpbiBEYXJ3aW4pIG9zPW1hY29zIDs7IExpbnV4KSBvcz1saW51eCA7OyAqKSBleGl0IDAgOzsgZXNhY1xuICAgIGNhc2UgXCIkKHVuYW1lIC1tKVwiIGluIGFybTY0fGFhcmNoNjQpIGFyY2g9YXJtNjQgOzsgeDg2XzY0fGFtZDY0KSBhcmNoPWFtZDY0IDs7ICopIGV4aXQgMCA7OyBlc2FjXG4gICAgVVJMPVwiaHR0cHM6Ly9hcGkuZW5kb3JsYWJzLmNvbS9kb3dubG9hZC9sYXRlc3QvZW5kb3JjdGxfJHtvc31fJHthcmNofVwiXG4gICAgQVJDSF9LRVk9XCJBUkNIX1RZUEVfJChlY2hvIFwiJHtvc31fJHthcmNofVwiIHwgdHIgJ1s6bG93ZXI6XScgJ1s6dXBwZXI6XScpXCJcbiAgICBjdXJyZW50PSQoWyAteCBcIiRCSU5cIiBdICYmIFwiJEJJTlwiIC0tdmVyc2lvbiAyPi9kZXYvbnVsbCB8IGF3ayAnL3ZlcnNpb24vIHtwcmludCAkTkY7IGV4aXR9JylcbiAgICBtZXRhPSQoY3VybCAtZnNTTCAtLWNvbm5lY3QtdGltZW91dCA1IC0tbWF4LXRpbWUgMzAgaHR0cHM6Ly9hcGkuZW5kb3JsYWJzLmNvbS9tZXRhL3ZlcnNpb24pIHx8IGV4aXQgMFxuICAgIGxhdGVzdD0kKGVjaG8gXCIkbWV0YVwiIHwgc2VkIC1uICdzLy4qXCJDbGllbnRWZXJzaW9uXCJbWzpzcGFjZTpdXSo6W1s6c3BhY2U6XV0qXCJcXChbXlwiXSpcXClcIi4qL1xcMS9wJylcbiAgICBleHBlY3RlZF9zaGE9JChlY2hvIFwiJG1ldGFcIiB8IHNlZCAtbiBcInMvLipcXFwiJHtBUkNIX0tFWX1cXFwiW1s6c3BhY2U6XV0qOltbOnNwYWNlOl1dKlxcXCJcXChbYS1mMC05XSpcXClcXFwiLiovXFwxL3BcIilcbiAgICBbIC1uIFwiJGxhdGVzdFwiIF0gfHwgZXhpdCAwXG4gICAgIyBWYWxpZGF0ZSB0aGUgZGlnZXN0IHVwIGZyb250IC0gYSBkb3dubG9hZCBpdCBjb3VsZCBub3QgZ2F0ZSBpcyB3YXN0ZWQgYmFuZHdpZHRoLlxuICAgIFsgJHsjZXhwZWN0ZWRfc2hhfSAtZXEgNjQgXSB8fCBleGl0IDBcbiAgICBjYXNlIFwiJGV4cGVjdGVkX3NoYVwiIGluICpbITAtOWEtZl0qKSBleGl0IDAgOzsgZXNhY1xuXG4gICAgaWYgWyAtbiBcIiRjdXJyZW50XCIgXSAmJiBbIFwiJGN1cnJlbnRcIiA9IFwiJGxhdGVzdFwiIF07IHRoZW5cbiAgICAgIHJtIC1mIFwiJFBBUlRcIiBcIiRTSEFGXCJcbiAgICAgIDogPiBcIiRTVEFNUFwiXG4gICAgICBleGl0IDBcbiAgICBmaVxuXG4gICAgIyBQaW4gdGhlIHBhcnRpYWwgdG8gdGhlIGRpZ2VzdCBpdCBpcyBiZWluZyBidWlsdCBmb3IuIGVuZG9yY3RsIGlzIHJlYnVpbHRcbiAgICAjIHJvdWdobHkgZGFpbHksIHNvIGEgcGFydGlhbCBzcGFubmluZyB0d28gYnVpbGRzIGNvdWxkIG5ldmVyIHZlcmlmeSAtIGlmIHRoZVxuICAgICMgZXhwZWN0ZWQgZGlnZXN0IG1vdmVkIHdoaWxlIGl0IHNhdCBvbiBkaXNrLCBzdGFydCBvdmVyIHJhdGhlciB0aGFuIHJlc3VtZVxuICAgICMgaW50byBhIG1pc21hdGNoIHRoYXQgd291bGQgcmVwZWF0IGV2ZXJ5IHNlc3Npb24uXG4gICAgaWYgWyAhIC1mIFwiJFNIQUZcIiBdIHx8IFsgXCIkKGNhdCBcIiRTSEFGXCIgMj4vZGV2L251bGwpXCIgIT0gXCIkZXhwZWN0ZWRfc2hhXCIgXTsgdGhlblxuICAgICAgcm0gLWYgXCIkUEFSVFwiXG4gICAgICBwcmludGYgJyVzXFxuJyBcIiRleHBlY3RlZF9zaGFcIiA+IFwiJFNIQUZcIiB8fCBleGl0IDBcbiAgICBmaVxuXG4gICAgIyBBIGNvbXBsZXRlLWJ1dC11bmluc3RhbGxlZCBwYXJ0aWFsIGlzIHBvc3NpYmxlIGlmIGEgcHJldmlvdXMgcnVuIHdhcyBraWxsZWRcbiAgICAjIGJldHdlZW4gdGhlIGRvd25sb2FkIGFuZCB0aGUgc3dhcCwgc28gdmVyaWZ5IGJlZm9yZSByZWZldGNoaW5nLlxuICAgIGlmIFsgISAtZiBcIiRQQVJUXCIgXSB8fCBbIFwiJChzaGEyNTYgXCIkUEFSVFwiKVwiICE9IFwiJGV4cGVjdGVkX3NoYVwiIF07IHRoZW5cbiAgICAgICMgUmVzdW1lIHdpdGggYW4gZXhwbGljaXQgY2xvc2VkIHJhbmdlLCBub3QgYGN1cmwgLUMgLWAuIFRoZSBkb3dubG9hZFxuICAgICAgIyBlbmRwb2ludCBhbnN3ZXJzIGEgY2xvc2VkIHJhbmdlIChieXRlcz1BLUIpIHdpdGggYSAyMDYsIGJ1dCBhbiBvcGVuLWVuZGVkXG4gICAgICAjIG9uZSAoYnl0ZXM9QS0pIHdpdGggdGhlIHdob2xlIGZpbGUgLSBhbmQgdGhlIG9wZW4gZm9ybSBpcyB3aGF0IC1DIC0gc2VuZHMsXG4gICAgICAjIHNvIGl0IGZhaWxzIG91dHJpZ2h0IHdpdGggXCJzZXJ2ZXIgZG9lc24ndCBzZWVtIHRvIHN1cHBvcnQgYnl0ZSByYW5nZXNcIi5cbiAgICAgICMgQXNraW5nIGZvciB0aGUgY2xvc2VkIGZvcm0gbWVhbnMga25vd2luZyB0aGUgdG90YWwgbGVuZ3RoIHVwIGZyb250LlxuICAgICAgdG90YWw9JChjdXJsIC1mc1NMSSAtLWNvbm5lY3QtdGltZW91dCA1IC0tbWF4LXRpbWUgMzAgXCIkVVJMXCIgMj4vZGV2L251bGwgXFxcbiAgICAgICAgfCB0ciAtZCAnXFxyJyB8IHNlZCAtbiAncy9eW0NjXW9udGVudC1bTGxdZW5ndGg6ICovL3AnIHwgdGFpbCAtMSlcbiAgICAgIGNhc2UgXCIkdG90YWxcIiBpbiAnJ3wqWyEwLTldKikgdG90YWw9IDs7IGVzYWNcbiAgICAgIHNpemU9JCh3YyAtYyA8IFwiJFBBUlRcIiAyPi9kZXYvbnVsbCB8IHRyIC1kICcgJylcbiAgICAgIGNhc2UgXCIkc2l6ZVwiIGluICcnfCpbITAtOV0qKSBzaXplPTAgOzsgZXNhY1xuICAgICAgIyBObyBsZW5ndGggbWVhbnMgbm90aGluZyB0byByZXN1bWUgYWdhaW5zdDsgYSBwYXJ0aWFsIGF0IG9yIHBhc3QgZnVsbFxuICAgICAgIyBsZW5ndGggdGhhdCBmYWlsZWQgaXRzIGRpZ2VzdCBpcyBjb3JydXB0ICh0d28gcmFjaW5nIGRvd25sb2FkZXJzIGNhbiBkb1xuICAgICAgIyBpdCkuIEVpdGhlciB3YXkgdGhlIG9ubHkgd2F5IGZvcndhcmQgaXMgdG8gc3RhcnQgb3Zlci5cbiAgICAgIGlmIFsgLXogXCIkdG90YWxcIiBdIHx8IFsgXCIkc2l6ZVwiIC1nZSBcIiR0b3RhbFwiIF07IHRoZW4gcm0gLWYgXCIkUEFSVFwiOyBmaVxuXG4gICAgICBuPTA7IG9rPVxuICAgICAgd2hpbGUgWyBcIiRuXCIgLWx0IDMgXTsgZG9cbiAgICAgICAgbj0kKChuICsgMSkpXG4gICAgICAgICMgUmVjb21wdXRlZCBwZXIgYXR0ZW1wdDogb25lIHRoYXQgZGllcyBtaWR3YXkgc3RpbGwgbGVhdmVzIGEgY29ycmVjdFxuICAgICAgICAjIHByZWZpeCBvbiBkaXNrLCBzbyB0aGUgbmV4dCBhdHRlbXB0IGNvbnRpbnVlcyBmcm9tIHRoZXJlLlxuICAgICAgICBzaXplPSQod2MgLWMgPCBcIiRQQVJUXCIgMj4vZGV2L251bGwgfCB0ciAtZCAnICcpXG4gICAgICAgIGNhc2UgXCIkc2l6ZVwiIGluICcnfCpbITAtOV0qKSBzaXplPTAgOzsgZXNhY1xuICAgICAgICBybmc9XG4gICAgICAgIFsgLW4gXCIkdG90YWxcIiBdICYmIFsgXCIkc2l6ZVwiIC1ndCAwIF0gJiYgcm5nPVwiLXIgJHNpemUtJCgodG90YWwgLSAxKSlcIlxuICAgICAgICAjIE5vIC0tbWF4LXRpbWU6IG9mZiB0aGUgY3JpdGljYWwgcGF0aCwgYSBnZW51aW5lbHkgc2xvdyBsaW5rIHNob3VsZCBiZVxuICAgICAgICAjIGFsbG93ZWQgdG8gZmluaXNoLiAtLXNwZWVkLWxpbWl0IGFib3J0cyBhIHN0YWxsZWQgdHJhbnNmZXIgaW5zdGVhZC5cbiAgICAgICAgIyAkcm5nIGlzIGRlbGliZXJhdGVseSB1bnF1b3RlZCAtIGl0IGlzIGVpdGhlciBlbXB0eSBvciB0d28gd29yZHMuXG4gICAgICAgIGlmIGN1cmwgLWZzU0wgLS1jb25uZWN0LXRpbWVvdXQgMTAgLS1zcGVlZC1saW1pdCAxMDI0MCAtLXNwZWVkLXRpbWUgNjAgXFxcbiAgICAgICAgICAgICAkcm5nIFwiJFVSTFwiID4+IFwiJFBBUlRcIjsgdGhlbiBvaz0xOyBicmVhazsgZmlcbiAgICAgICAgc2xlZXAgNVxuICAgICAgZG9uZVxuICAgICAgWyAtbiBcIiRva1wiIF0gfHwgZXhpdCAwXG4gICAgICAjIEFsc28gY2F0Y2hlcyBhIHNlcnZlciB0aGF0IGlnbm9yZWQgdGhlIHJhbmdlIGFuZCByZXNlbnQgdGhlIHdob2xlIGJvZHk6XG4gICAgICAjIHRoZSBwYXJ0aWFsIGVuZHMgdXAgb3Zlci1sb25nLCBmYWlscyBoZXJlLCBhbmQgaXMgcmVidWlsdCBmcm9tIHNjcmF0Y2guXG4gICAgICBbIFwiJChzaGEyNTYgXCIkUEFSVFwiKVwiID0gXCIkZXhwZWN0ZWRfc2hhXCIgXSB8fCB7IHJtIC1mIFwiJFBBUlRcIiBcIiRTSEFGXCI7IGV4aXQgMDsgfVxuICAgIGZpXG5cbiAgICBjaG1vZCAreCBcIiRQQVJUXCIgfHwgZXhpdCAwXG4gICAgbXYgXCIkUEFSVFwiIFwiJEJJTlwiIHx8IGV4aXQgMFxuICAgIHJtIC1mIFwiJFNIQUZcIlxuICAgIDogPiBcIiRTVEFNUFwiXG4gICkgPi9kZXYvbnVsbCAyPiYxIDwvZGV2L251bGwgJlxuZmlcblxuIyBOb3RoaW5nIHRvIGF1ZGl0IHdpdGggeWV0OiBza2lwIHRoaXMgc2Vzc2lvbiByYXRoZXIgdGhhbiBibG9jayBvbiB0aGUgaW5zdGFsbC5cbiMgRXhpdGluZyAwIChub3QgMSkga2VlcHMgdGhlIGhvb2sgc3VjY2Vzc2Z1bCBhbmQgc3RvcHMgdGhlIGFwcGVuZGVkIGF1ZGl0IGNhbGxcbiMgZnJvbSBydW5uaW5nIGFnYWluc3QgYSBiaW5hcnkgdGhhdCBpcyBub3QgdGhlcmUuXG5bIC14IFwiJEJJTlwiIF0gfHwgZXhpdCAwXG5FTkRPUl9BSV9BVURJVF9DQUNIRV9FTkFCTEVEPSd0cnVlJyBcIiRIT01FLy5lbmRvcmN0bC9lbmRvcmN0bFwiIC0tYXBpICdodHRwczovL2FwaS5lbmRvcmxhYnMuY29tJyAtLW5hbWVzcGFjZSAnc3BpZGVybWFuJyAtLWFwaS1rZXkgJ1BFUEUnIC0tYXBpLXNlY3JldCAnUEFQQScgYWktYXVkaXQgY29kZXgiCgpbW2hvb2tzLlVzZXJQcm9tcHRTdWJtaXRdXQpbW2hvb2tzLlVzZXJQcm9tcHRTdWJtaXQuaG9va3NdXQp0eXBlID0gImNvbW1hbmQiCmNvbW1hbmQgPSAiRU5ET1JfQUlfQVVESVRfQ0FDSEVfRU5BQkxFRD0ndHJ1ZScgXCIkSE9NRS8uZW5kb3JjdGwvZW5kb3JjdGxcIiAtLWFwaSAnaHR0cHM6Ly9hcGkuZW5kb3JsYWJzLmNvbScgLS1uYW1lc3BhY2UgJ3NwaWRlcm1hbicgLS1hcGkta2V5ICdQRVBFJyAtLWFwaS1zZWNyZXQgJ1BBUEEnIGFpLWF1ZGl0IGNvZGV4IgoKW1tob29rcy5QcmVUb29sVXNlXV0KbWF0Y2hlciA9ICIuKiIKW1tob29rcy5QcmVUb29sVXNlLmhvb2tzXV0KdHlwZSA9ICJjb21tYW5kIgpjb21tYW5kID0gIkVORE9SX0FJX0FVRElUX0NBQ0hFX0VOQUJMRUQ9J3RydWUnIFwiJEhPTUUvLmVuZG9yY3RsL2VuZG9yY3RsXCIgLS1hcGkgJ2h0dHBzOi8vYXBpLmVuZG9ybGFicy5jb20nIC0tbmFtZXNwYWNlICdzcGlkZXJtYW4nIC0tYXBpLWtleSAnUEVQRScgLS1hcGktc2VjcmV0ICdQQVBBJyBhaS1hdWRpdCBjb2RleCIKCltbaG9va3MuUGVybWlzc2lvblJlcXVlc3RdXQptYXRjaGVyID0gIi4qIgpbW2hvb2tzLlBlcm1pc3Npb25SZXF1ZXN0Lmhvb2tzXV0KdHlwZSA9ICJjb21tYW5kIgpjb21tYW5kID0gIkVORE9SX0FJX0FVRElUX0NBQ0hFX0VOQUJMRUQ9J3RydWUnIFwiJEhPTUUvLmVuZG9yY3RsL2VuZG9yY3RsXCIgLS1hcGkgJ2h0dHBzOi8vYXBpLmVuZG9ybGFicy5jb20nIC0tbmFtZXNwYWNlICdzcGlkZXJtYW4nIC0tYXBpLWtleSAnUEVQRScgLS1hcGktc2VjcmV0ICdQQVBBJyBhaS1hdWRpdCBjb2RleCIKCltbaG9va3MuUG9zdFRvb2xVc2VdXQptYXRjaGVyID0gIi4qIgpbW2hvb2tzLlBvc3RUb29sVXNlLmhvb2tzXV0KdHlwZSA9ICJjb21tYW5kIgpjb21tYW5kID0gIkVORE9SX0FJX0FVRElUX0NBQ0hFX0VOQUJMRUQ9J3RydWUnIFwiJEhPTUUvLmVuZG9yY3RsL2VuZG9yY3RsXCIgLS1hcGkgJ2h0dHBzOi8vYXBpLmVuZG9ybGFicy5jb20nIC0tbmFtZXNwYWNlICdzcGlkZXJtYW4nIC0tYXBpLWtleSAnUEVQRScgLS1hcGktc2VjcmV0ICdQQVBBJyBhaS1hdWRpdCBjb2RleCIKCltbaG9va3MuU3RvcF1dCltbaG9va3MuU3RvcC5ob29rc11dCnR5cGUgPSAiY29tbWFuZCIKY29tbWFuZCA9ICJFTkRPUl9BSV9BVURJVF9DQUNIRV9FTkFCTEVEPSd0cnVlJyBcIiRIT01FLy5lbmRvcmN0bC9lbmRvcmN0bFwiIC0tYXBpICdodHRwczovL2FwaS5lbmRvcmxhYnMuY29tJyAtLW5hbWVzcGFjZSAnc3BpZGVybWFuJyAtLWFwaS1rZXkgJ1BFUEUnIC0tYXBpLXNlY3JldCAnUEFQQScgYWktYXVkaXQgY29kZXgi diff --git a/agent-governance/examples/codex/requirements.toml b/agent-governance/examples/codex/requirements.toml index a7b981f..bdcc6cd 100644 --- a/agent-governance/examples/codex/requirements.toml +++ b/agent-governance/examples/codex/requirements.toml @@ -11,7 +11,7 @@ hooks = true [[hooks.SessionStart]] [[hooks.SessionStart.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\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 = "# Non-blocking endorctl bootstrap, inlined into each agent's session hook.\n#\n# The foreground path never touches the network: it decides whether work is\n# needed, hands it to a detached subshell, and returns. The audit call appended\n# after this snippet then runs against whatever binary is already installed, so\n# a ~300 MB download never holds up a session start. Steady state costs one\n# executable test and one stamp-age test - no metadata request, no binary spawn.\n#\n# A machine with no endorctl yet skips its first audit rather than blocking on\n# the install; the download proceeds in the background and later sessions are\n# audited. Downloads resume across sessions, so a killed background job wastes\n# nothing, and a lock keeps concurrent agents from each pulling their own copy.\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\n\nneed=\nif [ ! -x \"$BIN\" ]; then\n need=1\nelif [ -z \"${ENDORCTL_SKIP_UPDATE:-}\" ]; then\n # A stamp newer than the TTL means we checked recently, so skip even the\n # metadata request - a steady-state session start does no network I/O at all.\n [ -f \"$STAMP\" ] && [ -z \"$(find \"$STAMP\" -mmin +\"$TTL\" 2>/dev/null)\" ] || need=1\nfi\n\nif [ -n \"$need\" ]; then\n (\n # Detached: the redirections release the hook's stdout pipe, which the agent\n # waits on, and ignoring HUP is nohup's effect without depending on nohup.\n # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one,\n # and subshell trap inheritance varies by shell). A process-group kill is\n # survivable regardless - the next session resumes the partial download.\n trap '' HUP\n trap - EXIT\n\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\n # One downloader per machine: concurrent agents (Claude, Cursor, Codex, or\n # several windows) would otherwise each pull their own copy and compete for\n # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is\n # judged by the partial's mtime, which curl advances as it writes - a fixed\n # timeout would kill a live download on a very slow link.\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 # Another process could have broken and retaken the lock between that mv and\n # this mkdir, so stand down unless the marker is ours - and do it before\n # arming the trap, or we would delete a lock we do not hold. Should this\n # still race, the cost is a duplicated download; the digest gate below is\n # what keeps a bad binary from ever being installed.\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n # INT/TERM route through exit rather than cleaning up in place: a signal trap\n # resumes the script when it returns, which would drop the lock while the\n # download carried on. This way the EXIT trap does the one cleanup.\n # A trap does not run until the command in progress returns, so a signal\n # arriving mid-transfer takes effect when curl drains - correct, since curl\n # still owns the partial until then. Killing with -9 skips the trap and\n # leaves the lock behind; the staleness check above is what recovers that.\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n\n # Leftovers from the previous mktemp-based scheme, and from any run killed\n # before it could clean up. Age-gated so nothing in flight is deleted.\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n\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 # Validate the digest up front - a download it could not gate is wasted bandwidth.\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n\n # Pin the partial to the digest it is being built for. endorctl is rebuilt\n # roughly daily, so a partial spanning two builds could never verify - if the\n # expected digest moved while it sat on disk, start over rather than resume\n # into a mismatch that would repeat every session.\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\n # A complete-but-uninstalled partial is possible if a previous run was killed\n # between the download and the swap, so verify before refetching.\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n # Resume with an explicit closed range, not `curl -C -`. The download\n # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended\n # one (bytes=A-) with the whole file - and the open form is what -C - sends,\n # so it fails outright with \"server doesn't seem to support byte ranges\".\n # Asking for the closed form means knowing the total length up front.\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 # No length means nothing to resume against; a partial at or past full\n # length that failed its digest is corrupt (two racing downloaders can do\n # it). Either way the only way forward is to start over.\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n # Recomputed per attempt: one that dies midway still leaves a correct\n # prefix on disk, so the next attempt continues from there.\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 # No --max-time: off the critical path, a genuinely slow link should be\n # allowed to finish. --speed-limit aborts a stalled transfer instead.\n # $rng is deliberately unquoted - it is either empty or two words.\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 # Also catches a server that ignored the range and resent the whole body:\n # the partial ends up over-long, fails here, and is rebuilt from scratch.\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n\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\n# Non-blocking endorctl bootstrap, inlined into each agent's session hook.\n#\n# The foreground path never touches the network: it decides whether work is\n# needed, hands it to a detached subshell, and returns. The audit call appended\n# after this snippet then runs against whatever binary is already installed, so\n# a ~300 MB download never holds up a session start. Steady state costs one\n# executable test and one stamp-age test - no metadata request, no binary spawn.\n#\n# A machine with no endorctl yet skips its first audit rather than blocking on\n# the install; the download proceeds in the background and later sessions are\n# audited. Downloads resume across sessions, so a killed background job wastes\n# nothing, and a lock keeps concurrent agents from each pulling their own copy.\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\n\nneed=\nif [ ! -x \"$BIN\" ]; then\n need=1\nelif [ -z \"${ENDORCTL_SKIP_UPDATE:-}\" ]; then\n # A stamp newer than the TTL means we checked recently, so skip even the\n # metadata request - a steady-state session start does no network I/O at all.\n [ -f \"$STAMP\" ] && [ -z \"$(find \"$STAMP\" -mmin +\"$TTL\" 2>/dev/null)\" ] || need=1\nfi\n\nif [ -n \"$need\" ]; then\n (\n # Detached: the redirections release the hook's stdout pipe, which the agent\n # waits on, and ignoring HUP is nohup's effect without depending on nohup.\n # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one,\n # and subshell trap inheritance varies by shell). A process-group kill is\n # survivable regardless - the next session resumes the partial download.\n trap '' HUP\n trap - EXIT\n\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\n # One downloader per machine: concurrent agents (Claude, Cursor, Codex, or\n # several windows) would otherwise each pull their own copy and compete for\n # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is\n # judged by the partial's mtime, which curl advances as it writes - a fixed\n # timeout would kill a live download on a very slow link.\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 # Another process could have broken and retaken the lock between that mv and\n # this mkdir, so stand down unless the marker is ours - and do it before\n # arming the trap, or we would delete a lock we do not hold. Should this\n # still race, the cost is a duplicated download; the digest gate below is\n # what keeps a bad binary from ever being installed.\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n # INT/TERM route through exit rather than cleaning up in place: a signal trap\n # resumes the script when it returns, which would drop the lock while the\n # download carried on. This way the EXIT trap does the one cleanup.\n # A trap does not run until the command in progress returns, so a signal\n # arriving mid-transfer takes effect when curl drains - correct, since curl\n # still owns the partial until then. Killing with -9 skips the trap and\n # leaves the lock behind; the staleness check above is what recovers that.\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n\n # Leftovers from the previous mktemp-based scheme, and from any run killed\n # before it could clean up. Age-gated so nothing in flight is deleted.\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n\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 # Validate the digest up front - a download it could not gate is wasted bandwidth.\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n\n # Pin the partial to the digest it is being built for. endorctl is rebuilt\n # roughly daily, so a partial spanning two builds could never verify - if the\n # expected digest moved while it sat on disk, start over rather than resume\n # into a mismatch that would repeat every session.\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\n # A complete-but-uninstalled partial is possible if a previous run was killed\n # between the download and the swap, so verify before refetching.\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n # Resume with an explicit closed range, not `curl -C -`. The download\n # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended\n # one (bytes=A-) with the whole file - and the open form is what -C - sends,\n # so it fails outright with \"server doesn't seem to support byte ranges\".\n # Asking for the closed form means knowing the total length up front.\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 # No length means nothing to resume against; a partial at or past full\n # length that failed its digest is corrupt (two racing downloaders can do\n # it). Either way the only way forward is to start over.\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n # Recomputed per attempt: one that dies midway still leaves a correct\n # prefix on disk, so the next attempt continues from there.\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 # No --max-time: off the critical path, a genuinely slow link should be\n # allowed to finish. --speed-limit aborts a stalled transfer instead.\n # $rng is deliberately unquoted - it is either empty or two words.\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 # Also catches a server that ignored the range and resent the whole body:\n # the partial ends up over-long, fails here, and is rebuilt from scratch.\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n\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 we checked recently, so skip even the + # metadata request - a steady-state session start does no network I/O at all. + [ -f "$STAMP" ] && [ -z "$(find "$STAMP" -mmin +"$TTL" 2>/dev/null)" ] || need=1 +fi + +if [ -n "$need" ]; then + ( + # Detached: the redirections release the hook's stdout pipe, which the agent + # waits on, and ignoring HUP is nohup's effect without depending on nohup. + # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one, + # and subshell trap inheritance varies by shell). A process-group kill is + # survivable regardless - the next session resumes the partial download. + 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: concurrent agents (Claude, Cursor, Codex, or + # several windows) would otherwise each pull their own copy and compete for + # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is + # judged by the partial's mtime, which curl advances as it writes - a fixed + # timeout would kill a live download on a very slow link. + 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 + # Another process could have broken and retaken the lock between that mv and + # this mkdir, so stand down unless the marker is ours - and do it before + # arming the trap, or we would delete a lock we do not hold. Should this + # still race, the cost is a duplicated download; the digest gate below is + # what keeps a bad binary from ever being installed. + 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 rather than cleaning up in place: a signal trap + # resumes the script when it returns, which would drop the lock while the + # download carried on. This way the EXIT trap does the one cleanup. + # A trap does not run until the command in progress returns, so a signal + # arriving mid-transfer takes effect when curl drains - correct, since curl + # still owns the partial until then. Killing with -9 skips the trap and + # leaves the lock behind; the staleness check above is what recovers that. + trap 'rm -rf "$LOCK"' EXIT + trap 'exit 1' INT TERM + + # Leftovers from the previous mktemp-based scheme, and from any run killed + # before it could clean up. Age-gated so nothing in flight is deleted. 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 + # Validate the digest up front - a download it could not gate is wasted bandwidth. + [ ${#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, so a partial spanning two builds could never verify - if the + # expected digest moved while it sat on disk, start over rather than resume + # into a mismatch that would repeat every session. + if [ ! -f "$SHAF" ] || [ "$(cat "$SHAF" 2>/dev/null)" != "$expected_sha" ]; then + rm -f "$PART" + printf '%s\n' "$expected_sha" > "$SHAF" || exit 0 + fi + + # A complete-but-uninstalled partial is possible if a previous run was killed + # between the download and the swap, so verify before refetching. + if [ ! -f "$PART" ] || [ "$(sha256 "$PART")" != "$expected_sha" ]; then + # Resume with an explicit closed range, not `curl -C -`. The download + # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended + # one (bytes=A-) with the whole file - and the open form is what -C - sends, + # so it fails outright with "server doesn't seem to support byte ranges". + # Asking for the closed form means knowing the total length up front. + 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 means nothing to resume against; a partial at or past full + # length that failed its digest is corrupt (two racing downloaders can do + # it). Either way the only way forward is to 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 still leaves a correct + # prefix on disk, so the next attempt continues from there. + 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 genuinely slow link should be + # allowed to finish. --speed-limit aborts a stalled transfer instead. + # $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 + # Also catches a server that ignored the range and resent the whole body: + # the partial ends up over-long, fails here, and is rebuilt from scratch. + [ "$(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 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 ] From 5d20b2e83ead9f958e51d9adab91da9397bfb243 Mon Sep 17 00:00:00 2001 From: George Apostolopoulos Date: Thu, 30 Jul 2026 21:53:38 -0700 Subject: [PATCH 2/3] agent-governance: trim bootstrap comments and strip them when inlining The bootstrap is embedded in every session hook and base64'd into the Windows form, so its commentary landed in every generated profile - the Claude SessionStart command had grown to 7.9 KB, mostly comments. Cut download_endorctl.sh's commentary to the few non-obvious points (148 -> 119 lines) and have render.sh drop whole-line comments and blank lines when inlining either bootstrap. SessionStart is now 3,796 bytes with no comment lines. The longer rationale moves into the design note, which also picks up the signal/trap and exit-0 reasoning that was previously only in the source. Windows artifacts change too: download_endorctl.ps1 is untouched, but its inlined copy is now comment-stripped like the POSIX one. Verified the stripped PowerShell is intact - no block comments, no backtick continuations, and the one '#' inside a string literal sits on a line that is itself a comment. Co-Authored-By: Claude Opus 5 (1M context) --- ...26-07-30-nonblocking-endorctl-bootstrap.md | 20 +++++ .../com.anthropic.claudecode.mobileconfig | 77 +---------------- .../examples/claude/settings.json | 2 +- .../examples/claude/settings.windows.json | 2 +- .../codex/com.openai.codex.mobileconfig | 2 +- .../examples/codex/requirements.toml | 2 +- .../examples/codex/requirements.windows.toml | 2 +- agent-governance/examples/cursor/hooks.json | 2 +- .../examples/cursor/hooks.windows.json | 2 +- agent-governance/scripts/download_endorctl.sh | 86 ++++++------------- agent-governance/scripts/render.sh | 9 +- 11 files changed, 59 insertions(+), 147 deletions(-) diff --git a/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md b/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md index 44b8540..508d327 100644 --- a/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md +++ b/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md @@ -66,6 +66,12 @@ Five defects in the current script: and it is less acutely broken because it already has timeouts. - **No explicit marker for un-audited sessions** for now. Absence of events from a device is the admin's signal. Revisit later. +- **Keep the bootstrap's comments short, and strip them at render time.** The + script is embedded in every session hook and base64'd into the Windows form, + so its commentary lands in every generated profile. `render.sh` drops + whole-line comments and blank lines when inlining (`strip_src`), which took + the Claude `SessionStart` command from 7,879 to 3,796 bytes. The longer + rationale lives in this document rather than in the script. ## Target design @@ -132,6 +138,20 @@ Today's logic, hardened: range and resends the whole body, which would leave the partial over-long. - **Stamp on success only** (`$DIR/.update-check`), so a failed check retries on the next session rather than being suppressed for the full TTL. +- **Signals route through `exit`.** `trap 'exit 1' INT TERM` alongside + `trap 'rm -rf "$LOCK"' EXIT`, rather than cleaning up in the signal handler: a + signal trap *resumes* the script when it returns, which would drop the lock + while the download carried on. Note also that a trap does not fire until the + command in progress returns, so a signal arriving mid-transfer takes effect + when curl drains — correct, since curl still owns the partial until then. + A `kill -9` skips the trap entirely and leaks the lock; the staleness check is + what recovers that. +- **`exit 0`, never `exit 1`, when there is nothing to audit with.** The session + hook is composed as `bootstrap \n audit`, so exiting stops the audit call from + running against a missing binary, and exiting *zero* keeps the hook successful + so the agent shows the developer no error. Verified to behave identically in + all three of `render.sh`'s compositions, including Cursor's, where the + wrapper's `EXIT` trap still removes its stdin temp file. Verify SHA → `chmod +x` → atomic `mv` is unchanged from today. diff --git a/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig b/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig index 7f7c10d..6790def 100644 --- a/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig +++ b/agent-governance/examples/claude/com.anthropic.claudecode.mobileconfig @@ -89,43 +89,21 @@ command - # Non-blocking endorctl bootstrap, inlined into each agent's session hook. -# -# The foreground path never touches the network: it decides whether work is -# needed, hands it to a detached subshell, and returns. The audit call appended -# after this snippet then runs against whatever binary is already installed, so -# a ~300 MB download never holds up a session start. Steady state costs one -# executable test and one stamp-age test - no metadata request, no binary spawn. -# -# A machine with no endorctl yet skips its first audit rather than blocking on -# the install; the download proceeds in the background and later sessions are -# audited. Downloads resume across sessions, so a killed background job wastes -# nothing, and a lock keeps concurrent agents from each pulling their own copy. -BIN="${HOME}/.endorctl/endorctl" + BIN="${HOME}/.endorctl/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 we checked recently, so skip even the - # metadata request - a steady-state session start does no network I/O at all. [ -f "$STAMP" ] && [ -z "$(find "$STAMP" -mmin +"$TTL" 2>/dev/null)" ] || need=1 fi - if [ -n "$need" ]; then ( - # Detached: the redirections release the hook's stdout pipe, which the agent - # waits on, and ignoring HUP is nohup's effect without depending on nohup. - # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one, - # and subshell trap inheritance varies by shell). A process-group kill is - # survivable regardless - the next session resumes the partial download. trap '' HUP trap - EXIT - LOCK="$DIR/.update.lock" PART="$DIR/.endorctl.part" SHAF="$DIR/.endorctl.sha" @@ -134,39 +112,17 @@ if [ -n "$need" ]; then else shasum -a 256 "$1" | awk '{print $1}'; fi } mkdir -p "$DIR" || exit 0 - - # One downloader per machine: concurrent agents (Claude, Cursor, Codex, or - # several windows) would otherwise each pull their own copy and compete for - # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is - # judged by the partial's mtime, which curl advances as it writes - a fixed - # timeout would kill a live download on a very slow link. 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 - # Another process could have broken and retaken the lock between that mv and - # this mkdir, so stand down unless the marker is ours - and do it before - # arming the trap, or we would delete a lock we do not hold. Should this - # still race, the cost is a duplicated download; the digest gate below is - # what keeps a bad binary from ever being installed. 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 rather than cleaning up in place: a signal trap - # resumes the script when it returns, which would drop the lock while the - # download carried on. This way the EXIT trap does the one cleanup. - # A trap does not run until the command in progress returns, so a signal - # arriving mid-transfer takes effect when curl drains - correct, since curl - # still owns the partial until then. Killing with -9 skips the trap and - # leaves the lock behind; the staleness check above is what recovers that. trap 'rm -rf "$LOCK"' EXIT trap 'exit 1' INT TERM - - # Leftovers from the previous mktemp-based scheme, and from any run killed - # before it could clean up. Age-gated so nothing in flight is deleted. find "$DIR" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null - 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}" @@ -176,75 +132,44 @@ if [ -n "$need" ]; then 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 - # Validate the digest up front - a download it could not gate is wasted bandwidth. [ ${#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, so a partial spanning two builds could never verify - if the - # expected digest moved while it sat on disk, start over rather than resume - # into a mismatch that would repeat every session. if [ ! -f "$SHAF" ] || [ "$(cat "$SHAF" 2>/dev/null)" != "$expected_sha" ]; then rm -f "$PART" printf '%s\n' "$expected_sha" > "$SHAF" || exit 0 fi - - # A complete-but-uninstalled partial is possible if a previous run was killed - # between the download and the swap, so verify before refetching. if [ ! -f "$PART" ] || [ "$(sha256 "$PART")" != "$expected_sha" ]; then - # Resume with an explicit closed range, not `curl -C -`. The download - # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended - # one (bytes=A-) with the whole file - and the open form is what -C - sends, - # so it fails outright with "server doesn't seem to support byte ranges". - # Asking for the closed form means knowing the total length up front. 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 means nothing to resume against; a partial at or past full - # length that failed its digest is corrupt (two racing downloaders can do - # it). Either way the only way forward is to 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 still leaves a correct - # prefix on disk, so the next attempt continues from there. 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 genuinely slow link should be - # allowed to finish. --speed-limit aborts a stalled transfer instead. - # $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 - # Also catches a server that ignored the range and resent the whole body: - # the partial ends up over-long, fails here, and is rebuilt from scratch. [ "$(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 - -# Nothing to audit with yet: skip this session rather than block on the install. -# Exiting 0 (not 1) keeps the hook successful and stops the appended audit call -# from running against a binary that is not there. [ -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 diff --git a/agent-governance/examples/claude/settings.json b/agent-governance/examples/claude/settings.json index b7f9ce9..6a7850a 100644 --- a/agent-governance/examples/claude/settings.json +++ b/agent-governance/examples/claude/settings.json @@ -12,7 +12,7 @@ "hooks": [ { "type": "command", - "command": "# Non-blocking endorctl bootstrap, inlined into each agent's session hook.\n#\n# The foreground path never touches the network: it decides whether work is\n# needed, hands it to a detached subshell, and returns. The audit call appended\n# after this snippet then runs against whatever binary is already installed, so\n# a ~300 MB download never holds up a session start. Steady state costs one\n# executable test and one stamp-age test - no metadata request, no binary spawn.\n#\n# A machine with no endorctl yet skips its first audit rather than blocking on\n# the install; the download proceeds in the background and later sessions are\n# audited. Downloads resume across sessions, so a killed background job wastes\n# nothing, and a lock keeps concurrent agents from each pulling their own copy.\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\n\nneed=\nif [ ! -x \"$BIN\" ]; then\n need=1\nelif [ -z \"${ENDORCTL_SKIP_UPDATE:-}\" ]; then\n # A stamp newer than the TTL means we checked recently, so skip even the\n # metadata request - a steady-state session start does no network I/O at all.\n [ -f \"$STAMP\" ] && [ -z \"$(find \"$STAMP\" -mmin +\"$TTL\" 2>/dev/null)\" ] || need=1\nfi\n\nif [ -n \"$need\" ]; then\n (\n # Detached: the redirections release the hook's stdout pipe, which the agent\n # waits on, and ignoring HUP is nohup's effect without depending on nohup.\n # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one,\n # and subshell trap inheritance varies by shell). A process-group kill is\n # survivable regardless - the next session resumes the partial download.\n trap '' HUP\n trap - EXIT\n\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\n # One downloader per machine: concurrent agents (Claude, Cursor, Codex, or\n # several windows) would otherwise each pull their own copy and compete for\n # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is\n # judged by the partial's mtime, which curl advances as it writes - a fixed\n # timeout would kill a live download on a very slow link.\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 # Another process could have broken and retaken the lock between that mv and\n # this mkdir, so stand down unless the marker is ours - and do it before\n # arming the trap, or we would delete a lock we do not hold. Should this\n # still race, the cost is a duplicated download; the digest gate below is\n # what keeps a bad binary from ever being installed.\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n # INT/TERM route through exit rather than cleaning up in place: a signal trap\n # resumes the script when it returns, which would drop the lock while the\n # download carried on. This way the EXIT trap does the one cleanup.\n # A trap does not run until the command in progress returns, so a signal\n # arriving mid-transfer takes effect when curl drains - correct, since curl\n # still owns the partial until then. Killing with -9 skips the trap and\n # leaves the lock behind; the staleness check above is what recovers that.\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n\n # Leftovers from the previous mktemp-based scheme, and from any run killed\n # before it could clean up. Age-gated so nothing in flight is deleted.\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n\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 # Validate the digest up front - a download it could not gate is wasted bandwidth.\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n\n # Pin the partial to the digest it is being built for. endorctl is rebuilt\n # roughly daily, so a partial spanning two builds could never verify - if the\n # expected digest moved while it sat on disk, start over rather than resume\n # into a mismatch that would repeat every session.\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\n # A complete-but-uninstalled partial is possible if a previous run was killed\n # between the download and the swap, so verify before refetching.\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n # Resume with an explicit closed range, not `curl -C -`. The download\n # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended\n # one (bytes=A-) with the whole file - and the open form is what -C - sends,\n # so it fails outright with \"server doesn't seem to support byte ranges\".\n # Asking for the closed form means knowing the total length up front.\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 # No length means nothing to resume against; a partial at or past full\n # length that failed its digest is corrupt (two racing downloaders can do\n # it). Either way the only way forward is to start over.\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n # Recomputed per attempt: one that dies midway still leaves a correct\n # prefix on disk, so the next attempt continues from there.\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 # No --max-time: off the critical path, a genuinely slow link should be\n # allowed to finish. --speed-limit aborts a stalled transfer instead.\n # $rng is deliberately unquoted - it is either empty or two words.\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 # Also catches a server that ignored the range and resent the whole body:\n # the partial ends up over-long, fails here, and is rebuilt from scratch.\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n\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)\" ] || 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)\" ] || need=1\nfi\n\nif [ -n \"$need\" ]; then\n (\n # Detached: the redirections release the hook's stdout pipe, which the agent\n # waits on, and ignoring HUP is nohup's effect without depending on nohup.\n # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one,\n # and subshell trap inheritance varies by shell). A process-group kill is\n # survivable regardless - the next session resumes the partial download.\n trap '' HUP\n trap - EXIT\n\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\n # One downloader per machine: concurrent agents (Claude, Cursor, Codex, or\n # several windows) would otherwise each pull their own copy and compete for\n # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is\n # judged by the partial's mtime, which curl advances as it writes - a fixed\n # timeout would kill a live download on a very slow link.\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 # Another process could have broken and retaken the lock between that mv and\n # this mkdir, so stand down unless the marker is ours - and do it before\n # arming the trap, or we would delete a lock we do not hold. Should this\n # still race, the cost is a duplicated download; the digest gate below is\n # what keeps a bad binary from ever being installed.\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n # INT/TERM route through exit rather than cleaning up in place: a signal trap\n # resumes the script when it returns, which would drop the lock while the\n # download carried on. This way the EXIT trap does the one cleanup.\n # A trap does not run until the command in progress returns, so a signal\n # arriving mid-transfer takes effect when curl drains - correct, since curl\n # still owns the partial until then. Killing with -9 skips the trap and\n # leaves the lock behind; the staleness check above is what recovers that.\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n\n # Leftovers from the previous mktemp-based scheme, and from any run killed\n # before it could clean up. Age-gated so nothing in flight is deleted.\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n\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 # Validate the digest up front - a download it could not gate is wasted bandwidth.\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n\n # Pin the partial to the digest it is being built for. endorctl is rebuilt\n # roughly daily, so a partial spanning two builds could never verify - if the\n # expected digest moved while it sat on disk, start over rather than resume\n # into a mismatch that would repeat every session.\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\n # A complete-but-uninstalled partial is possible if a previous run was killed\n # between the download and the swap, so verify before refetching.\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n # Resume with an explicit closed range, not `curl -C -`. The download\n # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended\n # one (bytes=A-) with the whole file - and the open form is what -C - sends,\n # so it fails outright with \"server doesn't seem to support byte ranges\".\n # Asking for the closed form means knowing the total length up front.\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 # No length means nothing to resume against; a partial at or past full\n # length that failed its digest is corrupt (two racing downloaders can do\n # it). Either way the only way forward is to start over.\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n # Recomputed per attempt: one that dies midway still leaves a correct\n # prefix on disk, so the next attempt continues from there.\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 # No --max-time: off the critical path, a genuinely slow link should be\n # allowed to finish. --speed-limit aborts a stalled transfer instead.\n # $rng is deliberately unquoted - it is either empty or two words.\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 # Also catches a server that ignored the range and resent the whole body:\n # the partial ends up over-long, fails here, and is rebuilt from scratch.\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n\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)\" ] || 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\n# Non-blocking endorctl bootstrap, inlined into each agent's session hook.\n#\n# The foreground path never touches the network: it decides whether work is\n# needed, hands it to a detached subshell, and returns. The audit call appended\n# after this snippet then runs against whatever binary is already installed, so\n# a ~300 MB download never holds up a session start. Steady state costs one\n# executable test and one stamp-age test - no metadata request, no binary spawn.\n#\n# A machine with no endorctl yet skips its first audit rather than blocking on\n# the install; the download proceeds in the background and later sessions are\n# audited. Downloads resume across sessions, so a killed background job wastes\n# nothing, and a lock keeps concurrent agents from each pulling their own copy.\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\n\nneed=\nif [ ! -x \"$BIN\" ]; then\n need=1\nelif [ -z \"${ENDORCTL_SKIP_UPDATE:-}\" ]; then\n # A stamp newer than the TTL means we checked recently, so skip even the\n # metadata request - a steady-state session start does no network I/O at all.\n [ -f \"$STAMP\" ] && [ -z \"$(find \"$STAMP\" -mmin +\"$TTL\" 2>/dev/null)\" ] || need=1\nfi\n\nif [ -n \"$need\" ]; then\n (\n # Detached: the redirections release the hook's stdout pipe, which the agent\n # waits on, and ignoring HUP is nohup's effect without depending on nohup.\n # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one,\n # and subshell trap inheritance varies by shell). A process-group kill is\n # survivable regardless - the next session resumes the partial download.\n trap '' HUP\n trap - EXIT\n\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\n # One downloader per machine: concurrent agents (Claude, Cursor, Codex, or\n # several windows) would otherwise each pull their own copy and compete for\n # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is\n # judged by the partial's mtime, which curl advances as it writes - a fixed\n # timeout would kill a live download on a very slow link.\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 # Another process could have broken and retaken the lock between that mv and\n # this mkdir, so stand down unless the marker is ours - and do it before\n # arming the trap, or we would delete a lock we do not hold. Should this\n # still race, the cost is a duplicated download; the digest gate below is\n # what keeps a bad binary from ever being installed.\n echo \"$$\" > \"$LOCK/owner\" 2>/dev/null || { rmdir \"$LOCK\" 2>/dev/null; exit 0; }\n [ \"$(cat \"$LOCK/owner\" 2>/dev/null)\" = \"$$\" ] || exit 0\n # INT/TERM route through exit rather than cleaning up in place: a signal trap\n # resumes the script when it returns, which would drop the lock while the\n # download carried on. This way the EXIT trap does the one cleanup.\n # A trap does not run until the command in progress returns, so a signal\n # arriving mid-transfer takes effect when curl drains - correct, since curl\n # still owns the partial until then. Killing with -9 skips the trap and\n # leaves the lock behind; the staleness check above is what recovers that.\n trap 'rm -rf \"$LOCK\"' EXIT\n trap 'exit 1' INT TERM\n\n # Leftovers from the previous mktemp-based scheme, and from any run killed\n # before it could clean up. Age-gated so nothing in flight is deleted.\n find \"$DIR\" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null\n\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 # Validate the digest up front - a download it could not gate is wasted bandwidth.\n [ ${#expected_sha} -eq 64 ] || exit 0\n case \"$expected_sha\" in *[!0-9a-f]*) exit 0 ;; esac\n\n if [ -n \"$current\" ] && [ \"$current\" = \"$latest\" ]; then\n rm -f \"$PART\" \"$SHAF\"\n : > \"$STAMP\"\n exit 0\n fi\n\n # Pin the partial to the digest it is being built for. endorctl is rebuilt\n # roughly daily, so a partial spanning two builds could never verify - if the\n # expected digest moved while it sat on disk, start over rather than resume\n # into a mismatch that would repeat every session.\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\n # A complete-but-uninstalled partial is possible if a previous run was killed\n # between the download and the swap, so verify before refetching.\n if [ ! -f \"$PART\" ] || [ \"$(sha256 \"$PART\")\" != \"$expected_sha\" ]; then\n # Resume with an explicit closed range, not `curl -C -`. The download\n # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended\n # one (bytes=A-) with the whole file - and the open form is what -C - sends,\n # so it fails outright with \"server doesn't seem to support byte ranges\".\n # Asking for the closed form means knowing the total length up front.\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 # No length means nothing to resume against; a partial at or past full\n # length that failed its digest is corrupt (two racing downloaders can do\n # it). Either way the only way forward is to start over.\n if [ -z \"$total\" ] || [ \"$size\" -ge \"$total\" ]; then rm -f \"$PART\"; fi\n\n n=0; ok=\n while [ \"$n\" -lt 3 ]; do\n n=$((n + 1))\n # Recomputed per attempt: one that dies midway still leaves a correct\n # prefix on disk, so the next attempt continues from there.\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 # No --max-time: off the critical path, a genuinely slow link should be\n # allowed to finish. --speed-limit aborts a stalled transfer instead.\n # $rng is deliberately unquoted - it is either empty or two words.\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 # Also catches a server that ignored the range and resent the whole body:\n # the partial ends up over-long, fails here, and is rebuilt from scratch.\n [ \"$(sha256 \"$PART\")\" = \"$expected_sha\" ] || { rm -f \"$PART\" \"$SHAF\"; exit 0; }\n fi\n\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\"\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)" ] || need=1 fi if [ -n "$need" ]; then ( - # Detached: the redirections release the hook's stdout pipe, which the agent - # waits on, and ignoring HUP is nohup's effect without depending on nohup. - # Clear any EXIT trap inherited from the caller (Cursor's wrapper sets one, - # and subshell trap inheritance varies by shell). A process-group kill is - # survivable regardless - the next session resumes the partial download. + # 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 @@ -44,36 +33,22 @@ if [ -n "$need" ]; then } mkdir -p "$DIR" || exit 0 - # One downloader per machine: concurrent agents (Claude, Cursor, Codex, or - # several windows) would otherwise each pull their own copy and compete for - # the same scarce bandwidth. mkdir is the atomic primitive. Staleness is - # judged by the partial's mtime, which curl advances as it writes - a fixed - # timeout would kill a live download on a very slow link. + # 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 - # Another process could have broken and retaken the lock between that mv and - # this mkdir, so stand down unless the marker is ours - and do it before - # arming the trap, or we would delete a lock we do not hold. Should this - # still race, the cost is a duplicated download; the digest gate below is - # what keeps a bad binary from ever being installed. + # 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 rather than cleaning up in place: a signal trap - # resumes the script when it returns, which would drop the lock while the - # download carried on. This way the EXIT trap does the one cleanup. - # A trap does not run until the command in progress returns, so a signal - # arriving mid-transfer takes effect when curl drains - correct, since curl - # still owns the partial until then. Killing with -9 skips the trap and - # leaves the lock behind; the staleness check above is what recovers that. + # 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 any run killed - # before it could clean up. Age-gated so nothing in flight is deleted. + # Leftovers from the previous mktemp-based scheme, and from killed runs. find "$DIR" -name 'endorctl-download-*' -mmin +60 -delete 2>/dev/null case "$(uname -s)" in Darwin) os=macos ;; Linux) os=linux ;; *) exit 0 ;; esac @@ -85,7 +60,6 @@ if [ -n "$need" ]; then 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 - # Validate the digest up front - a download it could not gate is wasted bandwidth. [ ${#expected_sha} -eq 64 ] || exit 0 case "$expected_sha" in *[!0-9a-f]*) exit 0 ;; esac @@ -95,52 +69,41 @@ if [ -n "$need" ]; then exit 0 fi - # Pin the partial to the digest it is being built for. endorctl is rebuilt - # roughly daily, so a partial spanning two builds could never verify - if the - # expected digest moved while it sat on disk, start over rather than resume - # into a mismatch that would repeat every session. + # 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 - # A complete-but-uninstalled partial is possible if a previous run was killed - # between the download and the swap, so verify before refetching. if [ ! -f "$PART" ] || [ "$(sha256 "$PART")" != "$expected_sha" ]; then - # Resume with an explicit closed range, not `curl -C -`. The download - # endpoint answers a closed range (bytes=A-B) with a 206, but an open-ended - # one (bytes=A-) with the whole file - and the open form is what -C - sends, - # so it fails outright with "server doesn't seem to support byte ranges". - # Asking for the closed form means knowing the total length up front. + # 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 means nothing to resume against; a partial at or past full - # length that failed its digest is corrupt (two racing downloaders can do - # it). Either way the only way forward is to start over. + # 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 still leaves a correct - # prefix on disk, so the next attempt continues from there. + # 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 genuinely slow link should be - # allowed to finish. --speed-limit aborts a stalled transfer instead. + # 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 - # Also catches a server that ignored the range and resent the whole body: - # the partial ends up over-long, fails here, and is rebuilt from scratch. [ "$(sha256 "$PART")" = "$expected_sha" ] || { rm -f "$PART" "$SHAF"; exit 0; } fi @@ -151,7 +114,6 @@ if [ -n "$need" ]; then ) >/dev/null 2>&1 /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. From 426ec154c2eefb79471aca6b0020d27763f5761c Mon Sep 17 00:00:00 2001 From: George Apostolopoulos Date: Thu, 30 Jul 2026 22:55:43 -0700 Subject: [PATCH 3/3] agent-governance: drop the design note from the branch Keep the change to shipped scripts, docs and tests. The essential "why" that a maintainer needs is already in download_endorctl.sh as short comments - not using curl -C -, mtime-based lock staleness, exit 0 rather than exit 1, and signals routing through exit - so nothing load-bearing is lost. Also clears the three references that would otherwise dangle: the pointer in download_endorctl.sh's header, the one in render.sh's strip_src comment, and "design notes" in the README's repository layout. Co-Authored-By: Claude Opus 5 (1M context) --- agent-governance/README.md | 2 +- ...26-07-30-nonblocking-endorctl-bootstrap.md | 189 ------------------ agent-governance/scripts/download_endorctl.sh | 2 +- agent-governance/scripts/render.sh | 2 +- 4 files changed, 3 insertions(+), 192 deletions(-) delete mode 100644 agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md diff --git a/agent-governance/README.md b/agent-governance/README.md index 852886c..b6eab47 100644 --- a/agent-governance/README.md +++ b/agent-governance/README.md @@ -156,7 +156,7 @@ scripts/ 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, design notes, the support matrix +docs/ deployment runbooks + the support matrix ``` ## Tests diff --git a/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md b/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md deleted file mode 100644 index 508d327..0000000 --- a/agent-governance/docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md +++ /dev/null @@ -1,189 +0,0 @@ -# Design: non-blocking `endorctl` bootstrap - -Status: implemented -Date: 2026-07-30 -Scope: `agent-governance/scripts/download_endorctl.sh` (POSIX only this pass) - -## Problem - -Every SessionStart hook runs `download_endorctl.sh` inline and the agent blocks -until it returns. On slow networks this stalls agent startup, sometimes for -many minutes. - -Measured against `api.endorlabs.com` on 2026-07-30: - -| | | -| --- | --- | -| `endorctl_macos_arm64` size | 305,440,226 bytes (291 MiB) | -| Compression on the wire | none — server ignores `Accept-Encoding: gzip` | -| Range requests | closed (`bytes=A-B`) honored with `206`; open-ended (`bytes=A-`) answered with the **whole body**. `Accept-Ranges` is not advertised | -| Version / `Last-Modified` at the time | `v1.7.1085` / previous day | -| `endorctl --version` locally | ~0.77 s | - -291 MiB is roughly 24 s at 100 Mbps, 4 min at 10 Mbps, 20 min at 2 Mbps. The -binary appears to be rebuilt about daily, so this is not a first-run-only cost — -developers pay it on the first session of most days. - -Five defects in the current script: - -1. **No timeout ceiling.** Both curls (`download_endorctl.sh:10,23`) use - `-fsSL --retry 5 --retry-connrefused --retry-all-errors` with no - `--connect-timeout`, `--max-time`, or `--speed-limit`. curl has no default - transfer timeout, so a slow-but-alive link or a captive portal hangs the hook - indefinitely, and `--retry 5` multiplies it by up to 6. This is the direct - cause of the startup-blocking complaints. (`download_endorctl.ps1` does set - `-TimeoutSec 30`/`120` — the two paths disagree.) -2. **No resume.** A failure at 90% restarts from byte 0, up to five more times. - The server does support ranges, but only the closed form — see the Resume - note below for why the obvious `curl -C -` cannot be used here. -3. **Updates are on the critical path.** Even when a working binary is already - installed, the session waits for a newer one. -4. **Nothing serializes concurrent sessions.** Claude Code, Cursor, and Codex — - or three Claude windows — each download their own 291 MiB copy into their own - `mktemp` file, competing for the same scarce bandwidth. There is no lock. -5. **Failure is fail-closed and loud.** Every error path is `exit 1`, and - `render.sh:190` composes the session hook as `bootstrap \n audit`, so a - network hiccup yields both no audit event and a hook error shown to the - developer. The version check also runs every session even when there is - nothing to do; there is no "checked recently" stamp. - -## Decisions - -- **Scope: this repo only.** Server-side fixes (gzip the download, ship a - smaller binary) are the biggest single lever but belong to another team. -- **Background download is acceptable**, including on first run. One session - (plus any starting during the download window) runs un-audited on a fresh - machine. -- **Keep the updater inline** in the generated hook command rather than caching - it as `$HOME/.endorctl/update.sh`. Inlining roughly doubles the bootstrap's - size and makes the examples uglier, but the managed config stays the single - source of truth — which is the whole tamper-resistance story. A cached script - adds a refresh/staleness problem and hands the developer a file to neuter. -- **Update-check TTL: 24 h**, overridable by an env knob. Governance rules are - server-side and fetched at run time, so binary freshness is not urgent. -- **POSIX first; Windows is a follow-up.** `download_endorctl.ps1` needs a - different detach primitive and a different resume mechanism (see Follow-ups), - and it is less acutely broken because it already has timeouts. -- **No explicit marker for un-audited sessions** for now. Absence of events from - a device is the admin's signal. Revisit later. -- **Keep the bootstrap's comments short, and strip them at render time.** The - script is embedded in every session hook and base64'd into the Windows form, - so its commentary lands in every generated profile. `render.sh` drops - whole-line comments and blank lines when inlining (`strip_src`), which took - the Claude `SessionStart` command from 7,879 to 3,796 bytes. The longer - rationale lives in this document rather than in the script. - -## Target design - -### Foreground (inside the hook, blocking) - -```sh -BIN=$HOME/.endorctl/endorctl -if [ ! -x "$BIN" ]; then - spawn_background_installer - exit 0 # nothing to audit with; hook succeeds, audit line never runs -fi -if check_due && [ -z "$ENDORCTL_SKIP_UPDATE" ]; then - spawn_background_updater -fi -# fall through to the audit, using the binary already on disk -``` - -Steady-state foreground cost becomes one `[ -x ]` test plus one stamp-age test: -**zero network, zero binary spawn**, down from ~0.8 s plus an uncapped RTT. - -`exit 0` — not `exit 1` — is what skips the audit cleanly. It behaves -identically in all three composed forms in `render.sh`, including Cursor's, -where the `EXIT` trap still removes `$T`. - -### Background (detached subshell) - -Today's logic, hardened: - -- **Detach:** `( trap '' HUP; … ) >/dev/null 2>&1 -`, appending to the partial. The offset - is recomputed per attempt, so an attempt that dies midway still leaves a - correct prefix for the next one. Resume is also what makes a process-group kill - survivable, so the design does not depend on bulletproof detachment. -- **SHA-pin the partial:** record the expected SHA beside it (`$DIR/.endorctl.sha`). - The binary is rebuilt about daily, so a partial spanning two builds would fail - verification forever; if the server's SHA moved, discard the partial and start - fresh rather than retry into a permanent mismatch. -- **Discard a full-length partial that fails its digest.** A failed download - keeps its partial for the next session, which is right for a genuine partial - but wedges on a corrupt full-length one (two racing downloaders can produce - it) — every session would re-request a range past the end and fail the same - way for ever. The `HEAD` length makes this detectable: at or past full length - with a bad digest, start over. The same check covers a server that ignores the - range and resends the whole body, which would leave the partial over-long. -- **Stamp on success only** (`$DIR/.update-check`), so a failed check retries on - the next session rather than being suppressed for the full TTL. -- **Signals route through `exit`.** `trap 'exit 1' INT TERM` alongside - `trap 'rm -rf "$LOCK"' EXIT`, rather than cleaning up in the signal handler: a - signal trap *resumes* the script when it returns, which would drop the lock - while the download carried on. Note also that a trap does not fire until the - command in progress returns, so a signal arriving mid-transfer takes effect - when curl drains — correct, since curl still owns the partial until then. - A `kill -9` skips the trap entirely and leaks the lock; the staleness check is - what recovers that. -- **`exit 0`, never `exit 1`, when there is nothing to audit with.** The session - hook is composed as `bootstrap \n audit`, so exiting stops the audit call from - running against a missing binary, and exiting *zero* keeps the hook successful - so the agent shows the developer no error. Verified to behave identically in - all three of `render.sh`'s compositions, including Cursor's, where the - wrapper's `EXIT` trap still removes its stdin temp file. - -Verify SHA → `chmod +x` → atomic `mv` is unchanged from today. - -### Preserved behavior - -- `--skip-endorctl-update` keeps its current meaning: use the installed binary - as-is, no per-session version check, install only when missing. -- The existing age-gated sweep of `endorctl-download-*` leftovers still has a - job for pre-upgrade stragglers. -- SHA-256 verification before install is non-negotiable and unchanged. - -## Out of scope - -- Server-side compression or a smaller `endorctl`. -- MDM-side pre-provisioning of the binary (Jamf package / Intune Win32 app), - which would take the download off the laptop entirely. -- Any change to `render.sh`'s composition of the hook commands, beyond - regenerating output. - -## Follow-ups - -- **Windows parity.** `download_endorctl.ps1` needs `Start-Process powershell - -EncodedCommand … -WindowStyle Hidden` (re-encoding the updater from a - here-string) because `Start-Job` dies with its parent, plus a Range-header - resume loop replacing `Invoke-WebRequest -OutFile`. Note also that - `Invoke-WebRequest -TimeoutSec` is not a whole-transfer timeout, so today's - `120` does not bound a 291 MiB download. -- Reconsider an explicit signal for un-audited sessions. - -## Regeneration checklist - -Changing `download_endorctl.sh` changes every generated artifact. Regenerate all -eight `examples/` files with the demo credentials (`PEPE` / `PAPA` / namespace -`spiderman`) and the placeholder profile UUIDs already checked in, per the -commands in `agent-governance/README.md`. diff --git a/agent-governance/scripts/download_endorctl.sh b/agent-governance/scripts/download_endorctl.sh index e8f1d24..dc6e46c 100755 --- a/agent-governance/scripts/download_endorctl.sh +++ b/agent-governance/scripts/download_endorctl.sh @@ -2,7 +2,7 @@ # foreground decides what is needed and hands it to a detached background job, # so a ~300 MB download cannot hold up a session start. A machine with no # endorctl yet skips that one audit rather than waiting for the install. -# Rationale for the non-obvious parts: docs/design/2026-07-30-nonblocking-endorctl-bootstrap.md +# Comments are stripped when render.sh inlines this, so they cost nothing here. BIN="${HOME}/.endorctl/endorctl" DIR="${HOME}/.endorctl" STAMP="$DIR/.update-check" diff --git a/agent-governance/scripts/render.sh b/agent-governance/scripts/render.sh index 3b74892..f277813 100755 --- a/agent-governance/scripts/render.sh +++ b/agent-governance/scripts/render.sh @@ -120,7 +120,7 @@ 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 the script itself, or docs/design/, for why it does what it does. +# 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"