Skip to content

fix(contract): seal finding write-ups and the hardening portfolio - #240

Open
rohanpoudel2 wants to merge 3 commits into
openai:mainfrom
rohanpoudel2:fix/seal-writeups
Open

fix(contract): seal finding write-ups and the hardening portfolio#240
rohanpoudel2 wants to merge 3 commits into
openai:mainfrom
rohanpoudel2:fix/seal-writeups

Conversation

@rohanpoudel2

Copy link
Copy Markdown

Fixes #230

I filed that issue rather than opening a PR because the fix spans the bundled plugin and the migration story for already-sealed scans was yours to choose. This implements it with the most conservative migration I could find — no schema change, no version bump, and no previously-sealed scan becomes unreadable. If you would rather gate on the producer version, that shape is discussed under "Why the gate is data-derived" below.

Problem

Every entry in manifest.scan.artifacts is digest-verified on load, and every coverage receipt is required to be one of those sealed artifacts:

if (!artifactPaths.has(normalized)) {
  throw new ContractValidationError(
    `Coverage receipt is missing from sealed artifacts: ${receipt}`,
  );
}

finding.writeup.reportPath and manifest.scan.hardening.portfolioPath got neither treatment — they were opened and immediately closed:

const file = await openCheckedScanFile(
  scanDir, writeup.reportPath, `findings[${index}].writeup.reportPath`, signal, expectedRoot,
);
await file.close();

That is an existence check. The write-up is the artifact a person actually reads, and it could be replaced wholesale after sealing while the scan still loaded as intact.

It was by construction rather than an oversight in one layer. finalize_scan_contract.py built the sealed set from three sources — findings.json, coverage.json, and the coverage receipt refs — and handled write-ups separately in _require_derived_writeup_files, which only asserts the files exist.

Change

Producer — add the referenced derived Markdown to the sealed set, so its contents fall under the digest verification that already runs for every listed artifact. A path that is also a coverage receipt keeps a single record, because duplicate artifact paths are rejected on load.

SDK — require every referenced derived document to be one of the sealed artifacts, so a manifest cannot reference a write-up while leaving it outside the seal.

The two halves fix different things, and it is worth being precise about which does what:

  • The producer change is what closes the reported hole. Once the write-up is listed, the existing artifact loop verifies its digest — no SDK change is needed for tamper detection.
  • The SDK change closes the follow-on gap: a manifest that references a write-up but omits it from artifacts would otherwise fall back to the existence check.

Migration

Tightening the SDK unconditionally would reject every scan sealed by an older plugin, and loadContract accepts scans without a ScanExpectation, so historical scans on disk do load today and export depends on that. So the SDK requirement is gated on the manifest listing at least one derived document. New scans list them and are strictly verified; older manifests list none and keep the existence check they were sealed with.

The gate is not weaker than the alternative. It rests on manifest authenticity, and that is what the seal already assumes: artifact digests live in the same manifest they protect, so anyone who can rewrite the manifest can re-seal anything. The seal's real guarantee is that the artifacts on disk match what an authentic manifest recorded, and that is exactly the guarantee this restores for write-ups.

The gate also degrades in the safe direction. Stripping one write-up from a manifest that seals others leaves the gate active, so the missing one is rejected. Only stripping every derived entry reaches the lenient path, and that is manifest rewriting.

Why the gate is data-derived rather than version-gated

The obvious alternative is to gate on manifest.scan.producer.version. I did not take it for two reasons.

It is no stronger. An attacker who can rewrite the manifest to drop artifact entries can equally rewrite producer.version to an older value, so both gates rest on the same assumption.

It is more invasive. It would mean choosing and hardcoding a plugin version, bumping plugin.json and BUNDLED_PLUGIN_VERSION, and touching the release cut — version and release mechanics I would rather not decide from outside the canonical repo, especially so soon after #183. Say the word and I will switch it.

Not a bug, for the record

Repeating this from the issue since it is the natural next question: a manifest cannot dodge verification by omitting findings.json or coverage.json from scan.artifacts. The schema's allOf / contains / maxContains: 1 clauses force both into the array. That part is sound, and mediaType is only constrained to a non-empty string, so text/markdown on the new records needs no schema change.

Verification

Four tests, and each half was reverted independently to confirm the tests fail for the right reason.

scan-recovery.test.ts — a new test drives the real workbench end to end: a draft with a finding write-up and a hardening portfolio is sealed, and the resulting manifest is asserted to carry both as artifacts with the correct SHA-256 and text/markdown. With only the producer change reverted it fails, reporting the write-up artifact as undefined:

