Merged
Conversation
3019463 to
4aa74f8
Compare
4aa74f8 to
03ef69f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
@bookedsolid/rea@0.4.0
Minor Changes
a27fc06: Registry
env:values now support${VAR}interpolation.Registry entries can now reference process env vars via
${VAR}syntax in the explicitenv:map. Enables token-bearing MCPs (discord-ops, github, etc.) to route through rea-gateway without committing literal tokens toregistry.yamland without widening the restrictiveenv_passthroughallowlist. Missing vars fail the affected server at startup (fail-closed); the rest of the gateway still comes up.env_passthroughbehavior is unchanged.Grammar (deliberately minimal)
${VAR}— curly-brace form in env values. Keys are never interpolated.$VAR(ambiguous with shell semantics).${VAR:-fallback}) — kept out of the 0.3.0 surface.$(cmd)) — never.${FOO}resolves to a string that itself contains${BAR}, the inner text is treated as a literal. This is intentional: a hostile env var's contents cannot trigger further lookups.^[A-Za-z_][A-Za-z0-9_]*$. Empty${}or illegal identifier chars are rejected at load time with a clear error.Fail-closed on missing vars
If any
${VAR}referenced by an enabled server is unset at spawn time:Example
Export the tokens in the same shell that runs
rea serve:Redact-by-default contract
The template in
registry.yamlis auditable (it commits); the runtime value is not. Env values resolve only insidebuildChildEnvand pass straight to the child transport — they never flow intoctx.metadataor audit records. A newsecretKeyssignal identifies env entries that are secret-bearing (either because the key name matches/(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL)/ior because a${VAR}reference in the value does), so any future telemetry path can make the right call without re-deriving the heuristic.Compatibility
env_passthroughsemantics unchanged — still refuses secret-looking names at load time. The sanctioned path for secrets is nowenv: { NAME: '${ENV_VAR}' }.6e84930: feat(gateway): G5 — gateway observability. Adds three user-visible surfaces:
rea status— new CLI command that reports live-process state for arunning
rea serve(pid, session id, metrics endpoint URL), the policysummary (profile, autonomy, blocked-paths count, codex_required, HALT), and
audit log stats (lines, last timestamp, tail-hash smoke). Supports
--jsonfor composing with
jqand future tooling.rea checkremains theauthoritative on-disk snapshot —
rea statusis the running-process view.src/gateway/log.ts. HonorsREA_LOG_LEVEL(info default; debug/warn/error supported). Pretty-printswhen stderr is a TTY, emits JSON lines on non-TTY sinks. No new deps —
~200-line no-dep implementation.
rea servewires the logger intoconnection open/close/reconnect events and circuit-breaker state transitions.
[rea-serve]prefix preserved in pretty mode so existing grep-based smoketests (helix) continue to match.
/metricsHTTP endpoint. Opt-in viaREA_METRICS_PORT— no silent listeners. Binds
127.0.0.1only, serves Prometheus textexposition, exposes per-downstream call/error/in-flight counters, audit
lines appended, circuit-breaker state gauge, and a seconds-since-last-HALT
gauge. Rejects non-GET methods with 405 and non-
/metricspaths with 404(no request-path reflection in response bodies).
node:httponly — noexpress/fastify.
rea servenow writes a short-lived breadcrumb pidfile at.rea/serve.pidand session state at
.rea/serve.state.jsonforrea statusintrospection.Both files are removed on graceful shutdown (SIGTERM/SIGINT). The README
non-goal "no pid file" is narrowed to clarify that this is a read-only
breadcrumb, not a supervisor lock — there is still no
rea start/rea stop.862440d: G6 — Codex install assist at init time, and pre-push hook fallback installer.
rea initnow probes for the Codex CLI when the chosen policy setsreview.codex_required: true. If Codex is not responsive, init prints aclear guidance block pointing at the Claude Code
/codex:setuphelperinstead of silently succeeding;
/codex-reviewwould otherwise fail later.In no-Codex mode the probe is skipped entirely (no wasted 2s, no confusing
output).
rea initalso installs a fallbackpre-pushhook in the active githooks directory when Husky is not the consumer's primary hook path. The
fallback is a thin
execinto.claude/hooks/push-review-gate.shsothere is still exactly one implementation of the push-review logic. The
installer detects
core.hooksPathcorrectly, refuses to stomp foreignhooks (no marker → leave alone), and is idempotent across re-runs.
rea doctorgains a "pre-push hook installed" check that requires anexecutable pre-push at whichever path git is actually configured to fire
(
.git/hooks/pre-pushby default, or the configuredcore.hooksPath).A
.husky/pre-pushalone — withoutcore.hooksPath=.husky— no longersatisfies the check, closing the 0.2.x dogfooding gap where protected-
path Codex audit enforcement could be silently bypassed.
Non-goals (explicitly out of scope for G6): the
push-review-gate.shlogic itself is unchanged, the protected-path regex is unchanged, and no
middleware was moved.
795a8bc: G7 — Proxy-poisoning defense via TOFU fingerprints.
The gateway now fingerprints every downstream server declared in
.rea/registry.yamlon first startup and persists the result to.rea/fingerprints.json(versioned JSON, schema-validated). On everysubsequent
rea serve, each server is reclassified asunchanged,first-seen, ordrifted:Unchanged — proceed silently.
First-seen — LOUD stderr block announcing the new fingerprint,
structured
tofu.first_seenaudit record, allow the connection. Thisis deliberately noisy so a poisoned registry at first install is
visible in stderr, logs, and audit trail at the same time.
Drifted — stderr block,
tofu.drift_blockedaudit record (statusdenied), and the server is DROPPED from the downstream pool. Otherservers stay up; the gateway does not fail-close on drift of a single
server. To accept a legitimate rotation for one boot, set
REA_ACCEPT_DRIFT=<name>(comma-separated for multiple).The fingerprint is path-only:
name,command,args, sortedenvKEY SET, sortedenv_passthrough, andtier_overrides. EnvVALUES are intentionally excluded so rotating a token (
GITHUB_TOKENetc.) does not trip drift. We do NOT hash the binary at
config.command— that would be a slow-boot tax on every restart, legitimate MCP
upgrades would trip false-positive drift, and host-binary compromise is
a separate G-number, not G7. The G7 threat is YAML tampering, which the
canonicalized config hash covers.
A corrupt or schema-invalid
fingerprints.jsonfails the gatewayclosed: we never silently reset TOFU state, because that would downgrade
drift detection to first-seen acceptance. The operator can delete the
file deliberately to re-bootstrap.
rea doctorgrows afingerprint storerow that surfaces first-seen / drifted counts without waiting forrea serve.fa66785: G9 — Injection tier escalation: clean / suspicious / likely_injection.
Behavior change on upgrade for external profiles — read this before upgrading if you depend on the 0.2.x deny-on-any-match behavior.
The injection middleware (
src/gateway/middleware/injection.ts) was a single-threshold binary: any known phrase match in a tool result denied the call. That was too blunt — a single literal match at write tier is noise-prone, while multi-literal + base64-decoded matches at any tier are near-certain attacks that should deny regardless of context. G9 introduces a three-level classifier and a policy knob governing the middle bucket.Classification rules
Every PostToolUse scan now returns one of three verdicts (recorded in
ctx.metadata.injectionand exported to the audit log):clean— no match → allow, no log.suspicious— exactly ONE distinct literal pattern at write/destructive tier, with no base64-decoded match → warn (stderr + audit metadata). Whether this denies is governed by the newpolicy.injection.suspicious_blocks_writesflag.likely_injection— any of: ≥2 distinct literal patterns, any base64-decoded match, any match at read tier, or an unknown tier (fail-closed) → always deny, regardless of the flag.The narrow relaxation (the reason for the loud callout)
In 0.2.x, a single literal match at any tier denied. In 0.3.0, for profiles that do NOT pin the new flag (
open-source,client-engagement,minimal,lit-wc, and any hand-authored policy that omits theinjection:block), a single literal match at write/destructive tier is classifiedsuspicious→ warn-only by default. This means the call is ALLOWED through. The warning is written to stderr and the audit record still capturesverdict: suspiciouswith the matched phrase, but the tool result is NOT blocked.The
bst-internalandbst-internal-no-codexprofiles pinsuspicious_blocks_writes: true, preserving the 0.2.x strict-deny posture. This repo's own.rea/policy.yamlcontinues to inherit that strict posture by profile.Why ship narrower: silent tightening on upgrade is a worse footgun than the narrower default. External consumers who want the strict 0.2.x behavior can opt in explicitly:
likely_injectionremains an unconditional deny. The attacker cases that matter most (multi-pattern coordinated injection, base64-obfuscated payloads) still deny in every profile.Policy flag
New optional top-level policy block:
false(schema default):suspicious→ warn-only, tool result allowed through. Audit record carriesverdict: suspicious.true:suspicious→ deny at write/destructive tier (matches 0.2.x deny-on-literal semantics for writes). Audit record carriesverdict: suspiciousplusstatus: denied.likely_injectiondenies in either case.The loader defaults are
false; thebst-internal*profiles pintrue.Audit metadata
On any non-clean verdict the middleware writes
ctx.metadata.injection, which the audit middleware exports verbatim into the per-call record:{ "verdict": "likely_injection", "matched_patterns": ["disregard your", "ignore previous instructions"], "base64_decoded": false }matched_patternsis a sorted list of distinct phrase strings from the built-in phrase list. NO input payload text is ever written to metadata (guard against leaking the attack content through audit trail redaction bypass).Legacy
injection_detection: warninteractionOperators who pinned 0.2.x
injection_detection: warncontinue to get warn-only forsuspicious. However, under G9,likely_injection(multi-literal or base64-decoded) will now DENY even wheninjection_detection: warnis set. This is a narrow tightening for operators who explicitly pinned warn mode — the classifier's whole value is distinguishing high-confidence attacks from ambiguous single-hits, and high-confidence attacks deserve a deny. If you need the full-allow-through behavior for all matches (not recommended), disable the middleware by removing it from your gateway configuration.Stderr format change
The warning line format changed from
[rea] INJECTION-GUARD: ...to[rea] INJECTION-GUARD (<verdict>): .... Log consumers grepping for the old exact prefix should update their filters.Pattern list unchanged
This PR does NOT modify the built-in
INJECTION_PHRASESlist. Extending or reshaping the pattern set is explicit future work (a per-pattern "deny-tag" extension point is stubbed with a TODO inclassifyInjection).New public exports
From
src/gateway/middleware/injection.ts:classifyInjection(scan, tier) → InjectionClassification— pure classifierscanStringForInjection(s, result, safe)/scanValueForInjection(v, result, safe)— structured scannersdecodeBase64Strings(input: unknown) → string[]— pure base64 probeINJECTION_METADATA_KEY—'injection', the ctx.metadata key for the verdict recordInjectionClassifierMetadata,InjectionScanResult,InjectionClassification— typesBack-compat:
scanForInjection(string, safe) → string[]is retained as a wrapper soscripts/lint-safe-regex.mjsand any external consumer that imported it continue to work.Patch Changes
6a2f00c: ci: tarball smoke workflow (packaging regression gate)
Adds
scripts/tarball-smoke.sh, invoked on every PR and every push tomainvia a newTarball smokeCI job, and re-invoked in the release workflow immediately beforechangeset:publish. The script packs the repo withpnpm pack, installs the resulting tarball in an isolated tempdir, and asserts:rea --versionmatchespackage.jsonversionrea --helpprints the full command treerea init --yes --profile open-sourcecreates the expected layoutrea doctorreturns OK on the freshly installed artifacts.,./policy,./middleware,./audit) resolvesThis catches packaging regressions — missing files from the
files:allow-list, brokenexportsmap, shebang / chmod issues onbin/rea, postinstall failures, dependency-resolution drift — before the tarball reaches npm. No runtime behavior change.Branch protection on
mainshould be updated to includeTarball smokeas a required check alongside the existing seven.52e655d: fix(gateway/blocked-paths): restore absolute-path matching and close content-key + URL-escape bypasses
Address three post-merge Codex findings on BUG-001:
blocked_pathsentries (e.g./etc/passwd) no longer matched after the content-substring narrowing — restored.CONTENT_KEYSblanket skip onname/value/label/tag/tags/titlelet{name: ".env"}bypass — now only skipped when value is not path-shaped.%XXURL-escape silently disabled decode, enabling.rea/trust-root bypass via%2Erea%2F— now fails closed on malformed escapes.1e1f247: fix(gateway): G5 observability — post-merge Codex blocker sweep. Eight
BLOCKING findings from adversarial review of the G5 feature (merged as
PR feat(gateway): G5 — gateway observability (rea status, logs, /metrics) #22) are resolved ahead of 0.4.0:
startMetricsServernow validatesthe
hostoption against a strict loopback allowlist (127.0.0.1,::1). Anything else —localhost,0.0.0.0,::, any LAN IP — throwsa
TypeErrorBEFORE a socket is opened. Closes the path where a callercould accidentally expose the unauthenticated
/metricsendpoint tothe network. A test-only
__TEST_HOST_OVERRIDEsymbol preserves thehostname-resolution test path; the symbol is unreachable from YAML,
JSON, or CLI deserialization.
rea servenow writes.rea/serve.pidand
.rea/serve.state.jsonatomically (stage-to-temp +rename(2))and cleans them up only when the file still carries this process's pid
(pidfile) or session id (state). Two overlapping
rea serveinvocations in the same
baseDirno longer clobber each other'sbreadcrumbs on the first instance's shutdown.
rea statuspretty mode. Everydisk-sourced string field (
profile,autonomy_level,halt_reason,session_id,started_at,last_timestamp) is scrubbed through anew
sanitizeForTerminalhelper before reaching the operator'sterminal. C0 control bytes (0x00-0x1F) and DEL (0x7F) are replaced
with
?— the ESC byte that initiates CSI/OSC sequences and the BELbyte that terminates OSC 8 hyperlinks are both scrubbed. JSON mode
output is untouched (JSON.stringify already escapes safely).
createAuditMiddlewareandcreateKillSwitchMiddlewarenow accept an optionalMetricsRegistry.The audit middleware increments
rea_audit_lines_appended_totalonevery successful fsynced append; the kill-switch middleware refreshes
rea_seconds_since_last_halt_checkon every invocation (previouslythe gauge only reflected the startup-time mark).
rea servewiresthe same registry into both. Counter failures never crash the chain.
redactFieldhook applied to every string-valued field beforeserialization.
rea serveinstalls a redactor compiled from thesame
SECRET_PATTERNSthe redact middleware uses, so downstreamerror messages that carry env var names, argv fragments, or file
paths with credential material reach stderr already scrubbed. A
redactor that throws falls back to
[redactor-error]per field —the record itself is never dropped.
rea statusno longer reads thewhole
audit.jsonlinto a buffer to count lines or find the lastrecord. Line count uses a streaming 64-KiB-chunk scan; the last
record is sourced from a positioned 64-KiB tail-window read. On
multi-hundred-MB chains the memory footprint is bounded to the
window size plus the scan buffer.
close().startMetricsServertracks everylive socket and guarantees
close()resolves within 2 s even whena Prometheus scraper is holding a keep-alive connection open. On
deadline the server calls
closeIdleConnections()(Node 18.2+)and destroys any surviving tracked sockets. The timer is
unref'dso it never holds the process open.
that contain a cyclic reference no longer drop the entire record.
A safe-stringify wrapper substitutes a stable
[unserializable]placeholder so the operator still sees the event, level, and
message.
b6a69ff: fix(cli): harden pre-push fallback installer (G6 post-merge hardening)
Close four classification/write-path issues in the G6 pre-push fallback installer: existence-only skip bypass (doctor pass on foreign hooks), classify/write TOCTOU, substring
FALLBACK_MARKERcollision, and deterministic tmp-filename collisions.795a8bc: docs(registry/tofu): tighten rename-bypass defense scope
Clarify in
classifyServersthat the set-difference heuristic catches rename-with-removal (attacker removes old trusted entry at the same moment the tampered new entry appears), not rename-with-placeholder (attacker leaves old entry in place as a decoy, adds tampered new entry under a new name).Rename-with-placeholder lands as
first-seenwith a LOUD stderr banner — the documented, intentional TOFU contract for new entries. No code change; the docstring previously oversold the defense's scope.a5cca2a: fix(injection): guard base64 probe on timeout + correct changeset default-behavior doc
Address four post-merge Codex findings on the G9 three-tier injection classifier (PR feat(injection): G9 — three-tier classifier (clean / suspicious / likely) with opt-in strict flag #25):
denyOnSuspiciousflag behavior clarified: thesuspicious_blocks_writesflag defaults tofalsewhen omitted (preserving the 0.3.x warn-only default for unset installs). Consumers who want the tighter block posture must opt in explicitly withinjection.suspicious_blocks_writes: true. Thebst-internal*profiles pintrue. This was the correct approach: silently switching to block behavior on upgrade would be a breaking change for 0.3.x consumers.pretend you are,roleplay as). Broader candidates likeact as a/act as anwere considered but dropped: at read tier a single literal match escalates tolikely_injection, which would falsely deny benign prose such as "this proxy can act as a bridge." Pattern-set extensibility via policy is filed as G9.1 follow-up.decodeBase64Stringswas exported and tested but never wired into the middleware execution path — 28 lines of dead code advertised as a second-opinion base64 probe. It is now called from the middleware after the primary scan; any phrase detected in a decoded whole-string payload is merged intobase64DecodedMatches, triggering classification rule chore(ci)(deps): bump actions/setup-node from 4.0.3 to 6.3.0 #2 (likely_injection). The call is guarded behind!scanTimedOutso a timeout-induced incomplete scan cannot force unbounded CPU/memory in the base64 probe path; aMAX_BASE64_PROBE_LENGTHcap (16 KiB) is also applied per-string insidedecodeBase64Strings.injection.regex_timeoutbut noverdictfield underinjection. A newverdict: 'error'value is emitted when a timeout produces no actionable signal, giving downstream audit consumers a stable record shape. A newInjectionMetadataSchemazod schema is exported from the injection middleware module for internal test coverage; promoting it to a public package entrypoint is tracked as G9.2 follow-up (the module is not reachable via the currentexportsmap, so do not rely on it from outside this repo yet).likely_injectioncontinues to deny unconditionally in all configurations.4f4d19d: ci: close tarball-smoke coverage gaps (post-merge)
Address four post-merge Codex findings on the tarball-smoke gate:
.claude/agents/+.claude/hooks/only — now tree-equality asserts against.claude/commands/, recursivehooks/**(walkshooks/_lib/), and the shipped.husky/{commit-msg,pre-push}so a tarball missing those surfaces fails loud with a unified-diff delta..git/hooks/{commit-msg,pre-push}are also asserted as the real enforcement surface on a fresh consumer.npm init -ytemp files were not actually cleaned beforegit init— comment now matches behavior (rm -f package.json package-lock.json).${...}-style expansions do not break the require() call.EXITonly — now catchesHUP/INT/TERMso Ctrl-C during a local run does not leave/tmp/rea-smoke-*tempdirs behind.c0b8a2b: fix(gateway/blocked-paths): eliminate content-substring false positives (BUG-001)
The blocked-paths middleware previously substring-matched policy patterns against every string value in the argument tree, including free-form
contentandbodyfields. A secondary fallback stripped the leading.from patterns like.env, which caused the naked substringenvto match inside any string containing "environment" — breaking legitimate note creation on Helix (obsidian__create-notewith 14 KB of prose that mentioned GitHub Environments and.envfiles in passing).The matcher is now key-aware and path-segment aware:
path,file_path,filename,folder,dir,src,dst,target, …) are always scanned.content,body,text,message,description,summary,title,query,prompt,comment, …) are never scanned, regardless of how the value looks.~, is a dotfile, or matches a Windows drive prefix).*and?are single-segment globs (they do not cross/), and all other regex metacharacters in a pattern are escaped. Trailing/on a pattern means "this directory and everything under it"..rea/is still unconditionally enforced regardless of policy.The policy file format is unchanged. Existing installs that list both
.envand.env.*inblocked_pathscontinue to block every.envvariant. If a policy previously relied on accidental substring matching (e.g., listing only.envand expecting.env.localto be blocked), add.env.*explicitly — this is how thebst-internalprofile already works.c4c4cc8: fix(cli): correct
rea servehelp description — the serve command is no longer a stub. Also refresh.rea/install-manifest.jsonto reflect the post-G10/G1 content hashes for.claude/hooks/push-review-gate.shand.husky/pre-push.