Skip to content

fix(agent-challenge): owned CVM teardown, fail-loud list, staging stack - #51

Closed
echobt wants to merge 568 commits into
mainfrom
fix/ac-selfdeploy-staging-cvm-safety
Closed

fix(agent-challenge): owned CVM teardown, fail-loud list, staging stack#51
echobt wants to merge 568 commits into
mainfrom
fix/ac-selfdeploy-staging-cvm-safety

Conversation

@echobt

@echobtechobt commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Production safety defects addressed

  1. Account-wide CVM sweep — a staging teardown deleted a live prod eval CVM after key release and permanently wedged submission 11. Staging now deletes only CVMs owned by the current run (work/owned_cvms.txt + per-run track), logs the delete plan first, and never account-sweeps.
  2. Silent CVM count → false no-spend claim — bare GET /api/v1/cvms can return [] while inventory is non-empty. Authority is GET /cvms/paginated with X-Phala-Version: 2026-06-23. Unknown envelopes raise; indeterminate count is teardown FAILURE (never success with total=0).

Changes

  • Fail-loud Phala CVM listing (cvm_list.py + PhalaCloudClient.list_cvms via /cvms/paginated)
  • Loopback http:// only with SELFDEPLOY_ALLOW_INSECURE_LOOPBACK=1 for 127.0.0.1/localhost/::1
  • Staging compose + scripts/staging/ (secrets gitignored; .example only) + docs/staging.md + docs/prod-compose-upgrade.md
  • AGENTS.md: prefer local staging loop before prod SSH

Explicitly dropped (main already covers / not portable)

CandidateDecision
format_eval_shape_mismatch_errorDropped — main has it (measurements.py)
Plan-bound instance type / tdx.xlarge defaultDropped — main binds from plan when flag omitted; mismatch fails closed
X-Phala-Version header presenceKept delta only — main had 2026-01-21; this PR pins CLI 2026-06-23 + paginated list
Compose-hash pre-artifact pin matchingDeferred — main generator lacks artifact envs; cannot reproduce daf0f209… without broader compose delta
prism / constation / Lium / master / DB / uv.lockExcluded

Staging / execution proof honesty

  • The staging stack has never completed a full successful e2e run in this workstream.
  • Blocking prerequisite for execution proof: prod eval compose pin daf0f209… was measured withoutCHALLENGE_PHALA_EVAL_ARTIFACT_{URL,TOKEN} in allowed_envs. Proven on submission 13 whose deploy encrypted_env_names contained neither — a matching guest_artifact_proof is structurally impossible on that pin.
  • Lower-risk intermediate pin to consider: 3a81feaf… (current image + artifact envs only).
  • This PR does not claim execution proof was obtained.

Test plan

  • Baseline on clean origin/main (2bfa110c): 3221 passed, 62 failed, 6 errors, 8 skipped (env/docker/keyrelease pre-existing)
  • Branch: 3264 passed (+43 new), same 62 failed / 6 errors / 8 skipped — no regressions
  • Focused selfdeploy/staging: 81 passed (test_phala_cvms_list_parse, loopback policy, teardown policy, teardown http, create ack)
  • ruff check clean on touched Python files
  • npx phala@latest cvms list --jsontotal 0
  • No secrets committed (staging gitignore; only .example configs)

CI

  • Waiting for required checks on this head SHA.

Notes

Summary by CodeRabbit

  • New Features

    • Added a local staging workflow for running and validating Agent Challenge deployments with real confidential compute environments.
    • Added staging configuration templates, pinned attestation settings, health checks, and evidence capture.
    • Added fail-safe teardown planning to ensure only resources created by the current staging run are removed.
    • Added support for controlled loopback HTTP endpoints during local staging.
  • Documentation

    • Added guides for local staging and production compose upgrades, including setup, verification, rollback, and safety procedures.
  • Bug Fixes

    • Improved cloud resource-list parsing and error handling to fail safely on unexpected responses.
  • Tests

    • Added coverage for staging teardown safety, resource parsing, endpoint security, and cloud API compatibility.

