diff --git a/.githooks/allowed-secrets b/.githooks/allowed-secrets index a6d0de8..bff9301 100644 --- a/.githooks/allowed-secrets +++ b/.githooks/allowed-secrets @@ -27,6 +27,10 @@ # prove the venue listing is asked for once 0x0000000000000000000000000000000000000123 0x0000000000000000000000000000000000000def +# not addresses; two same-symbol tokens told apart by address, and a second vault +0x1111111100000000000000000000000000000001 +0x2222222200000000000000000000000000000002 +0x00000000000000000000000000000000000000ff # Binance's own published HMAC-SHA256 worked example, used to prove the signing # code matches the vendor's documented output. Public in their API docs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c4a54..023c0f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,6 @@ jobs: # The install path ships to users the same way the binary does, and it is # the one piece nothing else exercises until a tag is pushed. - run: bash scripts/install-test.sh - - name: every release target still cross-compiles - run: bash scripts/release-build.sh dist/release # The release job runs `bun run check` on macOS, where the binary is signed, so # the same command runs here: a failure only macOS shows has to fail the pull @@ -54,6 +52,20 @@ jobs: node-version: 22 - run: bun install --frozen-lockfile - run: bun run check + # Here, not on Linux: the darwin targets are re-signed with codesign, and + # release-build.sh refuses without it. + - name: every release target still cross-compiles + run: bash scripts/release-build.sh dist/release + # Launched, not only signature-checked; the Intel build under Rosetta. + - name: both macOS builds start + run: | + set -euo pipefail + /usr/bin/pgrep -q oahd || sudo softwareupdate --install-rosetta --agree-to-license + for a in arm64:arm64 x64:x86_64; do + work=$(mktemp -d) + tar -xzf dist/release/tula-v*-darwin-"${a%%:*}".tar.gz -C "$work" + arch "-${a##*:}" "$work/tula" --version + done # The site is the published origin of install.sh and of every claim a reader # sees before installing anything. Vercel builds it too, but a preview that diff --git a/.github/workflows/injection-eval.yml b/.github/workflows/injection-eval.yml index 7fe4511..7143fda 100644 --- a/.github/workflows/injection-eval.yml +++ b/.github/workflows/injection-eval.yml @@ -20,10 +20,10 @@ permissions: jobs: eval: runs-on: ubuntu-latest - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.2.16 @@ -31,7 +31,10 @@ jobs: # A fork has no secret and never will. Saying so is the point: a run that # failed here would read as a model that followed the payload. + # On this step alone, so `bun install`'s lifecycle scripts never see it. - name: run the eval, or say why it did not + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | set -euo pipefail if [ -z "${ANTHROPIC_API_KEY:-}" ]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d931046..fdae819 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,7 @@ jobs: echo "configured=true" >>"$GITHUB_OUTPUT" else echo "configured=false" >>"$GITHUB_OUTPUT" - echo "::warning::Apple signing is not configured; macOS binaries ship unsigned." + echo "::warning::Apple signing is not configured; macOS binaries ship ad-hoc signed." fi - name: sign and notarize macOS binaries @@ -218,14 +218,23 @@ jobs: # verifies, and a binary that starts. A signed Mach-O that Gatekeeper or a # bad re-tar has broken passes every check above and fails on first run. - name: the shipped archives verify and run + env: + VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail cd dist/release shasum -a 256 -c checksums.txt - work=$(mktemp -d) - tar -xzf "tula-v${{ steps.version.outputs.version }}-darwin-arm64.tar.gz" -C "$work" - "$work/tula" --version - rm -rf "$work" + # The runner's macOS may still run a binary with a broken signature + # that a newer one kills, so launching it alone proves nothing. The + # Intel build runs under Rosetta, the path it takes on Apple silicon. + /usr/bin/pgrep -q oahd || sudo softwareupdate --install-rosetta --agree-to-license + for a in arm64:arm64 x64:x86_64; do + work=$(mktemp -d) + tar -xzf "tula-v$VERSION-darwin-${a%%:*}.tar.gz" -C "$work" + codesign --verify --strict --verbose=2 "$work/tula" + arch "-${a##*:}" "$work/tula" --version + rm -rf "$work" + done # Signed by GitHub's workflow identity through sigstore: keyless, so there # is no signing key for this project to generate, publish, rotate or lose. diff --git a/.gitignore b/.gitignore index e5135e8..f90d105 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,15 @@ dist/ # Belt and braces: credentials live in ~/.config/tula, never the repo. # This catches a stray copy before it reaches a commit. *.key +*.pem +.npmrc credentials.json .env .env.* +# What TULA_CONFIG_DIR holds, should a scratch run point it inside the tree. +history.jsonl +state.json +preferences.json # The site is a separate package: its dependencies never enter the binary. site/node_modules diff --git a/AGENTS.md b/AGENTS.md index f3ca1b2..5e1c6bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,18 +34,12 @@ into a directory anyone can replace it in. None of those failed a type check. ## Versioning -[SemVer](https://semver.org), and the version describes a **release**, never a -plan — the milestones live in `ROADMAP.md` precisely so a reordered plan cannot -make a published number wrong. Pre-1.0: **patch** for fixes, security hardening -and doc or site corrections; **minor** for a new venue, command or capability, -and for anything breaking; **major** is reserved for `1.0.0` and `2.0.0`. - -A hyphen means pre-release. It is the only signal — `src/version.ts` derives -`IS_PRE_RELEASE` from `APP_VERSION`, and `release.yml` reads the same hyphen to -choose `--prerelease` over `--latest` and the npm dist-tag. They were once a -hand-set boolean apart and had already drifted into a stable release whose -binary called itself a pre-release; `guard.sh` now fails if the derivation is -replaced by a literal. +The version describes a **release**, never a plan; the bump rules are in +`ROADMAP.md`'s Versions section. A hyphen means pre-release and is the only +signal: `src/version.ts` derives `IS_PRE_RELEASE` from `APP_VERSION`, and +`release.yml` reads the same hyphen for `--prerelease` and the npm dist-tag. +`guard.sh` fails if the derivation is replaced by a literal — a hand-set boolean +once shipped a stable release whose binary called itself a pre-release. ## Stack @@ -198,7 +192,7 @@ src/ version.ts # APP_NAME, APP_VERSION, IS_PRE_RELEASE, REPO_URL, SITE_URL, # APP_DESCRIPTION — single source; guard.sh reads four of them core/ - position.ts # canonical schema: Position, NetExposure, LiquidationParams + position.ts # canonical schema: Position, NetExposure, LiquidationParams; rowIdentity untrusted.ts # visible() — the one filter over text somebody else wrote exposure.ts # netExposure, portfolioValue — equity, never a perp's notional; oldest risk.ts # liquidation distance, scenario shocks, what breaks first @@ -379,9 +373,9 @@ Two rules, and they are the reason the architecture exists: called in *in order*, so a venue in the book with an unread area that hides a liquidation makes the order wrong rather than short, and they say so. `ALTERED`, the fifth thing a view says about itself, is the only one about - text rather than holdings: a venue spelled an asset in characters this build - could not print, so the block names the venue, what was - done and the *bounded* name — never what the venue sent, which is the string + text rather than holdings: a venue spelled an asset, product or held-as name + in characters this build could not print, so the block names the venue, what + was done and the *bounded* name — never what the venue sent, which is the string the bound exists to keep off a terminal. `LoadResult.altered` carries it, and the first line says nothing is missing, or a reader has five states to tell apart and four of them mean go and fetch something. @@ -390,9 +384,8 @@ Two rules, and they are the reason the architecture exists: goes — and `src/coverage-plan.test.ts` fails the build on a path that is not a real task, on one already `done`, on a liquidation-hiding gap filed under the aggregator, and on any plan `ROADMAP.md`'s table does not name. Declaring a - gap costs one object and used to create no obligation at all, which is how - thirty accumulated with thirteen in no plan and two named in no file in the - repository. The declaration and the plan are one edit now. + gap costs one object, so without this gaps accumulate with no plan behind + them. The declaration and the plan are one edit. - **`src/index.ts` reaches the terminal UI only through a dynamic import.** A one-shot command draws its tables through `src/ui/table.ts` and never needs a reconciler; loading Ink and React for one cost 82ms against 56ms on @@ -618,7 +611,8 @@ venue in it. the error text of a venue or a price source, the error text of the model provider, and a Hyperliquid builder dex name, which is a venue label and so is refused unless it matches `DEX_NAME` rather than capped. The symbol is capped in - `src/cli/session.ts`, where every connector arrives; the error text is capped + `src/cli/session.ts`, where every connector arrives, and so are the venue's + `heldAs` spelling and `product` name beside it; the error text is capped by `remote()` in `src/core/errors.ts` where it enters, at the connector or the price source that received it, which is the only place that can tell tula's own words from somebody else's — and by `explain()` in @@ -657,6 +651,11 @@ endpoint — including "validate only" variants. The absence is the product. 7. Give it a colour in `src/ui/brand.ts`. A venue without one renders a hole beside the rest, and `src/ui/brand.test.ts` fails on it. 8. Do not sort — the command layer does that. +9. No two rows may read alike but for their figures. Where one label holds an + asset twice, say what the venue calls the difference: `product` for the + contract, margin book, vault or staking state, `heldAs` for the venue's own + spelling of an asset counted as another. Its test asserts `rowIdentity` is + unique over what it returns, as `kraken.test.ts` does. ## Working from tasks/ @@ -1014,10 +1013,9 @@ before pasting keys tied to their net worth. - **Never document an install path that does not work yet.** A published command that fetches nothing is an impersonation surface, not a convenience. -### Before the first release +### What a release needs outside the repository -None of this lives in the repository, and each missing piece fails a different -channel at a different moment. The tap and the npm scope fail *after* the GitHub +Each missing piece fails a different channel at a different moment. The tap and the npm scope fail *after* the GitHub release is already public, while the site is telling people to use them. | What | Why it blocks | Check | @@ -1029,16 +1027,15 @@ release is already public, while the site is telling people to use them. | `usetu.la` resolves, HTTPS enforced | the install command is the domain | `curl -sI https://usetu.la/install.sh` | | The Vercel root directory is `site` | `vercel.json`'s headers are read from there and nowhere else | `curl -sI https://usetu.la/` | | Vercel includes files outside that root | the build script copies `install.sh` in from there; without it the build fails | `curl -sI https://usetu.la/install.sh` | -| `APPLE_*` secrets | optional; without them macOS ships unsigned | `gh secret list` | +| `APPLE_*` secrets | optional; without them macOS ships ad-hoc signed | `gh secret list` | | A `release` environment with a required reviewer | `release.yml` names it on all three jobs, and naming it does nothing until it exists — GitHub silently creates an unprotected one on first use, and the run publishes unreviewed | `curl -s -o /dev/null -w '%{http_code}\n' https://api.github.com/repos/hsnice16/tula/environments/release` | Set each variable last, after its token exists: `true` without the token turns a skipped job into a failed one, and it fails after the GitHub release is public. -The site is already deployed and already names all three channels, so the table -above is not a checklist for later — every row that is not true when the tag is -pushed is a published command that fetches nothing for as long as it takes to -notice. Each can be checked without `gh` and without being signed in, which is +The site names all three channels, so every row above that is not true when a +tag is pushed is a published command that fetches nothing for as long as it +takes to notice. Each can be checked without `gh` and without being signed in, which is also how a reader would find out before you do: ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 744acf8..23e0dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,25 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message ## [Unreleased] +## [0.3.2] - 2026-09-22 + +### Fixed + +- **tula starts on macOS 27.** The macOS binaries shipped with a code signature that did not verify, which earlier releases of macOS let run and macOS 27 kills on launch — `zsh: killed tula`. They are signed on the build machine now, and a release whose binaries do not verify is not published. A tula that is killed cannot update itself: re-run the install line, `brew upgrade`, or `npm install -g @hsnice16/tula`. +- **No two rows read alike.** Wherever tula lists holdings — the tables, the lines under `/shock` and `/breaks`, a venue's `status`, and what the model is handed — two rows now differ in something other than a number. + - An asset counted as another says what the venue calls it: `ETH (as WETH)`, `USDT0 (as USDT)`, `PEPE (as kPEPE)`. + - A `PRODUCT` column names the contract, margin book, vault or staking state a row is held in: Binance's cross and isolated margin; each Coinbase perp; Kraken's margin pairs; Hyperliquid's vaults, and staked HYPE under the names its staking panel gives it, Total Staked and Available to Stake. + - A venue's own views gain a `VENUE` column wherever its rows come from sub-accounts, builder dexes, markets or chains: `/ positions`, `/ breaks`, and the borrowing table in `/ status`. + - Two Kraken wallets of one type are numbered, `kraken-spot-1` and `kraken-spot-2`. Several margin positions on one pair and side are one row, summed as Kraken's own `consolidation=market` sums them. + - A token calling itself the chain's gas token, and two Aave reserves one market would list under one name, carry their contract. +- **Two addresses on one Aave market are two health factors under `/shock`.** They were pooled into one market: the second address's factor was never printed, and the one shown moved on collateral from both. +- **An update that does not start is never installed.** `install.sh` and `/update install` run the new build once before pointing `tula` at it; one that fails leaves you on the version you had. +- **tula runs on every Mac from macOS 13.** The Intel build no longer needs AVX2, so it also runs under Rosetta on macOS 13 and 14. The installer fetches the native build on Apple silicon even from a shell running under Rosetta. The installer, Homebrew and npm say so on an older macOS. +- **An arrow key pressed while typing a key at `tula connect` is not saved into it.** Its escape sequence was stored as part of the secret, and the venue then refused a key that looked right. +- **A Kraken wallet tula did not read is named.** When Kraken's wallet list fails to load, only the default wallet is read, and the book now says so instead of reading as whole. +- **A Hyperliquid spot token called `WETH` is not ether.** It netted with ETH and took ether's price; any deployer can list a token under that name. +- **`tula connect --help` prints connect's usage.** It answered `Unknown venue "--help"`. + ## [0.3.1] - 2026-09-16 ### Fixed @@ -353,7 +372,8 @@ what breaks first. - `KeyScope` is tri-state. Kraken exposes no endpoint reporting a key's permissions, and every endpoint gated on trade permission mutates an order, so `canTrade` is `unknown` rather than guessed at. Withdraw scope is provable, and is proven. - Kraken margin and open orders are not read yet, so on a margin account this is not a complete Kraken picture. -[Unreleased]: https://github.com/hsnice16/tula/compare/v0.3.1...HEAD +[Unreleased]: https://github.com/hsnice16/tula/compare/v0.3.2...HEAD +[0.3.2]: https://github.com/hsnice16/tula/compare/v0.3.1...v0.3.2 [0.3.1]: https://github.com/hsnice16/tula/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/hsnice16/tula/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/hsnice16/tula/compare/v0.1.3...v0.2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e130552..2f99703 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,7 @@ goes on telling people to use that channel, so the run prints a warning naming each channel that is off. Read it before you announce anything. ```bash -bash scripts/release-build.sh dist/release # the same artifacts, locally +bash scripts/release-build.sh dist/release # the same artifacts; macOS only, it needs codesign bash scripts/install-test.sh # runs install.sh against a fake release bash scripts/npm-pack.sh dist/release # the npm tree that would be published bash scripts/homebrew-formula.sh dist/release tula # the formula, real checksums @@ -134,7 +134,7 @@ Two ways, neither of which publishes anything by accident. `publish` off. It builds all four targets, verifies them and runs the installer against them — then stops, and leaves the artifacts and `checksums.txt` on the run to inspect. It signs only where `secrets.APPLE_CERT_P12` is set; without it -the macOS binaries are unsigned and the run says so. Publishing is off by default +the macOS binaries ship ad-hoc signed and the run says so. Publishing is off by default because `GITHUB_REF_TYPE` is `branch` on a manual run, so the tag-matches-version check cannot protect it; without the gate, a manual run would cut a real release from whatever was on the branch. **It does not attest.** That step is gated with diff --git a/README.md b/README.md index 99ef69a..7e3a3fb 100644 --- a/README.md +++ b/README.md @@ -141,14 +141,14 @@ curl --proto '=https' --tlsv1.2 -LsSf https://usetu.la/install.sh | sh brew install hsnice16/tap/tula # or: npm install -g @hsnice16/tula ``` -macOS and Linux, on 64-bit Intel and ARM. Alpine and other musl systems are not +macOS 13 or later and Linux, on 64-bit Intel and ARM. Alpine and other musl systems are not supported, and there is no native Windows build — install inside WSL. The installer always checks the download against its published checksum, and checks the sigstore-backed attestation proving this repository's release workflow built it wherever the GitHub CLI can — saying so either way. Check one by hand: ```bash -gh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula \ +gh attestation verify tula-v0.3.2-darwin-arm64.tar.gz --repo hsnice16/tula \ --signer-workflow hsnice16/tula/.github/workflows/release.yml ``` diff --git a/ROADMAP.md b/ROADMAP.md index da10198..f0235ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,50 +33,21 @@ is only worth the check behind it, so a task marked `done` names the tests covering its acceptance and `src/tasks.test.ts` fails on one that does not — [`tasks/README.md`](./tasks/README.md) has the convention. -Three milestones are ◐ — **4**, **8** and **13** — and every open piece is named -here rather than left to the reader. A bullet that did not land is marked -`**Not met.**`, that spelling and no other, so `grep -rn 'Not met' tasks/*/` +The table names no versions: 6 landed alongside 4 and 5 rather than after them, +and a plan that moves makes a published number wrong. + +Three milestones are ◐ — **4**, **8** and **13**. A bullet that did not land is +marked `**Not met.**`, that spelling and no other, so `grep -rn 'Not met' tasks/*/` lists every one — the directories, not `tasks/README.md`, which documents the -marker and so contains it — and this paragraph can be checked rather than -believed. 8's open pieces are bullets that missed, each saying why in its own +marker. 8's open pieces are bullets that missed, each saying why in its own task; so is the one in 4's [`breadth/10`](./tasks/breadth/10-hyperliquid-depth.md), -which the venue's own documentation argues against doing. The rest of 4 and 13 -is open work rather than work that missed. 4's is the aggregator, which waits -for 11; Aave V4, which does not; [`breadth/12`](./tasks/breadth/12-chain-reach.md), -part-shipped; and the per-venue depth tasks 09, 11 and 13–16. 13 is ◐ the -other way round from every other row: the docs site shipped, and of the -hardening pass two bullets already hold on their own — no panic path leaves the -terminal in raw mode, on both the shell and the one-shot prompt, and every -venue-supplied string is treated as data on every path that renders it — while -the rest of that milestone has not started. - -**1**, **2** and **6** closed. 2's last bullet was the reader-facing half of the -injection work: venue text was labelled for the model — every tool result -carries a sidecar naming the paths its outside text sits at — and said nothing -to the reader, who is on the surface that works with no model at all. A name a -venue spelled in text tula could not print is `ALTERED` on every view now, -naming the venue, the bounded name and the `TULA__RPC` that chooses the -node that sent it; what the venue actually sent is not shown, because that -string is the one that repaints a line. - -1's last bullet accepted a `redact()` helper for the log and error paths; what -shipped instead is the property it would only have approximated — no module -holding a credential may hand one to a log, an error, a file or another process, -and `src/secrets/` has no way out of the process at all, both failing the build -in `scripts/guard.sh`. 6's was a shocked health factor computed as though the -debt stood still, which is a stablecoin borrow and not a same-asset one: the -assumption is checked against the book now and stated to the reader where it -breaks, rather than sitting in a comment on `healthFactorUnder`. - -**7** shipped. It closed 3's last gap — the credential store holds an ordered -set, every entry is read, and each row carries the account it came from — and it -added the line that says what tula never asked for: a chain never queried is not -a venue that *failed*, so `INCOMPLETE` stayed quiet and the total was short with -nothing saying so. Availability landed beside it, under 6, which is why 6 -closed on one unstated assumption rather than on four connectors summing a free -balance away. Labelling venue text *to the model* was 7's item; saying -so on screen was 2's, and both now hold — `ALTERED` is the fifth thing a view -says about itself. +which the venue's own documentation argues against doing. The rest of 4 is open +work: the aggregator, which waits for 11; Aave V4, which does not; +[`breadth/12`](./tasks/breadth/12-chain-reach.md), part-shipped; and the depth +tasks 09, 11 and 13–16. 13 is the other way round: the docs site shipped, and two +hardening bullets already hold — no panic path leaves the terminal in raw mode, +and every venue-supplied string is treated as data on every path that renders +it — while the rest has not started. The aggregator and Aave V4 are scheduled in different places, and one rule puts each where it is: everything uncovered that *can* be liquidated is hand-built, @@ -87,31 +58,19 @@ add is exposure nobody can be liquidated on, and it could not supply a health factor for it anyway. It completes a total rather than repairing one, which is the line 7 was drawn on. -**Aave V4 stays in 4**, and is the one piece of it scheduled here. It is live on -Ethereum, it holds real deposits, and it hides a *liquidation* — the connector -declares it unread and `scripts/conformance.live.ts` re-checks against the live -address book that the gap is still real. It was filed as deliberately deferred -on the grounds that V4 is Hubs and Spokes rather than Pools, which is a statement -about effort and not a decision: it is the venue tula already claims to read, one -major version on, and as v3 drains into v4 an Aave position tula cannot see is a -liquidation tula cannot rank. [`breadth/08`](./tasks/breadth/08-aave-v4.md) is -the task, and it is honest about what is unknown — the account model, whether a -health factor is per Hub, whether `getUserAccountData` has an equivalent. The -declaration stands until the connector reads V4, and `/venues` is where the -reader meets it — that, or the sentence under an Aave book that came back -empty, which is where somebody on V4 actually lands. - -The risk engine (6) landed alongside breadth and distribution rather than after -them, and that reordering is why this table stopped naming versions: it used to, -and a plan that moves makes a published number wrong. +**Aave V4 stays in 4.** It is live on Ethereum, holds real deposits, and hides a +*liquidation*; the connector declares it unread and `scripts/conformance.live.ts` +re-checks that the gap is still real. Hubs and Spokes rather than Pools is a +statement about effort, not a decision: as v3 drains into v4, an Aave position +tula cannot see is a liquidation tula cannot rank. +[`breadth/08`](./tasks/breadth/08-aave-v4.md) is the task, and names what is +unknown. 8, 9 and 10 come before any of 11 through 17, as 7 did. A gap inside something shipped outranks new scope — a wrong number and a rule only a good model keeps are both shipped, in a product that already stores exchange keys. 8 led -because it was the wrong number: 0.2.0 overstated the USDC row of a Hyperliquid -unified or portfolio-margin account by the account's whole value, after a fix -that did not remove it. Its input-line half -is not a gap in a figure, and [`field-report`](./tasks/field-report) orders it +because it was a wrong number in a shipped view. Its input-line half is not a +gap in a figure, and [`field-report`](./tasks/field-report) orders it behind every task that changes one. 10 is there rather than later because a risk tool you have to remember to open is a tool you forget. @@ -144,9 +103,8 @@ model. Nothing left in 8–12 changes that: a corrected figure, a second model provider, a venue read over MCP, and a user-added venue are all still reads. **2.0 is 15**, because that is where signing authority enters the product and -the read-only promise is retracted by plan. Retracting it is one commit across -the nine surfaces `src/site-claims.test.ts` pins the caveat to, which is what -makes it one commit rather than nine. +the read-only promise is retracted by plan. Retracting it is one commit, because +`src/site-claims.test.ts` pins the caveat to every surface that carries it. **14 sits between them on purpose.** A diff that states a proposed change in exposure terms — fees, slippage, the resulting move in liquidation distance, @@ -164,14 +122,11 @@ the list. That declaration exists so a half-read account is never served as a whole one — it was never meant to be a standing statement about the product, and a count of it beside every figure was one, so the count came off the view. -The obligation it creates is answered here instead. Twenty-four areas are -declared across seven venues, and each one names the task that would close it in the -manifest itself: `src/coverage-plan.test.ts` fails the build on a gap whose -plan is not a real task, on one filed under a task already finished, on one -hiding a liquidation filed under the aggregator, and on any plan this table -does not name. Declared-and-unfiled is what that test exists to make -impossible — thirteen of the thirty were in that state when it was written, two -of them mentioned in no file at all. +The obligation it creates is answered here instead. Each declared area names +the task that would close it in the manifest itself: `src/coverage-plan.test.ts` +fails the build on a gap whose plan is not a real task, on one filed under a task +already finished, on one hiding a liquidation filed under the aggregator, and on +any plan this table does not name. | What | Where | |---|---| @@ -185,18 +140,12 @@ of them mentioned in no file at all. | Stripe Treasury and connected accounts under a platform | [`breadth/16`](./tasks/breadth/16-stripe-depth.md) | | What an LP or vault receipt token is a claim on | [`breadth/01`](./tasks/breadth/01-aggregator-api.md) | -Ordered by venue rather than by size. An earlier draft of this paragraph called -three of them "a decode of bytes already fetched", and reading the code rather -than the declarations showed that claim was worth what such claims usually are. -The bytes are in hand; decoding them changes no figure on any screen, and -retiring a declaration for that would shorten the list without the reader seeing -anything new. What the exercise turned up instead was a gap nobody had declared -— the eMode category Aave liquidates an account against — and it was a wrong -number rather than a missing one, sitting under a health factor Aave did state, -which is why nothing about it looked wrong. It is fixed rather than listed -above; [`breadth/09`](./tasks/breadth/09-aave-depth.md) records what reading it -against the chain turned out to require, including the field that looks like -the answer and is not. +Ordered by venue rather than by size. Decoding bytes already fetched retires no +area here unless it changes a figure the reader sees: otherwise it shortens the +list and shows nobody anything new. Reading the code rather than the +declarations is what found the one gap nobody had declared — the eMode category +Aave liquidates against, a wrong number under a health factor Aave did state — +and [`breadth/09`](./tasks/breadth/09-aave-depth.md) records what fixing it took. Nothing here is a promise about a date. What it is is the difference between a gap somebody chose and a gap nobody has looked at, and the two sections at the diff --git a/SECURITY.md b/SECURITY.md index 1ffe072..aa679ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -75,7 +75,10 @@ Highest severity first: a column is drawn in, whichever comes first, so a cut never lands inside a code point and a wide script cannot push the columns beside it off the row. `src/connectors/evm.ts` caps its own decode as well, so a lying ABI length - prefix is never allocated. + prefix is never allocated. The venue's text beside a symbol — its own + spelling where the symbol nets as another (`WETH` beside `ETH`), and the + contract or margin book a row is held in (`BTCUSDT isolated`) — is the same + listing and takes the same cap in the same place. What is stripped is every codepoint that is invisible or moves what is drawn — `visible()` in `src/core/untrusted.ts`, the one filter all three @@ -288,7 +291,7 @@ keyless, so there is no signing key for this project to generate, publish, rotat or lose. ```bash -gh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula \ +gh attestation verify tula-v0.3.2-darwin-arm64.tar.gz --repo hsnice16/tula \ --signer-workflow hsnice16/tula/.github/workflows/release.yml ``` diff --git a/install.sh b/install.sh index 9875d8a..ce75bbe 100755 --- a/install.sh +++ b/install.sh @@ -109,6 +109,21 @@ detect_target() { *) die "tula has no build for $arch." \ "Build from source instead: https://github.com/$REPO" ;; esac + if [ "$os" = darwin ]; then + # Bun, which tula is compiled with, supports macOS 13 and later; on an older + # one nothing about the binary is tested, so it is refused here by name. + if command -v sw_vers >/dev/null 2>&1; then + macos=$(sw_vers -productVersion) + [ "${macos%%.*}" -ge 13 ] 2>/dev/null || + die "tula needs macOS 13 (Ventura) or later; this Mac runs $macos." \ + "Update it in System Settings > General > Software Update." + fi + # A shell under Rosetta reports x86_64 on Apple silicon. The native build is + # the one to install there; the chip is asked rather than the shell. + if [ "$arch" = x64 ] && [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then + arch=arm64 + fi + fi # Builds link against glibc. musl silently fails at exec time with a message # about a missing loader, which reads as a corrupt download rather than an # unsupported libc — so it is caught here instead. @@ -332,6 +347,18 @@ if [ -z "$ALREADY" ]; then [ -f "$VERSION_DIR/tula" ] || die "$ARCHIVE did not contain a tula binary." \ "Report it: https://github.com/$REPO/issues" chmod 755 "$VERSION_DIR/tula" + # A checksum and an attestation prove what was built, not that this machine + # will run it — a macOS newer than the build kills a binary that passed both. + # So it runs once before the receipt or the launcher can name it. + note "checking it starts" + # In a subshell that cannot exec it in place, so the shell's own "Killed: 9" + # report lands in the redirect rather than above the message below. + if ! (TULA_NO_UPDATE_CHECK=1 "$VERSION_DIR/tula" --version; exit $?) >/dev/null 2>&1; then + rm -f "$RECEIPT" + die "tula $VERSION was downloaded and verified, but does not start on this machine." \ + "Your launcher was left as it was. Report it with your OS version:" \ + "https://github.com/$REPO/issues — or pin an earlier release with TULA_VERSION." + fi # What the fast path above compares against on the next run. Written after the # archive passed its checksum and its attestation, so it records a binary this # script verified rather than one it merely found. diff --git a/package.json b/package.json index c558eb0..0934640 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hsnice16/tula", - "version": "0.3.1", + "version": "0.3.2", "description": "Your true exposure, what breaks first, and more, across every venue at once.", "license": "MIT", "type": "module", @@ -12,7 +12,7 @@ }, "scripts": { "dev": "bun run src/index.ts", - "build": "bun build src/index.ts --compile --outfile dist/tula", + "build": "bun build src/index.ts --compile --outfile dist/tula && if [ \"$(uname)\" = Darwin ]; then codesign --force -s - dist/tula && codesign --verify --strict dist/tula; fi", "typecheck": "tsc --noEmit", "test": "bun test", "check": "tsc --noEmit && bun test && bash scripts/install-test.sh && bash scripts/guard.sh && bash scripts/guard-test.sh && bash scripts/scan-test.sh", diff --git a/scripts/homebrew-formula.sh b/scripts/homebrew-formula.sh index ae02905..051232f 100755 --- a/scripts/homebrew-formula.sh +++ b/scripts/homebrew-formula.sh @@ -87,6 +87,7 @@ class $CLASS < Formula license "MIT" on_macos do + depends_on macos: :ventura on_arm do url "$BASE/tula-v$VERSION-darwin-arm64.tar.gz" sha256 "$SUM_DARWIN_ARM64" diff --git a/scripts/install-test.sh b/scripts/install-test.sh index 724c59b..a04c48e 100755 --- a/scripts/install-test.sh +++ b/scripts/install-test.sh @@ -83,7 +83,7 @@ mkdir -p "$BIN" # decompresses in-process — so a list built by reading install.sh on a laptop # misses it, and every extraction fails on Linux with "Cannot exec". for tool in sh env cp tar gzip gunzip uname mkdir grep cut sed basename dirname \ - mktemp chmod ln rm cat ls id readlink sha256sum shasum openssl; do + mktemp chmod ln rm cat ls id readlink sha256sum shasum openssl sw_vers sysctl; do path=$(command -v "$tool" 2>/dev/null) && ln -sf "$path" "$BIN/$tool" done @@ -427,6 +427,74 @@ else fi +# A Mac, whatever this suite runs on: `uname`, `sw_vers` and `sysctl` answer as +# one would, and each case restores the real tools after itself. +as_mac() { + mv "$BIN/uname" "$BIN/uname.real" + # Unlinked first: writing through the symlink would target the real tool. + rm -f "$BIN/sw_vers" "$BIN/sysctl" + printf '#!/bin/sh\ncase "$1" in -s) echo Darwin ;; -m) echo %s ;; esac\n' "$1" >"$BIN/uname" + printf '#!/bin/sh\necho %s\n' "$2" >"$BIN/sw_vers" + printf '#!/bin/sh\necho %s\n' "$3" >"$BIN/sysctl" + chmod 755 "$BIN/uname" "$BIN/sw_vers" "$BIN/sysctl" +} +real_platform() { + rm -f "$BIN/uname" "$BIN/sw_vers" "$BIN/sysctl" + mv "$BIN/uname.real" "$BIN/uname" + for tool in sw_vers sysctl; do + path=$(command -v "$tool" 2>/dev/null) && ln -sf "$path" "$BIN/$tool" + done +} + +H="$WORK/h-old-mac" +mkdir -p "$H" +as_mac arm64 12.7.4 1 +out=$(run "$H") +real_platform +if [ ! -e "$H/.tula/bin/tula" ] && case "$out" in *"needs macOS 13"*"12.7.4"*) true ;; *) false ;; esac; then + ok "refuses a macOS older than the runtime supports, and installs nothing" +else + bad "refuses a macOS older than the runtime supports, and installs nothing" "$out" +fi + +H="$WORK/h-rosetta" +mkdir -p "$H" +as_mac x86_64 15.1 1 +out=$(run "$H") +real_platform +case "$out" in *"darwin-arm64"*) ok "installs the native build from a shell running under Rosetta" ;; + *) bad "installs the native build from a shell running under Rosetta" "$out" ;; esac + +H="$WORK/h-intel" +mkdir -p "$H" +as_mac x86_64 13.0 0 +out=$(run "$H") +real_platform +case "$out" in *"darwin-x64"*) ok "installs the Intel build on an Intel Mac" ;; + *) bad "installs the Intel build on an Intel Mac" "$out" ;; esac + +# Verified and still dead on arrival: the launcher must not be pointed at it. +H="$WORK/h-dead" +mkdir -p "$H" +cp -R "$RELEASE" "$WORK/release.bak" +printf '#!/bin/sh\nexit 137\n' >"$RELEASE/stage/tula" +for t in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do + tar -czf "$RELEASE/tula-v9.9.9-$t.tar.gz" -C "$RELEASE/stage" tula LICENSE +done +if command -v sha256sum >/dev/null 2>&1; then + (cd "$RELEASE" && sha256sum ./*.tar.gz | sed 's| \./| |' >checksums.txt) +else + (cd "$RELEASE" && shasum -a 256 ./*.tar.gz | sed 's| \./| |' >checksums.txt) +fi +out=$(run "$H") +rm -rf "$RELEASE" && mv "$WORK/release.bak" "$RELEASE" +if [ ! -e "$H/.tula/bin/tula" ] && [ ! -e "$H/.tula/versions/9.9.9/.tula-sha256" ] && + case "$out" in *"does not start on this machine"*) true ;; *) false ;; esac; then + ok "refuses a verified binary that does not start, and leaves the launcher alone" +else + bad "refuses a verified binary that does not start, and leaves the launcher alone" "$out" +fi + # Nothing above may have touched a profile outside the sandbox. This test edits # shell config, so a leak is silent, permanent and in someone's real home. leaked=0 diff --git a/scripts/npm-pack.sh b/scripts/npm-pack.sh index 9bbb94d..20287e3 100755 --- a/scripts/npm-pack.sh +++ b/scripts/npm-pack.sh @@ -116,8 +116,9 @@ cat >"$WRAPPER/scripts/postinstall.mjs" <<'POSTINSTALL' * link. The installed binary is the compiled executable, so running tula never * starts Node — Node is needed to install it, not to run it. */ -import { chmodSync, copyFileSync } from 'node:fs' +import { chmodSync, copyFileSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' +import { release } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -125,10 +126,19 @@ const require = createRequire(import.meta.url) const here = dirname(fileURLToPath(import.meta.url)) const target = `${process.platform}-${process.arch}` +const launcher = join(here, '..', 'bin', 'tula') +// Darwin 22 is macOS 13, the oldest Bun supports. Below it the binary is +// untested, so the launcher says why instead of running it. +const tooOld = process.platform === 'darwin' && Number(release().split('.')[0]) < 22 + try { - const source = require.resolve(`@hsnice16/tula-${target}/bin/tula`) - const launcher = join(here, '..', 'bin', 'tula') - copyFileSync(source, launcher) + if (tooOld) { + const why = ['tula needs macOS 13 (Ventura) or later.', ' Update it in System Settings > General > Software Update.'] + writeFileSync(launcher, `#!/bin/sh\n${why.map((line) => `echo '${line}' >&2`).join('\n')}\nexit 1\n`) + console.error(why.join('\n')) + } else { + copyFileSync(require.resolve(`@hsnice16/tula-${target}/bin/tula`), launcher) + } chmodSync(launcher, 0o755) } catch { // Left as the placeholder, which says the same thing when run. Exiting diff --git a/scripts/release-build.sh b/scripts/release-build.sh index e86d33d..5c7441b 100755 --- a/scripts/release-build.sh +++ b/scripts/release-build.sh @@ -11,9 +11,11 @@ VERSION=$(grep -m1 'APP_VERSION' src/version.ts | sed "s/.*'\([^']*\)'.*/\1/") # The names the installer builds its URLs from. Changing one here without # changing install.sh produces a release nobody can install. +# Baseline on Intel macOS: Bun 1.2's default x64 build needs AVX2, which Rosetta +# before macOS 15 does not emulate. TARGETS=( "darwin-arm64:bun-darwin-arm64" - "darwin-x64:bun-darwin-x64" + "darwin-x64:bun-darwin-x64-baseline" "linux-x64:bun-linux-x64" "linux-arm64:bun-linux-arm64" ) @@ -44,6 +46,14 @@ for entry in "${TARGETS[@]}"; do echo "building $name" bun build src/index.ts --compile --target="$target" --outfile "$stage/tula" chmod 755 "$stage/tula" + # Bun 1.2.16 emits darwin binaries whose ad-hoc signature does not verify, and + # macOS 27 kills them on launch. The workflow's Developer ID step re-signs over + # this when its secrets exist; without them, this is the signature that ships. + # No codesign means no fix, so refuse rather than build a binary that dies. + if [[ $name == darwin-* ]]; then + codesign --force -s - "$stage/tula" + codesign --verify --strict "$stage/tula" + fi # Before the archive exists, so a binary carrying a debug listener is never a # file anybody can pick up. Every target, because what gets bundled is decided # by what resolves at build time and that is the same for all four. diff --git a/site/app/globals.css b/site/app/globals.css index 86a7117..22d8ad6 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -21,12 +21,11 @@ --color-ink: #eceae5; --color-dim: #8d877e; - /* 3.05:1 on the page ground, below the 4.5 AA asks of text this size, so it - is kept for one thing: the transcript inside a terminal frame, which is a - drawing of dim terminal output and reads as wrong at any lighter value. - Everything a reader needs the words of — section labels, a frame's title - bar, prose — takes --color-dim, which passes at 5.26. A third step light - enough to pass would sit on top of dim and stop being a third step. */ + /* 3.05:1 on the page ground, below the 4.5 AA asks of text, so no words take + it: only the Aside's info icon, which the dim text beside it restates. + Everything a reader needs the words of takes --color-dim, which passes at + 5.26. A third step light enough to pass would sit on top of dim and stop + being a third step. */ --color-faint: #666159; --animate-pop: pop 360ms cubic-bezier(0.2, 0.9, 0.3, 1.25); diff --git a/site/app/install/page.tsx b/site/app/install/page.tsx index b980a11..8dc93f0 100644 --- a/site/app/install/page.tsx +++ b/site/app/install/page.tsx @@ -80,7 +80,7 @@ const FLAGS = [ * the rows most worth printing, and a target list cannot carry them. */ const SYSTEMS = [ - ['macOS', 'Yes', 'Intel and ARM, 64-bit.'], + ['macOS 13 or later', 'Yes', 'Intel and ARM, 64-bit.'], ['Linux', 'Yes', 'Intel and ARM, 64-bit. Needs glibc.'], ['Alpine, or any musl Linux', 'No', 'The installer says so and stops.'], ['Windows', 'Through WSL', 'Install inside WSL. There is no native build.'], @@ -242,12 +242,12 @@ const CHANNELS: Channel[] = [

