Files
gstack/lib/redact-engine.ts
T
Garry Tan 9fd03fae9e v1.58.4.0 fix: high-priority community bug wave + PTY plan-mode smoke gate (#2077)
* fix(gbrain): stop forcing GBRAIN_PREPARE on transaction-mode poolers (#1965)

buildGbrainEnv auto-set GBRAIN_PREPARE=true whenever DATABASE_URL targeted
port 6543, and the /sync-gbrain capability check exported it for the rest
of the skill run. Both had the semantics inverted: gbrain auto-disables
prepared statements on transaction-mode poolers because they break every
write there ("prepared statement does not exist"); GBRAIN_PREPARE=true is
gbrain's documented override for SESSION-mode poolers on 6543, not a
requirement for transaction mode. The #1435 search symptom the auto-set
worked around was fixed gbrain-side.

Remove both force-sets. A caller-set GBRAIN_PREPARE (either value) still
passes through untouched, preserving the session-mode-on-6543 escape hatch.
isTransactionModePooler stays exported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain): classify probe timeout as its own status; sync proceeds instead of skipping (#1964)

The 5s engine probe misclassified healthy-but-slow engines (cold Supabase
pooler connections measured at 6.9-10.7s) as broken-config, so /sync-gbrain
silently skipped code+memory and told the user their config was malformed.

- New "timeout" status: probe killed at the deadline with no recognized
  stderr pattern. Default deadline is now 15s, overridable via
  GSTACK_GBRAIN_PROBE_TIMEOUT_MS (tests set 300ms against a fake that
  sleeps 2s).
- Sync stages PROCEED on timeout with a stderr warning naming the env knob;
  a genuinely-dead engine surfaces its real error at the first operation
  instead of a false config diagnosis.
- Consistency everywhere "ok" gated behavior: gstack-gbrain-detect --is-ok
  exits 0 on timeout, and gen-skill-docs' detection gate accepts it, so a
  slow engine no longer silently suppresses brain-aware features.
- Status cache: key now includes the effective probe timeout (raising it
  invalidates a cached timeout) and GBRAIN_HOME; config detection honors
  GBRAIN_HOME so relocated-home users stop being misclassified as
  missing-config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bins): cygpath-normalize SCRIPT_DIR for bun imports; surface learnings-log errors (#1950)

Under Windows git-bash, pwd yields a POSIX path (/c/Users/...) that Bun on
Windows cannot resolve as an ES module specifier. gstack-learnings-log
interpolates SCRIPT_DIR into a bun -e import, so every invocation died with
"Cannot find module" — and 2>/dev/null swallowed the error, silently
dropping every AI-logged learning for Windows users.

- 3-line cygpath -m guard in gstack-learnings-log and gstack-question-log
  (which gains the same import shape in the next commit). Matches the
  duplicated IS_WINDOWS convention in setup; no shared shell lib exists.
- learnings-log adopts question-log's set +e / TMPERR capture pattern
  wholesale: validation errors now print to stderr. The old
  `if [ $? -ne 0 ]` check was dead code under set -euo pipefail — the
  script exited at the failing assignment before reaching it.
- New test/bin-windows-bun-import-paths.test.ts: static invariant (any
  bash bin interpolating $SCRIPT_DIR into a bun -e import must carry the
  guard) + behavioral end-to-end run invoked via `bash <bin>` — added to
  the windows-free-tests workflow list so the conversion is proven on the
  only platform where the bug exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(question-log): dedupe INJECTION_PATTERNS via lib/jsonl-store (#1934)

bin/gstack-question-log carried a local copy of the injection-pattern list,
so pattern fixes to lib/jsonl-store.ts never propagated — including the
/override[:\s]/i false-positive fix arriving via community PR #1940.
Import the shared hasInjection instead (enabled by the previous commit's
cygpath guard). question-log also gets the lib's stricter superset
(human:, disregard, from-now-on, approve-all patterns).

Tests pin the contract in a #1940-order-independent way: an "Override:
ignore all previous instructions" header is rejected, "prose overrides the
deterministic table" is accepted, and a static invariant keeps local
INJECTION_PATTERNS duplicates out of the bin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): community-pulse + both dashboards never report fake zeros (#1947)

The security-signaling surface failed open at three layers — every failure
mode read as a reassuring "0 attacks" / "0 installs":

- community-pulse edge function: supabase-js returns {data,error} without
  throwing, and all five queries discarded `error` — a DB outage produced
  real-looking zeros via the SUCCESS path, and the catch (also returning
  zeros with HTTP 200) was unreachable for query failures. Every query now
  destructures and throws; the catch serves the stale cache (marked
  "stale": true) when one exists, else 503 {"error":"pulse_unavailable"}.
  Success responses carry "status":"ok" so clients can distinguish
  authoritative data from legacy backends. NOTE: the edge function deploys
  out-of-band (supabase functions deploy community-pulse).
- gstack-security-dashboard: captures the HTTP status; non-200 / network
  failure / error body / missing section → "unknown — backend error";
  jq missing → "unknown — install jq" (the lossy grep fallback broke on
  nested arrays and under-reported attacks as zero — removed); a 200
  without the new marker shows figures with an "unverified (legacy
  backend)" note. Also fixes a latent display bug: the TOTAL grep matched
  the digit 7 inside "attacks_last_7_days" and misreported every count.
- gstack-community-dashboard: same class — curl || echo "{}" plus
  grep || echo "0" printed "Weekly active installs: 0" on any failure.
  Now "unknown — backend error (HTTP N)".

test/security-dashboard-fallback.test.ts pins the matrix (200+marker,
200-legacy, 503, network failure) x (jq present, jq absent) for both bins:
"unknown" states never render as 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(telemetry): redact error_message spans before they leave the machine (#1947)

error_message was uploaded with only quote/newline escaping — stack traces
and failed-API errors can embed credentials, private paths, and hostnames,
and the sync path strips only _repo_slug/_branch.

New lib/redact-engine.ts export redactFindingSpans(): replaces EVERY
finding's span with <REDACTED-{id}> regardless of tier (applyRedactions is
the interactive PII-only path and exits nonzero on credential findings, so
it can't serve machine egress). Returns null when a span can't be located —
callers drop the whole payload rather than risk a leak.

gstack-telemetry-log pipes error_message through it at LOG time, so the
local JSONL at rest is clean too; surrounding text survives for crash
triage. FAIL CLOSED: bun missing, engine error, or non-JSON-string output
all null the field. Tests pin: embedded ghp_ token → <REDACTED-github.pat>
with context intact; redactor unavailable → null; raw bytes on disk never
contain the token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redact): prepush guard fails closed on git failure; /ship owns hook install (#1946)

Two gaps closed:

1. Fail closed. The git() helper returned "" on ANY non-zero exit or
   maxBuffer overflow (status null), addedLinesFor produced an empty
   string, and the push sailed through unscanned — fail-open on exactly
   the oversized-diff case where a large secret-bearing blob is most
   likely. The diff call now uses a strict variant that throws; main
   blocks with a clear message naming the GSTACK_REDACT_PREPUSH=skip
   escape valve. Probe calls (symbolic-ref, rev-parse, merge-base) keep
   the permissive helper — their failures are normal control flow.

2. Install path. The hook was installed by nothing ("opt-in, installed by
   nothing" was the issue's words). ./setup runs in the gstack checkout —
   the wrong repo for a per-project hook — so it gets a one-line hint
   only. /ship owns per-repo install: config redact_prepush_hook=true +
   hook missing → silent install (consent already given); config unset +
   no ~/.gstack/.redact-prepush-prompted marker → one-time machine-wide
   AskUserQuestion offer, answer persisted. ship/SKILL.md regenerated in
   this same commit (check-freshness bisect discipline).

Tests: unscannable diff (bogus SHAs) → exit 1 + valve named; empty-but-
successful diff → exit 0; static asserts pin setup as hint-only and the
ship template as the installer surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(redact): six new credential patterns — GitLab, HuggingFace, npm, DigitalOcean, Bearer, GCP SA (#1946)

Coverage gaps from the #1946 security review, including token types for
tooling gstack itself drives (glab):

HIGH (block): gitlab.token (glpat-/glptt-/gldt-), huggingface.token (hf_),
npm.token (npm_), digitalocean.token (dop_v1_), gcp.service_account (the
JSON-escaped "private_key" form that dodges pem.private_key's literal-block
match when minified, confirmed by "private_key_id" proximity).

MEDIUM (warn): auth.bearer — the most FP-prone shape in the set (docs are
full of "Authorization: Bearer <token>"), so it requires header-context
proximity and the same entropy>=3.0 + placeholder validator recipe as
env.kv. "Bearer YOUR_TOKEN_HERE" never fires; calibration over coverage,
per the cries-wolf principle.

All shapes are linear-time; test/redact-pattern-lint.test.ts covers them
automatically. Engine tests add positive + placeholder-negative cases per
pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: coverage-audit additions for the fix wave

Ship Step 7 gap-fill (all passing, 248 tests across the touched suites):
memory + dream stage probe-timeout proceeds, gbrain-detect override paths,
stale-flag passthrough, 200-body-missing-.security fail-closed case,
telemetry redaction edges, and credential-pattern edge cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pre-landing review fixes

Review army findings (1 critical, auto-fixed with regression tests):

- CRITICAL (security specialist, verified live): redactFindingSpans spliced
  only the regex capture span, and pem.private_key / gcp.service_account
  capture just the BEGIN-header — the key body survived "redaction" and
  shipped via telemetry. Marker-only patterns now drop the whole payload
  (null, fail closed). Overlapping spans (Bearer+JWT on the same bytes) are
  coalesced before splicing so stale offsets can't leave partial secret
  bytes behind.
- gitStrict: drop the dead `|| r.status === null` disjunct (null !== 0
  already covers it); add the signal-kill/null-status regression test the
  docstring promised.
- security-dashboard human mode flags stale snapshots ("figures may be out
  of date") instead of presenting frozen counts as current.
- community-dashboard marker check uses jq when available — the grep-only
  variant misclassified whitespaced/reserialized bodies as legacy.
- telemetry fail-closed test now shadows bun with a failing stub
  (deterministic on any host layout); stale "five status cases" describe
  title renamed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: adversarial review fixes (Claude + Codex cross-model passes)

Both adversarial passes ran against the wave; every FIXABLE finding landed
with a regression test:

- probeTimeoutMs clamps to >=1ms: a fractional override floored to 0, and
  execFileSync treats timeout:0 as NO timeout — the probe that exists to
  bound hangs could hang forever (found by both models independently).
- /ship silent hook install now requires the hooks dir to live inside
  .git: with core.hooksPath (husky's COMMITTED .husky/), the chaining
  installer would have renamed the team's committed pre-push and written a
  machine-local wrapper into the working tree (found by both models).
- gstack-config gbrain-refresh accepts the "timeout" status — the last
  consumer still gating on literal "ok" (Codex); gstack-gbrain-detect's
  config-derived fields honor GBRAIN_HOME so the detection JSON can't
  report status ok alongside config_exists false (Codex).
- prepush: a remote sha absent locally (shallow clone / stale fetch) falls
  back to the merge-base/empty-tree range — scans MORE, never blocks a
  legitimate push into training users toward --no-verify.
- dashboards: curl's own 000 no longer doubles to "HTTP 000000"; the
  community dashboard flags stale snapshots like the security one; array
  sections parse via jq (the sed/grep loops truncated at the first ']');
  the no-jq marker grep tolerates whitespace.
- telemetry: multi-line redactor output nulls the field instead of
  corrupting the JSONL record; setup's hint fires only when the config key
  is genuinely unset (an explicit false is a recorded decline); the /ship
  prompt marker honors GSTACK_HOME.

Kept as designed (cross-model tension noted): Bearer stays MEDIUM in the
prepush gate — a HIGH Bearer would block every docs example; the entropy
validator can't eliminate that FP class, and MEDIUM warns visibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.57.11.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: P1 TODO — eval harness live progress + incremental persistence

Root-caused during this ship: a killed eval run was indistinguishable from a
healthy one for hours (per-file output buffering across mega test files, no
incremental eval-store writes, no honest liveness signal). Full context and
starting points in the entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: fix operational-learning E2E fixture — copy lib/jsonl-store.ts

Pre-existing breakage, proven on main: gstack-learnings-log has imported
lib/jsonl-store.ts (shared injection patterns) since v1.57.5.0 / #1910, but
the fixture copies only the bin scripts — the bin exits 1 before writing
anything, on main silently (stderr swallowed) and on this branch loudly
(the #1950 error-surfacing made the four-day-old failure visible). A real
install always ships bin/ and lib/ together; the fixture now does too.
Verified: the fixture-shaped invocation writes the learning (exit 0) with
lib present, exits 1 on both main and this branch without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ios-qa): isolate E2E tests under --concurrent (3 real races)

The ios-qa E2E file failed intermittently under `bun test --concurrent`
(the eval harness default). Three distinct shared-state races, all fixed:

1. Shared pidfile: a module-level `workDir` reassigned in beforeEach was
   clobbered by parallel tests, so concurrent daemons collided on the same
   pidfile and the loser returned `already_running`. Each test now gets its
   own dir via makeWorkDir().
2. process.env path globals: tests set GSTACK_IOS_AUDIT_PATH /
   _ATTEMPTS_PATH / _ALLOWLIST_PATH on the shared process env; concurrent
   tests stomped each other's audit/attempts destinations. Threaded
   auditPath/attemptsPath/allowlistPath through DaemonOptions (and
   mintForCaller) as explicit args — env is no longer load-bearing.
3. afterEach cleanup race: the per-test cleanup drained a shared dir array,
   so the first test to finish deleted still-running tests' workDirs
   mid-assertion. Moved to afterAll (cleans once, after all settle).

Verified: 5/5 clean full-suite runs at --max-concurrency 15 (was
intermittent); daemon unit suite 91/91; daemon source compiles. The paths
default to the env-derived locations when options are omitted, so the
production CLI path is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(pty): pin spawned claude to EVALS model chain (default claude-sonnet-4-6)

launchClaudePty spawned the interactive `claude` TUI with no --model flag, so
the child inherited the operator's ~/.claude/settings.json model. On a
slow-thinking model that meant 5+ min of extended thinking on empty plan-mode
context, timing out the plan-mode smoke tests regardless of contention. Pin the
model via opts.model ?? EVALS_MODEL ?? 'claude-sonnet-4-6' — byte-identical to
session-runner.ts:144, so PTY and `claude -p` evals always agree.

Pushed before extraArgs (last flag wins, so a per-test --model still overrides).
Placement leaves the spawn region byte-stable for a clean merge with the
in-flight hermetic-env branch. Plumbed model through the three plan-skill
wrappers. Static-grep tripwires guard the pin, its fallback chain, the
before-extraArgs ordering, and all three wrapper forwards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pty): detect markdown bold-bullet prose AUQs (fixes office-hours smoke)

office-hours auto-mode renders its mode question as `- **Building a startup**`
markdown bullets (office-hours/SKILL.md.tmpl:102) with no letter/number marker.
isProseAUQVisible only matched `A)`-style lettered or `1.`-style numbered
options, so the question went undetected: the model surfaced it at ~2m19s
(well under the 300s budget) but the harness kept scoring the run "working"
off the spinner glyphs and timed out — a false timeout on a question that was
already on screen.

Add Pattern 3: when an interrogative line ('?') is present AND 3+ bold-bullet
markers (`- **`) appear in the 4KB tail, classify as a prose AUQ. Bold is the
discriminator vs incidental prose bullets; the line anchor is dropped (stripAnsi
can collapse option lines) and the existing `❯ 1.` cursor gate still defers to a
live native list. Wires through the existing classifyVisible 'asked' path and the
timeout high-water-mark, so office-hours now classifies 'asked' instead of
'timeout'. Five unit cases: the office-hours render passes; no-'?', <3-bullet,
plain-bullet, and native-cursor cases stay false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(pty): detect stripAnsi-collapsed prose AUQs + judge spinner-precedence

The plan-eng/plan-design plan-mode + finding-floor smokes timed out even when
the skill HAD rendered a complete prose AskUserQuestion and was waiting: the PTY
strips cursor-positioning escapes, collapsing the option newlines/spaces so
"A) ..." arrives as "A(recommended)" / "-B:" and "Reply with A, B, or C" as
"ReplywithA,B,orC". Every line-anchored detector (Patterns 1-3) returns false on
those bytes, so proseAUQEverObserved never latched and the run timed out on a
question that was already on screen.

Add Pattern 4/5: a two-signal collapsed-form detector — a reply/recommendation
marker (space-insensitive "reply with [A-D]", "Recommendation:", or
"(recommended)") AND 2+ distinct A-D letters each punctuated by ) : or (. The
conjunction is what separates a real AUQ from incidental report prose; verified
true on the verbatim failing-run buffers where Patterns 1-3 return false.

Also fix the Haiku judge spinner bias: of 614 verdicts, 569 were 'working' and
95 of those noted a question was visible — Claude Code keeps the spinner
animating at an idle prose decision, so the judge coin-flipped. Add a precedence
override: when an option list AND a Recommendation/Reply instruction are both
visible, classify WAITING even with spinner glyphs. Kept the strict dual-signal
gate (never option-list-alone) so auto-decide-preserved doesn't flip.

5 unit tests pin the two-signal contract (2 true on real collapsed bytes, 3
false guards). 90 -> 95 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plan-review): ask-first scope gate for plan-eng + plan-design review

On an empty/cold invocation, plan-eng-review and plan-design-review would dive
straight into repo exploration (plan-eng) or a 7-pass mockup+audit (plan-design)
and only ask the user much later, if at all. plan-ceo-review already asks first
via an unconditional Step-0 gate and behaves well; these two did not.

Add a hard-STOP scope gate as the FIRST operational instruction in each skill
(above the design-doc check / pre-review audit / mockup defaults it explicitly
overrides): the first tool call must be AskUserQuestion confirming the review
target, before any git/Read/Grep/Glob/Bash or mockup generation. Under
--disallowedTools the options render as plain column-0 lettered prose with a
Recommendation + "Reply with A, B, or C" line so the answer is detectable.

This is correct cold-start UX (confirm what to review before grinding a full
review on nothing) and it is the product half of the plan-mode smoke fix; the
harness collapsed-form detector is the deterministic half that catches the ask
however it renders. Templates + regenerated SKILL.md (default variant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tiers): reclassify stochastic plan-eng/plan-design ask-first smokes as periodic

plan-eng-review and plan-design-review run a long explore/audit before their
first AskUserQuestion, so whether the plan-mode + finding-floor smokes reach a
terminal outcome within the 300s/600s budget depends on stochastic ask-first
compliance (measured ~50-67%/run even with the hardened gate). Per the
"non-deterministic -> periodic" tiering rule, move the four affected smokes
(plan-eng/plan-design review-plan-mode + finding-floor) to periodic.

The deterministic harness fix (collapsed-form detector + judge precedence) and
the ask-first gate lift these from always-failing to mostly-passing and are the
real product+harness improvements; periodic monitoring tracks the rate weekly
without blocking PRs on an LLM coin-flip. plan-ceo/plan-devex ask-first reliably
and stay gate-tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(evals): gate the deterministic PTY plan-mode smokes in CI

The real-PTY plan-mode smokes never ran in CI — the gate was local-only. Add an
e2e-pty-plan-smoke matrix suite running the two deterministically-reliable ones
(office-hours-auto-mode, plan-mode-no-op) so a regression there blocks PRs. The
stochastic plan-eng/plan-design ask-first smokes stay periodic (touchfiles
E2E_TIERS) and are not CI-gated.

A fresh CI container has no ~/.claude.json, so the spawned interactive `claude`
would wedge on the onboarding + API-key-approval dialog. Add a scoped seed step
(hasCompletedOnboarding + key approval, its own ANTHROPIC_API_KEY env) before the
run — mirrors what the hermetic E2E child env seeds. Per-suite timeout override
(35 min) via matrix.suite.timeout so the PTY suite has headroom for --retry 2
without bumping the other 12 suites. Report runner count 12 -> 13.

Validate via workflow_dispatch before relying on the gate (PTY-in-CI is new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(evals): install gstack skill registry for the PTY smoke suite

The first dry-run of e2e-pty-plan-smoke failed: the spawned interactive `claude`
printed "Unknown command: /plan-ceo-review". .claude/skills is gitignored, so a
fresh CI checkout has no gstack skill registry and the TUI can't resolve
/office-hours or /plan-ceo-review.

Add a Register step (scoped to the suite, after Seed, before Run) that mirrors
setup's --no-prefix user-scoped registry minimally: $HOME/.claude/skills/gstack
-> repo (resolves the preambles' absolute ~/.claude/skills/gstack/bin/* and
<skill>/sections/* paths) + per-skill SKILL.md/sections symlinks for the two
skills these tests invoke. HOME is /github/home in this container and the runner
adds no HOME/CLAUDE_CONFIG_DIR override (no hermetic mode), so $HOME is the right
anchor — the Seed step already proved claude reads it. No ./setup (binary build
+ Chromium + fonts + /dev/tty prompt); SKILL.md + bin/ + sections/ are committed.

Self-validating: fails the step loudly on a dangling symlink or missing
`name:` frontmatter, so a moved target surfaces here instead of as a silent
35-min "Unknown command" timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.58.4.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-21 07:15:19 -07:00

541 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* redact-engine — pure scanning + auto-redaction over the shared taxonomy.
*
* No I/O. Deterministic. The CLI shim (`bin/gstack-redact`), the pre-push hook
* (`bin/gstack-redact-prepush`), and tests all import from here.
*
* Key behaviors (locked in /plan-eng-review + two Codex passes):
* - Normalization BEFORE matching (NFKC + strip zero-width + decode a small
* set of HTML entities) so Unicode-confusable / zero-width evasion fails.
* Findings map back to ORIGINAL offsets via an index map.
* - ReDoS safety: a hard input-size cap that fails CLOSED (oversize input
* returns a single synthetic HIGH "input too large to scan safely" finding,
* so callers block rather than skip). Patterns are linear-time (lint-tested).
* - NO visibility-based tier mutation. `repoVisibility` is recorded on each
* finding (drives sterner AUQ wording in the skill) but never promotes a
* MEDIUM to HIGH. (TENSION-2-followup.)
* - Placeholder suppression is per-matched-span.
* - Tool-attributed fences (``` ```codex-review ``` / ``` ```greptile ```)
* degrade credential findings to a non-blocking WARN — UNLESS the span is a
* live-format credential the doc-example heuristic can't excuse. No nonce,
* no trust exemption (the marker scheme was dropped as theater).
*/
import {
PATTERNS,
PATTERNS_BY_ID,
isPlaceholderSpan,
type RedactPattern,
type Tier,
type Category,
} from "./redact-patterns";
export type RepoVisibility = "public" | "private" | "unknown";
/** A WARN is a finding that does not block but is surfaced (tool-fence degrade). */
export type Severity = Tier | "WARN";
export interface Finding {
id: string;
tier: Tier;
/** Effective severity after tool-fence degrade. HIGH/MEDIUM/LOW or WARN. */
severity: Severity;
category: Category;
description: string;
/** 1-based line in the ORIGINAL (un-normalized) text. */
line: number;
/** 1-based column in the ORIGINAL text. */
col: number;
/** Safe-masked preview (never more than 4 leading chars of the secret). */
preview: string;
/** Whether this finding offers one-keystroke auto-redact (PII subset). */
autoRedactable: boolean;
/** Repo visibility at scan time — drives sterner AUQ wording, not the tier. */
repoVisibility: RepoVisibility;
/** True when degraded to WARN because it sat in a tool-attributed fence. */
toolFenceDegraded?: boolean;
}
export interface ScanOptions {
repoVisibility?: RepoVisibility;
/** Extra allowlist entries (exact strings) that suppress a matched span. */
allowlist?: string[];
/** The invoking user's own email (from `git config user.email`) — allowlisted. */
selfEmail?: string;
/**
* Emails already public in the repo (git log authors, package.json, CODEOWNERS).
* Suppressed for `pii.email` since they're not a new leak.
*/
repoPublicEmails?: string[];
/** Hard byte cap. Oversize input fails CLOSED. Default 1 MiB. */
maxBytes?: number;
}
export interface ScanResult {
findings: Finding[];
counts: { HIGH: number; MEDIUM: number; LOW: number; WARN: number };
repoVisibility: RepoVisibility;
/** True when the input-size cap tripped (caller should BLOCK). */
oversize: boolean;
}
const DEFAULT_MAX_BYTES = 1024 * 1024; // 1 MiB
const EMAIL_ALLOW_DOMAINS = [/@example\.(com|org|net)$/i, /@example\.[a-z]{2,}$/i];
const EMAIL_ALLOW_LOCALPARTS = [/^noreply@/i, /^no-reply@/i, /^donotreply@/i];
// ── Normalization ─────────────────────────────────────────────────────────────
const ZERO_WIDTH = /[]/g;
const HTML_ENTITIES: Record<string, string> = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
};
/**
* Normalize text for matching while producing an index map back to the original.
* Returns the normalized string and a function mapping a normalized offset to
* the corresponding original offset.
*
* Strategy: walk the original char-by-char, applying NFKC per char, dropping
* zero-width chars, and expanding a small fixed set of HTML entities. Each
* emitted normalized char records the original offset it came from. This keeps
* the map exact for the transformations we apply (which are all local).
*/
export function normalizeWithMap(input: string): {
normalized: string;
map: number[];
} {
const out: string[] = [];
const map: number[] = [];
let i = 0;
while (i < input.length) {
// HTML entity expansion (fixed small set; longest first).
let matchedEntity = false;
for (const ent in HTML_ENTITIES) {
if (input.startsWith(ent, i)) {
const rep = HTML_ENTITIES[ent];
for (const ch of rep) {
out.push(ch);
map.push(i);
}
i += ent.length;
matchedEntity = true;
break;
}
}
if (matchedEntity) continue;
const ch = input[i];
if (ZERO_WIDTH.test(ch)) {
ZERO_WIDTH.lastIndex = 0;
i += 1;
continue;
}
ZERO_WIDTH.lastIndex = 0;
const norm = ch.normalize("NFKC");
for (const nch of norm) {
out.push(nch);
map.push(i);
}
i += 1;
}
// Sentinel so an offset == length maps to the original length.
map.push(input.length);
return { normalized: out.join(""), map };
}
// ── Offset → line/col on the ORIGINAL text ────────────────────────────────────
function lineColAt(original: string, offset: number): { line: number; col: number } {
let line = 1;
let col = 1;
for (let i = 0; i < offset && i < original.length; i++) {
if (original[i] === "\n") {
line += 1;
col = 1;
} else {
col += 1;
}
}
return { line, col };
}
// ── Safe preview masking ──────────────────────────────────────────────────────
/** Show ≤4 leading chars, mask the rest. Never reconstructable. */
export function maskPreview(span: string): string {
const visible = span.slice(0, 4);
const masked = span.length > 4 ? "*".repeat(Math.min(span.length - 4, 8)) : "";
return `${visible}${masked}${span.length > 12 ? "…" : ""}`;
}
// ── Tool-attributed fence detection ───────────────────────────────────────────
const TOOL_FENCE_INFO = /^```(codex-review|greptile|eval|codex|tool-output)\b/;
/**
* Returns a sorted list of [start, end) offset ranges (in normalized text) that
* sit inside a tool-attributed fenced code block. Credential findings inside
* these ranges degrade to WARN (unless the doc-example heuristic says the span
* is live-format and must still block).
*/
function toolFenceRanges(normalized: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
const lines = normalized.split("\n");
let offset = 0;
let inFence = false;
let fenceStart = 0;
for (const ln of lines) {
const isFenceMarker = ln.startsWith("```");
if (isFenceMarker) {
if (!inFence && TOOL_FENCE_INFO.test(ln)) {
inFence = true;
fenceStart = offset + ln.length + 1; // content starts after this line
} else if (inFence) {
ranges.push([fenceStart, offset]); // up to start of closing fence
inFence = false;
}
}
offset += ln.length + 1; // +1 for the \n
}
if (inFence) ranges.push([fenceStart, normalized.length]); // unterminated → still degrade its own body
return ranges;
}
function inRanges(offset: number, ranges: Array<[number, number]>): boolean {
for (const [s, e] of ranges) if (offset >= s && offset < e) return true;
return false;
}
/**
* Doc-example heuristic: a credential span inside a tool fence still BLOCKS if
* it looks like a LIVE credential (not an obvious placeholder/example). We only
* downgrade-to-WARN spans that are clearly illustrative.
*/
function isObviousDocExample(span: string): boolean {
return isPlaceholderSpan(span);
}
// ── Proximity check ───────────────────────────────────────────────────────────
function hasNear(
normalized: string,
matchStart: number,
matchEnd: number,
nearRegex: RegExp,
window: number,
): boolean {
const from = Math.max(0, matchStart - window);
const to = Math.min(normalized.length, matchEnd + window);
const slice = normalized.slice(from, to);
const re = new RegExp(nearRegex.source, nearRegex.flags.replace(/g/g, ""));
return re.test(slice);
}
// ── Email allowlist ───────────────────────────────────────────────────────────
function emailAllowed(email: string, opts: ScanOptions): boolean {
const lower = email.toLowerCase();
if (opts.selfEmail && lower === opts.selfEmail.toLowerCase()) return true;
if (opts.repoPublicEmails?.some((e) => e.toLowerCase() === lower)) return true;
if (EMAIL_ALLOW_DOMAINS.some((re) => re.test(email))) return true;
if (EMAIL_ALLOW_LOCALPARTS.some((re) => re.test(email))) return true;
return false;
}
// ── The scan ──────────────────────────────────────────────────────────────────
export function scan(input: string, opts: ScanOptions = {}): ScanResult {
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
// #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value
// (NaN from a malformed --max-bytes, or a negative) would make `byteLen >
// maxBytes` always false and silently disable the fail-closed oversize guard.
// Guardrail: any non-finite or non-positive value falls back to the default
// cap. The CLI is the layer that rejects bad args; this is belt-and-suspenders
// so the engine never silently runs uncapped.
const maxBytes =
Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0
? (opts.maxBytes as number)
: DEFAULT_MAX_BYTES;
// Fail CLOSED on oversize input. Check byte length BEFORE heavy work.
const byteLen = Buffer.byteLength(input, "utf8");
if (byteLen > maxBytes) {
const finding: Finding = {
id: "engine.input_too_large",
tier: "HIGH",
severity: "HIGH",
category: "secret",
description: `Input too large to scan safely (${byteLen} > ${maxBytes} bytes) — blocking fail-closed`,
line: 1,
col: 1,
preview: "",
autoRedactable: false,
repoVisibility,
};
return {
findings: [finding],
counts: { HIGH: 1, MEDIUM: 0, LOW: 0, WARN: 0 },
repoVisibility,
oversize: true,
};
}
const { normalized, map } = normalizeWithMap(input);
const fenceRanges = toolFenceRanges(normalized);
const allow = new Set(opts.allowlist ?? []);
const findings: Finding[] = [];
// Dedup by (id, original-offset) so overlapping global matches don't double-count.
const seen = new Set<string>();
for (const pat of PATTERNS) {
const re = new RegExp(pat.regex.source, withFlags(pat.regex.flags));
let m: RegExpExecArray | null;
while ((m = re.exec(normalized)) !== null) {
// Guard against zero-width matches looping forever.
if (m.index === re.lastIndex) re.lastIndex++;
const span = m[1] ?? m[0];
const spanStartInMatch = m[1] !== undefined ? m[0].indexOf(m[1]) : 0;
const normOffset = m.index + Math.max(0, spanStartInMatch);
// Per-span placeholder suppression.
if (isPlaceholderSpan(span)) continue;
if (allow.has(span)) continue;
// Pattern-specific validators (Luhn, entropy, RFC1918, etc).
if (pat.validate && !pat.validate(span, m)) continue;
// Proximity requirement.
if (
pat.nearRegex &&
!hasNear(normalized, m.index, m.index + m[0].length, pat.nearRegex, pat.nearWindow ?? 100)
) {
continue;
}
// Email allowlist (layered on top of the pattern).
if (pat.id === "pii.email" && emailAllowed(span, opts)) continue;
const origOffset = map[Math.min(normOffset, map.length - 1)] ?? 0;
const key = `${pat.id}:${origOffset}`;
if (seen.has(key)) continue;
seen.add(key);
const { line, col } = lineColAt(input, origOffset);
// Tool-fence degrade: only credential-category, only obvious doc examples.
let severity: Severity = pat.tier;
let toolFenceDegraded = false;
if (
pat.category === "secret" &&
inRanges(normOffset, fenceRanges) &&
isObviousDocExample(span)
) {
severity = "WARN";
toolFenceDegraded = true;
}
findings.push({
id: pat.id,
tier: pat.tier,
severity,
category: pat.category,
description: pat.description,
line,
col,
preview: maskPreview(span),
autoRedactable: !!pat.autoRedactable,
repoVisibility,
...(toolFenceDegraded ? { toolFenceDegraded } : {}),
});
}
}
// Stable order: by line, then col, then id.
findings.sort((a, b) => a.line - b.line || a.col - b.col || a.id.localeCompare(b.id));
const counts = { HIGH: 0, MEDIUM: 0, LOW: 0, WARN: 0 };
for (const f of findings) counts[f.severity] += 1;
return { findings, counts, repoVisibility, oversize: false };
}
function withFlags(flags: string): string {
let f = flags;
if (!f.includes("g")) f += "g";
if (!f.includes("m")) f += "m";
return f;
}
// ── Auto-redaction ────────────────────────────────────────────────────────────
export interface RedactResult {
body: string;
/** ASCII unified-diff preview of the substitutions. */
diff: string;
/** Findings that could NOT be auto-redacted (structural-corruption guard). */
skipped: Finding[];
}
/**
* Substitute redact tokens for the given finding ids, right-to-left so offsets
* stay valid. Refuses to redact a span that sits inside a structural token
* (markdown link target, JSON string value) — those fall back to `skipped` so
* the skill drops the user to manual edit rather than silently mangling output.
*/
export function applyRedactions(
input: string,
findingIds: string[],
opts: ScanOptions = {},
): RedactResult {
const ids = new Set(findingIds);
const { findings } = scan(input, opts);
const targets = findings
.filter((f) => ids.has(f.id) && f.autoRedactable)
.map((f) => ({ f, ...locateSpan(input, f) }))
.filter((t) => t.start >= 0);
// Right-to-left so earlier offsets remain valid after splicing.
targets.sort((a, b) => b.start - a.start);
const skipped: Finding[] = [];
const diffLines: string[] = [];
let body = input;
for (const t of targets) {
const pat = PATTERNS_BY_ID[t.f.id];
const token = pat?.redactToken ?? "<REDACTED>";
if (inStructuralToken(body, t.start, t.end)) {
skipped.push(t.f);
continue;
}
const before = lineContaining(body, t.start);
body = body.slice(0, t.start) + token + body.slice(t.end);
const after = lineContaining(body, t.start);
diffLines.push(`- ${before}`);
diffLines.push(`+ ${after}`);
}
return { body, diff: diffLines.reverse().join("\n"), skipped };
}
/**
* Patterns whose regex captures only a MARKER, not the secret payload itself
* (the PEM header line; the GCP JSON key prefix). Span replacement on these
* would redact the header and forward the key body — so redactFindingSpans
* drops the whole payload instead.
*/
const MARKER_ONLY_PATTERN_IDS = new Set(["pem.private_key", "gcp.service_account"]);
/**
* Replace EVERY finding's span with `<REDACTED-{id}>`, regardless of tier or
* autoRedactable. For machine egress surfaces (telemetry error_message,
* #1947) where structure preservation doesn't matter and fail-closed beats
* fidelity. Returns null — caller must drop the whole payload — when:
* - any finding's span cannot be located, or
* - any finding matched a marker-only pattern (PEM / GCP service-account
* JSON): their regexes capture the header, not the key material, so a
* span splice would leak the body that follows the marker.
* Overlapping spans (e.g. a Bearer token that is also a JWT) are coalesced
* before splicing so stale offsets never leave partial secret bytes behind.
* (Contrast applyRedactions, which is the interactive, autoRedactable-only,
* structure-preserving path.)
*/
export function redactFindingSpans(input: string, opts: ScanOptions = {}): string | null {
const { findings } = scan(input, opts);
if (findings.some((f) => MARKER_ONLY_PATTERN_IDS.has(f.id))) return null;
const targets = findings.map((f) => ({ f, ...locateSpan(input, f) }));
if (targets.some((t) => t.start < 0)) return null;
// Coalesce overlapping/touching ranges — splicing two intersecting spans
// independently applies a stale end offset to already-modified text and
// can leave trailing secret bytes in place.
targets.sort((a, b) => a.start - b.start);
const merged: Array<{ start: number; end: number; ids: string[] }> = [];
for (const t of targets) {
const last = merged[merged.length - 1];
if (last && t.start <= last.end) {
last.end = Math.max(last.end, t.end);
if (!last.ids.includes(t.f.id)) last.ids.push(t.f.id);
} else {
merged.push({ start: t.start, end: t.end, ids: [t.f.id] });
}
}
// Right-to-left so earlier offsets remain valid after splicing.
let body = input;
for (let i = merged.length - 1; i >= 0; i--) {
const m = merged[i];
body = body.slice(0, m.start) + `<REDACTED-${m.ids.join("+")}>` + body.slice(m.end);
}
return body;
}
function locateSpan(input: string, f: Finding): { start: number; end: number } {
// Re-derive the offset from line/col on the original text.
let offset = 0;
let line = 1;
while (line < f.line && offset < input.length) {
if (input[offset] === "\n") line++;
offset++;
}
offset += f.col - 1;
const pat = PATTERNS_BY_ID[f.id];
if (!pat) return { start: -1, end: -1 };
const re = new RegExp(pat.regex.source, withFlags(pat.regex.flags));
re.lastIndex = Math.max(0, offset - 2);
const m = re.exec(input);
if (!m) return { start: -1, end: -1 };
const span = m[1] ?? m[0];
const start = m.index + (m[1] !== undefined ? m[0].indexOf(m[1]) : 0);
return { start, end: start + span.length };
}
function inStructuralToken(body: string, start: number, end: number): boolean {
// Markdown link target: [text](...span...). The span may sit anywhere inside
// the parenthesized target (e.g. an email embedded in a URL). Walk backward
// from the span: if we reach `](` before hitting `)`/whitespace, and forward
// we reach `)` before whitespace, the span is inside a link target.
for (let i = start - 1; i >= 0; i--) {
const ch = body[i];
if (ch === ")" || ch === "\n" || ch === " " || ch === "\t") break;
if (ch === "(" && i > 0 && body[i - 1] === "]") {
for (let j = end; j < body.length; j++) {
const c = body[j];
if (c === " " || c === "\t" || c === "\n") break;
if (c === ")") return true;
}
break;
}
}
// JSON string value: "key": "...span..." — span is inside a quoted value.
const before = body.slice(Math.max(0, start - 80), start);
const after = body.slice(end, Math.min(body.length, end + 4));
if (/:\s*"$/.test(before) && /^"/.test(after)) return true;
return false;
}
function lineContaining(body: string, offset: number): string {
const start = body.lastIndexOf("\n", offset - 1) + 1;
let end = body.indexOf("\n", offset);
if (end === -1) end = body.length;
return body.slice(start, end);
}
// ── Exit-code helper for the CLI shim ─────────────────────────────────────────
/** 0 clean, 2 MEDIUM present (no HIGH), 3 HIGH present. WARN does not gate. */
export function exitCodeFor(result: ScanResult): 0 | 2 | 3 {
if (result.counts.HIGH > 0) return 3;
if (result.counts.MEDIUM > 0) return 2;
return 0;
}