echobt added 30 commits July 2, 2026 11:27
- _seed_challenge_token: correct the stale ownership comment - the base-master-proxy
branch now runs --user root (+ docker.sock) for the in-proxy registry reconciler +
challenge-image-updater, so the master reads token files as root regardless of owner.
- VALIDATOR_MASTER_URL: make it a REQUIRED --validator-node override (fail fast if
unset) instead of defaulting to the node's own advertise address, which is always
wrong for a validator (a footgun).
- _stage_supervisor_release: export a committed-tree snapshot via 'git archive HEAD'
(-> tar) instead of cp -a of the whole working tree (incl .git + uncommitted
changes); the release version label derives from committed HEAD. Falls back to cp -a
only for a non-git release source.
Guard tests updated (validator-node master-url required + not self-referential;
staging asserts committed-tree snapshot, no cp -a). bash -n clean; IMAGE_* allowlist
unchanged.
…rage
- service_image: distinguish an absent service (None) from a transient
inspect failure (re-raise DockerOrchestrationError) so a momentary
dockerd error on an already-current service no longer triggers a
spurious --force redeploy in the challenge-image-updater.
- Add a stable public seam (SwarmImageUpdater.targets +
image_updater_from_task) and replace the fragile run.__self__._targets
reach-ins in three tests.
- Add tests: empty targets + validator-agent toggle OFF is a clean no-op;
transient inspect failure does not redeploy.
- weights.py docstring: acknowledge OnChainWeightSubmitter as a second
per-node submit path (default-OFF, never enabled for master); wording
only, no behavior change.
_stage_supervisor_release used the git archive HEAD branch whenever the source
was a git work tree, but git archive HEAD hard-fails on an unborn HEAD (a repo
with no commits). Gate that branch on git rev-parse --verify HEAD succeeding
and fall back to cp -a otherwise, so staging never hard-fails.
A transient (non-not-found) docker service inspect failure surfaced as
DockerOrchestrationError from running_image and propagated to the
per-challenge exception handler, logging a full stack trace every tick
during a dockerd hiccup even though the condition is expected and
self-correcting (retried next tick). Catch it in _converge_service, log
concisely at warning (no stack trace), and skip the roll
(action=skipped-inspect-error, no redeploy). Genuinely unexpected errors
are not caught here and stay at exception level.
…oute)
Make the master LLM gateway provider-agnostic and config-driven per the
llm-yunwu contract, superseding all deepseek/openrouter/deepseek-v4-pro guidance.
- tokens: add optional source (wire s) + model (wire m) claims to
GatewayTokenClaims/issue/issue_central_gate/verify (backward compatible).
- providers: replace deepseek/openrouter constants with a config-driven
provider registry (build_providers keyed by name; mock+real).
- gateway: single POST /llm/v1/{path} route; resolve provider+model from the
token source via config (default yunwu/claude-opus-4-8); overwrite request
body model + inject provider key; drop _enforce_model/DEEPSEEK_REQUIRED_MODEL;
provider-agnostic build + secret redaction.
- settings: GatewaySettings gains providers registry + default_provider +
default_model + sources; deepseek/openrouter fields removed.
- executor + assignment_coordination: emit BASE_LLM_GATEWAY_URL (={root}/llm/v1)
+ BASE_GATEWAY_TOKEN, stamp source=agent on assignment tokens.
- cli: build service from the yunwu key; mint-central-gate-token gains --source.
- update all platform gateway tests to the new contract (provider_mode=mock).
…l-swarm.sh)
Render the config-driven, provider-agnostic yunwu gateway block into
deploy/swarm/master.yaml + the install-swarm.sh master-config: providers.yunwu
(base_url https://yunwu.ai/v1, api_key_file /run/secrets/yunwu_api_key),
default_provider/default_model (claude-opus-4-8), and per-source routes
(agent/llm_review). install-swarm.sh now creates + mounts the single
base_gateway_yunwu_api_key secret at /run/secrets/yunwu_api_key on the master
proxy (provider_mode=real), keeps the mandatory HMAC gateway_token_secret,
routes both central gates through the master gateway /llm/v1 with the scoped
central-gate token (source=llm_review), and drops all deepseek/openrouter secret
creation + mounts. Update the swarm/secret/deploy unit tests + runbook to expect
the yunwu mount + absence of deepseek/openrouter. No yunwu key value in git.
VAL-LLM-CODE-010
Reframe yunwu branding in deploy/swarm/README.md so the gateway is described
generically: provider-agnostic, with the active provider + model chosen in
master.yaml and injected server-side from the token source claim. Operational
secret identifiers (base_gateway_yunwu_api_key / YUNWU_API_KEY /
/run/secrets/yunwu_api_key) are unchanged and kept where operators need them,
reframed as the configured-provider key. Docs-only; no code/config/prod change.
Remove stale DeepSeek/OpenRouter direct-provider-key prose from README.md and
docs/operations/validator.md. The master gateway resolves provider+model
server-side from the token 'source' claim (agent / llm_review) per master.yaml;
challenge/eval services + validators hold no raw provider key and authenticate
with a scoped gateway token (base_gateway_token, source=llm_review, /llm/v1).
The single provider key lives only on the master gateway at
/run/secrets/yunwu_api_key. Docs-only; no code/config/test changes.
yunwu intermittently closes the connection mid-response
(httpx.RemoteProtocolError) or returns a transient 5xx; the gateway had
no retry so these collapsed to caller-facing 502s. HttpLLMProvider.forward
now wraps the request in a bounded retry loop (default 3 attempts, each on
a FRESH client) with exponential backoff (0.25s then 0.5s), retrying the
enumerated transient transport exceptions and 502/503/504. A 429 / other
4xx (and 2xx/3xx) returns immediately and is never retried. Retrying is
safe because the gateway is fully buffered (no partial bytes reach the
caller before an attempt succeeds). Retry logs name only the exception
class + attempt counters, so no injected provider key can leak. Retry
policy is configurable via ProviderConfig with production-safe defaults.
Allow the frontend origins (joinbase.ai, www, localhost dev ports and
platform Vercel previews) to call the single public proxy cross-origin.
Public, cookie-less reads only: GET/HEAD/OPTIONS, allow_credentials=False,
so token-gated /v1/admin/* and signed uploads stay unchanged. Middleware
wraps the whole ASGI app, covering forwarded /challenges/* (incl. SSE) and
the included admin/registry routes. Origins overridable via
allowed_cors_origins.
The agent-challenge own_runner reads task definitions only from node-local
named volumes and fails closed on any digest mismatch; the runner image does
not bake the ~89 task trees. Previously the volumes were empty after a fresh
deploy, so every terminal-bench evaluation failed with terminal_bench_failed.
- Add download-terminal-bench-cache.sh: reads the byte-exact public source
(repo + revision + task count) from the frozen golden/dataset-digest.json,
clones at the pinned commit, and stages the task dirs (stripping only the
task-root .gitignore, as harbor does at packaging time) as a --source dir.
- Wire provision_agent_challenge_cache() into install-swarm.sh (STEP 9b, after
deploy_master, before deploy_challenges): download then
acquire-agent-challenge-cache.sh --apply to copy + digest-verify onto the
volumes. Both halves honor the script's dry-run/--apply gate.
No secrets are read or required.
Add src/base/compute/ package:
- provider.py: ProviderClient protocol, InstanceSpec (mandatory
max_lifetime_hours/max_price_per_hour bounds), Offer/Instance, typed
ProviderError/CostGuardrailError.
- lium.py: LiumClient over httpx (X-API-Key, base https://lium.io/api).
list_offers price filtering; provision refuses unbounded/over-priced
specs before any network call, always sends bounded termination_hours,
and terminates+verifies on any post-rent failure (try/finally);
idempotent ensure_ssh_key/ensure_template; idempotent terminate;
verify_terminated via GET /pods; stream_logs; watchtower digest;
GET /users/me balance. API key never logged/repr'd/in errors.
50 offline respx unit tests (VAL-PROV-001/003/004/005/011/017/018 +
secret hygiene). Full base gate green: 1316 passed, coverage 89.1%.
…able log level
Batch of tested master/broker hardening (previously staged in the working tree):
- Broker capacity: add `broker_max_concurrent_global` (server-wide cap on total
concurrent broker jobs across all slugs) and route both run paths through a
single `_register_job` choke point that enforces the per-slug + global caps
atomically under the workload-ledger lock (over-limit -> WorkloadCapacityError
-> HTTP 429 docker_quota_exceeded). Default None = unlimited (behavior-preserving).
- WorkloadLedger.register accepts `max_concurrent_global`, checked under the same
lock as insertion so check-and-register stays atomic.
- Broker output bound: add `broker_log_limit_bytes` (default 5MB) and broker job
lifecycle logging (accepted/finished/timed-out/rejected; never logs secrets).
- Observability: configurable `log_level` (case-insensitive, falls back to INFO).
- Submitter + install-swarm deploy wiring and matching unit tests.
Validation: ruff clean, ruff format clean, full unit suite 1234 passed.
Add build_lium_worker_template (Lium CustomTemplateRequest payload) and
build_targon_worker_app (Targon app definition) in src/base/compute/
worker_deployment.py. Both pin the docker image BY DIGEST via a shared
pinned_image_reference helper; the image+digest default to the published
prism-evaluator digest (M1 placeholder) and are inputs so M2 swaps in
docker/Dockerfile.worker with a one-line change.
Well-formedness is enforced (required fields, internal ports incl. 22, env
plumbing, is_private, no embedded secrets) and covered by offline respx tests
that also assert the whole compute suite makes zero real network calls under
respx strict mode and needs no provider credentials (VAL-PROV-009/010/016).
…TESTS
Add scripts/live_lium_e2e.py driving the M1 provider clients against
production: read-only reachability (Lium users/me + executors +
watchtower/digest, Targon inventory + apps) plus one batched Lium rental
cycle (ensure ssh key + template -> rent cheapest suitable executor ->
poll RUNNING -> ssh nvidia-smi -> logs -> DELETE -> verify gone -> record
balance delta). Pod is deleted in a finally on every path; opt-in via
BASE_LIVE_PROVIDER_TESTS=1 so the default suite stays offline.
…gpu shape
- LiumClient.provision rejects sub-1-hour max_lifetime_hours so
termination_hours never truncates to 0 (auto-termination stays on)
- widen post-rent cleanup guard to key off rent success, not pod-id
resolution: a transient GET /pods failure during resolution still
best-effort terminates + verifies the just-rented pod
- normalize Targon Offer.price_per_hour to per-GPU (cost_per_hour / gpu_count)
so the per-GPU max_price cap filters multi-GPU shapes correctly
- default WORKER_GPU_SHAPE 'h100' -> live-valid 'h100-small'
Add the miner-funded GPU worker plane registry (architecture.md sec 3.3),
gated behind compute.worker_plane_enabled:
- alembic 0009: worker_registrations + worker_faults + worker_request_nonces
on the existing chain (0008 -> 0009), no legacy table altered.
- WorkerCoordinationService mirroring validator_coordination patterns:
POST /v1/workers/register (sr25519 miner binding verified against the mock
metagraph, binding-nonce replay protection, no silent cross-owner rebind),
POST /v1/workers/{id}/heartbeat, GET /v1/workers (fleet: status/owner/
provider/last-seen/faults, authenticated-but-not-admin), and
GET /v1/workers/active?hotkey= (admission surface).
- Lifecycle pending -> active -> stale -> retired with
compute.worker_heartbeat_ttl_seconds (default 120); retired is terminal
(no heartbeat resurrection); staleness derived on read + a background pass.
- worker_auth: binding message/verify, signed-request verifier + registered-
worker/validator eligibility, worker nonce store.
- Wired into create_proxy_app + the master proxy CLI (flag-off => unmounted).
Unit + Postgres integration tests (15433) cover VAL-MASTER-001/002/015/016/
018/021 and VAL-AGENT-015.
…x spurious 502s
Broaden RETRYABLE_STATUS_CODES to include Cloudflare 520/522/524, Anthropic 529
(overloaded) and generic 500 so a transient edge/upstream overload is retried
instead of collapsed into an opaque 502. Add full-jitter backoff and a 4th
attempt. Add a true streaming passthrough for stream=true callers (the eval
agent): open the upstream stream with a per-chunk read timeout so a slow large
completion no longer trips Cloudflare 524 or the 30s buffered read cap, retrying
only pre-first-byte. Log the real upstream status (status+source only, never
bodies/keys) so a surfaced failure is no longer an opaque 502. The buffered
llm_reviewer safety-gate path response shape is unchanged.
Add the miner-funded WorkerAgent (src/base/worker/): register under a
miner-signed binding, heartbeat to stay active, pull gpu-only replicas,
execute via the AssignmentExecutor seam on its local broker, and post
results that always carry an ExecutionProof envelope (sr25519 over
sha256('{manifest_sha256}:{unit_id}'), pinned identically to prism).
Extract the shared agent-loop primitives into base/coordination/agent_loop
(BackoffPolicy, is_transient_error, AgentCycleSummary, sleep_until,
backoff_sleep); the validator agent now imports them with behavior unchanged.
Add the master worker assignment plane (worker_assignments table + 0010
migration, worker-authenticated pull/result routes gated on registration
and liveness, never a validator permit) and wire it behind
compute.worker_plane_enabled.
Fulfills VAL-AGENT-002/003/004/005/006/007/008/016/017/018.
Add a top-level `base worker` Typer app distinct from the legacy
`base master worker` Swarm group:
- deploy --provider local starts a miner-funded agent against a local
master and reports it active within 60s.
- deploy --provider lium|targon requires the provider key env (actionable
refusal before any network), bounds offer selection by --max-price
preferring an exact gpu_count executor (next-cheapest fallback), and
never transmits the provider key to the master (pod env excludes it).
- status renders the fleet from GET /v1/workers (signed as the worker key).
Thread startup_commands through InstanceSpec, LiumClient.ensure_template
and build_lium_worker_template (validated metachar-free) per the
live-learned Lium rent constraint; keep docker/Dockerfile.worker's
exec-form entrypoint metachar-free.
Add WorkerSettings, worker/miner keypair resolvers, signed list_workers,
config/worker.example.yaml, and unit tests. All behind
compute.worker_plane_enabled; full base gate green.
Add WorkerAssignmentEngine that materializes gpu work-unit replicas onto
ACTIVE distinct-owner workers behind compute.worker_plane_enabled: R=2 with
self-evaluation exclusion (unit waits under sole-capacity scarcity), graceful
degradation to R=1 with a recorded warning, per-worker gpu concurrency 1, and
per-replica deadline/reassignment bounded by max_attempts. The validator
AssignmentService skips worker-plane capabilities when the flag is on, so
flag-OFF gpu routing to validators stays byte-identical to legacy.
… behind flag
Reconcile replicated gpu worker results (architecture.md 3.3): matching
ExecutionProof.manifest_sha256 forwards exactly one result to the challenge;
divergent hashes dispute the unit (never forwarded, before or after audit) and
create a validator-executor audit unit whose outcome writes worker_faults for
the divergent worker(s), visible in fleet status. Single-replica reporting
terminates deterministically (accept-after-degrade with warning); late/foreign
posts stay rejected with replica state intact. All gated by
compute.worker_plane_enabled (reconciler is None when off).
Legacy AssignmentService.reclaim_stale_assignments treated a null
assigned_validator_hotkey as 'offline validator => reassignable', so under
the worker plane a worker-owned prism PRIMARY (ASSIGNED, null hotkey by
design) churned back to PENDING every MasterOrchestrationDriver.run_once
pass. _assign_pending_in_session already skipped such units via
worker_plane_capabilities; _reclaim_in_session did not. Factor the guard
into AssignmentService._worker_plane_owns and apply it in BOTH paths so a
worker-owned primary is neither assigned nor reclaimed here, while a
genuinely stale validator unit (cpu, or gpu AUDIT with executor_kind=
validator) is still reclaimed. Flag OFF keeps reclaim byte-identical to
legacy.
The static --static-challenges installer path built the agent-challenge api and
worker service env WITHOUT CHALLENGE_TERMINAL_BENCH_LOG_STREAM_URL or
CHALLENGE_DOCKER_BROKER_NETWORK, even though both services are already
multi-homed onto base_jobs_internal for exactly this purpose. Without them the
terminal-bench runner JOB lands on the default bridge network, cannot resolve
challenge-agent-challenge by name, and live task.log streaming silently no-ops.
Add both to the shared ac_eval_env array (applied to api+worker), mirroring the
dynamic seed path (cli_app._agent_challenge_own_runner_env), and lock it in with
a parity regression test.
…budget
The eval task concurrency was hardcoded (broker_max_concurrent_global=30,
CHALLENGE_EVALUATION_CONCURRENCY=15), over-committing a 62 GiB manager. Derive it
at install time from system RAM at a 4 GB/task budget:
EVAL_TASK_CONCURRENCY = max(4, floor((TOTAL_RAM_GB - reserve) / 4)), reserve 10
(-> 13 on the 62 GiB box), with EVAL_RAM_TOTAL_GB / EVAL_RAM_RESERVE_GB /
EVAL_TASK_CONCURRENCY overrides. Render it into both broker_max_concurrent_global
and CHALLENGE_EVALUATION_CONCURRENCY, and pin a durable 2g runner (own_runner
DooD client) memory ceiling. The 4 GB divisor only sizes concurrency; the inner
terminal-bench task container keeps its higher 8 GiB ceiling (low actual usage).
…ollback
The master image-updater fired docker service update --detach and never checked
convergence, so a broken :latest image silently degraded proxy/broker with no
revert, no retry budget, and no alert. Make auto-update durable:
- New retry.py: RetryPolicy (bounded exponential backoff + equal jitter) +
per-target RetryState.
- image_updater.py: record pre-update digest as last-known-good (persisted under
the release root, restart-durable), issue the update WITHOUT --detach, poll
UpdateStatus.State to convergence; on paused/rolled_back/timeout re-pin the
last-known-good digest and schedule exponential backoff; after max_attempts
emit an image_update_failed alert and wait for a new digest (which resets).
- Operator freeze: supervisor.image_update_hold (global) + per-target hold skip a
service entirely so a pinned known-good digest is never overridden.
- settings knobs (max_attempts/backoff base+max/hold), new alert kind, alert hook
wired into the image-updater task.
echobt added 23 commits July 28, 2026 00:41
…file
fix(master): load per-challenge embed.env into isolated child env
…e tests
Lock stage defaults (review tdx.small/20GB, eval tdx.xlarge/100GB), disk
bounds/billing, lifecycle budget with disk, provision disk_size emission, and
frozen eval/review compose hashes before any production sizing change.
…faults
Split review (tdx.small/20GB) and eval (tdx.xlarge/100GB) defaults, add disk
bounds and billing helpers, and fold optional disk into projected cost while
keeping CpuShape free of a disk field.
Charge both stages for compute plus disk against the shared $20 money cap and
update the ordered CLI lifecycle fixture for disk-aware totals.
Thread stage disk_size_gb on eval/review deployment plans and send disk_size as
a sibling of compose_file without mutating measured compose documents.
… CLI
Default eval to tdx.xlarge, add --review-disk-size-gb/--eval-disk-size-gb, pass
disks into lifecycle budget checks, and harden offline deploy test doubles.
Record review tdx.small/20GB and eval tdx.xlarge/100GB defaults, disk rate, and
unchanged CPU-only $20 money cap in miner and validator self-deploy docs.
Lock DELETE /cvms/{id} allowlist behavior, 204/404 success, unique app_id
resolution, and CLI teardown without a phala binary before the fix lands.
Allow DELETE /cvms/{id} (204/404 success) and refuse ambiguous app_id
matches when resolving teardown identity without a phala binary.
Route teardown through PhalaCloudClient.delete_cvm, accept optional
--cvm-id/--app-id, and resolve unique app_id matches from GET /cvms.
Assert delete_cvm path, ambiguous app_id refusal, and docs no longer
require a phala binary in default_phala_teardown.
Describe DELETE /cvms/{id} as the primary path and keep manual phala
cvms list/delete strings for VAL-DEPLOY-020 verification.
Check --cvm-id/--app-id before constructing PhalaCloudClient so missing
identity fails closed without requiring credentials.
Mirror review prepare recovery: when eval prepare returns token-less
secret_delivery, cancel the current run and retry so deploy receives a
fresh one-shot capability. Production residual on submission 3 left
attempts 1 and 2 stuck after standalone prepare/retry spent the token.
Never cancel when the token is already present; never invent tokens.
Assert token-present skips cancel/retry, token-absent recovers via
cancel+retry, sticky absence raises RouteClientError, and the
{env_key,token} secret_delivery shape stays fail-closed. Also pin
review deploy fixtures to the production REVIEW_API_BASE_URL.
…UN_TOKEN
Cover the production closed loop where eval deploy injects the one-time
run token into the CVM but never surfaces it for eval result. Assert
--emit-run-token / --token-output handoff, fail-closed without either on
live deploy, dry-run exemption, --output hygiene, and prepare/status
redaction regressions.
Live eval deploy now requires --token-output and/or --emit-run-token so
the host can post via eval result. Always include eval_run_id on success
stdout; write the token only to the secure 0600 file or optional stdout
key. Never put the token in --output, redacted prepare/status, logs, or
exception text. CVM encrypted_env injection is unchanged.
…pends it
The handoff check depends only on argv, but ran after
_obtain_eval_prepare_with_token had already consumed the single
EVAL_RUN_TOKEN delivery. A miner who omitted the flags burnt the token
and then hit the error, reproducing the unrecoverable state the handoff
exists to prevent. Validate the destination before any remote mutation
and cover it with a test asserting eval_prepare is never called.
Lock RED→GREEN contract for plan vs CLI shape mismatch and optional
--expected-measurement rtmr0 pin check before Phala create.
…la create
Name plan and CLI shapes plus vm_shape/instance_type/rtmr0, warn that a
stale allowlist pin only surfaces as a generic key-release denial, and
abort before spend. Optional --expected-measurement compares rtmr0 with
truncated prefixes only.
Document guest emit-only design, token handoff flags, host scrape of
BASE_BENCHMARK_RESULT, eval result posting, teardown, and the shape/pin
footgun so miners can finish a run from the docs alone.
…-sizing
feat(agent-challenge): CVM sizing, token handoff, and Phala eval deploy hardening
Rebase the safety-critical selfdeploy/staging work onto current main without
the older 38-commit stack that conflicted with PR #49.
- Fail-loud Phala CVM listing via GET /cvms/paginated + X-Phala-Version
2026-06-23; unknown envelopes raise instead of under-reporting as 0
- Loopback http:// only with SELFDEPLOY_ALLOW_INSECURE_LOOPBACK=1
- Staging compose/scripts/docs with owned-only teardown (never account-sweep)
- AGENTS.md points operators at the local staging loop first
Dropped changes main already covers (shape mismatch formatter, eval default
tdx.xlarge, HTTP delete_cvm). Compose pre-artifact pin matching deferred:
main's generator does not yet include artifact envs and cannot reproduce
daf0 without a broader compose delta.
@coderabbitai

coderabbitaiBot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an isolated local Agent Challenge staging stack using real Phala TDX CVMs, strict paginated CVM parsing, owned-only teardown planning, loopback HTTP controls, pinned staging configuration, end-to-end review/evaluation orchestration, and production compose upgrade documentation.

Changes

Agent Challenge staging

Layer / File(s)Summary
Strict CVM inventory contract
packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/*, tests/test_phala_cvms_list_parse.py, tests/test_selfdeploy_*
CVM list responses are parsed fail-loudly through paginated Phala Cloud snapshots, app-id resolution uses exact matches, and insecure HTTP is restricted to explicitly enabled loopback endpoints.
Owned-only CVM teardown
packages/challenges/agent-challenge/scripts/staging/cvm_teardown_policy.py, tests/test_staging_cvm_teardown_policy.py
Teardown plans resolve only identifiers from owned tracking files, report foreign CVMs separately, reject unowned IDs, and validate malformed account payloads.
Local staging runtime
packages/challenges/agent-challenge/docker-compose.staging.yml, packages/challenges/agent-challenge/scripts/staging/config/*, packages/challenges/agent-challenge/scripts/staging/run_staging.sh
Adds loopback-bound Compose services, pinned attestation configuration, KR TLS setup, tunnel management, runtime initialization, and cleanup handling.
Review and evaluation execution
packages/challenges/agent-challenge/scripts/staging/run_staging.sh
The runner submits the miner artifact, retries review deployments, polls evaluation results, validates guest_artifact_proof, records evidence, and tears down owned CVMs.
Staging and production procedures
AGENTS.md, packages/challenges/agent-challenge/docs/*
Documents local staging prerequisites and commands, attestation and teardown requirements, and the production compose pin upgrade, verification, and rollback procedure.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
participant Operator
participant run_staging.sh
participant AgentChallenge
participant PhalaCloud
participant KeyRelease
Operator->>run_staging.sh: start local staging run
run_staging.sh->>AgentChallenge: submit miner artifact
AgentChallenge->>PhalaCloud: deploy review CVM
run_staging.sh->>AgentChallenge: poll review outcome
run_staging.sh->>KeyRelease: start local KR service
AgentChallenge->>PhalaCloud: deploy evaluation CVM
PhalaCloud->>KeyRelease: request attested secret release
run_staging.sh->>AgentChallenge: poll guest_artifact_proof
run_staging.sh->>PhalaCloud: delete owned CVMs
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 11.11% 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 nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title is concise and accurately captures the main themes: owned-only teardown, fail-loud CVM listing, and staging stack updates.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/ac-selfdeploy-staging-cvm-safety

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.

@coderabbitaicoderabbitaiBot 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.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/challenges/agent-challenge/docs/prod-compose-upgrade.md (1)

1-504: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Reduce this shipping document to a minimal operational note.

This 500-line runbook embeds incident-specific hashes, submission state, host paths, timestamps, and validation evidence. Keep a short upgrade/rollback pointer in product docs and move measured evidence and change-window details to local untracked evidence or the ops change record.
As per coding guidelines: keep shipping documentation minimal and keep validation logs out of product documentation.

🤖 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 `@packages/challenges/agent-challenge/docs/prod-compose-upgrade.md` around
lines 1 - 504, Replace the oversized incident-specific runbook with a concise
product-documentation note covering only the artifact-aware eval pin upgrade,
required authorization, high-level upgrade/rollback pointers, and references to
the relevant implementation and operational procedures. Remove measured hashes,
submission state, host paths, timestamps, validation logs, and other
change-window evidence; direct operators to record those details in local
untracked evidence or the ops change record instead.

Source: Coding guidelines

🧹 Nitpick comments (8)
packages/challenges/agent-challenge/docs/staging.md (1)

311-313: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Pin the Phala CLI instead of executing npx phala@latest.

This command executes mutable, unreviewed package code and makes teardown verification non-reproducible. Use an approved pinned CLI version or repository-managed toolchain.
As per coding guidelines: treat shell commands and dependency changes as executable surfaces during review.

🤖 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 `@packages/challenges/agent-challenge/docs/staging.md` around lines 311 - 313,
Replace the teardown command that invokes `npx phala@latest` with an approved
pinned Phala CLI version or the repository-managed toolchain. Ensure the command
resolves a deterministic, reviewed CLI version rather than mutable latest
package code.

Source: Coding guidelines

packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cvm_list.py (2)

65-82: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Deduplicate _item_id / _extract_cvm_id.

Both walk _CREATE_CVM_ID_FIELDS identically; only the miss behavior differs.

♻️ Proposed dedupe
 def _extract_cvm_id(item: Mapping[str, Any]) -> str:
- for name in _CREATE_CVM_ID_FIELDS:- if name not in item:- continue- normalized = _normalize_id(item.get(name))- if normalized is not None:- return normalized- raise ValueError("Phala create response does not identify the CVM")+ found = _item_id(item)+ if found is None:+ raise ValueError("Phala create response does not identify the CVM")+ return found
🤖 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
`@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cvm_list.py`
around lines 65 - 82, Deduplicate the identical field-scanning logic in _item_id
and _extract_cvm_id by reusing one helper or having one function delegate to the
other. Preserve _item_id’s None result for missing or invalid identifiers, while
keeping _extract_cvm_id’s ValueError behavior when no identifier is found.

235-239: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ambiguity is signalled by an error message substring.

phala.resolve_cvm_id_from_list distinguishes this case with if "multiple CVMs match app_id" in msg. A dedicated subclass (e.g. AmbiguousCvmMatchError(CvmListParseError)) would make that contract robust against wording changes.

🤖 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
`@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cvm_list.py`
around lines 235 - 239, Introduce a dedicated AmbiguousCvmMatchError subclass of
CvmListParseError and raise it in the require_unique branch of the CVM
resolution logic when multiple matches are found. Update
phala.resolve_cvm_id_from_list to detect this exception type instead of
searching the error message for “multiple CVMs match app_id”, preserving
existing handling for other CvmListParseError cases.
packages/challenges/agent-challenge/scripts/staging/cvm_teardown_policy.py (1)

17-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

parse_account_cvms_payload raises SystemExit from a library-style function.

Callers that import this module (the tests do) get process-exit semantics instead of a catchable error. Prefer raising ValueError here and converting to SystemExit in main.

🤖 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 `@packages/challenges/agent-challenge/scripts/staging/cvm_teardown_policy.py`
around lines 17 - 39, Update parse_account_cvms_payload to raise ValueError from
CvmListParseError instead of SystemExit, preserving the original error message
and exception chaining. In main, catch the parsing ValueError and convert it to
SystemExit so library callers receive a catchable error while the CLI retains
its exit behavior.
packages/challenges/agent-challenge/tests/test_phala_cvms_list_parse.py (1)

48-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

All three branches of the second elif assign the same value; the dispatch logic is effectively dead.

Lines 61-70 collapse to self._queue = list(payloads). Also note that a genuine bare-list response body (e.g. [{"id": "cvm_1"}]) is misinterpreted as a one-element queue, so the opener can never serve a list body — worth fixing before someone adds such a test.

♻️ Proposed simplification
- if isinstance(payloads, list) and payloads and not isinstance(- payloads[0], dict- ):- # list of sequential response bodies- self._queue = list(payloads)- elif isinstance(payloads, list) and all(- isinstance(p, (dict, list)) for p in payloads- ):- # could be one list-body OR queue of bodies — treat multi as queue- # when first element looks like a full response object with items/total- if (- len(payloads) > 1- and isinstance(payloads[0], dict)- and ("items" in payloads[0] or "total" in payloads[0])- ):- self._queue = list(payloads)- elif len(payloads) == 1:- self._queue = list(payloads)- else:- self._queue = list(payloads)- else:- self._queue = [payloads]+ # ``payloads`` is either a queue of response bodies or a single body.+ self._queue = list(payloads) if isinstance(payloads, list) else [payloads]
🤖 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 `@packages/challenges/agent-challenge/tests/test_phala_cvms_list_parse.py`
around lines 48 - 72, Fix _CapturingOpener payload classification: remove the
redundant branches in the second elif and distinguish a bare list response body
from a queue of response bodies, ensuring a payload such as [{"id": "cvm_1"}] is
served as one body while genuine sequential bodies remain queued.
packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/phala.py (1)

329-347: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Unreachable fallback branch.

combined is parsed from a bare list, so combined.total == len(all_items) always. Therefore combined.total != reported_total implies len(all_items) != reported_total, and the inner if always raises — lines 336-341 are dead. Collapse to a single check.

♻️ Proposed simplification
 combined = parse_cvms_list_response(list(all_items))
- if reported_total is not None and combined.total != reported_total:- if len(all_items) != reported_total:- raise CvmListParseError(- "unrecognized CVM list shape: collected items "- f"{len(all_items)} != total {reported_total}"- )- return CvmListSnapshot(- items=combined.items,- total=reported_total,- ids=combined.ids,- source_shape="paginated-merged",- )+ if reported_total is not None and len(all_items) != reported_total:+ raise CvmListParseError(+ "unrecognized CVM list shape: collected items "+ f"{len(all_items)} != total {reported_total}"+ )
return CvmListSnapshot(
items=combined.items,
total=reported_total if reported_total is not None else combined.total,
ids=combined.ids,
source_shape="paginated-merged",
)
🤖 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 `@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/phala.py`
around lines 329 - 347, In the paginated response merge logic, simplify the
reported_total validation after parse_cvms_list_response(list(all_items)):
remove the unreachable inner branch that returns a snapshot, and directly raise
CvmListParseError when the merged item count differs from reported_total.
Preserve the existing snapshot construction for valid totals, including its
total and source_shape values.
packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cli.py (1)

204-212: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Re-serializing the snapshot into a listing dict re-runs shape validation unnecessarily.

resolve_cvm_id_from_snapshot(snapshot, app_id=identity, require_unique=True) avoids the round-trip through _total_from_mapping, which could reject an otherwise-valid merged snapshot on total/items consistency rules. Note the ambiguity case then raises CvmListParseError rather than PhalaApiError, so keep that translation in the caller.

🤖 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 `@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cli.py`
around lines 204 - 212, The teardown flow should resolve the CVM identity
directly from the parsed snapshot instead of rebuilding a listing dict and
invoking resolve_cvm_id_from_list. Replace that call with
resolve_cvm_id_from_snapshot(snapshot, app_id=identity, require_unique=True),
and preserve caller-level translation of ambiguity-related CvmListParseError
into the appropriate RouteClientError or API error.
packages/challenges/agent-challenge/tests/test_staging_cvm_teardown_policy.py (1)

13-28: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicated staging-policy module loader across two test files. Both modules define an identical POLICY_PATH and _load_policy() to import scripts/staging/cvm_teardown_policy.py; a single conftest.py fixture would keep the path in one place.

  • packages/challenges/agent-challenge/tests/test_staging_cvm_teardown_policy.py#L13-L28: move POLICY_PATH and _load_policy into tests/conftest.py and keep only the policy fixture consumption here.
  • packages/challenges/agent-challenge/tests/test_phala_cvms_list_parse.py#L32-L45: import the shared POLICY_PATH/policy fixture instead of redefining the loader.
🤖 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
`@packages/challenges/agent-challenge/tests/test_staging_cvm_teardown_policy.py`
around lines 13 - 28, Centralize the duplicated cvm_teardown_policy loader by
moving POLICY_PATH and _load_policy into tests/conftest.py and exposing the
shared policy fixture. In
packages/challenges/agent-challenge/tests/test_staging_cvm_teardown_policy.py#L13-L28,
remove the local definitions and consume the fixture; in
packages/challenges/agent-challenge/tests/test_phala_cvms_list_parse.py#L32-L45,
likewise remove the duplicate loader and use the shared POLICY_PATH/policy
fixture.
🤖 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 `@AGENTS.md`:
- Around line 96-98: The documentation must consistently define teardown as
owned-CVM-only. In AGENTS.md, replace account-wide zero-count language with
verification that the run-owned CVM count reaches zero, while preserving
paginated verification. In packages/challenges/agent-challenge/docs/staging.md,
update the --down documentation to state that it removes only owned CVMs and
never sweeps account-wide or foreign/production CVMs.
In `@packages/challenges/agent-challenge/docker-compose.staging.yml`:
- Around line 31-32: Remove the unconditional dcap-qvl host bind from the base
staging Compose configuration so the baked /usr/local/bin/dcap-qvl binary
remains available on hosts without the source path. If host overrides are
needed, place this read-only bind in a separate opt-in staging override file
such as docker-compose.staging.dcap.yml.
In
`@packages/challenges/agent-challenge/scripts/staging/config/challenge.env.example`:
- Line 17: Replace the hardcoded value for CHALLENGE_EVAL_KEY_RELEASE_ENDPOINT
with a clearly marked placeholder for the staging host’s externally reachable
IP, preserving port 8701, and add an adjacent comment documenting that operators
must set it to their staging host address.
In
`@packages/challenges/agent-challenge/scripts/staging/config/kr.example/README.md`:
- Around line 9-14: Replace the server-certificate placeholder in the KR staging
README with tested OpenSSL commands that issue a KR server certificate signed by
the generated CA and include the required Subject Alternative Name. Document the
exact generated server key and certificate paths expected by staging, while
preserving the existing CA and golden-key setup.
In
`@packages/challenges/agent-challenge/scripts/staging/config/measurements_source.md`:
- Around line 7-9: Remove the absolute workspace and production-host evidence
paths from the documentation list in measurements_source.md. Retain only
portable provenance such as dates, quote identifiers, and hashes, ensuring the
shipped markdown remains minimal and does not include validation-log locations.
In `@packages/challenges/agent-challenge/scripts/staging/cvm_teardown_policy.py`:
- Around line 250-254: Update the --dry-run argument definition and its handling
in the staging teardown entry point to clearly document that it is accepted for
compatibility and does not change behavior, or remove the flag entirely. Ensure
the help text and surrounding logic do not imply that omitting the flag performs
deletion, and eliminate the no-op unused-variable handling if the flag is
removed.
- Around line 144-162: Propagate the unresolved ids from resolve_delete_ids
through select_teardown_ids and plan_teardown as unresolved_owned, and include
that field in the generated plan output so unverified tracked tokens are
distinguishable from confirmed account ids. Update all callers, including tests
currently using two-value unpacking, to handle the expanded return value while
preserving existing deletion selection.
In `@packages/challenges/agent-challenge/scripts/staging/run_staging.sh`:
- Around line 660-665: Replace the predictable /tmp/hosts.staging.$$ staging in
run_staging.sh lines 660-665 with a mktemp-created file, remove the dead awk
write, and continue filtering and copying the result to /etc/hosts with cleanup.
Apply the same mktemp-based staging fix to the teardown unpin path at lines
506-514; both sites require direct changes using the generated temporary
filename consistently.
- Line 557: Replace the world-readable permissions in run_staging.sh at
packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L557-L557 for
challenge_token and review_evidence_encryption_key with group-scoped read access
shared with container uid 10001, using chgrp or equivalent ownership setup. Also
update
packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L475-L483 to
replace world read/write access on the staging SQLite database and world
read/write/execute access on its volume directory with group-scoped permissions
shared with the KR process; preserve required functionality without exposing
sensitive materials to other local users.
- Around line 370-409: Move the OWNED_CVMS_FILE pruning block out of the
pre-verification path in the teardown function. Compute owned_left from the
original tracking files and final CVM listing first, return failure when any
owned CVM remains, then prune only targeted IDs confirmed absent from the final
listing after the check succeeds; preserve the existing best-effort handling for
the cleanup write.
- Around line 205-214: Remove direct shell interpolation of remote IDs and file
paths into Python source across the identified unquoted heredocs. Pass each
value through environment variables or another data channel, then read it from
os.environ inside the corresponding Python blocks; update the DELETE request
block around cid and the other heredocs at the referenced staging-script
sections consistently.
- Around line 826-841: Remove the eval-based assignment around the Python JSON
parsing block in the staging result handling. Safely transfer reason_code and
retryable from the parsed response using a file or separate command
substitutions with robust shell-safe handling, preserving the existing defaults
for malformed responses and missing items without allowing API-controlled
reason_code content to execute as shell syntax.
In `@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/phala.py`:
- Around line 305-324: Update the paginated CVM aggregation loop around
parse_cvms_list_response to track each CVM’s unique ID and raise
CvmListParseError when an ID repeats across pages, before extending all_items.
Preserve normal pagination and total-consistency validation, failing closed
instead of returning duplicate rows when the server repeats a page.
---
Outside diff comments:
In `@packages/challenges/agent-challenge/docs/prod-compose-upgrade.md`:
- Around line 1-504: Replace the oversized incident-specific runbook with a
concise product-documentation note covering only the artifact-aware eval pin
upgrade, required authorization, high-level upgrade/rollback pointers, and
references to the relevant implementation and operational procedures. Remove
measured hashes, submission state, host paths, timestamps, validation logs, and
other change-window evidence; direct operators to record those details in local
untracked evidence or the ops change record instead.
---
Nitpick comments:
In `@packages/challenges/agent-challenge/docs/staging.md`:
- Around line 311-313: Replace the teardown command that invokes `npx
phala@latest` with an approved pinned Phala CLI version or the
repository-managed toolchain. Ensure the command resolves a deterministic,
reviewed CLI version rather than mutable latest package code.
In `@packages/challenges/agent-challenge/scripts/staging/cvm_teardown_policy.py`:
- Around line 17-39: Update parse_account_cvms_payload to raise ValueError from
CvmListParseError instead of SystemExit, preserving the original error message
and exception chaining. In main, catch the parsing ValueError and convert it to
SystemExit so library callers receive a catchable error while the CLI retains
its exit behavior.
In `@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cli.py`:
- Around line 204-212: The teardown flow should resolve the CVM identity
directly from the parsed snapshot instead of rebuilding a listing dict and
invoking resolve_cvm_id_from_list. Replace that call with
resolve_cvm_id_from_snapshot(snapshot, app_id=identity, require_unique=True),
and preserve caller-level translation of ambiguity-related CvmListParseError
into the appropriate RouteClientError or API error.
In
`@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cvm_list.py`:
- Around line 65-82: Deduplicate the identical field-scanning logic in _item_id
and _extract_cvm_id by reusing one helper or having one function delegate to the
other. Preserve _item_id’s None result for missing or invalid identifiers, while
keeping _extract_cvm_id’s ValueError behavior when no identifier is found.
- Around line 235-239: Introduce a dedicated AmbiguousCvmMatchError subclass of
CvmListParseError and raise it in the require_unique branch of the CVM
resolution logic when multiple matches are found. Update
phala.resolve_cvm_id_from_list to detect this exception type instead of
searching the error message for “multiple CVMs match app_id”, preserving
existing handling for other CvmListParseError cases.
In `@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/phala.py`:
- Around line 329-347: In the paginated response merge logic, simplify the
reported_total validation after parse_cvms_list_response(list(all_items)):
remove the unreachable inner branch that returns a snapshot, and directly raise
CvmListParseError when the merged item count differs from reported_total.
Preserve the existing snapshot construction for valid totals, including its
total and source_shape values.
In `@packages/challenges/agent-challenge/tests/test_phala_cvms_list_parse.py`:
- Around line 48-72: Fix _CapturingOpener payload classification: remove the
redundant branches in the second elif and distinguish a bare list response body
from a queue of response bodies, ensuring a payload such as [{"id": "cvm_1"}] is
served as one body while genuine sequential bodies remain queued.
In
`@packages/challenges/agent-challenge/tests/test_staging_cvm_teardown_policy.py`:
- Around line 13-28: Centralize the duplicated cvm_teardown_policy loader by
moving POLICY_PATH and _load_policy into tests/conftest.py and exposing the
shared policy fixture. In
packages/challenges/agent-challenge/tests/test_staging_cvm_teardown_policy.py#L13-L28,
remove the local definitions and consume the fixture; in
packages/challenges/agent-challenge/tests/test_phala_cvms_list_parse.py#L32-L45,
likewise remove the duplicate loader and use the shared POLICY_PATH/policy
fixture.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5dc68352-0804-4be8-86b7-594bbffff99f

📥 Commits

Reviewing files that changed from the base of the PR and between 2bfa110 and b64258e.

📒 Files selected for processing (27)
  • AGENTS.md
  • packages/challenges/agent-challenge/docker-compose.staging.yml
  • packages/challenges/agent-challenge/docs/README.md
  • packages/challenges/agent-challenge/docs/prod-compose-upgrade.md
  • packages/challenges/agent-challenge/docs/staging.md
  • packages/challenges/agent-challenge/scripts/staging/.gitignore
  • packages/challenges/agent-challenge/scripts/staging/config/challenge.env.example
  • packages/challenges/agent-challenge/scripts/staging/config/challenge_token.example
  • packages/challenges/agent-challenge/scripts/staging/config/dstack-client-trust.crt.example
  • packages/challenges/agent-challenge/scripts/staging/config/eval_allowlist.json
  • packages/challenges/agent-challenge/scripts/staging/config/kr.example/README.md
  • packages/challenges/agent-challenge/scripts/staging/config/kr_allowlist.json
  • packages/challenges/agent-challenge/scripts/staging/config/measurements_source.md
  • packages/challenges/agent-challenge/scripts/staging/config/pins.json
  • packages/challenges/agent-challenge/scripts/staging/config/review_allowlist.json
  • packages/challenges/agent-challenge/scripts/staging/config/review_evidence_encryption_key.example
  • packages/challenges/agent-challenge/scripts/staging/cvm_teardown_policy.py
  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh
  • packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cli.py
  • packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/client.py
  • packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/cvm_list.py
  • packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/phala.py
  • packages/challenges/agent-challenge/tests/test_phala_create_ack_and_cli_token.py
  • packages/challenges/agent-challenge/tests/test_phala_cvms_list_parse.py
  • packages/challenges/agent-challenge/tests/test_selfdeploy_loopback_http_policy.py
  • packages/challenges/agent-challenge/tests/test_selfdeploy_teardown_http.py
  • packages/challenges/agent-challenge/tests/test_staging_cvm_teardown_policy.py

Comment threadAGENTS.md
Comment on lines +96 to +98
- CVMs are **real** Phala TDX machines (billable). Staging tears down **only CVMs this run owns** (`work/owned_cvms.txt` + per-run track). It never account-sweeps foreign/prod CVMs. Always tear down owned CVMs before you leave.

Real Phala TDX CVMs + dual attestation flags; always tear down to a verified CVM count of 0 via paginated list (never trust bare `GET /cvms` empty arrays).

Copy link
Copy Markdown

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

Preserve the owned-CVM teardown boundary consistently.

The documentation mixes owned-only cleanup with account-wide zero-count and sweep language, which could lead to deletion of foreign or production CVMs.

  • AGENTS.md#L96-L98: verify that only the run-owned CVM count reaches zero; do not require an account-wide zero.
  • packages/challenges/agent-challenge/docs/staging.md#L102-L102: state that --down removes only owned CVMs and does not sweep the account.
📍 Affects 2 files
  • AGENTS.md#L96-L98 (this comment)
  • packages/challenges/agent-challenge/docs/staging.md#L102-L102
🤖 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 `@AGENTS.md` around lines 96 - 98, The documentation must consistently define
teardown as owned-CVM-only. In AGENTS.md, replace account-wide zero-count
language with verification that the run-owned CVM count reaches zero, while
preserving paginated verification. In
packages/challenges/agent-challenge/docs/staging.md, update the --down
documentation to state that it removes only owned CVMs and never sweeps
account-wide or foreign/production CVMs.

Comment on lines +31 to +32
# dcap-qvl is baked into the runtime image; host bind is optional fallback
- /root/.cargo/bin/dcap-qvl:/usr/local/bin/dcap-qvl:ro

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unconditional host bind of dcap-qvl will shadow the baked binary on hosts lacking /root/.cargo/bin/dcap-qvl.

Docker creates an empty directory at a missing bind source, then mounts it over /usr/local/bin/dcap-qvl, turning the baked binary into a directory and failing attestation startup. A "fallback" bind can't be conditional in a compose file — move it to an opt-in override (e.g. docker-compose.staging.dcap.yml) instead.

🔧 Proposed change
- # dcap-qvl is baked into the runtime image; host bind is optional fallback- - /root/.cargo/bin/dcap-qvl:/usr/local/bin/dcap-qvl:ro+ # dcap-qvl is baked into the runtime image. If you need a host override,+ # add it via an extra `-f` override file rather than here — a missing+ # source path would be auto-created as a directory and shadow the binary.
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# dcap-qvl is baked into the runtime image; host bind is optional fallback
- /root/.cargo/bin/dcap-qvl:/usr/local/bin/dcap-qvl:ro
# dcap-qvl is baked into the runtime image. If you need a host override,
# add it via an extra `-f` override file rather than here — a missing
# source path would be auto-created as a directory and shadow the binary.
🤖 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 `@packages/challenges/agent-challenge/docker-compose.staging.yml` around lines
31 - 32, Remove the unconditional dcap-qvl host bind from the base staging
Compose configuration so the baked /usr/local/bin/dcap-qvl binary remains
available on hosts without the source path. If host overrides are needed, place
this read-only bind in a separate opt-in staging override file such as
docker-compose.staging.dcap.yml.

CHALLENGE_EVAL_APP_KMS_PUBLIC_KEY_HEX=8820793b3116a96b7c8c7daf06b104b14cbf9ee5dd3fb65d8ad53b50cefc7809
CHALLENGE_EVAL_APP_MEASUREMENT={"mrtd":"f06dfda6dce1cf904d4e2bab1dc370634cf95cefa2ceb2de2eee127c9382698090d7a4a13e14c536ec6c9c3c8fa87077","rtmr0":"68102e7b524af310f7b7d426ce75481e36c40f5d513a9009c046e9d37e31551f0134d954b496a3357fd61d03f07ffe96","rtmr1":"07e6f51aa763abfe75c3ddfbf4f425fe3f0ceff66d807a75e049303dce9addf68e7218729bd419638af63a370f65878c","rtmr2":"df67e467e60edc1737bcf8e682d48131bfb427f523226aa7f197a7608e9b3784783fa759ef5b28191fa12f9ddb36b858","os_image_hash":"5c6d8f757e3adb0563efc809710076a631442db3b4de02ad32d33fe1994721e0","key_provider":"phala","vm_shape":"tdx.small"}
CHALLENGE_EVAL_APP_MEASUREMENT_ALLOWLIST=[{"mrtd":"f06dfda6dce1cf904d4e2bab1dc370634cf95cefa2ceb2de2eee127c9382698090d7a4a13e14c536ec6c9c3c8fa87077","rtmr0":"68102e7b524af310f7b7d426ce75481e36c40f5d513a9009c046e9d37e31551f0134d954b496a3357fd61d03f07ffe96","rtmr1":"07e6f51aa763abfe75c3ddfbf4f425fe3f0ceff66d807a75e049303dce9addf68e7218729bd419638af63a370f65878c","rtmr2":"df67e467e60edc1737bcf8e682d48131bfb427f523226aa7f197a7608e9b3784783fa759ef5b28191fa12f9ddb36b858","os_image_hash":"5c6d8f757e3adb0563efc809710076a631442db3b4de02ad32d33fe1994721e0","compose_hash":"0647b4d9b1e3d458b7910638ee187c968835840fe2e65f1f332dbe69c518dfd9"}]
CHALLENGE_EVAL_KEY_RELEASE_ENDPOINT=84.32.70.61:8701

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace the hardcoded host IP with a placeholder.

84.32.70.61:8701 is an operator-specific address baked into a committed template; copying this file verbatim points guest key-release traffic at a foreign host. Use a placeholder and document that it must be the staging host's externally reachable IP.

🔧 Proposed change
-CHALLENGE_EVAL_KEY_RELEASE_ENDPOINT=84.32.70.61:8701+# Externally reachable address of the host running the staging key-release server.+CHALLENGE_EVAL_KEY_RELEASE_ENDPOINT=REPLACE_WITH_STAGING_HOST_IP:8701
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
CHALLENGE_EVAL_KEY_RELEASE_ENDPOINT=84.32.70.61:8701
# Externally reachable address of the host running the staging key-release server.
CHALLENGE_EVAL_KEY_RELEASE_ENDPOINT=REPLACE_WITH_STAGING_HOST_IP:8701
🤖 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
`@packages/challenges/agent-challenge/scripts/staging/config/challenge.env.example`
at line 17, Replace the hardcoded value for CHALLENGE_EVAL_KEY_RELEASE_ENDPOINT
with a clearly marked placeholder for the staging host’s externally reachable
IP, preserving port 8701, and add an adjacent comment documenting that operators
must set it to their staging host address.

Comment on lines +9 to +14
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout scripts/staging/config/kr/ca.key \
-out scripts/staging/config/kr/ca.crt \
-days 365 -subj "/CN=ac-staging-kr-ca"
# ... issue server cert for your KR host; copy ca.crt to config/kr-server-ca.crt
openssl rand -out scripts/staging/config/kr/golden.key 32

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the complete KR server-certificate setup.

The command only creates the CA; the # ... issue server cert placeholder leaves the staging setup incomplete. Add tested commands that issue the KR server certificate with the required SAN and document the exact key/certificate paths consumed by staging.

🤖 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
`@packages/challenges/agent-challenge/scripts/staging/config/kr.example/README.md`
around lines 9 - 14, Replace the server-certificate placeholder in the KR
staging README with tested OpenSSL commands that issue a KR server certificate
signed by the generated CA and include the required Subject Alternative Name.
Document the exact generated server key and certificate paths expected by
staging, while preserving the existing CA and golden-key setup.

Comment on lines +7 to +9
- Review TEE evidence: `/work/baseintelligence/.omo/evidence/ac-attested-review-20260727/review-tee.json`
- T8 eval KR allowlist dumps: `/work/baseintelligence/.omo/start-work/T8-e2e/kr-meas-diag-20260725T233258Z.txt`
- Prod KR file (same core): host `/var/lib/base/keyrelease/eval-allowlist.json` on the prod master

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove internal evidence paths from shipped documentation.

The absolute workspace and production-host paths are environment-specific and turn this file into a validation log. Keep portable provenance such as dates, quote identifiers, and hashes, or store raw evidence locations outside the repository.

As per coding guidelines: **/*.md must keep shipping documentation minimal and must not add validation logs to product documentation.

🤖 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
`@packages/challenges/agent-challenge/scripts/staging/config/measurements_source.md`
around lines 7 - 9, Remove the absolute workspace and production-host evidence
paths from the documentation list in measurements_source.md. Retain only
portable provenance such as dates, quote identifiers, and hashes, ensuring the
shipped markdown remains minimal and does not include validation-log locations.

Source: Coding guidelines

Comment on lines +370 to +409
# Drop successfully targeted ids from durable owned list (best-effort).
if [[ -f "${OWNED_CVMS_FILE}" && -n "${will_delete// /}" ]]; then
local tmp_owned keep d
tmp_owned="$(mktemp)"
while read -r id; do
[[ -n "$id" ]] || continue
keep=1
for d in ${will_delete}; do [[ "$id" == "$d" ]] && keep=0 && break; done
[[ "$keep" == "1" ]] && echo "$id"
done <"${OWNED_CVMS_FILE}" >"${tmp_owned}" || true
mv -f "${tmp_owned}" "${OWNED_CVMS_FILE}"
fi
if ! listing="$(phala_get_cvms)"; then
log "FATAL: post-teardown CVM list failed — count indeterminate (not success)"
echo '{"count":-1,"ids":[],"indeterminate":true}' | tee "${RUN_DIR}/cvms-final.json"
return 1
fi
echo "${listing}" | tee "${RUN_DIR}/cvms-final.json"
local owned_left cnt indeterminate
cnt="$(python3 -c "import json;print(json.load(open('${RUN_DIR}/cvms-final.json')).get('count',-1))")"
indeterminate="$(python3 -c "import json;print(bool(json.load(open('${RUN_DIR}/cvms-final.json')).get('indeterminate')))")"
if [[ "${cnt}" == "-1" || "${indeterminate}" == "True" ]]; then
log "FATAL: CVM count indeterminate after teardown (count=${cnt})"
return 1
fi
owned_left="$(python3 -c "
import json
from pathlib import Path
final=set(json.load(open('${RUN_DIR}/cvms-final.json')).get('ids') or [])
owned=set()
for p in ('${CVM_TRACK}','${OWNED_CVMS_FILE}'):
path=Path(p)
if path.is_file():
owned |= {ln.strip() for ln in path.read_text().splitlines() if ln.strip() and not ln.strip().startswith('#')}
print(' '.join(sorted(owned & final)))
")"
if [[ -n "${owned_left// /}" ]]; then
log "WARNING: owned CVMs still present after teardown: ${owned_left} (account count=${cnt})"
return 1
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pruning the owned list before verification defeats the leftover check.

Lines 371-381 drop every targeted id from OWNED_CVMS_FILE, but the owned_left check at 395-405 re-reads that same file. Any CVM whose delete failed (the || true at line 367 swallows it) has already been removed from the owned set, so owned_left is empty and the run reports "teardown OK" while a billable CVM is still alive. Prune only after computing owned_left, and only for ids absent from the final listing.

🔧 Proposed reordering
- # Drop successfully targeted ids from durable owned list (best-effort).- if [[ -f "${OWNED_CVMS_FILE}" && -n "${will_delete// /}" ]]; then- local tmp_owned keep d- tmp_owned="$(mktemp)"- while read -r id; do- [[ -n "$id" ]] || continue- keep=1- for d in ${will_delete}; do [[ "$id" == "$d" ]] && keep=0 && break; done- [[ "$keep" == "1" ]] && echo "$id"- done <"${OWNED_CVMS_FILE}" >"${tmp_owned}" || true- mv -f "${tmp_owned}" "${OWNED_CVMS_FILE}"- fi
if ! listing="$(phala_get_cvms)"; then

…then, after owned_left is computed and found empty, drop the targeted ids that are confirmed gone from the final listing.

🤖 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 `@packages/challenges/agent-challenge/scripts/staging/run_staging.sh` around
lines 370 - 409, Move the OWNED_CVMS_FILE pruning block out of the
pre-verification path in the teardown function. Compute owned_left from the
original tracking files and final CVM listing first, return failure when any
owned CVM remains, then prune only targeted IDs confirmed absent from the final
listing after the check succeeds; preserve the existing best-effort handling for
the cleanup write.

log "miner zip ok hash=${zip_hash}"

if [[ "${SKIP_BUILD}" != "1" ]]; then log "building runtime image"; ${COMPOSE} build agent-challenge; fi
chmod a+r "${CONFIG_DIR}/challenge_token" "${CONFIG_DIR}/review_evidence_encryption_key" 2>/dev/null || 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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Permissions are widened to other where group/uid ownership would suffice. Both sites bridge the host↔container uid mismatch by granting world access to sensitive material, exposing it to every local user. Use chgrp plus group bits, or chown to the container uid (10001), in both places.

  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L557-L557: replace chmod a+r on challenge_token and review_evidence_encryption_key with group-scoped read for the container uid.
  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L475-L483: replace chmod a+rw on the staging SQLite DB and chmod a+rwx on its volume directory with group-scoped access shared with the KR process.

As per coding guidelines: "Never expose private keys, wallet mnemonics, API tokens, or full secret values in logs, documentation, or evidence."

📍 Affects 1 file
  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L557-L557 (this comment)
  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L475-L483
🤖 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 `@packages/challenges/agent-challenge/scripts/staging/run_staging.sh` at line
557, Replace the world-readable permissions in run_staging.sh at
packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L557-L557 for
challenge_token and review_evidence_encryption_key with group-scoped read access
shared with container uid 10001, using chgrp or equivalent ownership setup. Also
update
packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L475-L483 to
replace world read/write access on the staging SQLite database and world
read/write/execute access on its volume directory with group-scoped permissions
shared with the KR process; preserve required functionality without exposing
sensitive materials to other local users.

Sources: Coding guidelines, Linters/SAST tools

Comment on lines +660 to +665
awk -v h="${PUB_HOST}" 'index($0, h)==0 || $0 !~ (h "$") {print}' /etc/hosts > /tmp/hosts.staging.$$ || true
# Safer: drop lines ending with the hostname
grep -v -E "[[:space:]]${PUB_HOST}$" /etc/hosts > /tmp/hosts.staging.$$ || cp /etc/hosts /tmp/hosts.staging.$$ || true
echo "${PUB_IP} ${PUB_HOST}" >> /tmp/hosts.staging.$$
cp -f /tmp/hosts.staging.$$ /etc/hosts
rm -f /tmp/hosts.staging.$$

Copy link
Copy Markdown

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

Insecure predictable temp file used to rewrite /etc/hosts. Both /etc/hosts mutation paths stage the new file at /tmp/hosts.staging.$$, a guessable name in a world-writable directory, then copy it over /etc/hosts as root — a symlink/pre-creation race. One mktemp-based helper fixes both.

  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L660-L665: drop the dead awk write on line 660 and stage the filtered hosts file via tmp="$(mktemp)" before copying to /etc/hosts.
  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L506-L514: replace /tmp/hosts.staging.$$ in the teardown unpin path with the same mktemp-created file.
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 661-661: Building a temp file path in a world-writable directory from the PID ($$) or `` is predictable and racy: an attacker can pre-create or guess the name and win a symlink/race attack. Use mktemp (e.g. `f=$(mktemp)` or `f=$(mktemp /tmp/myapp.XXXXXX)`) so the kernel atomically creates a unique, unpredictable file.
Context: /tmp/hosts.staging.$$
Note: [CWE-377] Insecure Temporary File.

(tmp-file-pid-name-bash)


[warning] 661-661: Building a temp file path in a world-writable directory from the PID ($$) or `` is predictable and racy: an attacker can pre-create or guess the name and win a symlink/race attack. Use mktemp (e.g. `f=$(mktemp)` or `f=$(mktemp /tmp/myapp.XXXXXX)`) so the kernel atomically creates a unique, unpredictable file.
Context: /tmp/hosts.staging.$$
Note: [CWE-377] Insecure Temporary File.

(tmp-file-pid-name-bash)


[warning] 662-662: Building a temp file path in a world-writable directory from the PID ($$) or `` is predictable and racy: an attacker can pre-create or guess the name and win a symlink/race attack. Use mktemp (e.g. `f=$(mktemp)` or `f=$(mktemp /tmp/myapp.XXXXXX)`) so the kernel atomically creates a unique, unpredictable file.
Context: /tmp/hosts.staging.$$
Note: [CWE-377] Insecure Temporary File.

(tmp-file-pid-name-bash)


[warning] 663-663: Building a temp file path in a world-writable directory from the PID ($$) or `` is predictable and racy: an attacker can pre-create or guess the name and win a symlink/race attack. Use mktemp (e.g. `f=$(mktemp)` or `f=$(mktemp /tmp/myapp.XXXXXX)`) so the kernel atomically creates a unique, unpredictable file.
Context: /tmp/hosts.staging.$$
Note: [CWE-377] Insecure Temporary File.

(tmp-file-pid-name-bash)

📍 Affects 1 file
  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L660-L665 (this comment)
  • packages/challenges/agent-challenge/scripts/staging/run_staging.sh#L506-L514
🤖 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 `@packages/challenges/agent-challenge/scripts/staging/run_staging.sh` around
lines 660 - 665, Replace the predictable /tmp/hosts.staging.$$ staging in
run_staging.sh lines 660-665 with a mktemp-created file, remove the dead awk
write, and continue filtering and copying the result to /etc/hosts with cleanup.
Apply the same mktemp-based staging fix to the teardown unpin path at lines
506-514; both sites require direct changes using the generated temporary
filename consistently.

Source: Linters/SAST tools

Comment on lines +826 to +841
eval "$(python3 - <<PY
import json
from pathlib import Path
p=Path("${RUN_DIR}/review-result-a${attempt}-${i}.json")
try:
o=json.loads(p.read_text())
except Exception:
o={}
items=o.get("items") if isinstance(o,dict) else None
it=items[0] if isinstance(items,list) and items else {}
rc=str(it.get("reason_code") or "") if isinstance(it,dict) else ""
rt=it.get("retryable") if isinstance(it,dict) else False
print(f'reason_code={rc!r}')
print(f'retryable={"true" if rt else "false"!r}')
PY
)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

