Skip to content

worker: input-artifact resolver and materializer (candidate, not wired) - #72

Open
thegeorgepu wants to merge 5 commits into
mainfrom
worker/input-artifact-resolver-main
Open

thegeorgepu wants to merge 5 commits into
mainfrom
worker/input-artifact-resolver-main

Conversation

@thegeorgepu

Copy link
Copy Markdown
Contributor

Candidate only. Nothing calls this. No call-site change, no schema change, no envelope change — materializeEnvelope and the runVinci spawn path are untouched. Two new files, 873 insertions, 4 ahead of main and 0 behind.

The question this PR asks

Does this primitive safely turn an authorized artifact pointer into verified local input bytes suitable for the later worker integration?

Explicitly not: "does this complete input-artifact delivery?" It does not, and the sections below say exactly which parts are missing and who owns them.

Why it exists

ExecutionSpec.inputArtifacts is {id, digest}[] — already digest-bound, and task.mjs:461 records it verbatim under "no fetch in Wave 1B scope". So the contract can name an input a worker must consume, and nothing can deliver one.

{id, digest} establishes identity, not location. A search of vinci-code-cli, vinci-gpu-control, vinci-contracts and vinci-foundry found no resolver to reuse: the artifacts table says in its own schema comment that it is "a pointer ledger ONLY", GET /v1/jobs/{job_id}/artifacts returns metadata rather than bytes, the worker fetches nothing by digest anywhere, and vinci-foundry verifies digests only after reading an already-local path. The nearest precedent is vgc artifacts pull, and this follows it.

What it does

Given lookup(artifact_id, expected_digest) and download(uri) — both injected, because the authority endpoint and object-store client are deployment concerns — it refuses more than one pointer match; treats the ledger digest and the contract digest as two separate claims from two authorities; enforces a declared byte ceiling before fetching; verifies the downloaded bytes; verifies the staged file while it is still unpublished; publishes read-only by atomic rename under a digest-derived name into a 0700 directory; and re-reads the published path to produce the evidence chain (requested / resolved / downloaded / materialized) that vinci-gpu-control's input-delivery observation consumes.

The worker never resolves an id itself. The id goes to an authority which returns a pointer, and the on-disk name comes from the digest — never the id, never the URI, because a filename is a place and both of those are strings someone else chose.

Review disposition: GO-WITH-FINDINGS

Adversarial review of ambient capability found one critical defect, reproduced and fixed in 229bb4c6: a symlink pre-placed at the deterministic staging path was followed by both the write and the chmod, so a file outside destDir was overwritten and forced to 0400, while renameSync (which does not follow) published the link as the materialized artifact — and the digest re-read followed it and matched. Success reported, correct digest, arbitrary file clobbered, materialized_path pointing out of the sandbox.

Three defences now: unpredictable staging name, flag: "wx" exclusive create, and an lstat requiring a regular file at the published path. Removing the second or third individually survives — the random name alone blocks the attack — so they were removed pairwise: with only lstat left the attack dies as published_path_not_a_regular_file; with only exclusive-create left, as staging_path_occupied; with all three gone the symlink test goes red. Each backstop demonstrably contributes.

Correction to a comment inside the diff

The source calls the materialized residue "immutable". Read it as:

0400 and regular-file identity establish resolver-side read-only materialization. Immutability against a same-UID task is not established here and is a P0-D3 integration requirement.

runVinci changes no uid/gid, and a Unix owner can chmod its own 0400 file writable again. This repo already states the same limitation about its own clean room — vinci/worker/cleanroom.mjs:45 ("no user separation (child and daemon share a uid and can read each other's files)") and :196 ("a resolution boundary, not uid isolation"). The comment was left unedited to preserve the exact reviewed subject; this note exists so no reviewer inherits the false claim.

Remaining limits, all intentionally downstream

Limit Owner
Authoritative VGC lookup. lookup is injected; no route resolves (id, digest) → one pointer today, and resolution must also check that the bound execution spec for an authenticated principal actually requests that artifact. Lane 1 (VGC / artifact authority)
Structured downloader qualification. The namespace allowlist is a string prefix, not a URI parse: s3://vgc-artifacts/../../etc/passwd and its %2e%2e encoding both pass and collapse to the same path under a real parser. The downloader should receive a structured bucket/key object, not a generic string. Lane 1 / downloader
Streaming byte enforcement. The pre-fetch ceiling reads the pointer's declared size, so maxBytes is advisory at this layer — a lying pointer can under-declare and the adapter buffers the real object first. The delivered-vs-declared length check remains a separate control; it bounds materialization, not transfer. Lane 1 / downloader
Same-UID isolation and binding the bytes actually consumed. A post-run rehash cannot close this: a task can modify, consume, and restore. P0-D3 integration

