gum v2.0.0
A whole-repository review closed the gap between what the specification promised and what the binary did. The major version changes because several fixes now reject input that earlier versions accepted.
Highlights
gumno longer hangs at startup on a Linux host whose Secret Servicecsvandmarkdownare real encoders. Both were in the advertised format- Every result carries the
_expressionenvelope, so a caller can see that a - MCP tools enforce the
inputSchemathey advertise. A closed enum, a required tee_mode="failures"writes an artifact, the gain ledger records traffic, and- Five security fixes, including a symlinked confirmation signing key, a
collection is locked. Every OS keychain call is bounded.
enum and both returned TOON or JSON under the requested label.
profile dropped fields or collapsed rows. No production path emitted one.
property and additionalProperties:false were advisory.
a cache hit gets the same recovery artifact as a cold call. All three were dead paths.
risk-tier bypass that let gum.read execute a destructive variant, and a shared auth-subject fingerprint across every workload on GCE.
Install
brew install ehmo/tap/gum
# If already installed:
brew update
brew upgrade ehmo/tap/gum
gum --versionThe standalone installer remains available:
curl -fsSL https://raw.githubusercontent.com/ehmo/gum/main/install.sh | GUM_VERSION=v2.0.0 bashUpgrade notes
Most installations need no change. Four cases need action.
1. An expression profile using default_format. Rename the key to format. The specification and docs/expression-profile-dsl.md have always named it format; the parser read default_format, so every documented example failed to validate. The enum widens to toon|csv|json|markdown. raw is rejected, because it names a caller choice that skips shaping rather than an encoder a profile selects; pass --format raw instead. The config key output.default_format is unchanged.
-default_format = "json"
+format = "json"Validate before upgrading in place:
gum profile validate <path>2. A profile or call using field_mask_mode="dual_fetch". The mode is rejected with INVALID_ARGS. It promised a second unmasked upstream fetch to feed the recovery artifact and the kernel only ever issued one request. Use field_mask_mode="none" to send no mask, or drop the key.
3. A script calling MCP tools with arguments the schema forbade. Arguments are now validated. In particular gmail_search takes q, not query; flights_search takes departure_date, not departureDate; the four Gmail convenience tools require userId; and gum.code's destructive_scope items are {op_id, resource_key} objects, not strings.
4. A script reading the exit code of gum cache migrate, gum auth use-api-key, or gum auth use-ads-developer-token. All three returned 0 on failures that their own output reported. They now exit non-zero. A script with migrate || handle will start taking the handle branch on RSYNC_AMBIGUITY.
Breaking
- The expression-profile key is
format, notdefault_format, with the enum field_mask_mode="dual_fetch"is rejected before any upstream request.gum cache migrateexits non-zero onRSYNC_AMBIGUITY.gum auth use-api-keyandgum auth use-ads-developer-tokenexit non-zero- The nine MCP meta-tools, the two skill helpers and the 18 convenience tools
- The 18 convenience tool schemas use the §4.1 argument names of their backing
gum.read,gum.writeandgum.destructiveignoreallow_writeandgum.code'sdestructive_scopeitems are{op_id, resource_key}objects.gum.pollregistersRawJsonResultand reportsLRO_TIMEOUTas an error.gum.describe_op,gum.gainandgum.cache_statsregister the result- TOON keeps the keys of a map whose values are all empty.
truncate_stringsputs the ellipsis inside the limit, not past it.gum_printencodes a non-string value as JSON.- Response numbers decode through
json.Number, so an integer above 2^53 keeps - The per-op semantic TTL table is keyed on catalog op ids.
gum profile testtakes--name, not--profile, to pick one definition
toon|csv|json|markdown.
when the keychain write fails. A platform with no keychain backend keeps the environment-variable fallback and still exits 0.
validate arguments against their advertised inputSchema.
ops. drive.get_file drops mimeType, drive.share drops emailMessage, and gmail_get_message drops format, because no op declares them.
allow_destructive in their arguments.
schemas §2256 to §2258 name. skills_get drops its output schema.
its digits.
from a multi-profile file.
Added
gum plugin info <name>.--format=jsonemits the same object the--max-itemsongum read|write|destructive|callandmax_itemson thecsvandmarkdownencoders at stage 8.- The
_expressionenvelope on every §13 result shape, and the same unsupported_capabilitiesin the catalog ABI, and"partial"in the- The documented profile file envelope:
[output_profiles."<name>"], occurrence_counton a deduped row and<field>_truncatedsiblings fromGUM_KEYRING_TIMEOUTto override the keychain call bound.--destructive-budgetand repeatable--destructive-scopeongum code,- Five profile DSL keys documented that parsed only in Go:
projection,
gum://plugin/{name} MCP resource carries.
three MCP risk tools, replacing the profile's collapse_arrays cap for one invocation. all skips the stage. The override does not change the cache key or the args hash.
information on stderr as a one-line CLI shaping notice.
DescribeOpResult execution_support enums.
[override_bindings] and top-level [[tests]]. Override bindings now reach dispatch.
truncate_strings.
without which --allow-destructive could never execute.
flatten_singletons, sort_by, limit, omit_zero_counts.
Changed
- Stage 1 injects the variant's
default_fieldsas the upstream field mask when resultsis a recognised record-array key alongsideitems,dataand- The shaping notice states the row counts
dedupeandlimitremoved. _expression.omitted_countcounts every row shaping drops, and- CSV carries top-level scalars as repeated columns and secondary arrays as one
- Every kernel error carries a §7 envelope. Anything unstructured becomes
- A non-
gum_oauthauth failure carriesauth_strategy, gum plugin removeand reinstall run as registry transactions.- Catalog variants sort by
variant_idbefore JCS hashing. gen-catalogvalidates every variant'soutput_profilename at build.staticcheckpins to v0.8.1, which can read Go 1.26 export data.
a profile states none. No shipped variant declares default_fields, so nothing new goes upstream today.
messages.
_expression.lossy reads the cap in force.
count column each. Markdown no longer truncates cells.
SERVICE_DOWN with the cause kept in the chain.
missing_components and setup_command, and keeps its own error code.
Fixed
gumhung forever at startup on a locked Secret Service collection, whichtee_mode="failures"wrote nothing, because the write sat past the point the- The gain ledger was never written. Nothing set
dispatch.Config.Ledger - A cache hit skipped the recovery artifact, so a warm response named dropped
- TOON
Decodecut a string holding a newline in half and returned a nil dedupe,sort_byandlimitrequired a top-level array, which no realk=-1ongum.search_apispanicked the stdio server.- A confirmation token's replay marker was keyed on the caller's hex signature
canonicalizeArgsdrops null-valued keys at every depth, so an argumentgum cache clear --expiredcounted matched keys, not committed deletions.- The HTTP cache migration autocommitted per row, rewrote every
gum-cachekey - Stored plugin credentials reach the subprocess.
gum plugin setupwrote them gum plugin setupfailed on the second credential of a two-credentialgum auth use-oauth-clienterased the stored client secret when re-rungum auth logoutskipped thegum_oauthvault purge when no BYO client was--rawand--no-field-maskwere parsed and discarded.- A typed scalar nested in
body:='{"mode":5}'skipped the enum check the flat - The audit log's §11 omit-when-false rule re-added the keys it had just
covers headless, SSH, container and CI hosts. go-keyring parks on a D-Bus unlock prompt nobody can answer, and every invocation reached it through GrantedScopes. Keychain calls are bounded at 20s interactive, 2s background.
step-7 error path returns from.
outside tests, so gum gain reported an empty ledger however much traffic a profile had served. The file was also created 0644 instead of 0600, ignored XDG_DATA_HOME and the profile segment, and did not honour gain.enabled=false.
paths with no artifact to recover them from.
error. {"a": "x\ny=z"} decoded to {"a": "\"x", "y": "z\""}: truncated, with a dangling quote and a fabricated field. Verified with 30s of FuzzToonDecode, 11.2M executions, no crash.
Google list response is. dedupe also collapsed a result set to one row when its key fields were all absent.
string, so re-spelling that field in upper case bypassed it and one approval authorized unbounded executions.
spelled as null and an omitted argument share one cache key. Two profiles can no longer collide on the semantic cache key and be served each other's body.
into an unreachable form, and wrote every row with ttl 0.
to the keychain and nothing read them back, so every plugin needing a credential failed its canary.
manifest.
without a secret flag.
registered.
form gets.
dropped, in the wrong position. No shipped caller passes those keys.
Security
- A symlinked confirmation signing key is refused on both read paths. The
gum.readwithvariant_idpinned to a destructive variant executed it,gum plugin setupechoed every typed secret to the terminal.- Deleting one file disabled the plugin executable trust check.
plugins.lock - A quarantined plugin with no retry time was spawned anyway.
- A plugin manifest may not claim a host-owned environment variable in
- A plugin-catalog
schema_hashesvalue became an unvalidated path segment, so - On GCE, Cloud Run and GKE every workload derived the same auth-subject
- The
byo_oauthprincipal fingerprint keys on the account, not the refresh - Confirmation tokens no longer bind an empty principal.
govulncheckreports 0 vulnerabilities that gum's code calls and 0 in
lost-race adopt path followed symlinks, so anyone able to write the data directory could have gum sign destructive-confirmation tokens with a key they control.
because the risk-tier pre-check compared against the op's default variant while the kernel honoured the pin. The same handler read allow_destructive out of the caller's arguments. Reachable only through a plugin-contributed catalog; no shipped op has variants of differing risk class.
is now authoritative and the digest sidecar must agree with it.
needs_user_creds or env_allow, which a manifest could use to have setup prompt the operator for GUM_OAUTH_CLIENT_SECRET by display name.
a row carrying json/../../secret read any file the process could reach.
fingerprint. That fingerprint keys tee artifact HMACs, gum://results handles, cache entries and gain-ledger rows, so two service accounts sharing a profile directory could read and overwrite each other's artifacts. Resolve now reads the service-account email from the metadata server and fails with ADC_SUBJECT_UNKNOWN when it cannot.
token, which Google rotates on every re-login.
packages gum imports. One advisory remains in the module graph: GO-2026-5932 marks golang.org/x/crypto/openpgp unmaintained. gum imports no symbol from that package, and the advisory has no fixed version.
Known limitations
- No catalog variant declares
default_fields, so the §770 requirement that - Nothing converts a plugin registry variant row into a dispatchable catalog
- No catalog variant declares a non-
fullexecution_support, so field_mask_mode="dual_fetch"is refused rather than implemented.- The §10.2 HTTP and ETag cache backend has no production reader or writer.
- macOS binaries are not notarized. The Homebrew formula clears quarantine
every variant carry them is still unmet and the stage-1 field mask has nothing to inject. --fields completion is therefore empty.
variant, so gum plugin install runs no catalog validation. The plugin contract doc now says so.
unsupported_capabilities and "partial" are declared and tested but not exercised by shipped data.
http.db and http-wal.db are created and never used.
during installation. For standalone installs, inspect with spctl --assess --type execute --verbose gum and use xattr -d com.apple.quarantine gum if Gatekeeper rejects the binary.
Token savings
Measured with the release fixtures using a local build stamped 2.0.0. Run from the apps/gum directory of the matching source checkout:
gum gain --fixture-replay --format=toon
gum gain --fixture-replay --format=json| Default format | Total calls | Total tokens in | Total tokens saved | Aggregate savings |
|---|---|---|---|---|
toon |
10 | 3,922 | 0 | 0 % |
json |
10 | 3,922 | -12 | 0.31 % overhead |
Verification
All seven jobs in the v2.0.0 release workflow passed: tag validation, live docs match, tests, govulncheck, the GoReleaser build, the independent four-platform rebuild, and the provenance comparison.
The four downloaded archives matched checksums.txt, and each one matched its subject digest in gum-v2.0.0.intoto.jsonl. The extracted darwin/arm64 binary matched release-binaries.sha256.
A local rebuild reproduced all four published binaries. The command in Reproducibility below, run from a clean clone at tag v2.0.0 on one darwin/arm64 host with GOTOOLCHAIN=go1.26.7, produced these hashes:
| Target | sha256 |
|---|---|
| darwin/amd64 | e7cfd445523b3ea6bbddd4a24cf728f4c4b75d003f0f1f1a365187962d7304a5 |
| darwin/arm64 | 332081264900db51e6602826ffc4fa94b2bbe156885185752a27bc0e138c51d0 |
| linux/amd64 | 4b258174657c55210caef26a0fbe37ff8201d1d3373caf368638859202423af5 |
| linux/arm64 | 1cc2dba8270dda1b4274732487552070f84f8fc0d49a5f5769b3a182df4fc156 |
Each hash matches the matching line in release-binaries.sha256.
The Homebrew installation reports 2.0.0 and gum doctor passed every check. brew audit --strict --online --os=all --arch=all ehmo/tap/gum and brew test ehmo/tap/gum both passed.
Reproducibility
git clone https://github.com/ehmo/gum.git
cd gum && git checkout v2.0.0
cd apps/gum
GOTOOLCHAIN=go1.26.7 CGO_ENABLED=0 GOOS=<os> GOARCH=<arch> go build -trimpath \
-ldflags='-s -w -X main.version=2.0.0' ./cmd/gum
sha256sum gumAll four published binaries were built with go1.26.7. The hashes above were produced by cross-compiling every target from one host, so a single machine can check the whole set.
Build from a full clone, not from a linked git worktree. Go embeds the commit revision in the binary, and it silently skips that stamp in a linked worktree, which changes the hash.