eval on server-controlled JSON is a command-injection sink.

reason_code comes from the review API response. Python's !r switches to double quotes when the value contains a single quote, so a crafted reason_code breaks out of the shell quoting and executes arbitrary commands in a script that holds the Phala API key and (in this environment) root. Emit the values to a file and read them, or parse with python3 -c into separate command substitutions.

🔧 Proposed change
- eval "$(python3 - <<PY-import json-from pathlib import Path-p=Path("${RUN_DIR}/review-result-a${attempt}-${i}.json")-try:- o=json.loads(p.read_text())-except Exception:- o={}-items=o.get("items") if isinstance(o,dict) else None-it=items[0] if isinstance(items,list) and items else {}-rc=str(it.get("reason_code") or "") if isinstance(it,dict) else ""-rt=it.get("retryable") if isinstance(it,dict) else False-print(f'reason_code={rc!r}')-print(f'retryable={"true" if rt else "false"!r}')-PY-)"+ _hist_json="${RUN_DIR}/review-result-a${attempt}-${i}.json" \+ read -r reason_code retryable < <(HIST_JSON="${RUN_DIR}/review-result-a${attempt}-${i}.json" python3 - <<'PY'+import json, os, re+from pathlib import Path+try:+ o=json.loads(Path(os.environ["HIST_JSON"]).read_text())+except Exception:+ o={}+items=o.get("items") if isinstance(o,dict) else None+it=items[0] if isinstance(items,list) and items else {}+rc=str(it.get("reason_code") or "") if isinstance(it,dict) else ""+rc=re.sub(r"[^A-Za-z0-9_.:-]", "_", rc) or "unknown"+rt=it.get("retryable") if isinstance(it,dict) else False+print(rc, "true" if rt else "false")+PY+)
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
eval"$(python3 - <<PY
import json
from pathlib import Path
p=Path("${RUN_DIR}/review-result-a${attempt}-${i}.json")
try:
o=json.loads(p.read_text())
except Exception:
o={}
items=o.get("items") if isinstance(o,dict) else None
it=items[0] if isinstance(items,list) and items else {}
rc=str(it.get("reason_code") or "") if isinstance(it,dict) else""
rt=it.get("retryable") if isinstance(it,dict) else False
print(f'reason_code={rc!r}')
print(f'retryable={"true" if rt else "false"!r}')
PY
)"
_hist_json="${RUN_DIR}/review-result-a${attempt}-${i}.json" \
read -r reason_code retryable <<(HIST_JSON="${RUN_DIR}/review-result-a${attempt}-${i}.json" python3 - <<'PY'
import json, os, re
from pathlib import Path
try:
o=json.loads(Path(os.environ["HIST_JSON"]).read_text())
except Exception:
o={}
items=o.get("items") if isinstance(o,dict) else None
it=items[0] if isinstance(items,list) and items else {}
rc=str(it.get("reason_code") or "") if isinstance(it,dict) else""
rc=re.sub(r"[^A-Za-z0-9_.:-]", "_", rc) or "unknown"
rt=it.get("retryable") if isinstance(it,dict) else False
print(rc, "true"if rt else"false")
PY
)
🤖 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 `@packages/challenges/agent-challenge/scripts/staging/run_staging.sh` around
lines 826 - 841, Remove the eval-based assignment around the Python JSON parsing
block in the staging result handling. Safely transfer reason_code and retryable
from the parsed response using a file or separate command substitutions with
robust shell-safe handling, preserving the existing defaults for malformed
responses and missing items without allowing API-controlled reason_code content
to execute as shell syntax.