Full requirements: vinci-gpu-controldocs/ARTIFACT-RESOLUTION-LANE-1.md. Lane closure record: docs/P0-D-WORKER-LANE-CLOSED.md.

Validation

node vinci/test/worker-input-artifacts.mjs — four control groups, all green. node vinci/test/worker-contract-vectors.mjs — PASS (6 vectors, 11 float cases, 22 path-grant cases), unchanged by this diff.

Every refusal is paired with a legitimate case through the same call. Mutation rounds began with a known-kill control (make refuse a no-op, which must be caught) after an earlier harness silently reported six false survivors through bad output parsing.

Not established

No live lookup, no live download, no call site, no authority route, and no delivery has ever been observed. The reviewed original is preserved unmodified at worker/input-artifact-resolver @ 01cb4c2c.

🤖 Generated with Claude Code

https://claude.ai/code/session_013aLpjQi8CaDqo6WoGFRAU7

George Pu and others added 4 commits September 10, 2026 15:01
ExecutionSpec.inputArtifacts is `{id, digest}[]`, already digest-bound, and
task.mjs:461 records it verbatim under "no fetch in Wave 1B scope". So the
contract can NAME an input the worker must consume and nothing can deliver
one. This is the missing middle, written so the contract owner has a concrete
thing to accept, amend or reject rather than a description.

DELIBERATELY NOT WIRED. No call site changes, no schema change, no envelope
change. materializeEnvelope and the runVinci spawn path are untouched.

WHY IT IS NOT MERE WIRING. `{id, digest}` establishes IDENTITY, not LOCATION.
A search of vinci-code-cli, vinci-gpu-control, vinci-contracts and
vinci-foundry found no resolver to reuse: the artifacts table says in its own
schema comment that it is "a pointer ledger ONLY", GET /v1/jobs/{id}/artifacts
returns metadata rather than bytes, the worker fetches nothing by digest
anywhere, and foundry verifies digests only AFTER reading an already-local
path. The nearest precedent is `vgc artifacts pull`, and this follows it.

TWO IDENTITIES, CHECKED SEPARATELY. The ledger says "object X holds these
bytes"; the contract says "the expected input is digest Y". They are distinct
claims from distinct authorities, so both are asserted -- letting one pass
because the other did is how a swap survives.

THE WORKER NEVER RESOLVES AN ID ITSELF. The id goes to an authority which
returns a pointer. The on-disk name is derived from the DIGEST, never from
the id or the uri: a filename is a place, and both of those are strings
someone else chose. Materialized 0o400, because a task that can rewrite its
own input can rewrite the evidence of what it was given.

The returned chain -- requested, resolved, downloaded, materialized -- is
exactly what vinci-gpu-control's `input delivery observation` consumes.

Eight mutants against a green baseline; six caught immediately, and the two
survivors are recorded because they were the interesting ones:

  * Hashing the in-memory buffer instead of re-reading the file SURVIVED.
    That is a real gap, not a cosmetic one: it proves the download twice and
    labels the second one a materialization. Closed by making the read-back
    injectable purely so the property is testable.
  * Mutating writeFileSync's mode SURVIVED because the chmod after the
    rename decides the final mode -- the write mode was defence in depth
    that defended nothing observable. Removed, leaving one mechanism a test
    can see. Mutating the chmod is now caught.

One bug of my own, same class as the module's subject: the digest-mismatch
fixture used a substitute of a different LENGTH, so the truncation check
answered first and that test never reached the guard it names. Earlier-guard
masking, inside the tests for a module whose whole job is catching
substitutions. The substitute is now the same length.

Not established: no live lookup, no live download, no call site, and no
authority route for a per-id pointer. The existing job-scoped artifacts route
returns a list, so a caller must either supply the producing job or the VGC
owner must add a per-id lookup -- `lookup` is injected here precisely so that
decision stays theirs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aLpjQi8CaDqo6WoGFRAU7
…seam

Four corrections from review, all of which reproduced.