Node is needed to install it, not to run it.

- Name the version. npm update moves you back to the newest. + Name the version. Installing without one moves you back to the newest.

{'npm install -g @hsnice16/tula@'}
@@ -358,7 +358,7 @@ export default function Page() { { - "curl --proto '=https' --tlsv1.2 -fLO https://github.com/hsnice16/tula/releases/download/v0.3.1/tula-v0.3.1-darwin-arm64.tar.gz\ngh attestation verify tula-v0.3.1-darwin-arm64.tar.gz --repo hsnice16/tula --signer-workflow hsnice16/tula/.github/workflows/release.yml" + "curl --proto '=https' --tlsv1.2 -fLO https://github.com/hsnice16/tula/releases/download/v0.3.2/tula-v0.3.2-darwin-arm64.tar.gz\ngh attestation verify tula-v0.3.2-darwin-arm64.tar.gz --repo hsnice16/tula --signer-workflow hsnice16/tula/.github/workflows/release.yml" }

diff --git a/site/app/llms.txt/route.ts b/site/app/llms.txt/route.ts index 6efd4c6..89762b8 100644 --- a/site/app/llms.txt/route.ts +++ b/site/app/llms.txt/route.ts @@ -70,7 +70,7 @@ ${INSTALL_COMMAND} \`\`\` Also \`brew install hsnice16/tap/tula\` and \`npm install -g @hsnice16/tula\` — the same -binary. macOS and Linux, 64-bit Intel and ARM; Windows through WSL. Every release +binary. macOS 13 or later and Linux, 64-bit Intel and ARM; Windows through WSL. Every release carries a published checksum, which the installer always checks and refuses on, and a sigstore-backed build attestation, which it checks wherever the GitHub CLI is installed and signed in to read one. diff --git a/site/components/Footer.tsx b/site/components/Footer.tsx index 3c42a6d..4c754e3 100644 --- a/site/components/Footer.tsx +++ b/site/components/Footer.tsx @@ -42,7 +42,9 @@ export function Footer() { return (