3 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 1218d74bb8 feat(deps): evaluate a dependency's version range, and do not mask a deeper mismatch (#38)
* feat(deps): evaluate a dependency's version range instead of only carrying it

resolveDependencies called build(dep.name) and discarded dep.version one
character from where it was needed, so a manifest declaring
{"name":"lib","version":"^2.0.0"} with lib 1.0.0 installed reported
"installed". The range travelled intact from metadata.json through the .lgx,
`lgx verify` and `lgpm install` onto disk, and was asked a question nowhere.

build() now takes the whole PackageDependency, because the range and the
signer live on the EDGE rather than on the package and so have to travel with
the recursion, and compares it with logos::semver::satisfies -- already
linked, already called at :114. This was a wiring gap, not a missing
capability; no new semver, no build-system change. A dependency installed at
a version its dependant refuses now reports DependencyStatus::VersionMismatch.

Precedence is deliberate: ABSENCE OUTRANKS MISMATCH. A range can only be
judged against a version we actually have, and "install it" is the remedy
either way, so a dependency that is both absent and constrained still reports
not_installed -- the stronger fact, and the one the user can act on. Naming
the weaker one would point at the wrong fix. Either way the declared range
rides along on the node, so a caller can say WHICH version to install.

An unparseable range is treated as unsatisfied rather than ignored: silently
dropping a typo'd range would fail open, and lgx verify already rejects the
syntax upstream, so a manifest reaching us with one bypassed that gate.

The signer is carried as data and compared by nobody -- who may sign a
dependency is a trust decision that does not belong to the scanner.

VersionMismatch is APPENDED to the enum, never inserted:
logos-package-manager-module compiles against this header and links
libpackage_manager_lib at run time, so existing enumerator values are ABI.
Both new node fields are omitted from the JSON when absent, so a tree of
bare-name dependencies -- every package in the workspace today -- serialises
byte-identically to before.

Tests 137 -> 148. Red on the base, asserted through dependencyStatusToString
so the probe compiles without the new enumerator:
  Expected: "version_mismatch"  Which is: "installed"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(deps): pin what a diamond does to the flat dependency list

flatten() is the only projection resolveFlatDependencies -- and therefore
basecamp's load gate -- ever reads. It dedupes by name with first-wins, and
BFS reaches the depth-1 edge first, so when a package is named BOTH directly
(bare, as every manifest in the fleet does today) and by a dependency that
constrains it, the unconstrained edge wins and the mismatch is dropped.

Red on b7e2280, measured on the real module over real IPC first:
  resolveFlatDependencies appA true  -> lib "installed"      (masked)
  resolveDependencies     appA true  -> lib "version_mismatch" (tree is right)

Three cases: the diamond, the same graph with the root's entries swapped
(declaration order must not decide it), and a satisfied diamond as the
control that a duplicate is not by itself a mismatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deps): a deeper version mismatch must not be masked by a shallower edge

flatten() deduped by name with first-wins. BFS reaches the shallowest edge
first, so when a package is named BOTH directly by the root -- bare, which is
what every manifest in the fleet does today -- and by a dependency that
constrains it, the unconstrained edge won and the rejection was dropped.

Measured on the real module over real IPC, one installed tree, two calls:

  resolveDependencies     appA true -> helper -> lib "version_mismatch"
  resolveFlatDependencies appA true -> lib "installed"

The tree was right the whole time; the flat projection lost it. That matters
because the flat list is the ONLY one resolveFlatDependencies returns and the
only one basecamp's load gate reads, so a transitive mismatch admitted the
load with no diagnostic anywhere -- the same shape as the bug this series set
out to fix, one projection further down.

A package satisfies its dependants only if it satisfies ALL of them, so a
later edge that rejects what an earlier edge accepted now promotes the row and
carries its range. One row per package is unchanged: this rewrites the row it
already has rather than appending. Only Installed -> VersionMismatch is
promoted; the other statuses are properties of the package (absence is absence
on every edge) or structural (Cycle), and no second edge can contradict them.

Red on 991d092, a runtime failure rather than a compile error:
  DeeperMismatchIsNotMaskedByAShallowerBareEdge        it->status: installed
                                                       expected: version_mismatch
  DeeperMismatchSurvivesRegardlessOfDeclarationOrder
Green: 151 -> 151 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(deps): record who published an installed package, and compare a pin to it

