Skip to content

feat(safe): record proposal provenance (commit, branch, proposer, PR) (EXSC-692) - #2129

Draft
0xDEnYO wants to merge 1 commit into
mainfrom
feat/exsc-692-safe-proposal-provenance
Draft

feat(safe): record proposal provenance (commit, branch, proposer, PR) (EXSC-692)#2129
0xDEnYO wants to merge 1 commit into
mainfrom
feat/exsc-692-safe-proposal-provenance

Conversation

@0xDEnYO

@0xDEnYO 0xDEnYO commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Which Linear task belongs to this PR?

One PR covering three tickets — they are one change, split only by concern:

  • EXSC-691 — fail-soft git provenance helper (script/deploy/shared/git-provenance.ts)
  • EXSC-692 — capture provenance at the proposal storage funnel
  • EXSC-693 — show provenance to the signer in confirm-safe-tx

Why did I implement it this way?

The problem. A pending Safe proposal today says what it does but not where it came from. A signer asking "what is this, and can I trust the code behind it?" has to go and ask. Every proposal now records who created it, from which commit and branch, whether that commit is fetchable, whether the working tree was dirty, and optionally why.

Captured at the single storage funnel, not at call sites. All ten call sites — including the five bespoke task scripts and the Tron route — pass through storeTransactionInMongoDB, so capture lives there and every caller inherits it with zero changes. The new parameter is appended last and optional, so nothing else had to be touched.

Fail-soft, because this sits on the deploy path. A Mongo write failure here already aborts a deployment, so a git helper that throws would be able to take a production deploy down. Every probe swallows its own failure, returns an 'unknown' sentinel and records a one-line reason in captureErrors; a whole-capture backstop catches anything unexpected. There are three near-duplicate git snippets in the repo today with inconsistent behaviour (one of them throws) — this module is the single fail-soft policy, but the existing call sites are deliberately not refactored here.

Injectable override seam, in the same commit. safe-utils.test.ts calls storeTransactionInMongoDB directly. Without a seam that suite would start shelling out to real git and asserting against whatever checkout CI happens to have. provenanceOptions.override is what keeps it deterministic — a requirement of this change, not polish.

Optional field, for backward compatibility. provenance? is optional and old rows simply have none. The signer view renders one explicit "not recorded" line for them rather than a silent gap — a gap reads as "clean and authored by nobody", which is the one impression this block must never give.

Why the three parts ship together. The capture (EXSC-692) does not compile without the helper (EXSC-691), and the helper alone is dead code. The display (EXSC-693) could have been split out, but it is the only thing that makes the captured data visible, so reviewing it separately would mean reviewing a feature nobody can see. I have left "as small as possible" unticked rather than claim otherwise.