- "sha256": "8dadf6447ce21c5ad391682a32b10267da682a28a2b9bcc436931ed3295f436c"
+ undefined
(fail) seals finding write-ups and the hardening portfolio
16 pass / 1 fail

contract.test.ts — three new tests: a write-up replaced after the seal covered it is rejected, a hardening portfolio replaced after the seal covered it is rejected, and a referenced write-up left out of artifacts is rejected with the new message. With only the SDK change reverted, exactly the third fails, which is the correct split — the first two are the producer change being exercised through the pre-existing digest loop:

Expected promise that rejects / Received promise that resolved
(fail) rejects a referenced write-up left out of the sealed artifacts
31 pass / 1 fail

The pre-existing test accepts regular derived artifacts and a schema-valid scope summary builds a manifest that references a write-up and a portfolio without sealing either, which is the historical shape. It still passes untouched, so the migration path is covered by a test that predates this change.

Full suite on this base: 730 pass / 5 skip / 0 fail. pnpm run types and pnpm run format are clean, and finalize_scan_contract.py compiles under python3 -m py_compile.

Every entry in manifest.scan.artifacts is digest-verified when a scan loads, and
every coverage receipt is required to be one of those sealed artifacts.
finding.writeup.reportPath and manifest.scan.hardening.portfolioPath got neither
treatment. Both were opened and immediately closed, an existence check only, so
the human-readable write-up a person actually reads could be replaced wholesale
after sealing and the scan still loaded as intact.

The producer built the sealed set from findings.json, coverage.json and the
coverage receipt refs, and handled write-ups separately in
_require_derived_writeup_files, which only asserted that the files exist. Add
the referenced derived Markdown to the sealed set so its contents are covered by
the digest verification that already runs for every listed artifact. A path that
is also a coverage receipt keeps a single record, because duplicate artifact
paths are rejected on load.

On the SDK side, require every referenced derived document to be one of the
sealed artifacts, so a manifest cannot reference a write-up while leaving it out
of the seal. Gate that on the manifest listing at least one derived document, so
scans sealed before this keep loading with the existence check they were sealed
with instead of becoming unreadable. That gate rests on manifest authenticity,
which is what the seal already assumes: artifact digests live in the manifest,
so a rewritten manifest can always re-seal itself, and the guarantee being
restored is that artifacts on disk match what an authentic manifest recorded.

Fixes openai#230
@github-actions github-actions Bot added the bug Something isn't working label Aug 3, 2026
@mldangelo-oai

Copy link
Copy Markdown
Collaborator

@codex review exact head 6ff045f

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6ff045fde5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread sdk/typescript/src/contract.ts
Comment thread sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py
@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 6ff045fde5

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Sealing the referenced write-ups and hardening portfolio only closed the
integrity gap for SDK readers. `loadContract` rejected a manifest that
referenced several derived documents but sealed only some of them, while the
bundled Python paths still accepted it: `_read_sealed_scan` and
`validate_scan_contract.py` verified artifact digests and coverage receipts
without requiring the referenced documents to be among them, and
`_prepare_scan_finalization` only checked that the files existed. Direct CLI
exports, SARIF projection, workbench exports and comparisons, and Python
contract validation therefore loaded a partially unsealed contract that the SDK
refused.

`_validate_sealed_derived_documents` now applies the same all-or-none gate next
to `_validate_sealed_coverage_receipts`, and runs from every place that
validates a seal: `_read_sealed_scan`, both branches of
`_prepare_scan_finalization`, and `validate_scan_contract.py`. The condition
matches `validateSeal` exactly: when the manifest lists at least one referenced
derived document in `scan.artifacts`, every referenced document must be there;
otherwise the manifest predates the change and keeps the existence check it was
sealed with, so old sealed scans stay readable.

The published contract also stopped matching the producer. The generated report
called the portfolio unsealed, and the bundled guidance told authors these
documents were unsealed or revisable and must not join the sealed artifact list
- following it, an agent would edit a document after completion and then find
loading or exporting fails the digest check. The report now says the portfolio
is covered by the scan seal, and the reporting and artifact references, the
hardening skill, and the diff-scan skill describe finalization sealing these
documents. Wording about the `report.md` projection and the unsealed draft
manifest is unchanged, because both remain outside the seal.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Finding write-ups and the hardening portfolio are referenced by the manifest but left outside the seal

2 participants