1. PERMISSION BEFORE PUBLICATION. I had concluded the write mode was
   useless because a chmod set the final mode. That was the wrong lesson:
   write -> rename -> chmod leaves an interval where the FINAL pathname
   exists and is still writable, and no test observed that interleaving,
   which is exactly why the mutant survived. The ordering was the
   load-bearing part and nothing was checking it. Now every narrowing
   happens on the TEMP path, identity is verified while the file is still
   unpublished, and the rename publishes something already read-only. A
   staged file that does not hash correctly never acquires the final name.

2. LOOKUP TAKES id AND digest. Sending only the id would require artifact
   ids to be globally unique, and the search behind this module found no
   registry semantics establishing that. More than one matching pointer is
   now `ambiguous_pointer` -- unresolvable, never pick-the-newest, because
   choosing by recency is how the wrong artifact arrives with every digest
   check passing.

3. THE READ-BACK SEAM IS NOT CALLER-CONTROLLED. It exists so the
   buffer-versus-materialization property is testable, and a caller must
   not be able to supply the function that decides what the worker believes
   it materialized. The production entry point now enumerates what it
   forwards instead of passing an options object through, and a test proves
   an injected readBack does not reach the resolver that way.

4. The destination directory is created 0o700 before anything is written
   into it.

MUTATION NOTES, because three instruments were wrong before the code was.

  * My mutation harness reported all six mutants SURVIVING. It grepped
    `head -1` and caught an earlier console.log while hiding the assertion
    below it. Re-run on exit code: four caught, two survived. A mutation
    harness that cannot see a failure is worse than none, because it
    certifies.
  * The directory-privacy mutant then survived because the FIXTURE
    pre-created the destination with mkdtempSync, which already makes 0o700
    directories -- the fixture was doing the thing under test. The dest now
    hands the resolver a directory it must create.
  * The write-mode and chmod-on-temp mutants each survived individually
    because the other still set 0o400. That is genuine defence in depth
    rather than dead code -- unlike the earlier chmod-after-rename case --
    and the combined mutation IS caught, with "the temp file must be
    read-only before it is published".

Still not established: no live lookup, no live download, no call site, and
no authority route for a per-id pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aLpjQi8CaDqo6WoGFRAU7
Two corrections from review, plus a mutant that closed a real gap.

1. THE ALL-OR-NOTHING COMMENT OVERCLAIMED. The loop publishes each artifact
   as it goes, so a refusal on artifact N leaves 1..N-1 materialized. The
   property that actually holds is EXECUTION-ATOMIC: no spawn unless every
   declared input resolved, downloaded, verified and materialized. The
   residue is verified, immutable and unreferenced -- cleanup debris, not
   partial execution -- but it is residue, and calling this a transaction
   was a claim the code does not implement. Narrowed rather than fixed with
   staging machinery: staging the whole set buys nothing while the
   execution-atomic property is enforced at the call site, which is where
   the wiring must test it. A test now asserts the residue exists rather
   than leaving the reader to assume rollback.

2. AN AUTHORITY THAT CAN SELECT AN OBJECT DOES NOT THEREBY GAIN ARBITRARY
   NETWORK-FETCH AUTHORITY. The pointer decides where this worker goes, so
   the caller must declare the storage namespace its downloader is
   qualified for, and a pointer outside it is refused however well-formed.
   There is NO permissive default: an omitted allowlist is `no_uri_allowlist`,
   because a default would make the trust boundary invisible at the call
   site, which is the one place it has to be visible. Refused cases include
   another bucket, an http endpoint, a file:// url, the link-local metadata
   address, and a prefix-adjacent bucket name.

MUTATION, with the harness qualified first. Following the doctrine this
session earned: the run began with a KNOWN-KILL CONTROL -- make `refuse` a
no-op, which must be caught -- so a harness that cannot see failures is
detected before it certifies anything. It was caught, then four real
mutants ran.

Three were caught. The fourth, `startsWith` -> `includes`, SURVIVED: every
out-of-namespace case I had written also fails a substring test, so none
could tell a prefix check from a substring check. Closed with the namespace
appearing as a query parameter and buried in a key -- the two shapes where
the difference is the whole attack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aLpjQi8CaDqo6WoGFRAU7
Adversarial review of ambient capability found a real defect, and it is the
one that matters: the resolver followed a symlink at its staging path.

