Skip to content

feat: add secure public trajectory upload - #989

Merged
bingran-you merged 26 commits into
mainfrom
bry/traj-upload-azure
Aug 15, 2026
Merged

feat: add secure public trajectory upload#989
bingran-you merged 26 commits into
mainfrom
bry/traj-upload-azure

Conversation

@bingran-you

Copy link
Copy Markdown
Collaborator

Summary

  • adds the single public CLI surface: bench traj upload PATH
  • validates JSONL, structurally redacts secrets, and creates a transport-neutral content-addressed manifest
  • supports the built-in public broker and an optional trusted Azure direct mode
  • deploys an Azure Container Apps broker plus Event Grid, Storage Queue, quarantine validator job, private versioned Blob Storage, durable ledger/rate limit, least-privilege managed identities, lifecycle policy, and diagnostics
  • documents contributor privacy, idempotency, direct mode, and production deployment

Production evidence

  • broker: https://tasksminer-traj-broker.nicewave-c3abaecf.westus2.azurecontainerapps.io
  • deployed image: tasksminerregistry.azurecr.io/trajectory-upload:8b4d21f3f56d
  • deployment script completed successfully against both a fresh setup and the existing resource group
  • public demo digest 2d0263bd134435704109df1e8e405fe75104d45a9c1fb2dd19cc067102e18c19 was promoted to sources/community after the validator job succeeded; quarantine was empty afterward
  • replay of the same digest returned Already uploaded and performed no writes
  • trusted direct-mode demo uploaded payload plus manifest and replayed as a no-op

Security probes

  • object-name injection: HTTP 400 before SAS issuance
  • declared oversize and actual request-body oversize: HTTP 413 before SAS issuance
  • SAS replay as GET: HTTP 403
  • SAS replay as DELETE: HTTP 403
  • tampered expiry: HTTP 403
  • intentional quarantine overwrite retained two Azure versions; the validator rejected the hash/size mismatch and did not promote it
  • signed SAS URLs and Azure SDK INFO request logs are suppressed from CLI output

Verification

  • uv run ruff check .
  • uv run ty check src/
  • uv run pytest tests/
  • 5413 passed, 65 skipped, 7 deselected
  • focused trajectory upload suite: 40 passed
  • live health, Event Grid identity route, validator execution, promoted objects, ledger status, empty quarantine, direct upload, and replay all checked with az CLI

Review focus

Please verify the one-command CLI boundary, manifest/redaction contract, broker SAS permissions, validator fail-closed promotion, duplicate-event behavior, and Azure IAM scopes. The branch has also passed the requested thermo-nuclear structural review; unrelated uv.lock resolver churn was removed.

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 08:06 — with GitHub Actions Active
@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 08:07 — with GitHub Actions Active
@bingran-you
bingran-you requested review from kywch and xdotli August 15, 2026 08:07
@bingran-you

Copy link
Copy Markdown
Collaborator Author

CI note: manifest-parity is failing on external agents@ce26cbc because OpenClaw manifest main contains the GPT-5.4 shim update while benchflow main does not yet. This reproduces locally and is unrelated to this PR. The focused sync is #986, whose full CI including parity is green. I am intentionally not folding that unrelated OpenClaw change into this trajectory-upload PR; I will refresh this branch from main after #986 merges and rerun all checks.

@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: 8b4d21f3f5

ℹ️ 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".

def redact_value(value: Any, *, field_name: str | None = None) -> tuple[Any, int]:
"""Return a structurally redacted JSON value and replacement count."""
if isinstance(value, Mapping):
redacted: dict[Any, Any] = {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Redact sensitive fields before recursing into their values

When a denylisted field contains a mapping, sequence, or non-string value, these early branches run before the sensitive-key check, so inputs such as {"credentials":{"token":"opaque-secret"}}, {"secret":["opaque-secret"]}, or {"password":123456} receive zero redactions. The validator uses the same function for its final scan, so these secrets are accepted and promoted into the public trajectory dataset; redact the entire value of a sensitive field before dispatching on its type.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in cf0c95c. Sensitive-key handling now runs before mapping, sequence, string, or scalar dispatch and replaces the entire value with [REDACTED]. The regression covers mapping, list, and integer sensitive values, and the validator reuses the same fail-closed function.

Comment on lines +66 to +70
if path.name == "llm_trajectory.jsonl":
try:
records = load_llm_trajectory_jsonl(path, strict=True)
except PrimeSftTrajectoryJsonlError as exc:
raise CaptureRejected(str(exc)) from exc

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Convert invalid UTF-8 into a terminal rejection

For an anonymously uploaded artifact named llm_trajectory.jsonl, load_llm_trajectory_jsonl() calls Path.read_text() and lets UnicodeDecodeError escape rather than wrapping it in PrimeSftTrajectoryJsonlError. Since run_once() catches only CaptureRejected, a client can upload correctly hashed invalid UTF-8 and make every validator execution crash without deleting the queue message or quarantine prefix, repeatedly consuming the public validator job until the message expires.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in cf0c95c. Upload validation no longer special-cases llm_trajectory.jsonl through the whole-file loader; all JSONL now uses the streaming validator, whose UnicodeDecodeError path becomes a terminal CaptureRejected. Client and service regressions use invalid UTF-8 in an llm_trajectory.jsonl file.

Comment thread services/trajectory_upload/validator.py Outdated
Comment on lines +80 to +82
if self._capture_status(digest) in {"ingested", "rejected"}:
self._delete_message(message)
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clean terminal quarantine prefixes before acknowledging events

If a contributor reuses a still-valid SAS after a capture becomes ingested or rejected, the new manifest event takes this branch and only deletes the queue message. The grant includes create/write permission, and the returned If-None-Match header is controlled by the anonymous client, so it can be omitted to recreate or repeatedly overwrite every scoped blob after validation; those unvalidated blobs and versions then remain until lifecycle expiry, enabling substantial storage-cost abuse. Clean the terminal prefix before acknowledging such events and prevent further writes where possible.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in cf0c95c. Terminal duplicate/replay events now clean the exact inbox// prefix before acknowledging the queue message. The regression seeds replayed quarantine blobs for both ingested and rejected states and requires the prefix to be empty.

Comment on lines +66 to +68
if path.name == "llm_trajectory.jsonl":
try:
records = load_llm_trajectory_jsonl(path, strict=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Stream large LLM trajectory validation

For llm_trajectory.jsonl, this loads the whole file as text, splits it into another full set of strings, and retains every parsed record in a list. The public contract accepts artifacts up to 1 GiB while the deployed validator is limited to 1 GiB of memory, so a valid capture well below the advertised limit can OOM both staging and server validation and remain unpromoted; validate this format incrementally or lower the accepted size to a bound the job can process.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in cf0c95c. LLM trajectories now take the same line-by-line validation and secret-scan path as other JSONL rather than read_text/splitlines/list retention. The public per-artifact bound is also reduced from 1 GiB to 128 MiB (256 MiB per capture), and the event job is raised to 2 GiB for a defensible parsing envelope.

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 08:13 — with GitHub Actions Active
@bingran-you

Copy link
Copy Markdown
Collaborator Author

@codex review

@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

https://github.com/benchflow-ai/benchflow/blob/cf0c95cee71683776e7b385290e6537b45f26aa3/src/benchflow/publish/azure_backend.py#L203-L205
P1 Badge Prevent artifact-only SAS replays after terminal cleanup

The returned If-None-Match header is only advisory, so an anonymous caller can omit it and continue using the write-enabled SAS to recreate or repeatedly overwrite artifact blobs after validation, retaining large current blobs and versions until lifecycle expiry. The terminal cleanup added for the earlier report does not close this path: the deployed Event Grid filter at deploy-azure.sh:295-298 delivers only manifest.json events, so artifact-only replays never invoke the cleanup branch; enforce create-only/size-bounded writes or arrange cleanup for every post-terminal inbox write.

ℹ️ 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 on lines +125 to +128
blob = self.container.get_blob_client(prefix + relname)
expected_size = item.get("bytes")
if blob.get_blob_properties().size != expected_size:
raise CaptureRejected(f"size mismatch for {relname}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject missing artifacts instead of retrying forever

When an anonymous client uploads a syntactically valid manifest but omits any declared artifact, get_blob_properties() raises Azure's ResourceNotFoundError, which is not converted to CaptureRejected and therefore escapes run_once() without deleting the queue message. Because the deployed queue retains messages for seven days, each such manifest repeatedly crashes validator executions and enough incomplete captures can starve legitimate uploads; treat a missing declared blob as a terminal capture rejection.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 741dddb. ResourceNotFoundError from any declared manifest/artifact lookup is converted inside the download phase to CaptureRejected, so the exact quarantine prefix is cleaned, the ledger records rejected, and the queue message is deleted. The regression omits a declared artifact and requires terminal cleanup/no promotion.

@bingran-you

Copy link
Copy Markdown
Collaborator Author

Addressed the review-level artifact-only replay finding in 741dddb: SAS grants now use Azure BlobSasPermissions(create=True) with no write permission. This makes create-only enforcement a storage-service property rather than relying on the client-supplied If-None-Match header; omitting that header cannot overwrite an existing blob. A regression inspects the generated permission and requires create=true/write=false. I will also live-probe the second PUT without the precondition after deployment.

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 08:21 — with GitHub Actions Active
@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 08:24 — with GitHub Actions Active
@bingran-you

Copy link
Copy Markdown
Collaborator Author

Closed the remaining artifact-only replay path in 46b6a71. Event Grid now delivers every BlobCreated under bronze/inbox/ to the validator: pending artifact events are acknowledged without validation, manifest.json remains the only commit trigger, and any event for an ingested/rejected digest cleans the exact quarantine prefix. SAS remains Azure-enforced create-only (sp=c).

Live production proof on the exact deployed image trajectory-upload:46b6a7125611:

  • first artifact PUT: 201
  • second PUT with If-None-Match omitted: 403
  • manifest PUT: 201
  • ledger/promotion: ingested, 2 promoted objects, quarantine empty
  • same still-valid SAS recreated the deleted artifact once: 201
  • the all-inbox Event Grid route invoked the terminal cleanup path and quarantine returned to empty

Regression coverage requires pending artifact events to wait for the manifest and terminal artifact replays to be cleaned. The public stock CLI also completed a fresh anonymous demo upload at this image, digest 171f1e140c3a5299dfedfbe6d044a19310f1ab7c719e7e6e72035334b3d6931b, which promoted successfully.

@bingran-you

Copy link
Copy Markdown
Collaborator Author

@codex review

@bingran-you

Copy link
Copy Markdown
Collaborator Author

@kywch @xdotli This is ready for the required independent human review: full local suite is green (5417 passed, 65 skipped, 7 deselected), all feature CI is green, and the exact production image passed the anonymous stock-CLI upload plus overwrite/replay probes above. The sole failing parity check is external manifest drift already isolated to fully-green #986; after that focused PR merges I will refresh this branch, rerun CI, and squash-merge only after approval.

@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: 46b6a71256

ℹ️ 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 services/trajectory_upload/validator.py Outdated
Comment on lines +132 to +133
artifact_paths: dict[str, Path] = {}
for item in artifacts:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate the manifest before downloading artifacts

An anonymous client can replace the granted manifest with one containing thousands of duplicate entries for the same valid, up-to-128 MiB artifact. This loop downloads every entry before validate_local_capture() applies ContributionManifest's eight-artifact and uniqueness checks, so a sub-1 MiB manifest can amplify into hundreds of gigabytes of repeated downloads, time out the public validator, and retry from the queue. Parse and validate the complete manifest contract before issuing any artifact storage requests.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ca0df6b. The validator now parses and validates the complete ContributionManifest contract immediately after the bounded manifest download and before issuing any artifact get-properties or download call. A nine-entry duplicate manifest regression requires rejection with exactly one blob client request: manifest.json.

Comment thread services/trajectory_upload/validator.py Outdated
Comment on lines +125 to +127
manifest_data = json.loads(manifest_bytes)
artifacts = manifest_data["artifacts"]
except (json.JSONDecodeError, KeyError, TypeError) as exc:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject invalid UTF-8 manifests

Although invalid UTF-8 artifacts are now converted to CaptureRejected, this separate manifest decode still lets UnicodeDecodeError escape because the handler catches only JSONDecodeError, KeyError, and TypeError. An anonymous client can upload arbitrary manifest bytes through its scoped SAS, causing the validator job to fail without cleaning the prefix, recording rejection, or deleting the seven-day queue message, so the same event is retried repeatedly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ca0df6b. Manifest parsing is centralized in validate_manifest_bytes(), which converts UnicodeDecodeError, JSONDecodeError, and Pydantic ValidationError to terminal CaptureRejected. The invalid-UTF-8 regression requires rejected ledger state and queue acknowledgement.

Comment thread services/trajectory_upload/validator.py Outdated
self._delete_message(message)
return True

if self._capture_status(digest) in {"ingested", "rejected"}:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Claim pending captures before processing duplicate events

With at-least-once Event Grid delivery and up to five concurrent validator executions configured in infra/trajectory-upload/deploy-azure.sh:278, two manifest events can both observe pending here. If one promotes and cleans quarantine while the other is still downloading, the second converts the resulting missing blob into rejection and overwrites the first worker's ingested ledger status, allowing the broker to reopen an already-promoted digest. Atomically claim the pending row, or otherwise prevent a later rejection from downgrading an ingested capture.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ca0df6b. Manifest workers acquire an optimistic Azure Table ETag lease by transitioning pending to validating; only one worker owns a digest, active-lease duplicates leave their queue message for retry, and expired leases are reclaimable after a crash. The 35-minute lease exceeds the Job's 30-minute timeout. Broker re-grants preserve pending/validating and rejected digests cannot reopen. Terminal state is recorded before cleanup so replay events always observe the terminal state.

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 08:44 — with GitHub Actions Active
@bingran-you

Copy link
Copy Markdown
Collaborator Author

Production validation for ca0df6b3 is complete on exact image tasksminerregistry.azurecr.io/trajectory-upload:ca0df6b3b46a (broker revision 0000008):

  • valid anonymous capture 1f6f3f… reached ingested, promoted 2 objects, and cleared its validation lease
  • valid artifact + invalid-UTF-8 manifest 35d48b… reached terminal rejected, quarantine was empty, and a fresh broker handshake for the digest returned HTTP 422
  • two concurrent _claim_capture calls against the real Azure Table row produced exactly [false, true], confirming one ETag lease owner

Focused service/CLI tests: 51 passed; full Ruff/format/type checks green. Requesting another exact-head pass after these fixes: @codex review.

@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: 2c67d4e630

ℹ️ 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 on lines +160 to +163
manifest_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n",
encoding="utf-8",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the generated manifest size before upload

When result.json or config.json contains a scalar run-metadata field large enough to make this generated file exceed MAX_MANIFEST_BYTES, staging still succeeds because the broker handshake omits run and therefore accepts the request. The validator then terminally rejects the oversized manifest, and because the digest covers only artifacts, even retrying after removing the large metadata is blocked by the rejected ledger row; direct mode instead publishes a manifest the shared contract cannot consume. Fresh evidence beyond the earlier artifact-limit fix is that the final serialized manifest is never checked against its 1 MiB bound.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d124e18. MAX_MANIFEST_BYTES is now a shared staging/service constant, and staging checks the final UTF-8 serialized manifest before either broker or direct upload. The PR #989 regression injects large run metadata and proves local fail-closed rejection.

replacement_count += replacements
else:
shutil.copyfile(source, target)
payloads.append(_staged_file(target, relname, "application/jsonl"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recheck artifact limits after redaction

For inputs near the file or capture limit, structural redaction can substantially increase the staged size—for example, many short password values become [REDACTED]. The limits at lines 117–124 apply only to the source bytes, so this appended StagedFile may exceed the 128 MiB artifact or 256 MiB capture contract; broker mode then rejects output produced by the client itself, while direct mode uploads an out-of-contract capture. Fresh evidence after the prior direct-limit fix is that its checks still run before this size-expanding transformation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d124e18. Staging now rechecks every staged artifact and the aggregate capture after structural redaction, before digest/manifest construction. Separate PR #989 regressions prove both per-artifact and aggregate expansion are rejected.

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 11:00 — with GitHub Actions Active
@bingran-you

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: d124e18b87

ℹ️ 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 src/benchflow/publish/redact.py Outdated
Comment on lines +105 to +109
def _is_sensitive_key(field_name: str) -> bool:
normalized = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", field_name)
normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", normalized)
normalized = normalized.casefold().replace("-", "_")
return normalized in DENYLISTED_KEYS or normalized.endswith(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Normalize all key separators before secret checks

After the camelCase/hyphen hardening, separators such as spaces and dots are still left intact, so common records like {"api key":"opaque-secret"}, {"client secret":"opaque-secret"}, or {"access.token":"opaque-secret"} produce zero replacements. Because the validator uses the same redact_value function, these prefixless credentials pass the final scan and can be promoted publicly; normalize non-alphanumeric separators to underscores before applying the denylist and suffix checks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 939cf025afce. Sensitive field normalization now converts every non-alphanumeric separator run to _ after camelCase/acronym splitting, so api key, client secret, and access.token match the same denylist/suffix rules as their snake/camel/kebab forms. Client redaction and validator-side raw rejection share this function; regressions cover the new separator shapes in both paths.

Comment on lines +82 to +86
status = self._capture_status(digest)
if status == "ingested":
raise AlreadyUploaded(
base_url=self.container_url,
prefix=f"sources/community/{digest}/",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Charge terminal digest requests against the rate limit

Requests for an ingested digest return here before _consume_rate_limit() runs, and the rejected branch has the same behavior. An anonymous caller can therefore upload or reject one tiny capture and then issue unlimited valid handshakes for that digest, each forcing a serialized Azure Table lookup on the single-replica public broker while bypassing its advertised 20-request hourly limit; consume the rate-limit allowance before these terminal-status returns.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 939cf025afce. The durable per-IP quota is now consumed under the broker lock before any capture-ledger lookup, including terminal ingested and rejected digests. The regression sets a one-request quota for each terminal status, verifies the first request returns its terminal response, and requires the second to raise RateLimited before another terminal lookup.

Comment on lines +396 to +398
raw_rewards = result.get("rewards")
rewards: Mapping[str, Any] = raw_rewards if isinstance(raw_rewards, Mapping) else {}
return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bound run metadata before reading it into memory

When a selected trial contains an agent-produced result.json or config.json that is very large, read_text() materializes the entire file even though only six scalar metadata fields can reach the bounded manifest. A malformed trial can therefore make bench traj upload exhaust local memory before the final manifest-size check; inspect the file size and reject or ignore metadata beyond a small bound before reading it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 939cf025afce. Optional result.json and config.json metadata is now read through a bounded binary stream capped at 1 MiB plus one sentinel byte; oversized metadata is ignored before parsing, so no unbounded read_text/read_bytes allocation occurs. The regression shrinks the bound, makes unbounded Path.read_text fail if called, and verifies the oversized agent field never enters the manifest.

@bingran-you

Copy link
Copy Markdown
Collaborator Author

@codex review

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 11:12 — with GitHub Actions Active

@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: f2304df85c

ℹ️ 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 src/benchflow/publish/traj_capture.py Outdated
payloads: list[StagedFile] = []
replacement_count = 0
for source in resolved.files:
relname = f"trajectory/{source.name}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate artifact filenames before staging them

When an input is named TRACE.JSONL, my trace.jsonl, or uses other non-allowlisted characters, _resolve_input() accepts it and this line preserves the filename in the manifest. The broker subsequently rejects it because services/trajectory_upload/contract.py only permits trajectory/[A-Za-z0-9._-]{1,128}.jsonl, while --direct bypasses that validation and publishes a manifest that the shared contribution contract cannot parse. Apply the artifact-name constraint, or normalize the name, during staging for both transports.

Useful? React with 👍 / 👎.

def test_broker_validation_errors_are_fail_closed_and_json_safe(
tmp_path: Path,
) -> None:
"""Guards the live Azure fix after malformed handshakes returned HTTP 500."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Name the guarded change in the regression docstring

This docstring explicitly describes a regression—malformed handshakes previously returning HTTP 500—but identifies neither the PR nor the commit containing that behavior or its fix. Name the relevant PR/commit so the test complies with the repository's regression-test traceability requirement.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

@bingran-you

Copy link
Copy Markdown
Collaborator Author

@codex review

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 11:21 — with GitHub Actions Active

@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: 939cf025af

ℹ️ 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 src/benchflow/publish/redact.py Outdated
"_token",
"_secret",
"_password",
"_passwd",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat bare passwd fields as sensitive

After the earlier field-name hardening, the current predicate still recognizes only names ending in _passwd, not the common bare passwd field. A record such as {"passwd":"opaque-prefixless-value"} therefore receives zero replacements in both staging and validator scans, causing the original credential to be promoted publicly; include unambiguous bare credential names such as passwd in the denylist.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d1dba71bb052. Bare passwd is now explicitly sensitive, and the audit generalized the predicate to compact lowercase variants and every existing credential suffix family, so shapes such as clientsecret, accesskey, and servicecredentials cannot bypass separators/case normalization. Client and validator share the predicate; focused regressions cover these forms.

.replace("+00:00", "Z"),
"source_id": source_id,
"traj_digest": f"sha256:{traj_digest}",
"uploaded_by": uploaded_by,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the uploaded-by bound during staging

When BENCHFLOW_TRAJ_UPLOADED_BY exceeds 256 characters, staging accepts it even though UploadRequest.uploaded_by has max_length=256. Broker mode consequently rejects client-generated output, while --direct publishes a manifest that ContributionManifest cannot parse; apply the shared bound before either transport is selected.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d1dba71bb052. MAX_UPLOADED_BY_LENGTH = 256 now lives in shared staging and is imported by the service contract. Staging rejects an oversized contributor label before manifest construction for both broker and direct paths. The same parity audit moved canonical artifact-name validation into the shared module and added direct-path regressions for spaces, uppercase extensions, and overlong names.

Comment thread src/benchflow/publish/traj_capture.py Outdated
if len(payload) > MAX_RUN_METADATA_BYTES:
return {}
value = strict_json_loads(payload)
except (OSError, UnicodeDecodeError, ValueError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore recursively nested optional metadata

If an agent-produced result.json or config.json contains deeply nested but sub-1 MiB JSON, strict_json_loads() raises RecursionError, which this handler does not catch. The optional metadata then aborts bench traj upload instead of being ignored like other malformed metadata; catch recursion failures here as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in d1dba71bb052. Optional metadata parsing now catches RecursionError alongside other malformed/invalid metadata failures and ignores the file. A regression supplies a 1,200-level sub-1 MiB result.json and requires staging to finish without importing metadata or leaking the exception.

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 11:34 — with GitHub Actions Active
@bingran-you

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: d1dba71bb0

ℹ️ 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 on lines +126 to +127
if not hasattr(app.state, "backend"):
app.state.backend = app.state.backend_factory()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Initialize the broker backend before accepting requests

When the scale-to-zero broker receives simultaneous first requests, FastAPI can run these synchronous handlers in separate worker threads, allowing this unsynchronized lazy check to construct multiple AzureUploadBroker instances. Each instance then has a different _state_lock, so concurrent _consume_rate_limit() read/upsert cycles can lose increments and let a cold-start burst bypass the durable 20-request hourly limit; initialize the backend during app startup or guard this initialization with a shared lock.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ae2c32756987. Each FastAPI app now owns one shared initialization lock, and _backend() uses a double-checked critical section before calling the factory. All first-request threads therefore receive the same AzureUploadBroker and its single quota lock. An 8-thread barrier regression widens factory construction deliberately and requires exactly one construction plus identical backend identity for every caller.

Comment thread src/benchflow/publish/redact.py Outdated
Comment on lines +108 to +112
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
redacted_items = []
replacements = 0
for item in value:
clean, count = redact_value(item)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve sensitive flag context while scanning argument arrays

When a command is represented as an argv-style array such as {"command":["tool","--api-key","opaque-prefixless-value"]}, this branch scans each element independently, so neither the flag nor its following prefixless credential produces a replacement. Because client staging and the validator both use redact_value, the original secret can pass the final gate and be promoted publicly; recognize sensitive option elements and redact their following argument.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ae2c32756987. Structural redaction now recognizes sensitive long-option flags in argv arrays, redacts the following value, handles inline --secret=value, and applies the same logic to shell-command strings via shlex while leaving strings unchanged when no replacement occurs. Regressions cover --api-key, --client-secret, --access_token=value, shell strings, and validator-side rejection of an unredacted argv capture.

if len(manifest_bytes) > MAX_MANIFEST_BYTES:
raise CaptureRejected("manifest exceeds the 1 MiB limit")
try:
raw_manifest = strict_json_loads(manifest_bytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Decode manifest bytes as UTF-8 before JSON parsing

When an anonymous uploader supplies a manifest encoded as UTF-16 or UTF-32, Python's json.loads() auto-detects the byte encoding here, so no UnicodeDecodeError is raised and the validator promotes the original non-UTF-8 manifest bytes. Fresh evidence beyond the prior invalid-UTF-8 fix is that a BOM-prefixed UTF-16 serialization successfully reaches ContributionManifest.model_validate; explicitly decode with UTF-8 before calling the JSON parser.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ae2c32756987. Manifest bytes are explicitly decoded as UTF-8 before strict JSON parsing; decoding failures remain terminal invalid manifest rejections. Parametrized regressions require UTF-16 and UTF-32 JSON manifests to fail, while the existing invalid-byte queue test continues to prove terminal ledger rejection and cleanup.

@bingran-you

Copy link
Copy Markdown
Collaborator Author

@codex review

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 11:49 — with GitHub Actions Active

@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: ae2c327569

ℹ️ 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 on lines +101 to +104
clean, count = redact_value(
item,
field_name=key if isinstance(key, str) else None,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Redact name/value credential carriers

When a trajectory uses BenchFlow's own ACP header representation, such as {"headers":[{"name":"Authorization","value":"opaque-prefixless-value"}]} (produced by src/benchflow/acp/types.py:163), these recursive calls treat name and value independently, so the credential label is never applied to its sibling value and the record receives zero replacements. The validator reuses redact_value, allowing the prefixless authorization value to be promoted; recognize structured name/value and key/value carriers when propagating sensitive-field context.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 671ee89. Structured name/value and key/value carriers now propagate sensitive label context to value/values fields case-insensitively. Client and validator regressions cover a prefixless Authorization value.

Comment thread services/trajectory_upload/validator.py Outdated
Comment on lines +143 to +145
except CaptureRejected as exc:
logger.warning("capture %s rejected: %s", digest, exc)
self._record_status(digest, "rejected", detail=str(exc)[:512])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent anonymous manifests from poisoning known digests

When an anonymous caller knows a capture's artifact names and hashes, they can obtain its manifest SAS and upload an invalid manifest such as {} without possessing any artifact bytes. This branch then records the content digest as rejected; subsequent handshakes hit AzureUploadBroker.create_upload's terminal rejected branch, and there is no expiry or reset, so the legitimate exact capture is blocked indefinitely. Scope rejection to an upload attempt or otherwise allow a safe new attempt instead of creating a global digest tombstone from unauthenticated manifest bytes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 671ee89. Quarantine grants are now attempt-scoped, rejected digests reopen under a fresh isolated attempt, and stale Event Grid events clean only their old prefix without mutating the active ledger. Legacy in-flight prefixes remain supported. Added broker retry, valid promotion, and stale-event regressions.

Comment on lines +179 to +180
if redact:
redacted_manifest, manifest_replacements = redact_value(manifest)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve artifact names during manifest redaction

When an allowed filename itself resembles a credential, such as sk-abcdefghijklmnop.jsonl, whole-manifest redaction changes its manifest entry to trajectory/***REDACTED***.jsonl while the corresponding StagedFile retains the original name. Broker mode then rejects the generated handshake because the rewritten name violates the artifact-name contract, while direct mode uploads the original object alongside a manifest that cannot describe or validate it; exempt structural artifact fields from free-text redaction or reject such names before staging.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 671ee89. Shared artifact-name validation now rejects credential-like filenames before staging and is reused by the broker contract, so manifest redaction cannot diverge from the staged blob name. Client and HTTP contract regressions cover this.

@bingran-you
bingran-you had a problem deploying to pypi-internal-preview August 15, 2026 12:05 — with GitHub Actions Failure
@bingran-you

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: 671ee89909

ℹ️ 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".

raise CaptureRejected("invalid manifest: content must be UTF-8") from exc
try:
raw_manifest = strict_json_loads(manifest_text)
manifest = ContributionManifest.model_validate(raw_manifest)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject coerced manifest field types before promotion

When an anonymous manifest uses coercible but schema-invalid values such as "bytes":"123", "bytes":123.0, or a numeric created_at, Pydantic's default lax validation normalizes them in the returned model, so integrity checks succeed, but the validator later promotes the original manifest_bytes. The public manifest therefore retains types that violate the declared JSON contract and can fail strict downstream consumers; validate these fields strictly or promote a canonical serialization of the validated model.

Useful? React with 👍 / 👎.

@bingran-you
bingran-you deployed to pypi-internal-preview August 15, 2026 12:16 — with GitHub Actions Active
@bingran-you

Copy link
Copy Markdown
Collaborator Author

@kywch @xdotli @Galius5136 — exact head 671ee899 now has all CI green, a clean automated review, exact Azure deployment, live rejected-attempt recovery, and fresh installed-wheel upload evidence in the pinned verification comment. Independent human review is the final gate before squash merge; please review when available.

@Galius5136

Copy link
Copy Markdown

@bingran-you Thanks for tagging me! I went through 671ee899 in detail.

Overall the structure looks solid, and I checked a bunch of the security-sensitive parts around SAS scope, path handling, logging/redaction, validator claiming, packaging, and the new tests.

I did find three things I’d fix before merge.

1. Manifest validation vs promoted bytes

ContributionManifest is validated in Pydantic's lax mode, so values like "bytes":"39" or 39.0 are coerced into the declared types.

The checks then run against that normalized model, but the promotion path writes the original capture.manifest_bytes.

I reproduced cases where validation passes, but the promoted manifest still contains the original incompatible types and fails a strict re-parse of the contract.

I think the cleanest options are either:

  • promote a canonical serialization of the validated model; or
  • make the manifest validation strict.

2. _redact_cli_text can fail open on malformed shell text

If shlex.split() raises ValueError, the current code returns the original value with replacement count 0.

That means something as normal as an apostrophe can cause an opaque secret passed through a sensitive CLI flag to survive redaction.

For example:

curl --api-key <secret> https://x # don't forget

I reproduced this on the current head with a few malformed/unbalanced command strings.

I’d make that path fail closed, or add a conservative fallback redactor when shlex cannot parse the string.

3. Delegation-key refresh can leave a stale key cached

_delegation_key_expires is updated before the new user-delegation key fetch succeeds.

In a controlled probe, a transient refresh failure left the old key cached with a future expiry timestamp, so the broker kept returning apparently valid handshakes for a while even though the later Azure PUT failed.

I’d update the expiry only after a successful fetch and keep that refresh transition under the existing lock.

A couple of smaller notes:

  • services/ is currently outside the CI lint/type-check scope, although it passes locally now.
  • One regression-test docstring is missing the PR/commit reference required by AGENTS.md.

I also checked the surrounding paths and the SAS scoping, object-name validation, anonymous-read settings, logging behavior, validator claiming, filename allowlisting, packaging, and the targeted tests all look good from my side.

I couldn’t independently reproduce the live Azure deployment/recovery flow because that needs the author’s Azure environment.

I’d request changes for #1 and #2; I’d also fix #3 before merge, although I’m fine with maintainers deciding whether that one can be a fast-follow.

Once you push a new head, I’ll re-check the exact commit and run a full local E2E on my machine before approving.

@bingran-you
bingran-you merged commit 390e6d8 into main Aug 15, 2026
15 of 17 checks passed
@bingran-you
bingran-you deleted the bry/traj-upload-azure branch August 15, 2026 16:06
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.

2 participants