Skip to content

feat(packs): support unsigned packs from a static internal registry - #1389

Open
vjeantet wants to merge 4 commits into
nolabs-ai:mainfrom
vjeantet:feat/static-registry
Open

feat(packs): support unsigned packs from a static internal registry#1389
vjeantet wants to merge 4 commits into
nolabs-ai:mainfrom
vjeantet:feat/static-registry

Conversation

@vjeantet

@vjeantet vjeantet commented Jul 8, 2026

Copy link
Copy Markdown

feat(packs): support unsigned packs from a static internal registry

Closes #1388. Related to #857.

What

Enables air-gapped macOS fleets to distribute and update nono profiles,
Claude Code skills/plugins/hooks, and arbitrary enterprise files from an
internal static (nginx) registry, with signature verification disabled
and integrity guaranteed by per-artifact SHA-256.

This is the lower-friction, integrity-only transport discussed in
#1388 (a complement to the OCI direction of #857, not a replacement).
The keyed ECDSA trust mode is a separate follow-up PR layered on the same
[registry] config.

Changes

  • Config: [registry] section (url + verify, fail-secure verify = true by default) and a resolve_registry helper with precedence
    --registry > NONO_REGISTRY > config > default. Verification is turned
    off only via explicit --insecure, NONO_REGISTRY_INSECURE, or
    [registry].verify = false.
  • Pull (unsigned tier): when verify is off, skip the Sigstore bundle
    download/verify, namespace assertion, and subject-membership check; keep
    the SHA-256 check. Record an unsigned:internal-registry signer sentinel
    in the lockfile and write no .nono-trust.bundle.
  • Runtime guard: verify_profile_packs skips bundle re-verification
    only for packs whose lockfile signer is the sentinel, keyed on the
    lockfile so signed packs always get full re-verification. SHA-256 stays
    enforced.
  • Resilience: PullResponse tolerates the minimal static JSON (optional
    provenance; default bundle_url / scan_passed / size_bytes).
  • Zero-network posture: suppress registry phone-home (update hints +
    migration provider lookup) when verification is disabled.
  • Publish: nono pack publish-static emits the static registry tree
    (versions/<v>/pull, versions/latest/pull, status, files/…) reusing
    the serde types, with latest tracking the highest published semver.
  • Docs: air-gapped/internal-registry workflow + GitLab CI example.

Security notes

  • The unsigned tier is integrity-only, no provenance, strictly opt-in,
    never the default.
  • Fail-secure everywhere: default verify = true; SHA-256 enforced at
    install and at runtime; the lockfile-keyed sentinel guard prevents a
    signed pack from being silently downgraded to integrity-only.
  • No change to public-registry or existing keyless signing semantics.

Tests

Unit tests: registry precedence, fail-secure verify resolution, minimal
static JSON deserialization, publish-static generator round-trip, latest
tracking, sentinel runtime guard + tamper detection.

cargo check -p nono-cli and the crate test suite pass locally on macOS.

Stacking

Base branch: feat/static-registry off upstream/main. The keyed-trust PR
is stacked on top of this one and should be reviewed/merged after it.


Agent compliance check (AGENTS.md)

  • I am not prohibited from contributing under this policy
  • An issue already exists — new issue Static internal pack registry (nginx-servable, unsigned integrity tier) #1388 filed; awaiting
    maintainer guidance (see risks section of the issue) before merge
  • I described my intent and approach in the issue discussion
  • I reviewed repository coding and security rules for the affected area
  • I provided required attribution for reused or adapted code (builds on
    existing nono::trust / registry serde types; no external code copied)
  • I did not use forbidden patterns such as unwrap/expect
  • I used NonoError where required
  • I validated and canonicalized all relevant paths
  • This PR matches the approved or disclosed issue scope — disclosed;
    not yet approved (issue is a proposal pending maintainer direction)

Note: this contribution is authored with AI assistance. Two checklist
items above are intentionally left unchecked because #857/#1388 is
still a question-labelled proposal. Per AGENTS.md §5/§7 I am not
requesting merge yet — filing for maintainer guidance first.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

PR Review Summary

Size

Metric Value
Lines added +1004
Lines removed -66
Total changed 1070
Classification Large (> 300 lines)

Affected crates

  • crates/nono-cli — CLI changes. Verify argument parsing, flag documentation, and UX behaviour across supported platforms.

Blast radius — Contained

This PR touches: source code


Updated automatically on each push to this PR.

@vjeantet
vjeantet force-pushed the feat/static-registry branch from 78741d2 to 22bae4c Compare July 8, 2026 21:33
@vjeantet vjeantet mentioned this pull request Jul 11, 2026
5 tasks
@vjeantet
vjeantet force-pushed the feat/static-registry branch from 22bae4c to 1cdf71e Compare July 14, 2026 12:28
@vjeantet
vjeantet force-pushed the feat/static-registry branch from 1cdf71e to c67caa8 Compare July 14, 2026 14:37
@vjeantet
vjeantet force-pushed the feat/static-registry branch from c67caa8 to 4cf3e71 Compare July 15, 2026 18:09
@vjeantet
vjeantet force-pushed the feat/static-registry branch from 4cf3e71 to fb755b3 Compare July 16, 2026 12:10
@vjeantet
vjeantet force-pushed the feat/static-registry branch from fb755b3 to f4d788a Compare July 16, 2026 18:08
@vjeantet
vjeantet force-pushed the feat/static-registry branch from f4d788a to 788905c Compare July 17, 2026 12:13
@vjeantet
vjeantet force-pushed the feat/static-registry branch from 788905c to c5b7437 Compare July 17, 2026 18:09
@vjeantet
vjeantet force-pushed the feat/static-registry branch from 853f8b3 to cbb53a2 Compare August 2, 2026 13:03
@vjeantet
vjeantet force-pushed the feat/static-registry branch from cbb53a2 to 9d64e32 Compare August 5, 2026 07:10
@vjeantet
vjeantet force-pushed the feat/static-registry branch from 9d64e32 to 3fc6ef2 Compare August 5, 2026 19:56
@vjeantet
vjeantet marked this pull request as ready for review August 5, 2026 20:13

@nogent-nolabs-ai nogent-nolabs-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nogent code review

2 potential bugs bypassing resolve_registry, 1 minor robustness issue in highest_published_version.

Findings (not tied to a changed line):

  • 🐛 [MEDIUM · bug] crates/nono-cli/src/package_cmd.rs:515 — In crates/nono-cli/src/package_cmd.rs at lines L515 and L744, the nono update and nono outdated commands rely solely on status.installed_status from the registry response. However, static/internal registries serve flat JSON status files where installed_status is omitted (deserializing to None). In run_update (line L515), None falls into the wildcard match _ and is treated as outdated, triggering redundant /pull HTTP requests for every package even if they are already up-to-date. In run_outdated (line L744), None defaults to "unknown", preventing the command from identifying packages as up-to-date. Fix this by performing a client-side semver comparison (pkg.version == latest) when status.installed_status is None or "unknown".

Automated code + security review. CI already covers clippy, rustfmt, tests, cargo-audit and commit-lint.

Comment thread crates/nono-cli/src/migration.rs Outdated
Comment thread crates/nono-cli/src/pack_update_hint.rs Outdated
Comment thread crates/nono-cli/src/package_cmd.rs
vjeantet added a commit to vjeantet/nono that referenced this pull request Aug 5, 2026
The zero-network guards in the migration provider lookup and the pack
update hint read config.registry.verify straight from config.toml. That
skipped the rest of the precedence chain, so an operator who opted into
the air-gapped posture with NONO_REGISTRY_INSECURE=1 alone still paid a
background request to a registry that cannot answer it. Route both
through resolve_registry, the single place that folds the flag, the env
var and the config together.

Not a weakening of any check: disabling verification stays an explicit
operator decision, and nothing that was verified before is skipped now.

Also skip non-directories in highest_published_version. A stray file
named like a semver next to the real version directories could win the
comparison, and the follow-up read of versions/<latest>/pull would then
fail with ENOTDIR. file_type() is an lstat, so a symlinked version
directory is rejected too - the tree is generated by publish-static
itself, and the stricter reading avoids reopening a TOCTOU surface.

Reported in review of nolabs-ai#1389.

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
vjeantet added a commit to vjeantet/nono that referenced this pull request Aug 6, 2026
The zero-network guards in the migration provider lookup and the pack
update hint read config.registry.verify straight from config.toml. That
skipped the rest of the precedence chain, so an operator who opted into
the air-gapped posture with NONO_REGISTRY_INSECURE=1 alone still paid a
background request to a registry that cannot answer it. Route both
through resolve_registry, the single place that folds the flag, the env
var and the config together.

Not a weakening of any check: disabling verification stays an explicit
operator decision, and nothing that was verified before is skipped now.

Also skip non-directories in highest_published_version. A stray file
named like a semver next to the real version directories could win the
comparison, and the follow-up read of versions/<latest>/pull would then
fail with ENOTDIR. file_type() is an lstat, so a symlinked version
directory is rejected too - the tree is generated by publish-static
itself, and the stricter reading avoids reopening a TOCTOU surface.

Reported in review of nolabs-ai#1389.

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
@vjeantet
vjeantet force-pushed the feat/static-registry branch from 6ae5a77 to 826f5ec Compare August 6, 2026 18:48
vjeantet added a commit to vjeantet/nono that referenced this pull request Aug 8, 2026
The zero-network guards in the migration provider lookup and the pack
update hint read config.registry.verify straight from config.toml. That
skipped the rest of the precedence chain, so an operator who opted into
the air-gapped posture with NONO_REGISTRY_INSECURE=1 alone still paid a
background request to a registry that cannot answer it. Route both
through resolve_registry, the single place that folds the flag, the env
var and the config together.

Not a weakening of any check: disabling verification stays an explicit
operator decision, and nothing that was verified before is skipped now.

Also skip non-directories in highest_published_version. A stray file
named like a semver next to the real version directories could win the
comparison, and the follow-up read of versions/<latest>/pull would then
fail with ENOTDIR. file_type() is an lstat, so a symlinked version
directory is rejected too - the tree is generated by publish-static
itself, and the stricter reading avoids reopening a TOCTOU surface.

Reported in review of nolabs-ai#1389.

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
@vjeantet
vjeantet force-pushed the feat/static-registry branch from 826f5ec to ef43ca9 Compare August 8, 2026 07:53
vjeantet added a commit to vjeantet/nono that referenced this pull request Aug 8, 2026
The zero-network guards in the migration provider lookup and the pack
update hint read config.registry.verify straight from config.toml. That
skipped the rest of the precedence chain, so an operator who opted into
the air-gapped posture with NONO_REGISTRY_INSECURE=1 alone still paid a
background request to a registry that cannot answer it. Route both
through resolve_registry, the single place that folds the flag, the env
var and the config together.

Not a weakening of any check: disabling verification stays an explicit
operator decision, and nothing that was verified before is skipped now.

Also skip non-directories in highest_published_version. A stray file
named like a semver next to the real version directories could win the
comparison, and the follow-up read of versions/<latest>/pull would then
fail with ENOTDIR. file_type() is an lstat, so a symlinked version
directory is rejected too - the tree is generated by publish-static
itself, and the stricter reading avoids reopening a TOCTOU surface.

Reported in review of nolabs-ai#1389.

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
@vjeantet
vjeantet force-pushed the feat/static-registry branch from ef43ca9 to 47dffd8 Compare August 8, 2026 17:39
vjeantet added a commit to vjeantet/nono that referenced this pull request Aug 10, 2026
The zero-network guards in the migration provider lookup and the pack
update hint read config.registry.verify straight from config.toml. That
skipped the rest of the precedence chain, so an operator who opted into
the air-gapped posture with NONO_REGISTRY_INSECURE=1 alone still paid a
background request to a registry that cannot answer it. Route both
through resolve_registry, the single place that folds the flag, the env
var and the config together.

Not a weakening of any check: disabling verification stays an explicit
operator decision, and nothing that was verified before is skipped now.

Also skip non-directories in highest_published_version. A stray file
named like a semver next to the real version directories could win the
comparison, and the follow-up read of versions/<latest>/pull would then
fail with ENOTDIR. file_type() is an lstat, so a symlinked version
directory is rejected too - the tree is generated by publish-static
itself, and the stricter reading avoids reopening a TOCTOU surface.

Reported in review of nolabs-ai#1389.

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
Enable air-gapped macOS fleets to distribute and update nono profiles,
Claude Code skills/plugins/hooks, and arbitrary enterprise files from an
internal static (nginx) registry, with signature verification disabled
and integrity guaranteed by per-artifact SHA-256.

- Add a `[registry]` config.toml section (url + verify, fail-secure true)
  and a `resolve_registry` helper with precedence
  --registry > NONO_REGISTRY > config > default; verify off via
  --insecure flag, NONO_REGISTRY_INSECURE, or [registry].verify=false.
- Pull path: when verify is off, skip Sigstore bundle download/verify,
  namespace assertion, and subject-membership; keep the SHA-256 check.
  Record an `unsigned:internal-registry` signer sentinel in the lockfile
  and write no `.nono-trust.bundle`.
- Run-time guard: `verify_profile_packs` skips bundle re-verification for
  packs whose lockfile signer is the sentinel, keyed on the lockfile so
  signed packs always get full re-verification. SHA-256 still enforced.
- Make PullResponse tolerant of the minimal static JSON (optional
  provenance, default bundle_url/scan_passed/size_bytes).
- Suppress registry phone-home (update hints + migration provider lookup)
  when verification is disabled.
- Add `nono pack publish-static` to emit the static registry tree
  (versions/<v>/pull, versions/latest/pull, status, files/...) reusing
  the serde types, with `latest` tracking the highest published semver.
- Document the air-gapped/internal-registry workflow.

Adds unit tests (registry precedence, fail-secure verify, minimal
deserialization, generator round-trip, latest tracking, sentinel guard +
tamper detection).

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
Show an air-gapped GitLab pipeline that seeds the docroot from the nginx
host, runs nono pack publish-static to add the current version, then
rsyncs back. Seeding keeps latest/status at the true highest semver and
makes rsync --delete safe.

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
Pulling from a static or internal registry without --insecure failed with
"failed to parse bundle: JSON error: expected value at line 1 column 1".
publish-static emits an empty bundle_url by design, and handing that empty
URL to the bundle loader surfaced the JSON parser's error instead of the
actual situation. This is the first thing an operator hits when pointing
nono at an internal registry, so the message mattered.

Return NonoError::PackageVerification from download_and_verify_artifacts
when verification is requested but the pull response advertises no bundle,
before any network round-trip. The message names the cause and both ways
out: --insecure for a single command, [registry] verify = false fleet-wide.

Security behaviour is unchanged: the install is still refused and nothing
is written. Only the diagnostic changes. The guard is gated on `verify`, so
an absent bundle remains the expected shape for unsigned installs.

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
The zero-network guards in the migration provider lookup and the pack
update hint read config.registry.verify straight from config.toml. That
skipped the rest of the precedence chain, so an operator who opted into
the air-gapped posture with NONO_REGISTRY_INSECURE=1 alone still paid a
background request to a registry that cannot answer it. Route both
through resolve_registry, the single place that folds the flag, the env
var and the config together.

Not a weakening of any check: disabling verification stays an explicit
operator decision, and nothing that was verified before is skipped now.

Also skip non-directories in highest_published_version. A stray file
named like a semver next to the real version directories could win the
comparison, and the follow-up read of versions/<latest>/pull would then
fail with ENOTDIR. file_type() is an lstat, so a symlinked version
directory is rejected too - the tree is generated by publish-static
itself, and the stricter reading avoids reopening a TOCTOU surface.

Reported in review of nolabs-ai#1389.

Signed-off-by: vjeantet <valere.jeantet@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Static internal pack registry (nginx-servable, unsigned integrity tier)

1 participant