A `signer` pin was carried across the whole system and evaluated by nothing.
The comment that said so gave the reason:

    `requiredSigner` is carried as data only — nothing in this library compares
    it, because who may sign a dependency is a trust decision that does not
    belong to the scanner.

That is backwards, and it conflates two different questions:

    trust anchor : does any active anchor vouch for this key?  -> may it be
                                                                  installed
    signer pin   : is this the key the dependant named?        -> is it the
                                                                  same package

The first is authorization. It belongs to the trust-anchor gate in
installPluginFile, against the local keyring, and this walk still consults no
keyring. The second is IDENTITY: a pin is what separates `my_module` published
by me from `my_module` published by anybody else, and when it is present it is
as load-bearing as the name. Resolving identity is exactly what the scanner
does. Skipping it because the trust check lives elsewhere did neither.

THE BLOCKER: nothing recorded who published an installed package. Proven by
positive control — sign a package, anchor its key, install under
--require-signatures, then grep the install tree for did:jwk / signer /
signature: nothing. Every signer string in an install tree was a PIN some
dependant declared. The .lgx is deleted after extraction, so install-time
verification is the one and only moment anybody knows who signed it.

So this records it, following the `variant` sidecar precedent exactly: a
single-line, non-payload file dropped into the extracted variant directory,
copied into the install directory by the same copy, read back at scan time by
readInstalledSigner — the mirror of readInstalledVariant.

WHAT COUNTS AS AN OBSERVATION is a rule, not an inline condition, so it can be
tested: PackageManagerLib::observedSignerFrom requires `signature_valid`, never
`is_signed`, and never `signer_did` alone. logos-package sets signer_did from
manifest.sig BEFORE the Ed25519 check, so until it passes the DID is a CLAIM,
and anybody can write any DID into a manifest.sig. An unsigned package records
nothing; one whose signature failed records nothing; both read back as
"nothing recorded", which is the honest answer in each case.

That the rule is a separate function is deliberate. Today's trust-anchor gate
refuses a failed-signature package before the write site sees one, so
`signature_valid` and `is_signed` produce the same installed tree and no
end-to-end test can tell them apart — measured: a build with the rule weakened
to `is_signed` passed every end-to-end test in the new file. WARN means "warn,
do not refuse"; the day it stops refusing this case, a claim-based rule starts
minting publisher identities from attacker-written strings.

THE DESIGN CALL — a pin present, and the installed package has NO record.

Measured before choosing (whole workspace): 110 metadata.json, 65 declared
dependency edges, ZERO object-form entries — so zero signer pins and zero
version ranges — zero manifest.sig anywhere, and the shared release workflow
publishes `signing_mode: none`. The number of packages either option changes
TODAY is zero, under both. The choice therefore does not turn on blast radius;
it decides whether pins can be adopted at all.

Chosen: LENIENT — its own status, DependencyStatus::SignerUnknown, which does
not claim a mismatch it cannot prove. The decisive fact is EMBEDDED packages:
they are placed by the build and never pass through installPluginFile, so they
can NEVER acquire a record. Under a strict reading, a pin on an embedded
dependency is unsatisfiable by construction, forever, with no action a user
could take. That is not a strict policy, it is a broken one. It is also a
different sentence to say: "this is a different publisher's package" is an
accusation, "nobody recorded who published this" is a gap.

The flip is one line: UnknownSignerPolicy on the enum, `setUnknownSignerPolicy
(::Strict)` per instance, or change the member initialiser build-wide. Strict
makes the scanner emit SignerMismatch instead, so every downstream consumer
fails closed without a change of its own.

RANKING. One edge can fail more than one constraint, and two edges to the same
package can disagree, so both questions now go through one authority,
edgeVerdictSeverity:

    Installed(0) < SignerUnknown(1) < VersionMismatch(2) < SignerMismatch(3)

with absence outranking all of it (returned before any of this runs). Missing
evidence ranks BELOW a definite failure so "we could not tell" never masks an
actionable rejection. Identity ranks above a range because a range means
nothing until you know which package you are ranging over: "requires ^2.0.0,
found 1.0.0" sends a user hunting for a newer build of a package that is not
theirs at any version.

flatten()'s promotion is rewritten in terms of that ranking. It was the single
pair `Installed -> VersionMismatch`, which would have gone on silently dropping
a deeper SIGNER mismatch exactly the way it once dropped a deeper version one —
in the only projection basecamp's load gate reads.

Also here, because both are the same defect one notch out:
  - nodeResolvedToAnInstalledPackage replaces `Installed || VersionMismatch` at
    the serialisation sites. That chain named the statuses that existed when it
    was written, so the moment these two were appended it would have started
    blanking `version` on packages sitting on disk.
  - `lgpm info` and `lgpm --json info` report the signer, so the rest can be
    verified without a debugger. Absent prints "(not recorded)", never
    "unsigned" — nothing on disk records that distinction.

SignerMismatch warns on stderr once, where it is decided. SignerUnknown does
not: it is the expected state for every embedded package and every pre-sidecar
install, and a warning that fires on the normal case trains readers to ignore
the channel.

test_dependency_resolution's SignerIsCarriedButNotEvaluated asserted the old
behaviour and is rewritten, with the reasoning it encoded and why it was wrong.

RED (both behaviour sites reverted to "carried as data only"): 12 failed / 173
RED (recording rule weakened to the CLAIM, `is_signed`):         2 failed / 174
    ObservedSignerTest.OnlyAVerifiedSignatureCountsAsAnObservation
    ObservedSignerTest.ForgedSignatureRecordsNothingNotTheClaimedDid
GREEN: 174/174
    /nix/store/5y2kn2bapcyh6caskrawbv0dv1pmwpws-logos-package-manager-tests-1.0.0-dev

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deps): the observed-signer record must be evidence, not a leftover

The sidecar said who published an installed package, but two things other
than the verifier could put a value there, and both let a package wear an
identity nobody checked:

  1. THE PACKAGE PLANTS IT. `variants/<v>/` is copied wholesale into the
     install directory, so a package author picks filenames that land there.
     An UNSIGNED package shipping a file called `signer` holding somebody
     else's DID was reported as published by them. No key, no signature, no
     keyring — a `signer` pin satisfied by naming the answer.
  2. THE PREVIOUS RECORD OUTLIVES ITS PACKAGE. copyDirectoryContents MERGES
     into an existing install directory rather than replacing it, so a second
     install that verified nothing left the FIRST install's sidecar standing
     and an impostor inherited the identity of whatever it replaced.

Both were reachable end to end: an unsigned my_module reported `installed`
against a pin on a key that never touched it.

The recording is now TOTAL and happens AFTER the copy, in the install
directory: write on an observation, DELETE when there is none. Whatever the
package shipped and whatever the last install left are overwritten or
removed, so what remains is this install's own finding. Absent still means
"nothing was verified", which stays a different fact from "unsigned".

Both attacks now land in the already-documented Lenient residual — reported
SignerUnknown rather than claiming an identity — so the pin can no longer be
satisfied, only left unproven.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deps): a planted signer record shipped read-only survives on Windows only

The authoritative rewrite assumed it could always delete or reopen the
sidecar. A payload file arrives with the package's own permissions, and
everything in the Nix store is 0444 — nix-bundle-lgx copies a module's
`icon:` straight out of it, so shipping a read-only file is ordinary, not
exotic.

POSIX consults the PARENT DIRECTORY's write bit to unlink, so the remove
succeeds there and the fresh write lands. Windows refuses to delete a
FILE_ATTRIBUTE_READONLY file and refuses to reopen it for write, so a
planted `signer` shipped 0444 would have survived on exactly one platform —
the same asymmetry clearReadOnlyRecursive already exists for, and the same
one that made every Windows uninstall fail with "Access is denied".

Clear the bit before touching the file, and fall back to TRUNCATING rather
than a second delete: readInstalledSigner already collapses an empty file to
"nothing recorded", so emptying a file that will not unlink still erases the
claim. Both failure paths now warn instead of passing silently.

The accompanying test cannot go red on Linux and says so in as many words —
deleting there never consults the file's own mode. It discriminates on
Windows, which is where the defect lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deps): a signer pin is answered by verifying, not by trusting a record we wrote

The `signer` sidecar this replaces recorded the DID install had verified. It
was an ASSERTION BY THE INSTALLER, believed because the installer wrote it, and
three forgeries worked against it — each needing its own defence:

  A1  a package plants its own record. variants/<v>/ is copied wholesale, so an
      author ships a file named `signer` holding the pinned DID. No key, no
      signature, no keyring, pin satisfied.
  A2  the record outlives its package. copyDirectoryContents merges and never
      clears, so an unsigned reinstall inherits the previous publisher's name.
  A3  Windows. Everything in the Nix store is 0444, so a read-only plant is
      ordinary, and a bare fs::remove cannot clear one.

Evidence that needs defending is not evidence. All of it is gone: the sidecar,
its write site, its read-back, observedSignerFrom, the total write, the
read-only clearing and the truncate fallback.

What replaces it was already in the package and was being thrown away.
Package::signPackage signs manifest_.toJson(); extractLgxPackage writes
manifest.json from lgx_get_manifest_json(), which returns the same expression,
so the installed manifest IS the signed message, byte for byte, by
construction. Only the signature was discarded. It is now carried alongside.

At resolve, THE PIN SUPPLIES THE KEY: did:jwk embeds an Ed25519 public key, so
the pinned DID yields a key with no keyring involved, and the installed
signature is checked over the installed manifest under THAT key. The DID
written inside manifest.sig is never consulted. Reading it, comparing it to the
pin and verifying with that same key would prove only that the file agrees with
itself; a relabelled document — somebody else's genuine signature wearing the
publisher's name — passes that and is refused here.

None of A1, A2 or A3 survives, and nothing recognises them. A planted,
inherited or unclearable manifest.sig is just a file that does not verify.
A2 in particular cannot work any more even in principle: the manifest carries
the Merkle root over the payload, so a signature cannot describe other bytes.
A3 stops being a hazard at all because the signature is written during
EXTRACTION, and Package::extractVariant re-adds owner_write to everything it
writes — the trap existed only because the sidecar was written after the copy.

`observedSigner` is renamed `signerDid`, on the package and on the wire. It no
longer records an observation: it reports what the installed signature says of
itself, once checked against the key its own DID carries — enough to display,
and deliberately not enough to settle identity. The verdict does not come from
comparing it to anything.

Unchanged: SignerUnknown/SignerMismatch, the severity ranking, and the Lenient
default. The argument for Lenient survives the new mechanism intact — embedded
packages never pass through installPluginFile, so they can no more carry a
manifest.sig than they could a sidecar, and under Strict a pin on an embedded
dependency would be unsatisfiable by construction forever.

Needs logos-package feat/carry-the-manifest-signature for
lgx_get_manifest_sig_json / lgx_check_manifest_signature; flake.nix is pinned
to that branch and must go back to the bare URL once it merges.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(deps): say why the resolve path does not re-hash the payload

Carrying the signature makes tamper-evidence newly possible — the signed
manifest holds a Merkle tree over the package contents, so a verified signature
establishes what the payload should hash to. Writing down why resolve does not
do it, since the next reader will notice the same opening.

Cost is the smaller half: the signature check is ~450us and constant in payload
size, re-hashing is linear (~2.4ms for a 2.6 MB module, ~47ms for 50 MB, before
I/O) on a walk basecamp runs per refresh.

The correctness half is the real one. The Merkle tree is over TAR ENTRY PATHS,
and the install tree is flattened and added to: variants/<v>/x.so becomes
<name>/x.so, assets/ merges into the same directory, and extractLgxPackage
synthesises manifest.json, manifest.sig and `variant` there. On a real package
the variant leaf hash covers ONE file while the install directory holds four.
Re-deriving it means re-prefixing paths and excluding exactly the files install
invented, and getting that subtly wrong accuses a good package of tampering.

That belongs in a deliberately-tested verb, run on demand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(sig): write the installed manifest in BINARY — it is the signed message

installPluginFile wrote manifest.json through a text-mode ofstream. On Windows
that translates '\n' to "\r\n", so the bytes on disk stop being the bytes that
were signed.

The translation is not cancelled on the way back: readFileBytes() at :620
opens BINARY, so the resolve path reads the CRLF form and compares it against
a signature over the LF form. Every check returns MISMATCH — on packages the
pinned key genuinely signed.

Worst-case shape for a defect: silent (no error, just a wrong verdict),
wrong-cause (the user is told the publisher does not match when it does), and
Windows-only, so nothing in CI would ever show it.

Package::signPackage signs getManifest().toJson(); lgx_get_manifest_json()
returns that same expression; this write puts it on disk. All three have to
agree byte for byte or the whole mechanism is inoperative.

The added test asserts the installed manifest carries no carriage return.
Stated in the test itself: on Linux that assertion CANNOT fail, because there
is no translation to do — it guards the platform it names, and a green Linux
run is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deps): a malformed constraint must fail closed, not vanish

F2. The parser gated reading on is_string(), so `{"signer": 42}` produced no
pin at all — the dependent asked to be narrowed and got no check, which is
indistinguishable downstream from never having asked. Fail-OPEN on the two
fields whose entire job is to narrow what satisfies an edge.

It now carries the raw text. That cannot parse as a semver range or as a
did:jwk, so it lands as VersionMismatch / SignerMismatch and the operator sees
the offending value instead of silence.

`lgpm install` cannot reach this — logos-package rejects both shapes first —
so the reachable sources are hand-edited and build-time embedded manifests.

F3 IS DELIBERATELY NOT FIXED, and the reasoning is recorded at the site.

The audit called it a wrong-CAUSE message: an unsigned reinstall over a signed
package leaves the previous publisher's manifest.sig behind, because
copyDirectoryContents merges, and the operator is told the publisher does not
match when the truth is that this package has no publisher.

That is accurate, but deleting the stale file trades a wrong message for a
wrong VERDICT. Measured, both directions:

  stale sig kept    -> cannot verify against the rewritten manifest
                    -> SignerMismatch -> BLOCKS
  stale sig removed -> SignerUnknown -> does NOT block under Lenient

So an unsigned impostor replacing a signed package would start passing. That
case is a publisher DOWNGRADE with positive evidence for it, which is what
separates it from the never-signed packages Lenient exists to tolerate.
Removing it also broke A2_AStaleSignatureFromThePreviousInstallIsRefused, for
a real reason rather than a stale expectation.

The defect is in the REPORTING layer: "the installed signature does not verify
against this package" and "this package names a different publisher" need to
be different sentences. That is a separate change and does not belong here.

Also reverts the logos-package branch pin to master, now that
logos-co/logos-package#36 has merged.

184/184.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: trim the comments added by this PR

Cut the added comment lines from 816 to 365 and the largest block from 82
to 13. Removed narrative, history, ASCII rules, and points restated in
three registers; kept the non-obvious mechanisms, the load-bearing
orderings, the measured numbers, the deliberate refusals, and the honest
test limits. No code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: drop the stale branch-pin note

The pin it describes is gone — logos-package.url went back to the bare URL
when logos-co/logos-package#36 merged. flake.nix is now identical to master;
only flake.lock differs, carrying logos-package master with
lgx_get_manifest_sig_json / lgx_check_manifest_signature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:54:48 -03:00
Dario Gabriel LipicarandClaude Opus 5 0160d06772 fix: an object-form dependency lost the edge itself, not just its constraint
The manifest scan read dependencies as

    if (d.is_string()) { ...push_back(name)... }

with no else, so {"name":"lib","version":"^1.2.0"} was dropped ENTIRELY —
not merely stripped of its constraint. The edge never entered the graph.

That corrupts resolveDependencies and resolveDependents, and through them
everything derived from the graph. The user-visible harm, A/B'd against a
binary built from origin/master:

    MASTER:  lgpm dependents lib -> "No direct dependents of 'lib'"
    FIXED:   lgpm dependents lib -> "app"

Master says nothing breaks if you uninstall `lib`. The same silence reaches
basecamp's uninstall plan (UninstallPlan.cpp:125), logosctl's headless
equivalent (package_ops.cpp:178), and the derived caller allowlist — where a
dropped edge means a legitimate caller is DENIED under an enforcing access
policy.

Both forms are now accepted. `dependencies` stays vector<string> and gains a
parallel `dependencyConstraints`, deliberately rather than widening the
element type: logos-package-manager-module's test stub hand-mirrors this
struct without including package_manager_json.h, and widening would have
broken it silently.

Also fixes the same failure one notch down: a MALFORMED entry was a silent
`continue`. It now warns, with the empty-name case folded in, so 42, null,
{}, {"name":7} and {"name":""} all produce one diagnostic.

Nothing in the workspace authors object-form dependencies today, which is
exactly why this survived — see the PR for why that is itself a defect.

Proven: 121/121 pass; reverting only the .cpp turns six of them red, on the
EDGE rather than the constraint (`children.size()` is 0, expected 1). Three
invariance guards hold on both sides of the fix. Against master's binary,
plain-string manifests — which is every real package and both doctest specs
— produce byte-identical `list`, `info`, `deps` and `dependents` output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 20:47:36 -03:00
Dario Lipicar 9101875bc1 implement uninstall, dependencies and dependents resolution (#10) 2026-04-16 18:21:02 -03:00