Source: Linters/SAST tools

Comment on lines +305 to +324
while True:
path = f"/cvms/paginated?page={page}&page_size={page_size}"
raw = self.get(path)
snap = parse_cvms_list_response(raw)
if reported_total is None:
reported_total = snap.total
elif snap.total != reported_total:
raise CvmListParseError(
"unrecognized CVM list shape: total changed across pages "
f"({reported_total} -> {snap.total})"
)
all_items.extend(snap.items)
if reported_total <= len(all_items):
break
if not snap.items:
raise CvmListParseError(
"unrecognized CVM list shape: empty page before total reached "
f"(have={len(all_items)} total={reported_total})"
)
page += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pages are merged without identity checks; a server that ignores page yields duplicates.

If the endpoint returns page 1 repeatedly, all_items accumulates duplicate rows until len(all_items) >= reported_total, and the merged snapshot silently reports the same CVM twice — bad input for owned-only teardown planning. Consider tracking seen CVM ids and failing closed on repeats (or asserting the echoed page field matches the requested page).

🤖 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 `@packages/challenges/agent-challenge/src/agent_challenge/selfdeploy/phala.py`
around lines 305 - 324, Update the paginated CVM aggregation loop around
parse_cvms_list_response to track each CVM’s unique ID and raise
CvmListParseError when an ID repeats across pages, before extending all_items.
Preserve normal pagination and total-consistency validation, failing closed
instead of returning duplicate rows when the server repeats a page.

Sign up for freeto 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

@echobt@alpha1122x