THE ATTACK, reproduced before the fix. Pre-place a symlink at the
deterministic staging name `${digest}.input.partial` in destDir, pointing at
any file the worker can write. writeFileSync and chmodSync BOTH follow it,
so the victim outside destDir is overwritten with the artifact bytes and
forced to 0o400. renameSync does NOT follow, so it publishes the LINK as the
materialized file. The final digest re-read follows the link and matches --
so the chain reports success, with a correct materialized_digest, while an
arbitrary file has been clobbered and permission-locked and
`materialized_path` points out of the sandbox. Worse for the module's own
claim: the attacker still owns that location, so the "materialized, verified,
immutable" artifact can be rewritten after resolution returns.

Measured: victim content became the artifact bytes, mode 400, and
lstat(materialized_path).isSymbolicLink() was true.

Three independent defences, because one is a single point of failure on a
path that had none:

  * the staging name is now unpredictable (randomUUID), so it cannot be
    pre-created;
  * the write is `flag: "wx"` -- exclusive create fails EEXIST on anything
    already there, symlink included, rather than writing through it;
  * the published path is lstat-ed and must be a regular file.

MUTATION, harness qualified first with a known-kill control. Y2
(exclusive create) and Y3 (lstat) each SURVIVE individual removal, because
the unpredictable name alone already blocks the attack. That is the
redundant-guards-mask-each-other pattern, so they were removed PAIRWISE:
with only lstat left the attack dies as
`published_path_not_a_regular_file`; with only exclusive-create left it dies
as `staging_path_occupied`; with all three gone the symlink test goes red
with "a file outside destDir must not be written through a symlink". Each
backstop demonstrably contributes rather than being decoration.

Also from the same review:

  * Unanchored allowlist prefixes are refused. `s3://vgc-artifacts` without
    the delimiter admits `s3://vgc-artifacts@evil.example/x`, which passes
    startsWith while a WHATWG parser reads host evil.example and userinfo
    vgc-artifacts. A structural rule on configuration, not a URI parser --
    the module previously placed no requirement on these entries at all.
  * Wrong-typed inputArtifacts raised a bare TypeError, escaping callers
    that catch the documented InputArtifactError contract. Now refuses.
  * The size ceiling is documented as HONESTY-DEPENDENT: it reads the
    pointer's declared size, so a lying authority can under-declare and the
    adapter still buffers the real object before this module sees a byte.
    maxBytes reaches download as ADVISORY and this module cannot verify the
    adapter honoured it. What is enforced is delivered-length equals
    declared-length, which bounds materialization, not transfer.
  * `download_truncated` renamed `download_length_mismatch`: an oversized
    payload is not a truncated one.

The reviewer confirmed the sponsor's URI question directly: `..` and
`%2e%2e` both pass the string check and collapse to the same path under a
real parser. That divergence stands as a known limit of a prefix test; the
structured replacement belongs with the real downloader integration and is
deliberately not built here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aLpjQi8CaDqo6WoGFRAU7
@thegeorgepu

Copy link
Copy Markdown
Contributor Author

Do not merge yet — merge gate

This is a qualified candidate waiting, not a merge queue entry. main should mean "independently useful now, or a stable dependency a named downstream consumer is ready to use." This is neither yet: it is deliberately uncalled, and merging it would freeze an interface that Lane 1 is likely to change.

Specifically, Lane 1 is expected to bind the requesting WorkOrder and authenticated principal into resolution, which changes lookup's signature; and P0-D3 still owns the same-UID isolation and actual-consumption requirements.

Lane 1 authoritative resolver selected
        ↓
contract owner agrees the interface
        ↓
229bb4c6 composed/amended if the signature changes
        ↓
real downloader semantics qualified
        ↓
D3 call-site design established
        ↓
exact-head review + CI
        ↓
MERGE, then consume immediately

The order matters. Merging the primitive first and integrating later means changing a main API after assumptions shift. Freezing the candidate, resolving the dependency, making the final interface adjustment, reviewing the integrated shape, then merging and consuming — is the cleaner sequence.

There is a second reason to keep dormant components off main, specific to this programme: the recursive-improvement work needs to know what generation actually had what capability. If half-finished future components accumulate on main, a later result becomes ambiguous — did generation N improve because of the candidate under test, or because unrelated dormant code had already landed? Agents inspecting main would also read scaffolding as an operational capability.

Nothing is at risk from waiting: the branch, its SHAs, this review history and CI all preserve it. The reviewed original is at worker/input-artifact-resolver @ 01cb4c2c.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant