#!/bin/sh set -e # Quint Agent install script # Usage: curl -fsSL https://get.quintai.dev | sudo sh -s -- --token # --------------------------------------------------------------------------- # Banner # --------------------------------------------------------------------------- cat <<'BANNER' ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⠀⠀⠀⠀⣠⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⢀⣴⠛⡄⠀⣠⠞⠁⠀⠀⠀⠀⠀⠀⣠⣾⠟⠛⠛⠛⠿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠀⠀⠀⠀⠀⢀⣀⠀⠀⠀⠀⣿⡇⠀⠀⠀⠀⠀ ⠀⠀⠀⢀⣀⣤⣶⣋⣁⣀⣱⣞⣁⣀⣀⣀⡀⠀⠀⠀⢠⣿⠃⠀⠀⠀⠀⠀⢹⣷⠀⢸⣿⠀⠀⠀⠀⣿⠀⠀⢸⣿⠀⠀⣿⡧⠚⠛⠻⣿⡆⠀⠛⣿⡟⠛⠃⠀⠀⠀ ⠀⠀⠀⠈⠉⠉⠉⢉⣽⠟⡉⠉⣩⠿⠛⠉⠁⠀⠀⠀⢸⣿⠀⠀⠀⠀⠀⠀⢸⣿⠀⢸⣿⠀⠀⠀⠀⣿⠀⠀⢸⣿⠀⠀⣿⡇⠀⠀⠀⣿⡇⠀⠀⣿⡇⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⢀⣴⠟⠁⠀⣧⡞⠁⠀⠀⠀⠀⠀⠀⠀⠀⢻⣧⣀⠀⠀⢀⣠⣾⠏⠀⠸⣿⡀⠀⢀⡰⣿⠀⠀⢸⣿⠀⠀⣿⡇⠀⠀⠀⣿⡇⠀⠀⣿⡇⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠛⠁⠀⠀⠀⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠛⠛⠛⢻⣧⠀⠀⠀⠙⠛⠛⠋⠀⠛⠀⠀⠘⠛⠀⠀⠛⠃⠀⠀⠀⠛⠃⠀⠀⠘⠛⠛⠃⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ Firewall for AI Agents ⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ BANNER # --------------------------------------------------------------------------- # Check root # --------------------------------------------------------------------------- if [ "$(id -u)" -ne 0 ]; then echo "Error: this installer must be run as root." echo "" echo " curl -fsSL https://get.quintai.dev | sudo sh -s -- --token " echo "" exit 1 fi # --------------------------------------------------------------------------- # Defaults # --------------------------------------------------------------------------- TOKEN="" API_URL="https://api.quintai.dev" # --------------------------------------------------------------------------- # Parse arguments # --------------------------------------------------------------------------- while [ $# -gt 0 ]; do case "$1" in --token) TOKEN="$2" shift 2 ;; --api-url) API_URL="$2" shift 2 ;; *) echo "Unknown option: $1" shift ;; esac done if [ -z "$TOKEN" ]; then echo "Usage: curl -fsSL https://get.quintai.dev | sudo sh -s -- --token [--api-url ]" echo "" echo "Options:" echo " --token (required) Agent enrollment token" echo " --api-url API endpoint (default: https://api.quintai.dev)" exit 1 fi # --------------------------------------------------------------------------- # Detect OS and architecture # --------------------------------------------------------------------------- OS="$(uname -s | tr '[:upper:]' '[:lower:]')" ARCH="$(uname -m)" # The published assets are named `x64`, NOT `amd64` — verified against the real # v1.1.1 release and the S3 mirror. This said "amd64", which broke Intel Macs and # x86 Linux two ways at once: the download URL 404'd (quint-darwin-amd64.tar.gz # does not exist), and `pinned_sha_for darwin amd64` fell through its case to the # empty string, silently downgrading integrity to the weaker same-origin # checksum. Apple Silicon was unaffected, which is why nobody noticed. case "$ARCH" in x86_64) ARCH="x64" ;; amd64) ARCH="x64" ;; aarch64) ARCH="arm64" ;; arm64) ARCH="arm64" ;; *) echo "Error: unsupported architecture: $ARCH" exit 1 ;; esac echo "Detected OS=${OS} ARCH=${ARCH}" # --------------------------------------------------------------------------- # Fetch latest version from GitHub releases API # --------------------------------------------------------------------------- # Must be a version that is actually ON the public mirror, since that is the # only place an outside user can download from. It was "1.0.0", which has never # been mirrored (measured: HTTP 403), so every fallback path — the one taken # precisely when GitHub is unreachable — resolved to an undownloadable version # and the install died two steps later. Keep this equal to PINNED_VERSION so the # fallback also gets the strong pinned checksum instead of the weaker # same-origin one. scripts/check-installers.sh enforces both. # # THE FALLBACK IS NOT A FALLBACK — IT IS THE ONLY PATH. The lookup below hits # api.github.com/repos/Quint-Security/proxy/releases/latest UNAUTHENTICATED, and # the repo is private, so it answers 404. Measured 2026-09-12: HTTP 404. So # $VERSION is always empty, this warning always prints, and every real install # resolves to exactly this version. That makes the pin the product version for # every stranger who runs the documented curl|sh line, which is why it being three # releases stale mattered: 1.1.16 predates the sysext-carrying package work. FALLBACK_VERSION="1.1.21" VERSION="$(curl -fsSL https://api.github.com/repos/Quint-Security/proxy/releases/latest 2>/dev/null \ | grep tag_name | cut -d'"' -f4 | sed 's/^v//' || true)" if [ -z "$VERSION" ]; then echo "Warning: could not reach GitHub releases API, falling back to v${FALLBACK_VERSION}" VERSION="$FALLBACK_VERSION" fi echo "Installing Quint Agent v${VERSION} ..." # --------------------------------------------------------------------------- # Download binary (tarball format) # --------------------------------------------------------------------------- # Asset naming verified against the real release: `quint-darwin-arm64.tar.gz`. # As of v1.1.1 it contains TWO binaries, `quint` and `quint-hook` (v1.0.0 had # only `quint`). Both the repo name (renamed quint-proxy -> proxy) and the # `quint-proxy-` asset prefix were wrong here, so every download 404'd. # PUBLIC MIRROR FIRST. Quint-Security/proxy is private, and GitHub answers 404 # (not 403) for unauthenticated requests to a private repo's release assets — so # the GitHub URL cannot work for anyone outside the org, which is every real user # of this script. Assets are mirrored to a public S3 bucket for exactly that. RELEASE_MIRROR="${QUINT_RELEASE_MIRROR:-https://quint-releases-public.s3.amazonaws.com}" TARBALL_URL="${RELEASE_MIRROR}/v${VERSION}/quint-${OS}-${ARCH}.tar.gz" TARBALL_URL_GITHUB="https://github.com/Quint-Security/proxy/releases/download/v${VERSION}/quint-${OS}-${ARCH}.tar.gz" CHECKSUM_URL="${TARBALL_URL}.sha256" # Checksums PINNED HERE, not trusted from the mirror. # # A .sha256 fetched from the same origin as the tarball proves only that the # transfer wasn't corrupted: whoever controls that origin controls both files. The # pin below arrived over TLS from get.quintai.dev, which is the trust the user # already extended by piping this script into a shell. S3 bucket names are also # globally unique, so a deleted bucket could be re-claimed by anyone and serve this # exact URL — the pin is what makes that survivable. # BUMPED 1.1.16 -> 1.1.21, and 1.1.21 rather than 1.1.22 for a measured reason: # v1.1.22's tarballs are NOT on the mirror. All twelve answer 403 (measured # 2026-09-12), because its release run's mirror job failed on a credential that # does not exist — see the mirror job in .github/workflows/release.yml. Pinning # 1.1.22 would pin a version no stranger can download. # # v1.1.21 is pinned because it is VERIFIED PRESENT, not because it is the highest # number. Measured 2026-09-12, anonymously, all four platforms: the mirror's bytes, # the mirror's published .sha256, and the GitHub release's bytes agree; every # .sha256.sig verifies against internal/updater/release-signing-pub.pem, the key # deployed daemons compile in. Re-verify with: # scripts/publish-release-mirror.sh --tag v1.1.21 --from-release --dry-run PINNED_VERSION="1.1.21" pinned_sha_for() { case "$(printf '%s_%s' "$1" "$2" | tr '-' '_')" in darwin_arm64) printf '%s' "c1c9070ad378c83789969b5187fd6c6b8f9f953aabc0df640716a638b3b7798a" ;; darwin_x64) printf '%s' "b0c5cad5956f43308f0a278d31692120961594fa4b9fc174ebace0499f3bc101" ;; linux_arm64) printf '%s' "9e01c1acd31b0de8855e1dceb61b1f7cabae64f90779972e83631fcab4fcf161" ;; linux_x64) printf '%s' "a9bd374933bdb279b3af19f032186e77248103f2e3b9061478c6a3448a91ae40" ;; *) printf '' ;; esac } # A PRIVATE STAGING DIRECTORY, NOT A FIXED NAME IN /tmp. # # This installer is hard-gated to root above, and it used to download to the fixed paths # /tmp/quint-download.tar.gz and /tmp/quint-download.sha256. `curl -o` is fopen("wb"): it FOLLOWS a # pre-planted symlink and writes through it, leaving the link intact. Proven, not assumed: # # $ printf 'ORIGINAL\n' > victim.conf && ln -s "$PWD/victim.conf" ./download.tar.gz # $ curl -fsSL -o ./download.tar.gz https://example.com # $ cat victim.conf -> ... # overwritten, link intact # # /tmp is world-writable AND cleared on reboot, so the name is reliably absent at a known moment. Any # local unprivileged user could run # # ln -s /Library/LaunchDaemons/com.quint.security.plist /tmp/quint-download.tar.gz # # and the next admin running the documented `curl … | sudo sh` line would have root overwrite that file # with tarball bytes — permanently disabling the Quint sensor. For an EDR that is the whole product. # This was live: get.quintai.dev served the vulnerable script, and the release tarball URL returns 200. # # `mktemp -d` gives a 0700 directory owned by root with an unpredictable name, so there is nothing to # pre-plant. THIS IS NOT A NEW PATTERN — deploy/install.sh:574 has done exactly this all along. Two # installers, one hardened and one not, is a drift this repo has now been bitten by three times, so # scripts/check-installers.sh gains a rule for it in this same change. STAGING_DIR="$(mktemp -d)" trap 'rm -rf "$STAGING_DIR"' EXIT INT TERM TARBALL_PATH="${STAGING_DIR}/quint-download.tar.gz" CHECKSUM_PATH="${STAGING_DIR}/quint-download.sha256" echo "Downloading from ${TARBALL_URL} ..." if ! curl -fsSL -o "${TARBALL_PATH}" "$TARBALL_URL"; then # Mirror miss — try GitHub, which works only with repo access. echo "Mirror miss, trying GitHub releases ..." if curl -fsSL -o "${TARBALL_PATH}" "$TARBALL_URL_GITHUB"; then CHECKSUM_URL="${TARBALL_URL_GITHUB}.sha256" fi fi if [ ! -s "${TARBALL_PATH}" ]; then echo "Error: failed to download quint v${VERSION}" echo " tried ${TARBALL_URL}" echo " tried ${TARBALL_URL_GITHUB}" echo # Be explicit about the most likely cause rather than leaving a bare 404. # Quint-Security/proxy is a PRIVATE repository, and GitHub returns 404 (not # 403) for unauthenticated requests to a private repo's release assets — so # this path cannot work for anyone outside the org, no matter what the URL says. echo " The Quint repository is currently private, so release assets are not" echo " publicly downloadable. Until public artifact hosting is in place, use" echo " the notarized installer package instead:" echo echo " 1. Get quint-.pkg from your Quint contact" echo " 2. spctl -a -vvv -t install quint-.pkg" echo " (expect: accepted / source=Notarized Developer ID)" echo " 3. Double-click it, then: sudo quint setup --token " echo echo " The .pkg is the supported path — it installs the kernel EndpointSecurity" echo " and NetworkExtension components that this tarball does not contain." exit 1 fi # Verify SHA256 checksum echo "Verifying checksum ..." EXPECTED="$(pinned_sha_for "$OS" "$ARCH")" CHECKSUM_SOURCE="pinned in installer" if [ -n "$EXPECTED" ] && [ "$VERSION" != "$PINNED_VERSION" ]; then # The pin is for a different release, so it cannot apply — never compare against # the wrong version's checksum. EXPECTED="" fi if [ -z "$EXPECTED" ] && curl -fsSL -o "${CHECKSUM_PATH}" "$CHECKSUM_URL" 2>/dev/null; then EXPECTED="$(awk '{print $1}' "${CHECKSUM_PATH}")" CHECKSUM_SOURCE="downloaded (same origin as the binary — weaker)" echo "Warning: no pinned checksum for v${VERSION} ${OS}/${ARCH} in this installer." echo " Falling back to the published checksum, which does NOT protect against a" echo " compromised mirror. Re-download this installer for a pinned build." fi if [ -n "$EXPECTED" ]; then if command -v shasum >/dev/null 2>&1; then ACTUAL="$(shasum -a 256 "${TARBALL_PATH}" | awk '{print $1}')" elif command -v sha256sum >/dev/null 2>&1; then ACTUAL="$(sha256sum "${TARBALL_PATH}" | awk '{print $1}')" else # Refuse rather than pretend. Setting ACTUAL="$EXPECTED" made the comparison # below trivially pass, so the script printed a warning and then installed an # unverified binary as root. shasum ships with macOS and sha256sum with every # mainstream Linux, so reaching this branch is genuinely anomalous. echo "Error: no SHA256 tool (shasum/sha256sum) found — refusing to install" echo " an unverified binary. Install one, or use the notarized .pkg." rm -f "${TARBALL_PATH}" "${CHECKSUM_PATH}" exit 1 fi if [ "$EXPECTED" != "$ACTUAL" ]; then echo "Error: checksum mismatch!" echo " Expected: $EXPECTED" echo " Got: $ACTUAL" rm -f "${TARBALL_PATH}" "${CHECKSUM_PATH}" exit 1 fi echo "Checksum verified (${CHECKSUM_SOURCE})." rm -f "${CHECKSUM_PATH}" else # Same reasoning: "couldn't fetch the checksum" is not a licence to skip # integrity verification on a root install. If the checksum is genuinely # absent from a release, that release is not fit to install. echo "Error: no checksum available for v${VERSION} ${OS}/${ARCH}." echo " No pin in this installer, and ${CHECKSUM_URL} was unreachable." echo " Refusing to install an unverified binary." rm -f "${TARBALL_PATH}" exit 1 fi # EXTRACT INTO THE PRIVATE STAGING DIR, NOT /tmp. # # The archive was unpacked into world-writable /tmp and the members were then tested with `[ -f ... ]` # and `mv`'d to /usr/local/bin as root. `[ -f ]` follows symlinks and `mv` moves the link's target, so # a planted name is a candidate path to root-owned content. macOS bsdtar happens to REPLACE a planted # symlink rather than write through it — measured — so the archive's own members destroy the plant, but # that is a property of the extractor and of this tarball's member list, not a guarantee. Staging in a # 0700 root-owned directory with an unguessable name removes the question instead of relying on it. tar xzf "${TARBALL_PATH}" -C "${STAGING_DIR}" # As of v1.1.1 the tarball contains TWO binaries: `quint` and `quint-hook`. # The legacy `quint-proxy` name is kept as a fallback so an older release still # installs. if [ -f "${STAGING_DIR}/quint" ]; then mv "${STAGING_DIR}/quint" /usr/local/bin/quint elif [ -f "${STAGING_DIR}/quint-proxy" ]; then mv "${STAGING_DIR}/quint-proxy" /usr/local/bin/quint else echo "Error: could not find quint binary in tarball" rm -f "${TARBALL_PATH}" exit 1 fi chmod +x /usr/local/bin/quint # quint-hook records an agent's DECLARED tool calls, which is one half of the # divergence signal. `quint setup` writes a ~/.claude/settings.json hook entry # pointing at /usr/local/bin/quint-hook, so failing to install it leaves a config # referencing a binary that does not exist: the agent exec's it on every tool # call and fails silently, while the install looks successful. That is exactly # the bug the .pkg had before v1.1.1 — do not let this copy drift back into it. if [ -f "${STAGING_DIR}/quint-hook" ]; then mv "${STAGING_DIR}/quint-hook" /usr/local/bin/quint-hook chmod +x /usr/local/bin/quint-hook echo "Installed hook to /usr/local/bin/quint-hook" else # Not fatal: pre-v1.1.1 tarballs genuinely do not carry it, and the kernel and # proxy lanes still work. Say so plainly rather than implying full coverage. echo "Note: quint-hook not present in this tarball — declared-intent capture" echo " (the agent-side half of divergence) will be unavailable." fi rm -f "${TARBALL_PATH}" echo "Installed binary to /usr/local/bin/quint" # Also install to homebrew path if it exists (avoid PATH shadowing on macOS) if [ -d "/opt/homebrew/bin" ]; then cp /usr/local/bin/quint /opt/homebrew/bin/quint 2>/dev/null || true echo "Copied binary to /opt/homebrew/bin/quint" fi # --------------------------------------------------------------------------- # Check for dylib (macOS local monitoring) # --------------------------------------------------------------------------- DYLIB_PATH="/usr/local/lib/quint_monitor.dylib" if [ "$OS" = "darwin" ] && [ ! -f "$DYLIB_PATH" ]; then echo "Note: ${DYLIB_PATH} not found. Local monitoring will be unavailable" echo " until the dylib is installed. Run 'quint setup' again after" echo " installing it to enable local monitoring." fi # --------------------------------------------------------------------------- # Delegate to quint setup (handles everything else) # --------------------------------------------------------------------------- # quint setup handles: # - CA certificate generation and system trust # - LaunchAgent for GUI app env vars # - PAC file generation and system proxy configuration # - User config directory (~/.quint/) and CA copy # - env.sh / env.fish with agent wrappers # - Shell profile injection (.zshrc, .bashrc, fish) # - /etc/quint/config.yaml with all required fields # - LaunchDaemon / systemd service install and start # - Daemon readiness check echo "" echo "Running quint setup ..." echo "" SETUP_ARGS="--token $TOKEN --api-url $API_URL" /usr/local/bin/quint setup $SETUP_ARGS # Data-capture notice. THIS script is the one get.quintai.dev serves, so this is # the only disclosure a curl|sh installer ever sees — deploy/install.sh having # it is not enough, and this pair has drifted twice before (scripts/check-installers.sh). # # The text lives in the binary so there is one copy; older released binaries do # not know the flag, so fall back rather than dumping a flag-parser usage block # where the notice should be. if ! /usr/local/bin/quint onboard --print-disclosure 2>/dev/null; then echo "" echo " NOTICE: Quint records the text of your AI-agent prompts, the contents" echo " of files agents read, and the output of commands they run, and uploads" echo " them to Quint's cloud. Run 'quint onboard --print-disclosure' for the" echo " full notice and how to turn capture off." echo "" fi # --------------------------------------------------------------------------- # Verify # --------------------------------------------------------------------------- echo "" if /usr/local/bin/quint version >/dev/null 2>&1; then INSTALLED_VERSION="$(/usr/local/bin/quint version 2>/dev/null || echo "v${VERSION}")" echo "Quint Agent installed successfully! (${INSTALLED_VERSION})" echo "" echo " Binary: /usr/local/bin/quint" echo " Verify: quint status (and 'quint doctor' for CA/trust state)" echo " Logs: /var/log/quint/agent.log" echo "" echo " Traffic routing is system-wide via the PAC file — no shell wrappers" echo " and nothing to eval. Open a new terminal, then run 'quint status'." else echo "Warning: 'quint version' check failed, but the binary was installed." echo "Check logs at /var/log/quint/quint.log for details (agent.err for a crash)." exit 1 fi