Design details worth a reviewer's attention

  • Scoped dirty tree. git status --porcelain minus the paths the deploy pipeline rewrites during its own run (deployments/**, script/deploy/_targetState.json). config/whitelist.json and config/networks.json are deliberately not excluded — a dirty whitelist at proposal time is exactly what a reviewer wants to know about. Capped at 20 entries with a truncation flag.
  • Memoized per process. The multi-network task scripts store one proposal per network in a loop; without the memo a 50-network run would spawn several hundred git processes. Measured on this branch: ~120 ms for the git probes, ~870 ms including the gh lookup, then 0 ms for every subsequent proposal in the run.
  • PR-URL lookup is best-effort. 5 s timeout, non-interactive gh environment, skipped for main/detached/unknown branches, skippable via an option, and every failure (missing gh, unauthenticated, no PR, timeout) is swallowed without recording a capture error — otherwise every proposer without gh would see a spurious "capture incomplete" marker on every proposal.
  • commitOnRemote is honest rather than clever. It reads local remote-tracking refs (git branch --remotes --contains), so a stale checkout can report false for a commit that is in fact pushed. The CLI says NOT PUSHED (per local refs) instead of pretending, and no network fetch is added to the hot path.
  • Never reads safeTx. The Tron flow fabricates that object through a cast and its shape is not trustworthy, so capture reads ambient git state only. Covered by a test that stores a Tron-shaped document.
  • The CI and bot branches are forward-looking. No workflow creates Safe proposals today, so those paths are unit-tested via process.env stubs only. This does not close an existing CI gap.
  • SAFE_PROPOSAL_REASON is read from the environment — no CLI plumbing in this PR, that is EXSC-694 — and is optional, with no warning spam. SAFE_PROPOSAL_ACTOR=bot is the opt-in a future unattended job sets. Both are in .env.example.

Provenance is context, not a security control

Worth stating plainly, because the field names invite the opposite reading: this data is self-reported by the proposing machine. It makes honest mistakes visible — an unpushed commit, a dirty whitelist, a proposal nobody can explain — and it gives later checks something concrete to verify against. It is not a defence against a proposer who is deliberately lying, and a signer should not read a green "clean / pushed" line as authentication of anything.

Governance impact (rule 105)

None. This is additive metadata on a MongoDB document. No change to Safe thresholds, owner sets, timelock delays, roles, proposal authorization, signing, or execution. No Solidity is touched and no on-chain behaviour changes; the only user-visible difference is a few extra informational lines in the confirm-safe-tx prompt. Nothing was added to DEPLOYMENT_QUERY_EQ_KEYS and mongo-log-utils.ts is untouched, so deployment-record identity and upsert behaviour are unchanged too.

Verification

  • bun test script/633 pass, 0 fail (35 files). script/deploy/safe/ + script/deploy/shared/ alone: 458 pass, 0 fail. 70 of those tests are new: 44 in git-provenance.test.ts, 13 in provenance-display.test.ts, 13 added to safe-utils.test.ts.
  • bunx eslint and bunx tsc-files --noEmit on all seven changed/added files: exit 0. Also typechecked all ten call sites of the changed signature: exit 0.
  • Smoke-checked the real capture path against this worktree. That is how the one real bug in the first draft surfaced: trimming git status output ate the leading status column and turned .env.example into env.example. Fixed, with a regression test for an unstaged first entry.

Follow-ups (not in this PR)

  • EXSC-694--reason CLI flag plumbed through propose-to-safe.ts and the bash chain.
  • EXSC-695 — the deploy-log twin (gitBranch / dirtyTreeScoped / actor on IDeploymentRecord), reusing this module. Note for whoever picks it up: those fields must not go into DEPLOYMENT_QUERY_EQ_KEYS, or an upsert becomes branch-sensitive and starts duplicating records.
  • Exposing provenance through IProposalSummary for list-pending-proposals --json, and hashing governance config into the block, are both deliberately deferred.

Checklist before requesting a review

Checklist for reviewer (DO NOT DEPLOY and contracts BEFORE CHECKING THIS!!!)

  • I have checked that any arbitrary calls to external contracts are validated and or restricted
  • I have checked that any privileged calls (i.e. storage modifications) are validated and or restricted
  • I have ensured that any new contracts have had AT A MINIMUM 1 preliminary audit conducted on by <company/auditor>

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Safe proposal provenance

Layer / File(s) Summary
Git provenance capture
script/deploy/shared/git-provenance.ts, script/deploy/shared/git-provenance.test.ts
Adds fail-soft Git/PR metadata capture, dirty-tree analysis, CI handling, memoization, and extensive tests.
Proposal provenance persistence
.env.example, script/deploy/safe/safe-utils.ts, script/deploy/safe/safe-utils.test.ts
Adds rationale normalization, provenance types, one-time capture before retries, and MongoDB persistence.
Signer-facing provenance display
script/deploy/safe/provenance-display.ts, script/deploy/safe/provenance-display.test.ts, script/deploy/safe/confirm-safe-tx.ts
Formats provenance states and appends them to Safe transaction confirmation details.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • lifinance/contracts#2076: Updates overlapping Safe transaction storage and confirmation display surfaces with parked-task origin references.

Suggested labels: requires-types

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 53.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is clear and matches the main change: recording Safe proposal provenance.
Description check ✅ Passed The description covers the required Linear task, rationale, and checklist sections from the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/exsc-692-safe-proposal-provenance

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@0xDEnYO

0xDEnYO commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
script/deploy/safe/safe-utils.ts (1)

1339-1373: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Override path returns the caller's object by reference.

Unlike captureGitProvenance, which clones, buildProposalProvenance hands back options.override itself, so the stored document aliases the caller's block (a test fixture reused across cases can be mutated downstream). A shallow copy would keep the seam side-effect free.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/safe/safe-utils.ts` around lines 1339 - 1373, Update the
override branch in buildProposalProvenance to return a shallow copy of
options.override rather than the caller’s object directly, while preserving the
existing reason-merging behavior and avoiding mutation of the supplied override.
script/deploy/shared/git-provenance.ts (1)

509-551: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Memo is keyed on nothing, so resolvePrUrl and injected context only apply to the first call.

A later captureGitProvenance({ resolvePrUrl: false }) still returns the cached prUrl (and vice versa: a first PR-less capture permanently hides it), and on a cache hit options.errors is never repopulated. Harmless for the single production caller, but worth documenting on the export so a future caller doesn't rely on per-call options.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/shared/git-provenance.ts` around lines 509 - 551, Document on
the exported captureGitProvenance function that its cached result is not keyed
by per-call options, so resolvePrUrl, injected context, and options.errors only
affect the first invocation; clarify that subsequent calls return the existing
cached provenance unchanged.
script/deploy/shared/git-provenance.test.ts (1)

77-95: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Docstring claims longest-prefix matching; the implementation takes the first insertion-order match.

Object.keys(handlers).find(...) returns the first registered prefix that matches, so a broad key (e.g. 'git ') added before a specific one would shadow it. Either sort candidates by descending length or fix the comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/shared/git-provenance.test.ts` around lines 77 - 95, Update
stubRunner’s handler selection to honor its documented longest-prefix behavior
by choosing the matching key with the greatest length, rather than the first
Object.keys(handlers) match. Preserve the existing command logging and
unstubbed-command failure behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@script/deploy/safe/provenance-display.ts`:
- Around line 98-105: The provenance display must sanitize proposer-supplied
text before applying color codes: in script/deploy/safe/provenance-display.ts
lines 98-105, strip C0/C1 control characters from reason and the other stored
strings rendered by the display. In script/deploy/safe/safe-utils.ts lines
1321-1327, update normalizeProposalReason to remove control characters as well
as collapsing whitespace, ensuring stored values cannot contain escape
sequences.

---

Nitpick comments:
In `@script/deploy/safe/safe-utils.ts`:
- Around line 1339-1373: Update the override branch in buildProposalProvenance
to return a shallow copy of options.override rather than the caller’s object
directly, while preserving the existing reason-merging behavior and avoiding
mutation of the supplied override.

In `@script/deploy/shared/git-provenance.test.ts`:
- Around line 77-95: Update stubRunner’s handler selection to honor its
documented longest-prefix behavior by choosing the matching key with the
greatest length, rather than the first Object.keys(handlers) match. Preserve the
existing command logging and unstubbed-command failure behavior.

In `@script/deploy/shared/git-provenance.ts`:
- Around line 509-551: Document on the exported captureGitProvenance function
that its cached result is not keyed by per-call options, so resolvePrUrl,
injected context, and options.errors only affect the first invocation; clarify
that subsequent calls return the existing cached provenance unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 85f4b525-19f0-4b06-aa6b-ec2002d86c51

📥 Commits

Reviewing files that changed from the base of the PR and between 358c2b9 and 1f5e122.

📒 Files selected for processing (8)
  • .env.example
  • script/deploy/safe/confirm-safe-tx.ts
  • script/deploy/safe/provenance-display.test.ts
  • script/deploy/safe/provenance-display.ts
  • script/deploy/safe/safe-utils.test.ts
  • script/deploy/safe/safe-utils.ts
  • script/deploy/shared/git-provenance.test.ts
  • script/deploy/shared/git-provenance.ts

Comment on lines +98 to +105
lines.push(
detailLine(
'Reason',
provenance.reason
? color(GREEN, provenance.reason)
: color(YELLOW, '— none given —')
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Provenance text is never stripped of control characters, so proposer-supplied text can spoof the signer prompt. Neither normalization at capture time nor rendering at confirm time removes \u001b/\r, and the rendered block is read by a different human before signing.

  • script/deploy/safe/provenance-display.ts#L98-L105: strip C0/C1 control characters from reason (and the other stored strings) before wrapping them in colour codes.
  • script/deploy/safe/safe-utils.ts#L1321-L1327: extend normalizeProposalReason to drop control characters in addition to collapsing whitespace, so nothing stored can carry escape sequences.
📍 Affects 2 files
  • script/deploy/safe/provenance-display.ts#L98-L105 (this comment)
  • script/deploy/safe/safe-utils.ts#L1321-L1327
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script/deploy/safe/provenance-display.ts` around lines 98 - 105, The
provenance display must sanitize proposer-supplied text before applying color
codes: in script/deploy/safe/provenance-display.ts lines 98-105, strip C0/C1
control characters from reason and the other stored strings rendered by the
display. In script/deploy/safe/safe-utils.ts lines 1321-1327, update
normalizeProposalReason to remove control characters as well as collapsing
whitespace, ensuring stored values cannot contain escape sequences.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant