From eb5487f63e1d87c9b8763e8fb9e65e6be021aca0 Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Mon, 28 Sep 2026 22:51:33 +0000 Subject: [PATCH 1/6] fix(security): require eyJ header and payload segments in the JWT rule The publication secret gate scans agent output in positive-only mode and fails the node on any hit. Its supplemental JWT rule matched any three dotted runs of base64url characters (20/10/10 minimum lengths), which also describes ordinary qualified identifiers: a report citing `ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck`, or a JSON path such as `auditProfileResolution.settingOrigins.property_priority_threshold`. The gate rejected such a file after the agent had finished its work. A JWT's header and payload are base64url-encoded JSON objects. In the compact form that JWT libraries emit, each opens with '{"' and a letter, which encodes to "eyJ". The rule now adds a (?=eyJ) lookahead to the header and payload segments and keeps the old length minimums, so it matches exactly the old matches whose first two segments begin with "eyJ". The existing k07 fixture and an HS256 token are still rejected. Co-Authored-By: Claude Opus 5.5 --- packages/artifacts/test/artifacts.test.ts | 17 +++++++++++++++++ packages/security/src/sensitive-redaction.ts | 8 +++++--- .../security/test/sensitive-redaction.test.ts | 12 ++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/artifacts/test/artifacts.test.ts b/packages/artifacts/test/artifacts.test.ts index 390b021cc..0f0f571c8 100644 --- a/packages/artifacts/test/artifacts.test.ts +++ b/packages/artifacts/test/artifacts.test.ts @@ -1118,6 +1118,23 @@ test("canonical publication secret gate fails closed without rewriting bytes", ( ) ); + // The positive rules must not fire on ordinary contract output either: a + // qualified identifier with three long dotted segments was JWT-shaped until + // the rule required the eyJ header and payload. + assert.doesNotThrow(() => + assertArtifactPublicationsContainNoSecrets( + new Map([ + [ + "setup/call-graph.md", + Buffer.from( + "`ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck` reverts on re-entry\n", + "utf8" + ) + ] + ]) + ) + ); + // Every credential format the previous hand-rolled patterns could name is // still rejected — via a secretlint library finding or a documented // supplemental pattern. diff --git a/packages/security/src/sensitive-redaction.ts b/packages/security/src/sensitive-redaction.ts index 5cd358d51..2954f6cc0 100644 --- a/packages/security/src/sensitive-redaction.ts +++ b/packages/security/src/sensitive-redaction.ts @@ -49,9 +49,11 @@ const SUPPLEMENTAL_SECRET_PATTERNS: readonly RegExp[] = [ /\b(?:ak|as)-[A-Za-z0-9_]{16,}\b/gu, // No Google OAuth access-token rule in the recommended preset. /\bya29\.[A-Za-z0-9._-]{20,}\b/gu, - // No JWT rule in the recommended preset; a three-part base64url token is a - // positive format identification. - /\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, + // No JWT rule in the recommended preset. A JWT's header and payload are + // base64url JSON objects, which encode to "eyJ" when they open with '{"' and + // a letter; that prefix is the identification. Three dotted segments alone + // also match qualified identifiers such as Contract.function.check. + /\b(?=eyJ)[A-Za-z0-9_-]{20,}\.(?=eyJ)[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, // Provider-keyed RPC URLs embed the credential in the path; no secretlint // rule covers Alchemy/Infura project keys. /\b(?:https?|wss?):\/\/[^\s"'`]*(?:alchemy\.com\/v2\/|infura\.io\/v3\/)[A-Za-z0-9_-]{16,}\b/giu diff --git a/packages/security/test/sensitive-redaction.test.ts b/packages/security/test/sensitive-redaction.test.ts index dfe094049..59d4d20df 100644 --- a/packages/security/test/sensitive-redaction.test.ts +++ b/packages/security/test/sensitive-redaction.test.ts @@ -222,6 +222,18 @@ test("the English word bearer does not redact following prose while Bearer token ); }); +test("the JWT rule requires eyJ header and payload segments, so dotted identifiers publish", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // gitleaks:allow -- fake credential fixture for the redaction tests + assert.equal(redactSecretsInText(`session=${jwt};`, undefined, [], "positive-only"), "session=;"); + for (const fixture of [ + "ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck", + "IExampleLendingPoolCore.liquidationCall.healthFactorBefore", + "auditProfileResolution.settingOrigins.property_priority_threshold" + ]) { + assert.equal(containsSensitiveSecrets(fixture, [], "positive-only"), false, fixture); + } +}); + test("in-content secretlint-disable comments cannot suppress detection", () => { // Scanned content is agent-controlled; the filter-comments rule is disabled // so contaminated output cannot exempt itself from the fail-closed gate. From 247c75ad530c3e84b29899af66fd6751fceafe0f Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Mon, 28 Sep 2026 22:52:18 +0000 Subject: [PATCH 2/6] fix(security): let the publication gate pass Anvil's public dev mnemonic and keys Anvil prints the "test test test test test test test test test test test junk" mnemonic and the 10 keys it derives at m/44'/60'/0'/0/0-9 on every startup. Foundry tests and scripts sign with them: forge-std's own test_DeriveRememberKey quotes the mnemonic and key (0). The fail-closed publication gate rejected both. The mnemonic has a valid BIP39 checksum, and `uint256 privateKey = 0xac09...ff80;` satisfies the context-labeled private-key rule. So a generated test that derived or signed with a default Anvil account failed its node's verification. The labeled-key rule now skips those exact 10 keys (compared case-insensitively, with or without 0x), and the mnemonic rule skips a window that is exactly that phrase. The keys and the mnemonic were checked against `anvil` 1.8.3's startup output and `cast wallet private-key --mnemonic-index 0..9`. The keys are public, so exempting them hides nothing. Any other labeled 64-hex key, including key (0) with one digit changed, and any other valid mnemonic, including one that shares the first 11 words, is still rejected. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + packages/artifacts/test/artifacts.test.ts | 14 +++++- packages/security/src/sensitive-redaction.ts | 24 +++++++++- .../security/test/sensitive-redaction.test.ts | 45 +++++++++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 960ba9394..b44e86fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ ### Other changes +- **[security]** The fail-closed publication secret gate no longer rejects Anvil's public development accounts or long dotted identifiers in agent output. The JWT rule now requires the `eyJ` prefix that a JSON header and payload encode to, so a qualified name such as `ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck` no longer reads as a token; the labeled-private-key and mnemonic rules skip the published `test test … junk` mnemonic and the 10 keys `anvil` prints, which Foundry tests sign with. Real JWTs, other labeled 64-hex keys, and other valid mnemonics are still rejected. - **[runtime]** `resume --refresh-controller` on a dynamically-expanded run now publishes a task manifest that still re-derives from its sealed base, closing a data-integrity regression. Current-controller rendering keeps binding each plan-time prompt to its authenticated retained snapshot (#980), but that execution-only binding now reaches the task-spec literal alone instead of the compiled task manifest, whose `renderedPromptPath` is normalized back to the sealed launch path recorded in `plan.json`. Previously the refreshed controller published the rebound `prompt-snapshots/.md` path into `smithers/tasks.json`, while `verifyDynamicRuntimeMaterialization` re-derived the same document from the sealed `controls/runtime-base-tasks.json` carrying the launch path; the two fingerprinted differently and every gated command (`sync`, `pause`, replay, fork) failed `WORKFLOW_CONTROL_EVIDENCE_INVALID` for the rest of the run's life, with no self-heal on a repeat refresh. Because resume is ungated, a run already damaged this way is recoverable: the normalization scrubs a rebound manifest on the next refresh, including when the sealed base manifest is absent and the live document is the compile input. Prompt authentication is unchanged -- a retained snapshot whose bytes no longer match `plan.json`'s recorded digest still aborts the refresh, and a task with no plan row still fails closed rather than falling back to the cleanup-owned launch path (#1002). - Pi model profiles now accept and forward the CLI's `max` thinking level, in addition to the previously supported levels through `xhigh`. - Upgrades the pinned workflow engine to Smithers 0.35.0. The Effect 4 tree is unchanged at `4.0.0-beta.105`, so the `@effect/*` override family and the `pnpm-workspace.yaml` mirror keep their exact pins; the deterministic npm resolution cutoff moves to `2026-08-18T06:00:00Z`, past `smthrs@0.35.0`'s publish instant. Six compatibility patches are re-derived against upstream's new `smithersRuntimeSpawn`/`smithersRuntimeReentry` indirection and the re-nested `adapter.insertRun` call; none is retired, because every workaround they encode is still absent upstream. Ultrafuzz's Smithers state mirrors gain the new `succeeded-with-failures` run state and `stalled` node state, and the inspect contract admits `tokenUsage`, `run.cancellationSource` and `runState.warnings`. Existing `0.34.0` dependency manifests migrate forward in place at `init`, so an already-scaffolded project converges onto the new pin without `--force` regenerating the run. Native resume never read the target project's `.smithers/package.json` -- launch admission asserts the manifest only against the operator controller's own freshly rendered temporary root -- so a stopped run already resumed under its own run ID and continues to. Ultrafuzz's strict readers for `why`, `status`, `node` and the `TokenUsageReported` event stream also admit the release's new `warnings`, `counts.stalled`, `tokenUsage.freshInputTokens`, `freshInputTokens`/`costUsd` and `stalled` blocker fields, and its two new `NodeStalled`/`RunConcurrencySaturated` event types; a stalled node now resets under `resume --retry-failed` alongside failed ones, and the evmbench adapter no longer aborts a converged run on the release's widened `degraded` verdict. The runner adds four forward-only SQLite migrations (0041-0044) (#955). diff --git a/packages/artifacts/test/artifacts.test.ts b/packages/artifacts/test/artifacts.test.ts index 0f0f571c8..7acc0cee3 100644 --- a/packages/artifacts/test/artifacts.test.ts +++ b/packages/artifacts/test/artifacts.test.ts @@ -1119,11 +1119,21 @@ test("canonical publication secret gate fails closed without rewriting bytes", ( ); // The positive rules must not fire on ordinary contract output either: a - // qualified identifier with three long dotted segments was JWT-shaped until - // the rule required the eyJ header and payload. + // Foundry test deriving and signing with Anvil's published mnemonic and + // account (0) key, and a qualified identifier with three long dotted + // segments (JWT-shaped until the rule required the eyJ header and payload). assert.doesNotThrow(() => assertArtifactPublicationsContainNoSecrets( new Map([ + [ + "generated-tests/AnvilSigner.t.sol", + Buffer.from( + 'string memory mnemonic = "test test test test test test test test test test test junk";\n' + + "uint256 privateKey = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80;\n" + // gitleaks:allow -- public Anvil dev key fixture for the redaction tests + "assertEq(vm.deriveKey(mnemonic, 0), privateKey);\n", + "utf8" + ) + ], [ "setup/call-graph.md", Buffer.from( diff --git a/packages/security/src/sensitive-redaction.ts b/packages/security/src/sensitive-redaction.ts index 2954f6cc0..73d7f6261 100644 --- a/packages/security/src/sensitive-redaction.ts +++ b/packages/security/src/sensitive-redaction.ts @@ -338,6 +338,26 @@ function redactExactSecretValues(value: string, placeholder: string, forbiddenSe return redacted; } +/** + * Anvil's and Hardhat's default mnemonic, and the keys of the 10 dev accounts + * Anvil derives from it and prints on startup (m/44'/60'/0'/0/0-9). They are + * published, not secret, and Foundry tests and scripts sign with them: + * forge-std's own test_DeriveRememberKey quotes the mnemonic and key (0). + */ +const PUBLIC_DEVELOPMENT_MNEMONIC = "test test test test test test test test test test test junk"; +const PUBLIC_DEVELOPMENT_PRIVATE_KEYS: ReadonlySet = new Set([ + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "dbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" +]); + function redactBip39Mnemonics(value: string, placeholder: string): string { type WordToken = { word: string; start: number; end: number }; let run: WordToken[] = []; @@ -359,7 +379,8 @@ function redactBip39Mnemonics(value: string, placeholder: string): string { for (const wordCount of BIP39_WORD_COUNTS) { if (run.length < wordCount) continue; const window = run.slice(-wordCount); - if (!validateMnemonic(window.map((token) => token.word).join(" "), englishWordlist)) continue; + const phrase = window.map((token) => token.word).join(" "); + if (phrase === PUBLIC_DEVELOPMENT_MNEMONIC || !validateMnemonic(phrase, englishWordlist)) continue; ranges.push({ start: window[0]!.start, end: window.at(-1)!.end }); run = []; break; @@ -382,6 +403,7 @@ function redactUnlabeledFortyHexSecrets(value: string, placeholder: string): str function redactContextLabeledPrivateKeys(value: string, placeholder: string): string { THIRTY_TWO_BYTE_HEX_PATTERN.lastIndex = 0; return value.replace(THIRTY_TWO_BYTE_HEX_PATTERN, (candidate, offset: number) => + !PUBLIC_DEVELOPMENT_PRIVATE_KEYS.has(candidate.replace(/^0x/iu, "").toLowerCase()) && /(?:private|secret|signing|wallet|ethereum|evm)[-_\s]*(?:key|scalar)\s*(?:(?:[:=]|\bis\b)\s*)?["'`]?$/iu.test( value.slice(Math.max(0, offset - 96), offset) ) diff --git a/packages/security/test/sensitive-redaction.test.ts b/packages/security/test/sensitive-redaction.test.ts index 59d4d20df..7216e8d36 100644 --- a/packages/security/test/sensitive-redaction.test.ts +++ b/packages/security/test/sensitive-redaction.test.ts @@ -222,6 +222,51 @@ test("the English word bearer does not redact following prose while Bearer token ); }); +test("positive-only scans publish Anvil's dev mnemonic and keys while other mnemonics and keys stay detected", () => { + // The mnemonic and keys `anvil` prints on startup. Foundry tests and scripts + // sign with them, so generated tests and reports quote them under a label. + const anvilMnemonic = "test test test test test test test test test test test junk"; + assert.equal(containsSensitiveSecrets(`string memory mnemonic = "${anvilMnemonic}";`, [], "positive-only"), false); + // A different valid mnemonic sharing its first 11 words is still a secret. + assert.equal( + redactSecretsInText(`mnemonic: ${anvilMnemonic.replace(/junk$/u, "absent")}`, undefined, [], "positive-only"), + "mnemonic: " + ); + + const anvilKeys = [ + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6", + "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a", + "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba", + "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e", + "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356", + "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97", + "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6" + ]; + for (const key of anvilKeys) { + for (const fixture of [ + `uint256 privateKey = ${key};`, + `PRIVATE_KEY=${key}`, + `signing key: ${key.slice(2).toUpperCase()}` + ]) { + assert.equal(containsSensitiveSecrets(fixture, [], "positive-only"), false, fixture); + } + } + // Only the exact published keys are exempt: key (0) with its last digit + // changed is an unknown key. + assert.equal( + redactSecretsInText( + "uint256 privateKey = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff81;", // gitleaks:allow -- fake credential fixture for the redaction tests + undefined, + [], + "positive-only" + ), + "uint256 privateKey = ;" + ); +}); + test("the JWT rule requires eyJ header and payload segments, so dotted identifiers publish", () => { const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // gitleaks:allow -- fake credential fixture for the redaction tests assert.equal(redactSecretsInText(`session=${jwt};`, undefined, [], "positive-only"), "session=;"); From 93c52595ba7c167f42edb0813e2e6ab0028701a3 Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Tue, 29 Sep 2026 02:44:55 +0000 Subject: [PATCH 3/6] fix(security): end the BIP39 word run at Anvil's public mnemonic The mnemonic exemption skipped a window equal to the public phrase with `continue`, so the word run was never reset. Every later BIP39 word was then checked in 12- to 24-word windows that overlap the phrase, windows origin/main never examined because it reset the run after redacting the phrase. Reproduced on the previous head: - The gate still rejected the phrase when a whitespace-separated BIP39 word followed it ("... junk one account per index"), and two copies of it on consecutive lines. - Redaction weakened next to a real mnemonic. The phrase followed by "abandon x11 about" on the next line became "test abandon ... about", leaving 11 of the real mnemonic's 12 words in the clear. origin/main redacted both. A valid window that is exactly the public phrase now ends the run like any other detected mnemonic; only its redaction range is dropped. The run therefore evolves as on origin/main, and the rule's output is origin/main's with those placeholders restored. Co-Authored-By: Claude Opus 5.5 --- packages/security/src/sensitive-redaction.ts | 8 +++++++- packages/security/test/sensitive-redaction.test.ts | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/security/src/sensitive-redaction.ts b/packages/security/src/sensitive-redaction.ts index 73d7f6261..d5860c4ea 100644 --- a/packages/security/src/sensitive-redaction.ts +++ b/packages/security/src/sensitive-redaction.ts @@ -380,7 +380,13 @@ function redactBip39Mnemonics(value: string, placeholder: string): string { if (run.length < wordCount) continue; const window = run.slice(-wordCount); const phrase = window.map((token) => token.word).join(" "); - if (phrase === PUBLIC_DEVELOPMENT_MNEMONIC || !validateMnemonic(phrase, englishWordlist)) continue; + if (!validateMnemonic(phrase, englishWordlist)) continue; + // The public phrase is left in place but still ends the run, so the + // words after it are checked exactly as if it had been redacted. + if (phrase === PUBLIC_DEVELOPMENT_MNEMONIC) { + run = []; + break; + } ranges.push({ start: window[0]!.start, end: window.at(-1)!.end }); run = []; break; diff --git a/packages/security/test/sensitive-redaction.test.ts b/packages/security/test/sensitive-redaction.test.ts index 7216e8d36..c9f0ac2b0 100644 --- a/packages/security/test/sensitive-redaction.test.ts +++ b/packages/security/test/sensitive-redaction.test.ts @@ -232,6 +232,11 @@ test("positive-only scans publish Anvil's dev mnemonic and keys while other mnem redactSecretsInText(`mnemonic: ${anvilMnemonic.replace(/junk$/u, "absent")}`, undefined, [], "positive-only"), "mnemonic: " ); + // The phrase ends its word run: BIP39 words that follow it ("one") start a + // new run, so prose after it publishes and a real mnemonic on the next line + // is still redacted in full. + assert.equal(containsSensitiveSecrets(`${anvilMnemonic} one account per index`, [], "positive-only"), false); + assert.equal(redactSecretsInText(`${anvilMnemonic}\n${mnemonic}`), `${anvilMnemonic}\n`); const anvilKeys = [ "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", From d101467d65b4f567e5e496b0d6e6cd3a68bb62bb Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Tue, 29 Sep 2026 02:45:09 +0000 Subject: [PATCH 4/6] fix(security): constrain only the JWT header segment The payload (?=eyJ) lookahead removed none of the false positives this branch targets: the header lookahead alone already stops the rule matching each dotted identifier fixture. What the payload lookahead did remove was detection of compact JWE tokens, whose second segment is the encrypted key, and of JWS tokens whose payload is not compact JSON ('{ "sub": ...' encodes to "eyA"). origin/main detected both. The rule now requires "eyJ" on the header segment only, and the test pins an RSA-OAEP JWE and a space-padded payload as still detected. The CHANGELOG entry drops the claims that real JWTs, other keys and other mnemonics are all still rejected, and tells cloud operators what to do when resume recomputes the classification of an allowlisted variable that holds one of these values. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- packages/artifacts/test/artifacts.test.ts | 2 +- packages/security/src/sensitive-redaction.ts | 12 +++++++----- packages/security/test/sensitive-redaction.test.ts | 11 ++++++++++- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b44e86fbb..2d8c496f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Other changes -- **[security]** The fail-closed publication secret gate no longer rejects Anvil's public development accounts or long dotted identifiers in agent output. The JWT rule now requires the `eyJ` prefix that a JSON header and payload encode to, so a qualified name such as `ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck` no longer reads as a token; the labeled-private-key and mnemonic rules skip the published `test test … junk` mnemonic and the 10 keys `anvil` prints, which Foundry tests sign with. Real JWTs, other labeled 64-hex keys, and other valid mnemonics are still rejected. +- **[security]** The publication secret gate's pattern rules skip Anvil's public `test test … junk` mnemonic and the 10 keys `anvil` prints, and its JWT rule requires an `eyJ` header, not just three long dotted segments. A cloud run started before this change that allowlists a variable holding that mnemonic or such a dotted value must drop it from `ULTRAFUZZ_AGENT_ENV_ALLOWLIST` to resume, replay or fork. - **[runtime]** `resume --refresh-controller` on a dynamically-expanded run now publishes a task manifest that still re-derives from its sealed base, closing a data-integrity regression. Current-controller rendering keeps binding each plan-time prompt to its authenticated retained snapshot (#980), but that execution-only binding now reaches the task-spec literal alone instead of the compiled task manifest, whose `renderedPromptPath` is normalized back to the sealed launch path recorded in `plan.json`. Previously the refreshed controller published the rebound `prompt-snapshots/.md` path into `smithers/tasks.json`, while `verifyDynamicRuntimeMaterialization` re-derived the same document from the sealed `controls/runtime-base-tasks.json` carrying the launch path; the two fingerprinted differently and every gated command (`sync`, `pause`, replay, fork) failed `WORKFLOW_CONTROL_EVIDENCE_INVALID` for the rest of the run's life, with no self-heal on a repeat refresh. Because resume is ungated, a run already damaged this way is recoverable: the normalization scrubs a rebound manifest on the next refresh, including when the sealed base manifest is absent and the live document is the compile input. Prompt authentication is unchanged -- a retained snapshot whose bytes no longer match `plan.json`'s recorded digest still aborts the refresh, and a task with no plan row still fails closed rather than falling back to the cleanup-owned launch path (#1002). - Pi model profiles now accept and forward the CLI's `max` thinking level, in addition to the previously supported levels through `xhigh`. - Upgrades the pinned workflow engine to Smithers 0.35.0. The Effect 4 tree is unchanged at `4.0.0-beta.105`, so the `@effect/*` override family and the `pnpm-workspace.yaml` mirror keep their exact pins; the deterministic npm resolution cutoff moves to `2026-08-18T06:00:00Z`, past `smthrs@0.35.0`'s publish instant. Six compatibility patches are re-derived against upstream's new `smithersRuntimeSpawn`/`smithersRuntimeReentry` indirection and the re-nested `adapter.insertRun` call; none is retired, because every workaround they encode is still absent upstream. Ultrafuzz's Smithers state mirrors gain the new `succeeded-with-failures` run state and `stalled` node state, and the inspect contract admits `tokenUsage`, `run.cancellationSource` and `runState.warnings`. Existing `0.34.0` dependency manifests migrate forward in place at `init`, so an already-scaffolded project converges onto the new pin without `--force` regenerating the run. Native resume never read the target project's `.smithers/package.json` -- launch admission asserts the manifest only against the operator controller's own freshly rendered temporary root -- so a stopped run already resumed under its own run ID and continues to. Ultrafuzz's strict readers for `why`, `status`, `node` and the `TokenUsageReported` event stream also admit the release's new `warnings`, `counts.stalled`, `tokenUsage.freshInputTokens`, `freshInputTokens`/`costUsd` and `stalled` blocker fields, and its two new `NodeStalled`/`RunConcurrencySaturated` event types; a stalled node now resets under `resume --retry-failed` alongside failed ones, and the evmbench adapter no longer aborts a converged run on the release's widened `degraded` verdict. The runner adds four forward-only SQLite migrations (0041-0044) (#955). diff --git a/packages/artifacts/test/artifacts.test.ts b/packages/artifacts/test/artifacts.test.ts index 7acc0cee3..b96c1cd4b 100644 --- a/packages/artifacts/test/artifacts.test.ts +++ b/packages/artifacts/test/artifacts.test.ts @@ -1121,7 +1121,7 @@ test("canonical publication secret gate fails closed without rewriting bytes", ( // The positive rules must not fire on ordinary contract output either: a // Foundry test deriving and signing with Anvil's published mnemonic and // account (0) key, and a qualified identifier with three long dotted - // segments (JWT-shaped until the rule required the eyJ header and payload). + // segments (JWT-shaped until the rule required an eyJ header). assert.doesNotThrow(() => assertArtifactPublicationsContainNoSecrets( new Map([ diff --git a/packages/security/src/sensitive-redaction.ts b/packages/security/src/sensitive-redaction.ts index d5860c4ea..ba72ed136 100644 --- a/packages/security/src/sensitive-redaction.ts +++ b/packages/security/src/sensitive-redaction.ts @@ -49,11 +49,13 @@ const SUPPLEMENTAL_SECRET_PATTERNS: readonly RegExp[] = [ /\b(?:ak|as)-[A-Za-z0-9_]{16,}\b/gu, // No Google OAuth access-token rule in the recommended preset. /\bya29\.[A-Za-z0-9._-]{20,}\b/gu, - // No JWT rule in the recommended preset. A JWT's header and payload are - // base64url JSON objects, which encode to "eyJ" when they open with '{"' and - // a letter; that prefix is the identification. Three dotted segments alone - // also match qualified identifiers such as Contract.function.check. - /\b(?=eyJ)[A-Za-z0-9_-]{20,}\.(?=eyJ)[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, + // No JWT rule in the recommended preset. A JWT header is a base64url JSON + // object, which encodes to "eyJ" when it opens with '{"' and a letter; that + // prefix is the identification. Three long dotted segments alone also + // describe qualified names such as + // ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck. Later + // segments stay unconstrained: a JWE's second one is its encrypted key. + /\b(?=eyJ)[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu, // Provider-keyed RPC URLs embed the credential in the path; no secretlint // rule covers Alchemy/Infura project keys. /\b(?:https?|wss?):\/\/[^\s"'`]*(?:alchemy\.com\/v2\/|infura\.io\/v3\/)[A-Za-z0-9_-]{16,}\b/giu diff --git a/packages/security/test/sensitive-redaction.test.ts b/packages/security/test/sensitive-redaction.test.ts index c9f0ac2b0..896a24872 100644 --- a/packages/security/test/sensitive-redaction.test.ts +++ b/packages/security/test/sensitive-redaction.test.ts @@ -272,9 +272,18 @@ test("positive-only scans publish Anvil's dev mnemonic and keys while other mnem ); }); -test("the JWT rule requires eyJ header and payload segments, so dotted identifiers publish", () => { +test("the JWT rule requires an eyJ header, so dotted identifiers publish", () => { const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"; // gitleaks:allow -- fake credential fixture for the redaction tests assert.equal(redactSecretsInText(`session=${jwt};`, undefined, [], "positive-only"), "session=;"); + // Only the header is constrained. A JWE's second segment is its encrypted + // key, and a payload with a space after '{' encodes to "eyA". + const base64url = (json: string): string => Buffer.from(json).toString("base64url"); + for (const token of [ + `${base64url('{"alg":"RSA-OAEP","enc":"A256GCM"}')}.OKOawDo13gRp2ojaHV7LFpZcgV7T.48V1_ALb6US04U3b.5eym8TW_c8SuK0ltJ3rpYIzOeDQz.XFBoMYUZodetZdvTiFvSkQ`, + `${base64url('{"alg":"HS256"}')}.${base64url('{ "sub": "1234567890" }')}.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk` + ]) { + assert.equal(containsSensitiveSecrets(`session=${token};`, [], "positive-only"), true, token); + } for (const fixture of [ "ReentrancyGuardUpgradeable.nonReentrantModifier.lockedStateCheck", "IExampleLendingPoolCore.liquidationCall.healthFactorBefore", From 0df3af510da3ce8ea2eeacd880ae0dcb48579a0b Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Tue, 29 Sep 2026 02:59:12 +0000 Subject: [PATCH 5/6] docs: keep the security CHANGELOG entry within the 400-character line limit markdownlint's MD013 in the external static analysis job allows 400 characters per line. The rewritten entry was 408; it is now 398 with the same claims. The job still fails on the file's pre-existing long lines. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8c496f1..4724a514a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Other changes -- **[security]** The publication secret gate's pattern rules skip Anvil's public `test test … junk` mnemonic and the 10 keys `anvil` prints, and its JWT rule requires an `eyJ` header, not just three long dotted segments. A cloud run started before this change that allowlists a variable holding that mnemonic or such a dotted value must drop it from `ULTRAFUZZ_AGENT_ENV_ALLOWLIST` to resume, replay or fork. +- **[security]** The publication secret gate's patterns skip Anvil's public `test test … junk` mnemonic and the 10 keys `anvil` prints, and its JWT rule requires an `eyJ` header, not just three long dotted segments. A cloud run from before this change whose allowlisted variable holds that mnemonic or such a dotted value must drop it from `ULTRAFUZZ_AGENT_ENV_ALLOWLIST` to resume, replay or fork. - **[runtime]** `resume --refresh-controller` on a dynamically-expanded run now publishes a task manifest that still re-derives from its sealed base, closing a data-integrity regression. Current-controller rendering keeps binding each plan-time prompt to its authenticated retained snapshot (#980), but that execution-only binding now reaches the task-spec literal alone instead of the compiled task manifest, whose `renderedPromptPath` is normalized back to the sealed launch path recorded in `plan.json`. Previously the refreshed controller published the rebound `prompt-snapshots/.md` path into `smithers/tasks.json`, while `verifyDynamicRuntimeMaterialization` re-derived the same document from the sealed `controls/runtime-base-tasks.json` carrying the launch path; the two fingerprinted differently and every gated command (`sync`, `pause`, replay, fork) failed `WORKFLOW_CONTROL_EVIDENCE_INVALID` for the rest of the run's life, with no self-heal on a repeat refresh. Because resume is ungated, a run already damaged this way is recoverable: the normalization scrubs a rebound manifest on the next refresh, including when the sealed base manifest is absent and the live document is the compile input. Prompt authentication is unchanged -- a retained snapshot whose bytes no longer match `plan.json`'s recorded digest still aborts the refresh, and a task with no plan row still fails closed rather than falling back to the cleanup-owned launch path (#1002). - Pi model profiles now accept and forward the CLI's `max` thinking level, in addition to the previously supported levels through `xhigh`. - Upgrades the pinned workflow engine to Smithers 0.35.0. The Effect 4 tree is unchanged at `4.0.0-beta.105`, so the `@effect/*` override family and the `pnpm-workspace.yaml` mirror keep their exact pins; the deterministic npm resolution cutoff moves to `2026-08-18T06:00:00Z`, past `smthrs@0.35.0`'s publish instant. Six compatibility patches are re-derived against upstream's new `smithersRuntimeSpawn`/`smithersRuntimeReentry` indirection and the re-nested `adapter.insertRun` call; none is retired, because every workaround they encode is still absent upstream. Ultrafuzz's Smithers state mirrors gain the new `succeeded-with-failures` run state and `stalled` node state, and the inspect contract admits `tokenUsage`, `run.cancellationSource` and `runState.warnings`. Existing `0.34.0` dependency manifests migrate forward in place at `init`, so an already-scaffolded project converges onto the new pin without `--force` regenerating the run. Native resume never read the target project's `.smithers/package.json` -- launch admission asserts the manifest only against the operator controller's own freshly rendered temporary root -- so a stopped run already resumed under its own run ID and continues to. Ultrafuzz's strict readers for `why`, `status`, `node` and the `TokenUsageReported` event stream also admit the release's new `warnings`, `counts.stalled`, `tokenUsage.freshInputTokens`, `freshInputTokens`/`costUsd` and `stalled` blocker fields, and its two new `NodeStalled`/`RunConcurrencySaturated` event types; a stalled node now resets under `resume --retry-failed` alongside failed ones, and the evmbench adapter no longer aborts a converged run on the release's widened `degraded` verdict. The runner adds four forward-only SQLite migrations (0041-0044) (#955). From 1343f65cc1497532d6883c18e07b74d3c3591f90 Mon Sep 17 00:00:00 2001 From: Antonio Viggiano Date: Tue, 29 Sep 2026 03:55:35 +0000 Subject: [PATCH 6/6] chore: move the changelog entry to the consolidated release notes Every pull request in this batch inserts its entry at the same place in CHANGELOG.md, so each merge would conflict with the next. The entries are collected into one changelog update instead. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4724a514a..960ba9394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ ### Other changes -- **[security]** The publication secret gate's patterns skip Anvil's public `test test … junk` mnemonic and the 10 keys `anvil` prints, and its JWT rule requires an `eyJ` header, not just three long dotted segments. A cloud run from before this change whose allowlisted variable holds that mnemonic or such a dotted value must drop it from `ULTRAFUZZ_AGENT_ENV_ALLOWLIST` to resume, replay or fork. - **[runtime]** `resume --refresh-controller` on a dynamically-expanded run now publishes a task manifest that still re-derives from its sealed base, closing a data-integrity regression. Current-controller rendering keeps binding each plan-time prompt to its authenticated retained snapshot (#980), but that execution-only binding now reaches the task-spec literal alone instead of the compiled task manifest, whose `renderedPromptPath` is normalized back to the sealed launch path recorded in `plan.json`. Previously the refreshed controller published the rebound `prompt-snapshots/.md` path into `smithers/tasks.json`, while `verifyDynamicRuntimeMaterialization` re-derived the same document from the sealed `controls/runtime-base-tasks.json` carrying the launch path; the two fingerprinted differently and every gated command (`sync`, `pause`, replay, fork) failed `WORKFLOW_CONTROL_EVIDENCE_INVALID` for the rest of the run's life, with no self-heal on a repeat refresh. Because resume is ungated, a run already damaged this way is recoverable: the normalization scrubs a rebound manifest on the next refresh, including when the sealed base manifest is absent and the live document is the compile input. Prompt authentication is unchanged -- a retained snapshot whose bytes no longer match `plan.json`'s recorded digest still aborts the refresh, and a task with no plan row still fails closed rather than falling back to the cleanup-owned launch path (#1002). - Pi model profiles now accept and forward the CLI's `max` thinking level, in addition to the previously supported levels through `xhigh`. - Upgrades the pinned workflow engine to Smithers 0.35.0. The Effect 4 tree is unchanged at `4.0.0-beta.105`, so the `@effect/*` override family and the `pnpm-workspace.yaml` mirror keep their exact pins; the deterministic npm resolution cutoff moves to `2026-08-18T06:00:00Z`, past `smthrs@0.35.0`'s publish instant. Six compatibility patches are re-derived against upstream's new `smithersRuntimeSpawn`/`smithersRuntimeReentry` indirection and the re-nested `adapter.insertRun` call; none is retired, because every workaround they encode is still absent upstream. Ultrafuzz's Smithers state mirrors gain the new `succeeded-with-failures` run state and `stalled` node state, and the inspect contract admits `tokenUsage`, `run.cancellationSource` and `runState.warnings`. Existing `0.34.0` dependency manifests migrate forward in place at `init`, so an already-scaffolded project converges onto the new pin without `--force` regenerating the run. Native resume never read the target project's `.smithers/package.json` -- launch admission asserts the manifest only against the operator controller's own freshly rendered temporary root -- so a stopped run already resumed under its own run ID and continues to. Ultrafuzz's strict readers for `why`, `status`, `node` and the `TokenUsageReported` event stream also admit the release's new `warnings`, `counts.stalled`, `tokenUsage.freshInputTokens`, `freshInputTokens`/`costUsd` and `stalled` blocker fields, and its two new `NodeStalled`/`RunConcurrencySaturated` event types; a stalled node now resets under `resume --retry-failed` alongside failed ones, and the evmbench adapter no longer aborts a converged run on the release's widened `degraded` verdict. The runner adds four forward-only SQLite migrations (0041-0044) (#955).