Skip to content

fix(checker): a moved secret's aliases are moved too - #642

Merged
amritk merged 20 commits into
mainfrom
feature/secret-alias-move/ownership-rule
Oct 16, 2026
Merged

amritk merged 20 commits into
mainfrom
feature/secret-alias-move/ownership-rule

Conversation

@amritk

@amritk amritk commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

secret(v) now takes only a value nothing else holds. Moving v into a Secret used to forget every other name for the same bytes: const alias = raw; const key = secret(raw) compiled, alias read the key outside expose, and after wipe(key) it read zeros. The checker now refuses v whenever something else still holds it, says where that second reference was made, and suggests a fresh value or a copy:

`secret` takes a value nothing else holds, and `raw` has a second reference: `const alias = raw` (line 6). That reference would read the key outside the `Secret`, and zeros once it is wiped, so pass a fresh value (`secret([1, 2, 3])`, a `new`, or a call that makes one) or a copy: `const copy: u8[] = new Array<u8>(raw.length); copy.set(raw)`

A literal, null, and a new whose constructor keeps no this are fresh. A ternary is fresh when both arms are. A call is fresh when its callee's answer is, judged over the callee's own body by the same rule, through every depth of calls. A local is fresh when it is the only reference to its value: everything assigned to it is fresh and none of it is another local, it is not a for...of binding, and no other use of it leaves a second name.

These each leave a second name:

  • storing the value, or putting it in a literal;
  • passing it on through a ternary arm or ??;
  • wrapping it in Ok(raw) or Err(raw), since the Result holds it;
  • returning it;
  • using an assignment to it as a value (alias = raw = [1, 2, 3], xs.push(raw = [...])); an assignment whose value is dropped, as a statement or in a for header, is fine;
  • handing it to a function or constructor that keeps it;
  • a later declarator of the same statement;
  • capturing it in a data-parallel arrow or a spawned task, or handing it to a task as its argument.

r.orReturn(), r.expect(m) and r.unwrapOr(d) answer what r carries in its Ok, so a function that returns Ok of a fresh array still gives secret a fresh value through them, as the last release accepted. secret(c ? a : b) now moves the local in either arm, and refuses a parameter there as NL2447 does elsewhere. All of std/ compiles unchanged.

The rule refuses a few safe programs, each with a compile error that names the reference, never with a missed read:

  • a data-parallel arrow or spawned task that reads the local before the move;
  • a Result local copied into another;
  • a let given another local's array after the move.

Refs: docs/LANGUAGE.md#secrets-nishsecret, docs/security/secret.md
Tests: tests/cases/reject_secret_alias_*


Part of #625.

Approach

Option (b), the ownership rule: secret(e) accepts e only when it is the sole reference to its value, and anything else is a compile error that names the second reference. The alternative, an alias-aware move, would have to find every name that may hold the value (fields, elements, callee-kept parameters, captures) and make each one unreadable across functions. A missed name there is a missed read. Here a missed shape is a refusal.

What changed

  • src/secret.ts:
    • checkSoleReferences judges each secret(...) site, which collectFlow collects, in a body the ownership walk accepted. So NL2447 and NL2445 are still said once, by that walk.
    • FreshJudge decides whether an expression is fresh.
    • ReferenceScan walks one body for every occurrence of a local, sorts each use as harmless or as a second name, and resolves a captured name through sourceLocal (feat(checker): parallelMap, parallelFrom and arrows that read the enclosing function's consts #609), which also covers a task spawned as an arrow (feat(checker): arrow tasks with captures, tasks without a destination, and a seeded Rng #644).
    • ReferenceScan.harmlessAt counts an assignment to the local as harmless only where its value is dropped, by a statement or a for header. Anywhere else, the assignment's value is judged as the local is.
    • ReferenceScan.handsOn follows a value through every wrapper whose value it is: parentheses, a ternary arm, ??, Ok/Err, and the receiver or default of orReturn/expect/unwrapOr.
    • A Result method that answers the payload is judged by what its receiver carries. At a secret(...), a local carried there is refused, because the move does not reach it.
    • The identifier builtins that keep an argument are named in the code: Ok and Err.
    • What only the whole program knows is left as a SecretNeed: either a callee's answer, or whether a callee keeps a parameter.
    • SecretFlow.move and collectMoved follow ternary arms.
  • src/compilation.ts: Compilation.checkSecrets answers the needs with FreshResults.
    • Whether a callee keeps a parameter is PointerParamFacts.captured, read from the facts.
    • A callee's answer is judged over its own body, inside its instantiation's tables. The answers are settled as a greatest fixpoint over the calls between them, so a recursive function that only returns a literal is fresh.
    • One refusal is reported per site.
  • src/program.ts: SecretNeed and CheckedProgram.secretNeeds.
  • src/codes.ts: NL2512. main holds NL2492–NL2511, which typed JSON, feat(checker): parallelMap, parallelFrom and arrows that read the enclosing function's consts #609 and feat(checker): arrow tasks with captures, tasks without a destination, and a seeded Rng #644 took.
  • tests/run.js:
    • nish:secret: reverting the sole-reference rule lets every reject_secret_alias_* case compile (${aliasCases.length} cases) copies src/ with the rule taken out (the site walk, the whole-program questions, and the ternary arms of the move) and builds that copy with the seed. That compiler accepts all 38 cases the rule alone refuses. reject_secret_alias_declarator_moved is exempt by name, because the move refuses it itself (NL2445). The check finds its three edit lines by exact text.
    • nish:secret: secret(v) still takes ${what} checks that a recursive function returning a literal, a fresh payload through Ok/orReturn/expect, and the suggested copy are all accepted.
    • nish:secret: every std/ caller of secret(v) compiles (${callers.length} modules) checks tls, x509-random, hkdf, x25519 and x509.
  • Docs:
    • docs/LANGUAGE.md "Secrets": the secret(v) row and bullet, with every case named.
    • docs/AI.md "Secrets": the sole-reference condition, with a compiled NL2512 example.
    • docs/guide/using-nish/standard-library.md: one refused example and one accepted copy, both compiled by node tests/run.js guide.
    • A new record, docs/security/secret.md (SECRET-1, Medium, fixed), and its row in docs/security/README.md.
  • Goldens: tests/self/goldens/checked-self.txt was regenerated with node tests/self/goldens.js --update after each merge of main. No positive case was added, so nothing is registered with the differential goldens.

No std/, runtime or emitter change, and no IR moved: every new case is a refusal, so no .ll golden is added, and every existing .ll is unchanged.

Negatives

One file per road, each with its own .err and no absolute path:

Case Road
reject_secret_alias_const const alias = raw
reject_secret_alias_let let alias = raw, and alias = raw after the declaration
reject_secret_alias_reassigned let raw = …; raw = other before the move
reject_secret_alias_reassigned_after raw = other after the move (a refusal of a safe program, pinned)
reject_secret_alias_assign_chain, _assign_init, _assign_push, _assign_field, _assign_kept, _assign_helper an assignment to the local used as a value: alias = raw = [...], const alias = (raw = [...]), rows.push(raw = [...]), h.buf = (raw = [...]), keep(h, raw = [...]), and the same inside a callee
reject_secret_alias_assign_parents the same assignment as an array literal element, a record literal field, a ternary arm, a ?? arm and an Ok(...) argument
reject_secret_alias_assign_constructor the same assignment as a kept constructor argument
reject_secret_alias_assign_source raw = (alias = [...]): an assignment as a local's value
reject_secret_alias_declarators const n = 1, alias = raw, and const alias = raw, key = secret(raw)
reject_secret_alias_declarator_moved const key = secret(raw), alias = raw (NL2445)
reject_secret_alias_field h.buf = raw, { buf: raw }
reject_secret_alias_element [raw], rows[0] = raw, rows.push(raw)
reject_secret_alias_param a callee that stores, or returns, its parameter
reject_secret_alias_return const alias = id(raw), and secret(id(raw)) with raw read after
reject_secret_alias_mutual secret(f(2, raw)) where f answers g's answer and g answers its parameter
reject_secret_alias_ternary const alias = c ? raw : other, secret(c ? raw : other) with raw read after (NL2445), secret(c ? p : [1]) (NL2447)
reject_secret_alias_nullish const alias = maybe ?? raw
reject_secret_alias_ok, reject_secret_alias_err const r = Ok(raw), const r = Err(raw)
reject_secret_alias_unwrap_or secret(none().unwrapOr(d)) with d read after
reject_secret_alias_unwrap_helper secret(wrap(raw).orReturn()) and secret(wrap(raw).expect(m))
reject_secret_alias_unwrap_local secret(r.orReturn()) and secret(r.expect(m)) for a local r = Ok(raw)
reject_secret_alias_result_copy a Result local copied into another before it is returned (a refusal of a safe program, pinned)
reject_secret_alias_constructor new Holder(raw) keeping its argument, new Pair(r) handing this out
reject_secret_alias_receiver a method that keeps this, a method that answers this.buf
reject_secret_alias_pop secret(rows.pop()): a method of a value never answers a fresh value
reject_secret_alias_for_of a for...of binding
reject_secret_alias_capture_map, _from, _map_into, _reduce an arrow given to each data-parallel form that captures raw
reject_secret_alias_capture_spawn a task spawned as an arrow (#644) that captures raw
reject_secret_alias_spawn_arg, reject_secret_alias_spawn_with raw as a task's argument, s.spawn(first, raw, out, 0) and s.spawn(sum, raw): the scope keeps it until the task has run

The following were probed and found refused:

  • a generic identity function;
  • a callee that answers a field (get(h));
  • a callee that answers Ok of a field through orReturn;
  • a callee that stores its fresh result into a parameter, directly or through another callee;
  • Map.set(k, raw);
  • rows[0] as an initialiser;
  • h.l = this in a constructor;
  • every route in the plan.

The compound assignments (+=, ??=, ||=) take no array, so they are no road.

Accepted unchanged:

  • secret_flow, secret_wipe, secret_wipe_o2 and secret_wipe_o2_plain;
  • every std/ caller (tls.ts, x509-random.ts, hkdf.ts, x25519.ts, x509.ts);
  • the suggested copy;
  • integer reads, push and indexOf before the move;
  • crypto.getRandomValues(raw), and a helper that writes through its parameter;
  • a recursive function that answers a literal;
  • make().orReturn() and make().expect(m) for a fresh Ok payload;
  • expose with a fresh arrow result;
  • a let given a fresh value after the move;
  • raw = [...] as a statement, or in a for header, before the move.

Two compilers were compared over every corpus program (tests/cases, tests/link and its directory programs, examples, docs/cookbook, tests/differential/corpus, bench, std): this branch, and the same tree with the rule reverted. They differ only on the new cases.

Testing

Head 32cee13, with main at a22a056 merged in (including #640, #641 and #644). Each command was run bare, with its exit status read, after bash scripts/fetch-seed.sh --force:

  • npm run check: exit 0. npm run lint: exit 0.
  • npm test, LLVM 18 on PATH, no DEGRADED: line: 18386 passed, 0 failed, 15 skipped.
  • WASI_SYSROOT=/usr node tests/run.js wasi, with wasi-libc and libclang-rt-18-dev-wasm32 installed: 49 passed, 0 failed, 0 skipped. That includes web: src/ links into one wasi module.
  • node tests/run.js performance: 211 passed, 0 failed. The ratchet holds, with no new NL9007 or NL9011.
  • node tests/run.js selfhost: 187 passed, 0 failed.
  • WASI_SYSROOT=/usr node tests/run.js c-symbols: 39 passed, 0 failed, 0 skipped.
  • node scripts/gen-diagnostic-codes.mjs --check: exit 0. node docs/check-links.mjs: ok.
  • Every check string the record cites was grepped against tests/run.js.

main has since gained #659, which is test-only (a new case, its goldens and tests/run.js), merges cleanly, and touches nothing in this diff.

Work in progress: the sole-reference rule for secret(v), its negative
cases, the reverted-rule pin, the docs and the record.
Work in progress: simplify the sole-reference rule, refuse a second
name made through `??`, regenerate the goldens.
Work in progress: judge each secret(v) site in a function of its own,
so the loop over them keeps no allocation past a pass.
# Conflicts:
#	docs/security/README.md
#	src/codes.ts
#	tests/self/goldens/checked-self.txt
# Conflicts:
#	src/codes.ts
#	src/secret.ts
#	tests/self/goldens/checked-self.txt
# Conflicts:
#	tests/self/goldens/checked-self.txt

@amritk amritk left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review round 1 at db5a03e. Three independent reviewers read the diff, and a separate verifier then compiled a probe or read the code to confirm each finding below. Seven findings were confirmed. Eleven others were refuted and won't be raised again.

Blocking

  1. Ok(raw) / Err(raw) leave a second reference that the rule doesn't see. See the inline comment on src/secret.ts:1612.

Low (fix in the same push)

  1. makeKey().orReturn() is now falsely refused, contradicting "every program that passed before still passes". See src/secret.ts:1736.
  2. The copy hint names an undefined v for an argument that isn't a plain name. See src/secret.ts:1825.
  3. No negative pins a statement with several declarators, though both shapes are refused today. CLAUDE.md asks for one per shape: const a = raw, b = 1, and const k = secret(raw), alias = raw.
  4. The record cites a check string that doesn't exist. See docs/security/secret.md:71.
  5. The record claims a recursion property with no test pinning it. See docs/security/secret.md:52.
  6. docs/AI.md:1512 still says only "secret(local) moves the local too". It is the whole-language rules page and should state the new sole-reference condition. This file is added to the stage's scope for this fix.

Please address all seven in one push. Merge main again first if it has moved, and re-run the full gate, reading each exit status.


Generated by Claude Code

Comment thread src/secret.ts Outdated
Comment thread src/secret.ts
Comment thread src/secret.ts Outdated
Comment thread docs/security/secret.md Outdated
Comment thread docs/security/secret.md
Review round 1. A `Result` now carries its value as the rule sees it:
`Ok(raw)` and `Err(raw)` leave a second name, as a ternary arm does, and
`r.orReturn()`, `r.expect(m)` and `r.unwrapOr(d)` answer what `r` carries
in its `Ok`. So `const r = Ok(raw); secret(raw)` is refused, and
`secret(make().orReturn())` is accepted when `make` returns `Ok` of a fresh
array, as the last release accepted it. A local carried to a `secret(...)`
through `unwrapOr`'s default is refused, since the move does not reach it.
The identifier builtins that keep their argument are named: `Ok` and `Err`.
The copy the diagnostic suggests no longer names a `v` that does not exist.

Refs: docs/LANGUAGE.md#secrets-nishsecret, docs/security/secret.md
Tests: tests/cases/reject_secret_alias_ok, reject_secret_alias_err, reject_secret_alias_unwrap_or, reject_secret_alias_declarators, reject_secret_alias_declarator_moved, reject_secret_alias_mutual

@amritk amritk left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review round 2 at d15f5a8. Three fresh reviewers read the diff, and each finding was then verified with probes compiled against this head.

No soundness hole was found: every route probed (exposeWith, expose, orReturn/expect through a helper or a local, Err through a callee, methods returning or keeping this, generics, constructors, and receivers with no recorded type) is refused. All round-1 findings are fixed. Four findings were confirmed, all low or medium and none blocking:

  1. [medium] The PR body describes an older head. Its first section becomes the squash commit and the changelog entry. Please update the following:
    • The Testing section names head eeada0b, with counts taken at that head. Re-run the gate on the final head and report those numbers.
    • "What changed" says the revert check "compiles all 17 cases". It now compiles 22 of 23; declarator_moved is exempt.
    • The Negatives table lists 14 rows. It omits declarators, declarator_moved, mutual, ok, err and unwrap_or.
    • The closing note says docs/AI.md "is left for its owner", but this PR now updates it, and the Docs list omits it.
    • The changelog paragraph before --- doesn't mention that Ok/Err wrapping and orReturn/expect/unwrapOr are followed. A release-note reader should learn that Ok(raw) counts as a second reference.
  2. [low] The Result-unwrap routes have no negative. See src/secret.ts:1574.
  3. [low] The record claims two over-refusals that no test pins. See docs/security/secret.md:61.
  4. [low] reject_secret_alias_pop doesn't depend on the alias it sets up. See its line 7.

Please address all four in one push, and re-run the full gate on the final head, reading each exit status.


Generated by Claude Code

Comment thread src/secret.ts
Comment thread docs/security/secret.md Outdated
Comment thread tests/cases/reject_secret_alias_pop.ts Outdated
…e's over-refusals

Review round 2. `secret(wrap(raw).orReturn())`, `secret(wrap(raw).expect(m))`
and the same through a local `Result` holding `raw` each get a negative. So
do the two places the rule refuses a safe program: a `Result` local copied
into another, and a `let` given another local after the move.
`reject_secret_alias_pop` now pins what it tests, that a method of a value
never answers a fresh value, without an alias it did not need.

Tests: tests/cases/reject_secret_alias_unwrap_helper, reject_secret_alias_unwrap_local, reject_secret_alias_result_copy, reject_secret_alias_reassigned_after, reject_secret_alias_pop

@amritk amritk left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review round 3 at 0fb115c. Fresh reviewers read the code shards, and a verifier then compiled and ran probes against this head.

Blocking:

  1. An assignment used as a value (alias = raw = [...], (raw = [...]) as an initialiser, an argument or a field value) leaves a second reference. The checker accepts it, and the second name reads the wiped key. See src/secret.ts:1516.

Everything else in the code and test shards held. All 27 negatives exercise their own routes, and the mutation pin's case list and exemption are sound.

The PR body is still the one from round 2 finding 1. Please rewrite it on the final head after this fix, with the gate's exact numbers, and read it back. Round 4 runs on that head, docs and PR body included.


Generated by Claude Code

Comment thread src/secret.ts
…array it stores

Review round 3. An assignment answers the array it stores, so `alias = raw
= [1, 2, 3]`, `const alias = (raw = [...])`, `xs.push(raw = [...])`,
`h.k = (raw = [...])` and `keep(h, raw = [...])` each gave the moved array
a second name the rule did not see. An assignment to the local is now
harmless only where its value is dropped, by a statement or a `for`
header; anywhere else its value is judged as the local itself is. The
compound assignments take no array, so they are no road.

Tests: tests/cases/reject_secret_alias_assign_chain, reject_secret_alias_assign_init, reject_secret_alias_assign_push, reject_secret_alias_assign_field, reject_secret_alias_assign_kept, reject_secret_alias_assign_helper

@amritk amritk left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review round 4 at 8b29eb3. Fresh reviewers read the code shards, and a verifier compiled probes at this head.

The src/secret.ts shard found no soundness hole. It traced assignment-as-value in every parent, the for-header exception, all Result wrappers, array methods, interface dispatch and the crash paths. The round 3 fix holds: all seven chained-assignment probes are refused. CI is green, 19/19.

Two low coverage findings were confirmed; every probe was refused, and nothing is pinned:

  1. Six more parents of an assignment used as a value: array element, record field, ternary arm, ?? arm, Ok(...), kept constructor argument. See src/secret.ts:1528.
  2. An assignment as a local's source (raw = (alias = [...])), the only way to reach the judge default branch. See src/secret.ts:1787.

Please add these negatives and put them in the pin. In the same push, rewrite the PR body (round 2 finding 1), including the gate's exact numbers on that head. Round 5 reviews all three shards, docs and body included, on that head. If it comes back clean, this merges.


Generated by Claude Code

Comment thread src/secret.ts
Comment thread src/secret.ts
… an assignment as a local's source

Review round 4. An assignment to the local used as an array literal's
element, a record literal's field, a ternary arm, a `??` arm, the argument
of `Ok` or a kept constructor argument is refused, and so is a local given
an assignment as its value (`raw = (alias = [1, 2, 3])`). Each now has a
negative in the mutation pin.

Tests: tests/cases/reject_secret_alias_assign_parents, reject_secret_alias_assign_constructor, reject_secret_alias_assign_source
# Conflicts:
#	tests/self/goldens/checked-self.txt
# Conflicts:
#	src/codes.ts
#	tests/self/goldens/checked-self.txt

@amritk amritk left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review round 5b @ 71d51fa: how the rule interacts with #644 (task captures and spawn forms). One confirmed finding, low severity (coverage).

1. The argument forms of spawn are a way in with no negative of their own. Each of these is refused only by NL2512, through PointerParamFacts.captured on a generic std method of ThreadScope. No case pins either route, and the mutation pin would not notice if that route regressed. The only spawn case, reject_secret_alias_capture_spawn, is the arrow form, which is judged by the "inside an arrow" rule instead.

const first = (b: u8[]): i32 => toI32(b[0]);
// ...
const raw: u8[] = [7, 2, 3];
const out: i32[] = [0];
{
  using s = scope();
  s.spawn(first, raw, out, 0);  // `ThreadScope.spawn<u8[], i32, first>` keeps `arg`
}
const key: Secret<u8[]> = secret(raw);  // NL2512 is the only error

The same holds for the form without a destination: s.spawn(sum, raw) with a pure sum: (b: u8[]) => void reaches ThreadScope.spawnWith<u8[], void, sum>, and NL2512 is again the only error. Per CLAUDE.md, "a checker rule covers every way in", so each route needs its own negative. Add a case for each form (or one case with both, if the .err and the pin can tell them apart), put them in the reject_secret_alias_* set the mutation pin reverts, and mention the route in docs/security/secret.md next to the arrow capture.

Checked and found sound:

  • s.spawn(first, raw, out, 0) with secret(raw) inside the open scope: refused by NL2512 and by #644's scope rule.
  • raw as a spawn destination: refused by #644's destination rule.
  • secret(raw) before the spawn: NL2445.
  • A secret(...) site inside a task arrow's body, with a const alias: NL2512.
  • #644 adds no task handle or join that FreshJudge could take as fresh.
  • Rng returns no u8[].
  • The renumbering to NL2512 is consistent, and #644's own NL2506 is untouched.

Generated by Claude Code

Review round 5. `s.spawn(entry, raw, dst, at)` and `s.spawn(entry, raw)`
hand `raw` to the scope, which keeps it until the task has run, so
`secret(raw)` after the block is refused through `ThreadScope.spawn`'s and
`spawnWith`'s kept parameter. Each form now has a negative in the mutation
pin, and the record names the route.

Tests: tests/cases/reject_secret_alias_spawn_arg, reject_secret_alias_spawn_with
@amritk
amritk merged commit 464f41e into main Oct 16, 2026
23 checks passed
@amritk
amritk deleted the feature/secret-alias-move/ownership-rule branch October 16, 2026 10:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants