Spec M48 -- packages, second evolution: mclib, tools, [[permission]], and the NuGet-like registry
Owner's five statements (2026-09-06, Portuguese), read as: (1) lib/ is ONE package, mclib, released with the compiler's own version; (2) tools are packages of kind = "exe" that mc tool install compiles on the developer's machine, run in the sandbox under permissions the package requests and the developer confirms; (3) libraries are kind = "obj", tools kind = "exe", the registry detects the kind from the manifest inside the published archive, and the site crosses dependencies, downloads and the rest, NuGet-style; (4) [[tool]] states tool dependencies, [[permission]] states permissions for any kind, and installing either shows the permissions and the sub-dependencies, which the server records. Written against minicompiler/mc main at da506ce (v0.15.8) and minicompiler/mc-registry at 362ed5f (branch registry-alias; not checked out or pulled here -- read-only session).
Amendment (owner, 2026-09-06):
[tools]is a key table, not[[tool]]. The owner wrote[[tool]]on the assumption that dependencies were arrays; asked for "the best form", tool dependencies take EXACTLY the shape[deps]already has --[tools]withname = "minimum"rows -- so a project reads one way for both kinds of dependency and the lock/index need no second grammar.[[permission]]stays an array of tables (a row has several keys).[package].binstays. Every[[tool]]below reads as[tools]; the index/lock keytoolskeeps the"<name> <minimum>"string form, which is[deps]'s.
Amendment (owner, 2026-09-06): no
--unconfined. The behaviour is the same on every host.mc tool install NAME --yesprints the same plan and the same permission table and accepts them the same way on macOS, Windows and Linux;mc tool runboxes the tool where the host has a sandbox and runs the binary directly where it has none. The only host-specific thing is ONE informational line in the plan on a host without a box (note: this host has no sandbox; the permissions above are recorded, not enforced), and the install manifest recordssandbox = true|falseas a factmc tool listshows. No flag, no exit 2, no second road; when a host gains a sandbox (M43 Layer 2 or later) the same install runs boxed with no re-install. § 3.4's "macOS and Windows" paragraph, D7's last clause and risk 11 read accordingly.
0. What this stands on (read, not assumed) #
- The index reader ignores unknown keys.
pkg_index_read(src/pkg.mc:288-328) copies exactlyversion url strip sha256 yanked depsout of a[[versions]]row andname repoout of[package];toml_parsefills a flat table and every consumer asks for the keys it knows. So a row that gainskind,permissions,tools,binorlicenceis read by every compiler shipped so far (0.15.x) exactly as before. The same holds for the lock (dep_read_lock,src/deps.mc:777-837:name version sha256 lib deps) and for a package'smc.toml([package].checkalready rides this:docs/reference/packages.md§ 3, registry § 22.1). - The plan is the prompt.
mchas noisatty;sync|add|updateprint what they would download and stop without--yes(M44 D13,pkg_sync,src/pkg.mc:794-889). A permission confirmation has to be the same shape or it is a second convention. - The lock writer takes the tree's manifest as the truth for
libanddeps(pkg_write_lock,src/pkg.mc:721-787); the tree hash coversmc.tomlfirst (dep_hash_tree), so anything the index says about a manifest is verifiable by the hash a consumer already recomputes on every build (D7). - The sandbox's surface (
docs/reference/sandbox.md):mc sandbox exec BINruns one step in a box with/srcan overlay of the binary's directory,--ro DIRbound read-only at/roN(sb_bind_ro_dirs,src/sandbox_box.mc:171-181), Landlock rules per root (sb_landlock_apply,src/seccomp.mc:303-340:/src/outfull,/roNLL_READ_ONLY, net field handled and granted to nobody at ABI >= 4), an empty netns, a measured seccomp allowlist with the four process-creating calls always notified and counted (0 for a run step, 16 for a compile, 64 with--allow=threads), an environment ofHOME=/src PATH=/.--allow=accepts onlythreads(sb_parse,src/sandbox.mc:860-866); there is no--rw, no--allow=net, no way to bind an executable or pass an environment variable. On macOS and Windows every verb refuses with exit 126 (host_sandbox_supported()). - The registry's validator (
web/job.mcjb_do_tag,:914-1201): clone the tag, refuse submodules and >64 MiB, read the tag'smc.tomlwithweb/manifest.mc(a reader of[package] name description lib files check,[deps], and whether[project]and itskindexist --mf_parse,:233-305),mc pkg hash, one box percheckunit as akind = "obj"project that vendors the checkout underdeps/<name>/(jb_check_mc_toml), a second box running the package's own[project]when it has one, thensql_version_insert(11 columns:repo_id version tag commit_sha tree_hash deps lib prerelease url release_url sort_key). The row'surlis the forge's tag archive (fg_tag_archive_url,web/forge.mc:233),strip = 1. A repository is identified by its forge triple, UNIQUE (repos_forge_key,db/004-registration.sql), so one repository is onereposrow today. - The site (
web/pages.mcpages_one,web/templates/package.html): name, description, the[deps]line to copy, the source link, a versions table (version, published, tree hash, requires, yanked/advisory), the report link, the index URL. No kind, no permissions, no dependents, no counters (§ 13 of the registry spec dropped counters when hosting was dropped). - The globals budget: the seed's
MAXGLOBALSis at 454/512 (88%) (CLAUDE.md, thesource_claimentry);scripts/check-limits.shfails at 90% = 460. Every table added since M43 went into one arena record behind an accessor (sb_state, the one global of that milestone;pkinpkg.mc;dp/dp_libs_optindeps.mc;fxinfetch.mc).src/driver.mcstill holds nine file-level globals (cfg_file drv_sdk_cache drv_os drv_arch drv_stubs_cache drv_bname drv_static drv_lim_mode drv_tol,:65-139). - Downloader measurement, this host,
curl --proto '=https' --proto-redir '=https' -fLsS(the flagsfetch_getpasses,src/fetch.mc:139-148): a release asset (releases/download/v0.15.8/...) answers 200 after one redirect tohttps://release-assets.githubusercontent.com/...?<signed>; the tag archive answers 200 after one redirect tocodeload.github.com;releases/latest/download/...after two. Both hops are HTTPS, so the flags admit them. The sysroot fetcher has been through the same road since M25. - Two names in M44 that this spec has to touch: the amendment's B4/B5 give the slim binary's library archive the name
mc-libs-<ver>.tar.gz(the WHOLEmcpackage:src/,lib/,bundle.list). Steps 4-5 are not built (src/install.mc,src/upgrade.mcdo not exist), so the name is free to reassign; § 2 does, and says whatmc installfetches instead. - Names.
[a-z][a-z0-9_]*(dep_name_ok), so no hyphen: the LSP's package ismc_lsp; a binary name may carry a hyphen (§ 3). The registry reservesmc*/minicompiler*for the administrator (pkg_reserved_prefix), somclibandmc_lspare admin registrations throughmcreg-admin register(web/admin.mc:297,allow_reserved = 1). - teko's manifest (as reported by the owner:
[package] name="teko" lib="lib/rt.tk" check=[...], no local copy found under~/projects/teko-*) carries no[project], likemc's root manifest and everytests/pkg/src/*fixture (geo,mathx,teach,float,plot,heavy,bad). The.tkextension is served as written (libs_opentriesrestbeforerest + ".mc").
1. The manifest #
1.1 The kind rule #
the package's mc.toml | kind | who it is |
|---|---|---|
[package] and no [project] | lib | every manifest published or fixtured so far: mc, teko, geo, mathx, teach, float |
[package] + [project] kind = "obj" | lib | a library that also builds an object of its own (rare; allowed) |
[package] + [project] kind = "exe" | tool | a program mc tool install builds and runs |
[package] + [project] with no kind line | refused by the validator (a package's [project] must say kind = "obj" or "exe") | the driver defaults kind to exe (docs/reference/toml.md § [project]), and a library that carried a [project] for its tests would be silently classified as a tool. No published package has this shape, so nothing breaks |
kind is a property of the name: the validator refuses a version whose kind differs from the first published version's (refused: this package is a lib; a tool cannot become a library, an attempts row). The compiler reads [project].kind exactly where it does today (drv_run, src/driver.mc:775) and nowhere new: for the consumer, the kind arrives in the index row and in the lock (§ 4.3).
1.2 The binary name: [package].bin, not a [tool] table #
The consolidated reading pairs a [tool] table (the binary's name) with [[tool]] rows (dependencies). TOML forbids that: a table and an array of tables may not share a name (TOML 1.0 § Array of Tables), and while src/toml.mc's flat table would take both (tool.name beside tool.0.name), the registry's reader (mf_header treats every header as a table) and every editor's TOML mode would call the file invalid. The owner's own words name only [[tool]] and [[permission]]; the [tool] table was the reading's addition. Decision:
[package]
name = "mc_lsp"
bin = "mc-lsp" # optional: the name in ~/.mc/bin; default = basename of [project].out
bin is [a-z][a-z0-9_-]*, at most 32 bytes (a hyphen is allowed here and nowhere else, because it is a file name and not an identifier). It is read by mc tool install and by the registry; mc build ignores it.
1.3 [[tool]] -- dependencies on tools #
[[tool]]
name = "mc_lsp"
version = "0.1.0" # a minimum, like [deps]
Allowed in a project and in a package. Each name must be a registered package of kind tool (§ 5.2). In a project, mc pkg sync resolves them in the same MVS graph as [deps] (a tool's own [deps] are libraries) and writes them into the lock with kind = "tool"; they register no include root (a tool is not compiled into the consumer) and are what mc tool install with no argument installs (§ 3.2). In a package they are transparency for now: the registry records and shows them, the validator checks they are registered, and nothing runs them during validation (a package whose test needs a tool is the priced follow-up in § 7).
Why an array of tables and not a [tools] key table like [deps]: the owner asked for [[tool]], and a row has room for a second key later (optional = true, or a bin override) without a second spelling.
1.4 [[permission]] -- grammar, defaults, workspace, paths #
[[permission]]
kind = "fs.read" # fs.read | fs.write | net | exec | env
path = "workspace" # fs.*: workspace | workspace/<rel> | home/<rel> | tmp
name = "mc" # exec: a program's basename; env: a variable name
reason = "reads the sources it is asked about" # optional, <= 120 bytes; the site shows it
| kind | required key | meaning, in the words the developer reads at install |
|---|---|---|
fs.read | path | may read files under PATH |
fs.write | path | may create, change and delete files under PATH |
net | -- | may open network connections (any host, any port -- § 7.1 says why nothing finer) |
exec | name | may run the program NAME found on your PATH |
env | name | may read the environment variable NAME |
Paths. No absolute path is ever accepted: the validator cannot verify one, it leaks a host's layout into a manifest, and the box cannot promise what it maps to. The four forms are:
workspace-- the directory the tool is invoked from (§ 3.5 says exactly which);workspace/<rel>,home/<rel>--<rel>obeysdep_rel_okas it stands (src/deps.mc:383: no leading/, no./../empty component, no backslash, no byte below 0x20, none of the seven Windows-reserved characters). One rule for every path in every manifest -- the containment reuse the brief asks for; the registry's transcriptionmf_rel_ok(web/manifest.mc:341) applies the same rule server-side;tmp-- a scratch directory that exists for the process and dies with it.
Defaults. No [[permission]] means stdio only: a tool reads its stdin, writes its stdout and stderr, sees its own install tree read-only, and nothing else. A library with no rows declares that code using it needs nothing beyond what the program itself has.
Canonical one-line form (the lock, the index row, the install table): fs.read workspace, fs.write workspace/build, fs.read home/.cache/mc-lsp, net, exec mc, env HOME. Duplicates collapse; the set is sorted bytewise wherever it is written (rule 2 of docs/determinism.md: a total order over unique keys). reason is not part of the canonical form -- it is shown on the site and by mc pkg list --long, never hashed into a decision.
1.5 [package].test -- what the validator RUNS #
check (§ 22.1 of the registry spec) names units the validator compiles. Nothing today names a unit it runs except "the package's own [project]", and once [project] kind = "exe" means tool, that second box would run an LSP with no arguments. So:
[package]
check = ["float_rt.mc"] # compiled, one box each (unchanged)
test = ["tests/smoke.mc"] # compiled AND run, one box each, under the derived profile (§ 4.1); exit 0 = pass
test entries obey check's rules (package-relative, dep_rel_ok, listed in files). The compiler ignores the key. It is what makes "a package doing more than it declares is refused by name" a measurement and not a sentence: the test program runs inside the profile derived from the package's own [[permission]] rows, and a refused: line is a refused publication. The validator's "second box over the package's [project]" is retired in favour of test for both kinds (for a tool the [project] is built in a box, § 5.3).
1.6 [package].licence #
An SPDX identifier string ("MIT", "Apache-2.0"), at most 64 bytes of [A-Za-z0-9.+-], or absent. The registry stores and shows it; the compiler ignores it. Not validated against the SPDX list (no list to ship); shown as written, escaped.
1.7 What each side reads #
| key | compiler | registry |
|---|---|---|
[package] name files lib module | reads (unchanged) | reads |
[package] check test licence description | ignores | reads |
[package] bin | mc tool install reads | reads |
[project] kind | mc build reads (unchanged); mc pkg/mc tool read it to classify a fetched tree | reads (the kind rule) |
[deps] | reads (unchanged) | reads |
[[tool]] name version | mc pkg sync, mc tool install read | reads, checks registered |
[[permission]] kind path name | mc pkg sync|add, mc tool install|run read | reads; derives the box profile |
[[permission]] reason | mc pkg list --long prints | shows |
1.8 Backward compatibility, manifest by manifest #
mc's root manifest (/Users/schivei/projects/mini_compiler/mc.toml): no[project], no rows ->lib, permissions[], unchanged bytes, unchanged hash.geo/mathx/teach/floatfixtures: the same;tests/pkg/*/mc.lockfiles stay valid (a row withoutpermissionsis read as "nothing accepted yet", § 4.3) andscripts/check-pkg.sh§ 4's byte-identicalmc.lock.expectis re-recorded once when the writer gains the two keys.- teko: no
[project]->lib;lib = "lib/rt.tk"andcheckuntouched. - A 0.15.x compiler reading a manifest with
[[permission]]/[[tool]]/bin/test/licence: inert keys (measured forcheckin registry § 22.1; the same parser, the same reason). A 0.15.x compiler reading a project with[[tool]]installs no tool and says nothing -- the tool is missing loudly when the developer runs it, which is the honest failure until they upgrade. - A new compiler reading a lock without
permissions: asks once (§ 4.3). A 0.15.x compiler reading a new lock: ignores the two keys.
2. mclib: lib/ as one package, versioned with the compiler #
2.1 What the package is #
lib/mc.toml, checked in:
[package]
name = "mclib"
description = "The libraries the mc compiler ships: float, i128, f16, prelude, io, sys, the system layers and the teaching demos, at this compiler's version"
licence = "MIT" # whatever LICENSE says; the owner's call
# no [project]: a library
check = ["prelude.mc", "io.mc", "float_rt.mc", "sys_linux_x86_64.mc"] # units that compile alone on the worker (linux/x86_64); the implementer measures the set with the S5 § 5.1 rehearsal
files = [ "backend_arm64.mc", "f16.mc", "float.mc", ... ] # the `lib/` rows of tools/bundle.list, paths relative to lib/, byte order
files is generated by the same one-liner the root manifest uses (grep ' lib/' tools/bundle.list | cut -f2 | sed 's|^lib/||' | LC_ALL=C sort -u; 39 entries, 312 KiB today) and scripts/check-pkg.sh § 30a-style asserts no drift. lib/linux/<arch>/sys_arch.mc are not bundle rows and are not in the package. No lib key: there is no single entry point, so a bare <mclib> is refused and a consumer writes <mclib/float_rt.mc>, <mclib/prelude.mc>.
What mclib is and is not for. It is <mclib/...>: a project pinning [deps] mclib = "0.16.0" on any compiler gets those trees under that spelling, hashed and locked. It does not override the bare bundled names: resolution step 1 (lex_include_name, packages.md § 2) matches a lock row by the FIRST path component, so <float> still comes from the blob unless the project pins a package named float (A5). Publishing float, i128, ... as packages of their own is M47-S5 § 4 as priced there and is not this spec: one package, the owner's word. The value is (a) the registry's dogfood at every release, (b) the base every tool depends on by version, (c) a newer lib/ on an older compiler where the hook surface allows it, (d) the road mc install (§ 2.6) and the LSP (§ 3.6) both take.
2.2 The two roads, priced #
| A. release asset (recommended) | B. subdirectory of the tag archive | |
|---|---|---|
the row's url | https://github.com/minicompiler/mc/releases/download/v<ver>/mc-libs-<ver>.tar.gz, strip = 1 (one top directory mc-libs-<ver>/, release-assets.sh's convention) | the tag archive, plus a new row key subdir = "lib" |
| what a shipped compiler does with it | pkg_fetch_one -> fetch_get (one HTTPS redirect, measured) -> fetch_check_members -> extract -> dep_hash_tree -> manifest. Zero compiler lines: url, strip, sha256 are all it reads | a 0.15.x compiler ignores subdir, extracts the whole repository with strip = 1, reads the ROOT mc.toml and dies with the archive's mc.toml names another package (pkg_check_archive) or a hash mismatch. Every consumer must upgrade first |
| compiler cost | 0 | ~70 lines in pkg.mc/fetch.mc (subdir in VR_*, a member subset to fetch_extract -- whose post-extraction "every member is openable" check is skipped for subsets today -- and the strip arithmetic) |
mc repository cost | lib/mc.toml (~60 lines); scripts/release-assets.sh --libs VERSION [OUTDIR] (~45 lines: stage lib/ + LICENSE under mc-libs-<ver>/, the existing sorted-members/mtime-0/ustar/gzip -n rules, the .sha256); release.yml build job: one step after Package (scripts/release-assets.sh --libs "$VERSION" dist; the upload glob dist/mc-*.tar.gz and publish's gh release create dist/mc-*.tar.gz already match the name -- zero lines there); check-pkg.sh +30 (files drift, mc pkg hash lib == scripts/pkg-hash.sh lib, the tarball reproducible) | lib/mc.toml and the same gates; no asset |
| registry cost | repos.subpath + repos.asset (§ 5.1), the validator hashing <checkout>/<subpath> and writing the asset URL, the poll fanning out to every row of a triple (~110 lines) | repos.subpath and the validator hashing the subtree (~70 lines); the index generator emitting subdir |
| when the archive exists | after publish (the job publish-to-registry is needs: publish; a scheduled poll that arrives earlier gets a 404 from the download half and treats it as "not ready", no attempts row -- the § 24.1 rule, extended to assets) | at the tag |
| what changes in a row's semantics | nothing: a row is url strip sha256 deps, as M44 § 5 wrote it | a second way to name a tree |
Decision D1: road A. It is the road with zero compiler lines, it works for every compiler already released, and the archive is produced by the script that already produces every other release artefact under the determinism rules. The asset's .sha256 is for a person; the row's sha256 is the tree hash over lib/mc.toml + files, which the validator computes on the tag's lib/ subtree and a consumer recomputes after extraction -- so the asset's bytes are verified by content, and a regenerated or tampered asset is checksum mismatch for mclib 0.16.0 at every consumer.
2.3 What the index row looks like #
[[versions]]
version = "0.16.0"
url = "https://github.com/minicompiler/mc/releases/download/v0.16.0/mc-libs-0.16.0.tar.gz"
strip = 1
sha256 = "<tree hash of lib/ at v0.16.0>"
deps = []
kind = "lib"
permissions = []
tools = []
licence = "MIT"
2.4 The admin registration #
mcreg-admin register git_url=https://github.com/minicompiler/mc account=<owner login> subpath=lib asset=mc-libs-{version}.tar.gz reason=... -- register gains two optional parameters (§ 5.5); the row's name is read from <default branch>/<subpath>/mc.toml (rg_register fetches the raw file: the path gains the subpath). The reserved prefix is lifted by allow_reserved = 1 as for mc. The forge triple is now (host, owner, repo, subpath) (§ 5.1).
2.5 What mc pkg sync needs, and mc pkg check's download half #
Nothing. sync plans mclib 0.16.0 with the asset URL, --yes downloads it through the same downloader as a sysroot, checks members, extracts with strip = 1, hashes, writes <libs>/mclib/v0.16.0.toml last. mc pkg check index/mclib.toml --yes (the registry's recheck download half; mclib is not in the compiler's exact reserved set, so § 22.2's skip does not apply) downloads the asset and compares the tree hash, the name and the deps -- unchanged code. The one observable difference from a tag archive is the redirect target; § 0 measured that the flags admit it.
2.6 What this does to M44 steps 4-5 (amendment, to ratify) #
B4/B5 named the slim binary's archive mc-libs-<ver>.tar.gz and had release-assets.sh --libs produce it by running mc install --from-bundle into a staging directory. Reassign the name to mclib's asset and have mc install fetch the mc package's own index row (https://pkg.minicompiler.dev/index/mc.toml -> the tag archive, strip = 1, the 97-file files list hashed exactly as any package) into <libs>/mc/v<mc_version()>/, copying tools/bundle.list to the root of the tree afterwards (M47-S5 § 3.2's note). One fetcher, one hash, one row that the registry already publishes; the slim binary's second archive and its hand-listed member set disappear. What does not come for free and stays step 4's: the installed src/bundle_data.mc is the checked-in (mode 0) form, so a taught compiler built from an on-disk <mc/core> pays the M21.5 arena cost -- step 4 measures whether that matters and may rewrite the file in mode 1 after the hash check (the hash is over files; a rewritten file must not be in the list, or the tree is written first and hashed second as today).
3. Tools #
3.1 The commands (src/tool.mc, in <mc/core_pkg>, one subcommand("tool", ...)) #
mc tool install NAME[@VERSION] [--yes] [--unconfined] [--registry URL|DIR] [--libs-dir DIR] [--bin-dir DIR]
mc tool install [DIR] [--config FILE] [--yes] [--unconfined] ... every [[tool]] of the project's lock
mc tool list [--bin-dir DIR]
mc tool remove NAME [--bin-dir DIR]
mc tool upgrade [NAME] [--yes] [--unconfined] ... the newest non-yanked, non-pre-release row of the same major, then install
mc tool run NAME [--] [ARGS] what the launcher calls
install is, in order: (1) load the index rows and run MVS with the tool as the root and its [deps] as requirements (pkg_resolve, unchanged: a tool's dependencies are libraries); (2) print the plan -- the archives, AND the permission table of § 4.2 for the tool and every library under it -- and stop without --yes; (3) fetch into <libs> exactly as sync does; (4) stage a buildable copy: ~/.mc/tools/<name>/v<ver>/ receives mc.toml + files (the pkg_copy_tree road), deps/<dep>/ for every library (the vendor road), and an mc.lock written by pkg_write_lock -- the shape the validator itself builds (§ 18 of the registry spec) and the shape mc build rehashes; (5) mc build that directory by spawning <self> build DIR --libs-dir ... (drv_spawn, the drv_teach precedent for "the binary that is running builds the next thing"); (6) write ~/.mc/tools/<name>/v<ver>.toml last -- [tool] name version bin sha256 kind unconfined + permissions = [...] in canonical form -- and the launcher ~/.mc/bin/<bin>; (7) print the PATH line once (add ~/.mc/bin to your PATH).
Why the build is not boxed: mc sandbox run keeps every write in an overlay that dies with the box (M43 § Out of scope: no --keep), and a tool's build must produce a file that survives. The build runs with the trust mc build of any project has today -- its libraries are compiled, its [compiler].modules run inside the taught compiler (M44 Risk 1). The validator has already compiled the same tree in a box; what runs on the developer's machine is the tool itself, boxed (§ 3.3). Boxing the build is priced in § 7.
3.2 The ~/.mc layout #
~/.mc/libs/<pack>/v<ver>/ the fetched trees (unchanged, D10')
~/.mc/tools/<name>/v<ver>/ the staged, built copy: mc.toml, files, deps/, mc.lock, build/<bin>
~/.mc/tools/<name>/v<ver>.toml the install manifest (written last; the claim)
~/.mc/bin/<bin> the launcher (macOS/Linux: a two-line sh; Windows: <bin>.cmd)
--bin-dir and --libs-dir override the two roots (the --sysroot-dir precedent; CI depends on no HOME). ~/.mc/bin/<bin> names ONE version -- the newest installed; mc tool run inside a project whose lock pins another version runs that one (the lock wins, as it does for includes). remove unlinks the launcher and the two manifests' listed files (mc has no rmdir: the directories stay, empty -- is not installed afterwards, the outcome that was wanted).
3.3 The launcher: a one-line script, and the logic in mc tool run #
#!/bin/sh
exec mc tool run mc-lsp -- "$@"
and on Windows @mc tool run mc-lsp -- %*. Not a tiny mc binary that execs: mc has posix_spawnp + waitpid and no exec, so a binary launcher would be a spawn-and-wait too; and --exe is unavailable on Windows without a linker, so a compiled launcher would need the object+lld-link road for one line. The script carries no derived flags on purpose: what runs is decided by mc tool run from the install manifest, so a change in how permissions map to the box (§ 4.1) never needs launchers regenerated, and a person reading ~/.mc/bin/mc-lsp sees one line.
mc tool run NAME (Linux, host_sandbox_supported()): reads ~/.mc/tools/<name>/v<ver>.toml, derives the mc sandbox exec line (§ 4.1), spawns it with stdio inherited (no file actions: an LSP's stdin/stdout pass straight through, as the box already passes P's fds 1 and 2), waits, exits with the child's code (128 + signal for a killed child, drv_spawn's rule). Two processes more than a bare binary (mc tool run, then mc sandbox exec's P) -- the box's measured cost is 1.7-3.7 ms (sandbox.md § Hosts), which an editor never sees.
mc tool run NAME (macOS, Windows): runs the binary directly, only if the install manifest says unconfined = true (§ 3.4); otherwise mc: mc-lsp was installed for a sandbox this host does not have: re-install with --unconfined, exit 2.
3.4 What mc sandbox lacks, and the macOS/Windows rule #
mc sandbox exec gains, in sb_parse/sb_build_tree/sb_landlock_apply/the profile:
| flag | box mechanism | lines |
|---|---|---|
--rw DIR | bind DIR read-write at its own absolute path inside the box (mkdir the components on the box tmpfs, then MS_BIND without the read-only remount); Landlock: the full fs mask on that path | sandbox_box.mc +45, seccomp.mc +8 |
--ro DIR | exists; gains the same-path placement when --at-path is given (a tool receives host paths from its caller -- an LSP is handed file:///Users/x/proj/main.mc -- so /roN numbering, right for the test corpus, is wrong for tools) | +15 |
--tmp | a writable /tmp on the box tmpfs; Landlock full | +12 |
--allow=net | keep the host's network namespace (unshare without CLONE_NEWNET); do not put the net field into the Landlock ruleset; add the net delta to the profile: socket connect bind listen accept4 sendto recvfrom sendmsg recvmsg setsockopt getsockopt getsockname getpeername shutdown -- measured by scripts/sandbox-trace.sh over a probe that does a TCP round trip, written to tools/sandbox/<arch>-<libc>-net.list, never typed | sandbox.mc +10, sandbox_box.mc +6, seccomp.mc +6, sandbox_profiles.mc generated, sandbox-trace.sh +40 |
--bin PROG | resolve PROG on the host's PATH (a host_which() walk over host_environ(), ~25 lines, the host_home shape), bind it read-only+exec at /bin/<basename>, PATH=/bin in the box; Landlock LL_FILE_EXEC; the spawn delta (the compile profile's clone/clone3/wait4/pipe2/dup3... minus what the program profile has, measured) and the process counter set to 16 like a compile step, the execve counter to 1 + the number of --bin flags | sandbox_box.mc +30, sandbox.mc +20, seccomp.mc +10 |
--env NAME | copy NAME=<value> from the host environment into the box's environment (three entries today, sb_env()) | sandbox.mc +15 |
Every new flag is a primitive; none knows what a permission is. tests/sandbox/ gains one case per flag (a program that writes under --rw and is refused one directory up; a connect that succeeds under --allow=net and is refused: syscall 203 (connect) without; an execve of /bin/true that succeeds under --bin true and is refused: process limit (0) without; getenv through --env), each with an .expect. scripts/sandbox-trace.sh --check in CI keeps the two new lists honest in both directions.
macOS and Windows. There is no box (M43 § 7; Layer 2 is post-1.0.0 and is about a web playground, not a Mac box). Rule: mc tool install on such a host prints the permission table and stops with
mc: this host has no sandbox: mc-lsp would run with the permissions above and nothing to hold it to them
run: mc tool install mc_lsp --yes --unconfined
exit 2. With --unconfined it installs, records unconfined = true in the install manifest, and mc tool run runs the binary directly and silently thereafter (accepted once, at install -- the same rule as the lock). The word is --unconfined and not --yes, so that a CI script written for Linux does not silently accept "no box" when run on a Mac.
3.5 The workspace literal #
workspace is the current working directory of the invocation -- what mc build . means by ., and what an editor sets when it launches a language server (VS Code: the workspace folder). mc tool run resolves it once (sb_getcwd, which exists) and refuses two values without an explicit --workspace DIR: $HOME and / -- a tool started from a shell in the home directory would otherwise be granted every file the user owns under the word "workspace". § 7.2 says what this does not close.
3.6 The LSP as the first consumer #
- Name: package
mc_lsp(the name rule has no hyphen),bin = "mc-lsp";mc*is admin-registered. - Where its source lives: a new repository
minicompiler/mc-lsp, nottools/lsp/inminicompiler/mc. Reasons: its release cadence is its own (an LSP fix is not a compiler release); it consumesmcliband<mc/core_min>as a consumer would, which is the dogfood the whole road needs; a subdirectory package would be a second subpath registration and a second asset road for no gain; anddocs/plan.mdM28 already says "the language server is the taught compiler", which a package built on the running compiler's bundle IS:#include <mc/core_min>gives it the lexer, parser and hooks of the compiler that built it, version-locked by construction ("compila na maquina do dev"). - Manifest (the shape, for the spec of M28 to fill):
[package]
name = "mc_lsp"
bin = "mc-lsp"
files = ["lsp.mc", "json.mc", "rpc.mc", "tests/smoke.mc", ...]
check = ["lsp.mc"]
test = ["tests/smoke.mc"]
licence = "MIT"
[project]
entry = "lsp.mc"
out = "build/mc-lsp"
kind = "exe"
[deps]
mclib = "0.16.0"
[[permission]]
kind = "fs.read"
path = "workspace"
reason = "reads the sources the editor opens and the project's mc.toml"
[[permission]]
kind = "exec"
name = "mc"
reason = "builds the project's taught compiler for diagnostics through the taught surface"
exec mc is the LSP's honest need (§ 7.3): the project-aware half of M28 (examples/lang's taught words) is a taught compiler, which only mc build produces.
- VS Code extension (M29): the setting
mc.lsp.pathdefaults tomc-lsp, resolved onPATH; the README's install line ismc tool install mc_lsp --yesplus the PATH line. The extension never derives sandbox flags: it launches the launcher, and the launcher launchesmc tool run, so a Linux user's LSP is boxed and a Mac user's is unconfined, both by the developer's own accepted install.
4. Permissions end to end #
4.1 The validator's derivation of the box profile (kind by kind) #
| permission | M43 mechanism the validator (and mc tool run) uses |
|---|---|
| (none) | mc sandbox exec as today minus the writable /src: the tool's install tree bound read-only (--ro <tree> --at-path), cwd there, empty netns, program profile, HOME=/src PATH=/, process counter 0 |
fs.read workspace / workspace/<rel> / home/<rel> | --ro <resolved dir> --at-path: bind + MS_RDONLY remount, Landlock LL_READ_ONLY on the path |
fs.write ... | --rw <resolved dir>: bind without the remount, Landlock full fs mask; for home/<rel> the launcher mkdirs the path first (mc has mkdir), so a cache directory can be created by the tool inside a directory that exists |
tmp | --tmp |
net | --allow=net: no CLONE_NEWNET, no Landlock net field, the measured net delta in the seccomp allowlist |
exec NAME | --bin NAME: the program bound ro+exec at /bin/NAME, PATH=/bin, the spawn delta, process counter 16, execve counter 1 + N |
env NAME | --env NAME |
The same function, tool_box_argv(manifest), in src/tool.mc, is what mc tool run executes and what the registry's jb_sandbox writes for a test box (§ 5.3) -- the registry does not re-implement the mapping: it spawns mc tool box-args <mc.toml> (a hidden verb, ~10 lines, prints the derived flags one per line) so that there is ONE definition of "what fs.read workspace means", in the compiler, versioned with it. In the validator's box workspace is the checkout, home/<rel> is a directory under the box tmpfs, and exec mc binds the worker's own mc.
What is refused by name: a test program that opens a file outside its declared roots gets sandbox: refused: open /etc/passwd; one that connects without net gets refused: syscall N (socket); one that forks without exec gets refused: process limit (0). Each is exit 125, the unit is not published, and the line is in the report the author reads (GET /jobs/<id>).
4.2 The install table (the compiler's union-and-confirm) #
mc pkg sync|add|update and mc tool install, after MVS and before any download, print one block per package in the build list whose permission set is not already accepted by the current lock (§ 4.3), then the tools required:
fetch mc_lsp 0.1.0 tool bin mc-lsp
url https://github.com/minicompiler/mc-lsp/archive/refs/tags/v0.1.0.tar.gz
sha256 8c1e...
into /Users/me/.mc/libs/mc_lsp/v0.1.0/
permissions
mc_lsp 0.1.0 fs.read workspace may read files under the directory it is run from
exec mc may run the program `mc` found on your PATH
mclib 0.16.0 (none: stdio only)
tools required by this project
mc_lsp >= 0.1.0
nothing was downloaded: re-run with --yes to fetch and to accept the permissions above
The permission set comes from the index row (permissions = [...]), so the developer sees it before a byte is fetched; after the fetch the tree's mc.toml is what the lock is written from (pkg_write_lock's rule), and the two cannot disagree without the tree hash disagreeing too. The wording per kind is fixed text in tool.mc (the fourth column above), one sentence per kind, so the table reads the same on every machine.
--yes accepts what was printed. There is no second flag: --yes already means "I read the plan", the plan now includes the permissions, and the lock diff in the project's git history is the review record for a CI run. sync with nothing to download but a permission set not covered by the lock also stops without --yes (today's "nothing to download needs no --yes" deviation keeps its case: covered sets and no fetch).
4.3 The lock record (exact TOML) #
# written by `mc pkg sync` -- do not edit (docs/reference/packages.md)
[[package]]
name = "mc_lsp"
version = "0.1.0"
kind = "tool"
bin = "mc-lsp"
sha256 = "8c1e..."
deps = ["mclib"]
permissions = ["exec mc", "fs.read workspace"]
[[package]]
name = "mclib"
version = "0.16.0"
sha256 = "..."
deps = []
permissions = []
kind is written only for a tool (absent = lib, so every existing lock and every fixture lock reads as before and the mc.lock.expect goldens move once, for the permissions line). permissions is the set accepted, sorted. Rule for "asks again": before writing the new lock, for each row compare its set with the OLD lock's row of the same name (any version): a set that is not a subset of the old one is unaccepted and needs --yes; a row absent from the old lock is unaccepted; an old row without a permissions key is an empty accepted set. A dependency whose new version adds net is therefore printed and stops a sync without --yes, and a version that drops a permission is silent. mc pkg list gains a column (stdio, or the canonical set), mc pkg list --long prints the reasons out of the trees.
dep_read_lock reads the two keys into PK_* (+2 fields); deps_apply uses neither -- a build asks nothing (D24: a project with a lock builds byte for byte; permissions are an install-time fact). The registered roots skip kind = "tool" rows.
4.4 What the registry cannot verify for a library, said plainly #
A library is compiled into the consumer's program; there is no process to box. Its [[permission]] rows are the author's statement, checked only where a test unit exercises them (§ 1.5) and shown at install. The install table says so: (declared by the author; a library runs inside your program) on every lib row that has permissions. What the validator DOES hold a library to is unchanged and mechanical: the closure rule on every file the check units read, the hash, and the box around check and test. A library with a compiler module is the one case where library code RUNS on the developer's machine at build time (M44 Risk 1); its row is marked runs at build time in the table, and boxing drv_teach's spawn under the union of module permissions is § 7.6.
5. The registry side #
5.1 Migration db/006-kinds.sql (~70 lines) #
ALTER TABLE repos ADD COLUMN subpath text NOT NULL DEFAULT ''; -- '' = the root; 'lib' for mclib
ALTER TABLE repos ADD COLUMN asset text NOT NULL DEFAULT ''; -- '' = the tag archive; a name with {version}
ALTER TABLE repos ADD COLUMN kind text NOT NULL DEFAULT '' -- '' until the first version is published
CHECK (kind IN ('', 'lib', 'tool'));
DROP INDEX repos_forge_key;
CREATE UNIQUE INDEX repos_forge_key ON repos (forge_host, forge_owner, forge_repo, subpath) WHERE forge_host <> '';
ALTER TABLE versions ADD COLUMN kind text NOT NULL DEFAULT 'lib' CHECK (kind IN ('lib', 'tool'));
ALTER TABLE versions ADD COLUMN bin text NOT NULL DEFAULT '';
ALTER TABLE versions ADD COLUMN licence text NOT NULL DEFAULT '';
ALTER TABLE versions ADD COLUMN permissions text[] NOT NULL DEFAULT '{}'; -- canonical one-liners, sorted
ALTER TABLE versions ADD COLUMN tools text[] NOT NULL DEFAULT '{}'; -- "<name> <minimum>", the [deps] shape
-- versions_immutable(): the five new columns join the immutable list (replace the function, as 004 and 005 did)
CREATE TABLE fetches ( -- index fetches, per package per UTC day: what this server can measure
repo_id bigint NOT NULL REFERENCES repos(id),
day date NOT NULL,
n bigint NOT NULL DEFAULT 0,
PRIMARY KEY (repo_id, day)
);
CREATE TABLE asset_counts ( -- GitHub's own download_count for asset-published versions, refreshed by recheck
version_id bigint PRIMARY KEY REFERENCES versions(id),
n bigint NOT NULL,
read_at timestamptz NOT NULL
);
Two counter tables and not columns on versions, so the immutability trigger is untouched and a counter is a fact about traffic, not about the row. Every published row gets kind = 'lib', permissions = '{}' by default -- true for all of them.
5.2 The validator's rules (web/job.mc, web/manifest.mc) #
mf_parse gains: mf_bin, mf_licence, mf_test (an array, MF_MAXCHECK), mf_tools (rows: name version, MF_MAXDEPS), mf_perms (rows in canonical form, at most 32; kind outside the five words, a path failing mf_rel_ok after the workspace//home/ prefix is cut, a path on net/exec/env, a missing name on exec/env -> mf_fail, refused with the offending row named). Reading an array of tables is new to this reader: [[permission]] and [[tool]] headers start a row; keys land in the current row (~90 lines).
jb_do_tag, after the manifest checks and before the hash:
- kind:
mf_project == 0 || kind == "obj"->lib;kind == "exe"->tool;mf_project && kind == ""->refused: a package's [project] must say kind, attempts row. Atoolmust have a[project].out;bindefaults to its basename and must match[a-z][a-z0-9_-]*. Ifrepos.kindis set and differs ->refused: <name> is a <kind>; a version cannot change that, attempts row.libwithkind = "obj"is allowed and pointless; the report saysnote: [project] kind = "obj" builds nothing a consumer uses. - dependency-registered rule: every
[deps]name and every[[tool]]name must be areposrow in stateactive(orpendingfor the package's own name? no: a package cannot depend on itself), with at least one non-delisted version >= the minimum; a[[tool]]name must bekind = 'tool', a[deps]namekind = 'lib'. Refused with the name, attempts row. (mc's own manifest has no deps;mclibhas none; the first tool depends onmclib, which is whymclibpublishes first, § 6.) - subpath and asset (
repos.subpath <> ''): the tree is<checkout>/<subpath>; the vendored copy undercheck/deps/<name>/is that subtree;mc pkg hashruns on it. Withrepos.asset <> ''the row'surlishttps://github.com/<o>/<r>/releases/download/<tag>/<asset with {version} replaced>, and before publishing the worker runs the download half once:mc pkg checkof a one-row index it writes,--yes, in the work directory (this isjb_do_recheck's existing road, moved into a function): a 404 from the downloader is "the asset is not there yet" -- no attempts row, next poll -- and any other disagreement is a refusal. - the boxes: the
checkunits as today (a compile,kind = "obj"project); for atool, one more compile box that BUILDS the package's own[project](mc buildof the vendored tree, in the box,--outcapped; the output is discarded with the overlay -- the point is that it builds); then eachtestunit compiled and run in a box whose flags aremc tool box-args <tree>/mc.toml(§ 4.1), withworkspace= the vendored tree. The former "second box over the package's[project]" is retired (nothing published depends on it). sql_version_insertgains the five columns (16 parameters;PG_MAXPARAMSis 16, measured in § 18 -- raise it to 24 in the same change).repos.kindis set when '' (UPDATE ... WHERE kind = '').
jb_do_poll's lookup of "the repository" becomes "every row of this triple", one job per row (the poll endpoint and the admin poll queue one job per row; sql_job_pending is per row already).
5.3 The index (pkg_index_toml) and the counters (web/registry.mc) #
Each [[versions]] row gains kind (always), bin (tools), licence (when set), permissions = [...], tools = [...] -- written through toml_str, after deps, before yanked/advisory, so web/tests/registry/*.toml goldens change by four lines per row and the S2 comparison is re-recorded once. reg_index (and the alias, one handler) does INSERT INTO fetches ... ON CONFLICT (repo_id, day) DO UPDATE SET n = n + 1 after a 200 -- one write per origin hit, bounded in size by (packages x days), undercounted by the 300 s edge cache and Cloudflare, and named for exactly that on the page: "index fetches (origin, per day)". The weekly recheck reads the Release JSON it already fetches (jb_has_release, the token road; web/json.mc keeps a tree since S4b) for assets[].download_count of the row's asset and upserts asset_counts: "asset downloads (GitHub's counter)". Two numbers, each named for what it measures; neither is "downloads".
5.4 The NuGet-like pages #
/packages/<name> (pages_one), in this order, each a <h2>:
- header: name, kind badge (
library/tool), description, licence, the source link, the[deps]line or -- for a tool --mc tool install <name> --yes; - Permissions of the latest version: the canonical line, the fixed sentence, the author's reason;
(none: stdio only); for a library the § 4.4 caveat in one line; - Dependencies (the latest version's
depsandtools, each a link) and Dependents:SELECT DISTINCT r.name FROM versions v JOIN repos r ON r.id = v.repo_id WHERE r.state = 'active' AND v.delisted_at IS NULL AND EXISTS (SELECT 1 FROM unnest(v.deps || v.tools) d WHERE starts_with(d, $1 || ' '))--starts_with, neverLIKE(a name may contain_, which is aLIKEwildcard; the file's own rule); at most 200, ordered by name; - Versions: the table gains
kind,permissions(as a compact list), pre-release, yanked, advisory,source missing, and the report link of the job that published it (jobsbyrepo_id,state = 'done',report LIKE '%published <version>%'is not a query; aversions.job_id bigintcolumn written at insert is -- add it to 006, immutable); - Activity: index fetches, last 30 days, and the asset count where present;
- the report form link and the index URL, as today.
/packages gains a kind column and a kind=tool filter (?kind=), and the search stays a prefix.
5.5 The public MCP (S7) JSON, and the admin tools that change #
S7's package tool answers one object; the columns above are its fields, so S7 should land after 006 or add them itself:
{"name":"mc_lsp","kind":"tool","bin":"mc-lsp","description":"...","repo":"https://github.com/minicompiler/mc-lsp","licence":"MIT","latest":"0.1.0",
"versions":[{"version":"0.1.0","kind":"tool","tree_hash":"8c1e...","url":"...","deps":["mclib 0.16.0"],"tools":[],"permissions":["exec mc","fs.read workspace"],"prerelease":false,"yanked":false,"advisory":"","published":"2026-09-20","job":41}],
"dependents":["mc_fmt"],"fetches":{"index_30d":120,"asset":null}}
resolve (the MVS tool) returns the same union table mc pkg sync prints, as {"packages":[{"name","version","kind","permissions"}],"tools":[...]}. Admin (web/admin.mc): register gains subpath, asset; stats gains fetch totals; a new set_kind does not exist (kind is the manifest's); everything else unchanged. docs/admin.md gains the two parameters.
6. Sequence, gates, costs #
6.1 Where it lands #
Current state: compiler main at v0.15.8 with M44 steps 1-3 and M43 done, steps 4-5 (slim, install, upgrade) not started; registry at S6 + the index alias (§ 26), S7 (public MCP + account tokens) not started; publish-to-registry gated by MC_REGISTRY_PUBLISH until the 1.0.0 flip.
Rule for the order: a row the registry serves must never be one a released compiler cannot read, and a key a compiler writes must never be one the registry cannot store. Both hold at every step below because unknown keys are inert on both sides (§ 0), so the order is chosen for who can test what, not for compatibility.
| # | repository | step | depends on | cost (code lines) |
|---|---|---|---|---|
| C0 | mc | globals diet: the nine src/driver.mc file-level globals into one arena record drv behind accessors (the sb_state precedent); check-inert identical; goldens move once | -- | ~70 moved, -8 globals (454 -> 446) |
| R1 | registry | migration 006; manifest.mc rows; the kind rule, the dependency-registered rule, subpath/asset, the columns, the index keys, the pages, the counters, register's two parameters; goldens re-recorded | -- | ~500 mc + 70 SQL + ~220 test (repos.py, test-job.sh fixtures fixtool, fixperm, fixsub, pages.py) |
| C1 | mc | the manifest and the lock: deps.mc reads [[permission]]/[[tool]]/bin, the canonical form, kind from [project]; pkg.mc reads the index keys into VR_*, MVS over [[tool]], the union table, the accept rule, --yes's second meaning, the lock's two keys, list columns; check-pkg.sh fixtures tool-0.1.0 (kind exe, two permissions), net-1.0.0 (a lib declaring net), locks re-recorded | C0 | deps.mc +90, pkg.mc +170, toml.mc 0, check-pkg.sh +130; 0 globals (fields in pk/dp) |
| C2 | mc | the sandbox primitives: --rw, --at-path, --tmp, --allow=net, --bin, --env; the net and spawn deltas measured; four tests/sandbox/ cases; CI's four cells | -- | sandbox.mc +60, sandbox_box.mc +85, seccomp.mc +25, sandbox-trace.sh +40, test-sandbox.sh +60; 0 globals (sb_state) |
| C3 | mc | mc tool: src/tool.mc (install/list/remove/upgrade/run, box-args, the staging, the build spawn, the launchers, the install manifest, --unconfined), core_pkg.mc +2; scripts/check-tool.sh (Linux-only box cases self-skip elsewhere) | C1, C2 | tool.mc ~430, check-tool.sh ~260, docs; +1 global (tl) |
| M1 | mc | mclib: lib/mc.toml, release-assets.sh --libs, one release.yml step, check-pkg.sh +30; docs/reference/packages.md § 11 gains the second package | R1 deployed (for the registration); a release after M1 produces the first asset | ~60 TOML, ~45 sh, +1 YAML step |
| R2 | registry | the validator boxes test under the derived profile (mc tool box-args through the pinned compiler), the tool build box; MC_VERSION bumped to the release carrying C2+C3 | C3 released | job.mc +90, fixtures +120 |
| L1 | mc-lsp (new) | the first tool: the package, its test, the .vsix pointing at mc-lsp | C3 released, mclib published | M28/M29's own specs |
| S7 | registry | public MCP + tokens, with § 5.5's fields | R1 | as priced in § 14/§ 19 (+~60 for the fields) |
Totals: compiler ~830 code lines of mc (+ ~450 of shell tests), registry ~600 mc + 70 SQL (+ ~340 tests), mc repository ~140 lines of TOML/sh/YAML. Globals: 454 -> 446 (C0) -> 447 (C3); M44 steps 4-5 then have 13 of headroom under 460, which is what the diet buys.
6.2 Relative to S7, M44 steps 4-5, 1.0.0 #
- R1 + C0 + C1 before 1.0.0: they are the format -- the index keys, the lock keys, the kind rule -- and 1.0.0's first registry-published release should be one whose rows already carry
kind/permissions, so that no row published under 1.0.0 is ever re-shaped. They cost nothing to any shipped compiler. - C2, C3, R2, L1 after 1.0.0: features on the format, each CI-gated on the Linux cells; nothing in them changes a published byte.
- M44 steps 4-5 are independent of this spec except § 2.6's amendment (the asset name;
mc installfetching themcrow); do them after C1 soinstall.mc/upgrade.mcland on the dieted globals. - S7 is independent; if it lands before R1 its JSON gains the fields with R1.
- M1 can land any time after R1 is deployed; the first asset appears at the next release; the admin registers
mclibbefore that release so the poll finds it.
6.3 Owner actions #
- Ratify D1-D8 below, in particular
[package].binin place of a[tool]table,--yesaccepting permissions, and--unconfinedfor hosts without a box. mcreg-admin registerformclib(subpath=lib asset=mc-libs-{version}.tar.gz) after R1 deploys, and formc_lsponce its repository exists.- Create
minicompiler/mc-lsp(public) and decide its licence string; decidemclib's licence string (the repository'sLICENSE). - Bump the registry's
MC_VERSIONto the release carrying C2+C3 before R2. - Approve the permission vocabulary (five kinds, four path forms) -- § 7.1 is the argument for its size.
7. Risks #
- The vocabulary is too coarse or too fine. Five kinds, four path forms.
nethas no host or port: Landlock's net rules are per TCP port and know no host, the netns is all-or-nothing, and a host allowlist needs a proxy the sandbox does not have -- ahost = "..."key would be a promise the box cannot keep, so it is refused rather than accepted and ignored.fs.*has no glob: a subtree is what a bind can express.exec NAMEnames one program: a shell (exec sh) is honest about being "everything". If a real tool needs finer words, the row shape has room (port,pattern) and the canonical form is a string, so the lock and the index do not move. workspacefrom another directory. The launcher resolves cwd; an editor sets it to the workspace folder, a shell to wherever the user is.$HOMEand/are refused without--workspace; a project inside$HOME/ais still$HOME/aand nothing above it. What is not closed: a user whocds into a directory full of secrets and runs a tool withfs.read workspacegranted it exactly that; the install table said so in words, once.- A tool that needs
execto be useful: the LSP spawningmc buildfor a taught compiler.exec mcbinds the worker's/host'smcread-only, gives the spawn delta and a process budget of 16 -- andmc buildthen runs the taught compiler inside the same box, under the same Landlock roots: it writesbuild/underworkspace, which needsfs.write workspace/buildtoo. The LSP's manifest carries both, the table shows both, and a compiler module of the project's own[compiler].modulesruns boxed as a side effect -- the first time M44 Risk 1's code runs under M43's box on a developer's machine. - The trust model, in the words a user reads. The install table's fourth column is fixed text per kind; the last line before
--yesis:these permissions were declared by each package's author and checked by the registry where the package runs a test; a library runs inside your program and is held to nothing at run time. That sentence is inpackages.md§ 1's trust box, in the table, and on every package page. It does not say "safe"; it says what was checked. - What the registry cannot verify for libraries (§ 4.4): a library's declared set is a claim; a library with no
testis unverified except for compiling; the closure rule and the hash are the mechanical holds. A library that declares nothing and, through the program that includes it, does everything is not a lie by this scheme's rules -- the scheme is about processes, and a library is not one. The page saysdeclared, neververified, for alibrow; atoolrow saysverified by a testonly whentestis non-empty. - Boxing the build (
mc tool install's step 5) andmc build's taught-compiler spawn: both are M44 Risk 1's seam. Priced:mc sandbox run --keep DIR(copy/src's overlay upper out after the box, ~60 lines: afind-less copy needs the member list, which the build's usage filebuild/.mc-usage.tomldoes not carry -- so--keeplists the files the build wrote throughopenatnotifications P already sees, ~40 more lines) and thentool installbuilds in the box;drv_teachwrapping its spawn inmc sandbox execwith the union of module packages' permissions on Linux, ~80 lines. Follow-up, not this milestone. --yesnow means two things. A CI script that ransync --yesaccepted downloads; it now also accepts new permissions. The lock diff carries the acceptance into review; a team that wants a hard stop sets nothing in this spec -- a--no-new-permissionsflag (~15 lines) is the priced answer if asked for.- Two
reposrows per triple changes a lookup that was one row (sql_repo_by_forge, the poll endpoint,jb_load_repo); the partial unique index keeps the invariant, and the S4c test phasepollcigains a two-row case. A registration by an ordinary account never setssubpath, so the public form's road is one row as before. - The asset is not there when the validator looks (a scheduled poll between the tag and the Release's assets): treated as not ready, no
attemptsrow, exactly like "no Release yet" (§ 24.1) -- asserted intest-job.shwith a stub that answers 404 once and 200 afterwards. - Same-path binds inside the box (
--at-path) put a host path into the box's tree; the report's no-host-path rule is about the report, which prints what the program passed and the fixed vocabulary, and stays true. What changes is that a tool can now tell where it is -- which an LSP must, and which the empty netns and the two walls do not care about. - Windows launchers are
.cmdfiles callingmc tool run, and Windows has no box: every Windows install is--unconfined. The manifest records it andmc tool listprintsunconfined; the honest cell for a Windows CI job is "installs, runs, accepted without a box". - The globals ceiling: C0 first, always. Without it C3's one global lands at 455 and steps 4-5 of M44 hit 460.
8. Decisions (architect) -- to ratify with the owner #
- D1 --
mclibis a release asset,mc-libs-<ver>.tar.gz,strip = 1, registered withsubpath=lib asset=...; zero compiler lines; the M44 slim-install archive of that name is withdrawn andmc installfetches themcpackage's own row. Rejects: the subdirectory road (breaks every shipped compiler; a new row key) and a mirror repository (a second source of truth). - D2 -- kind by
[project]: absent orobj= library,exe= tool, present withoutkind= refused; kind is a property of the name. Rejects: a[package].kindkey (a second place the same fact is written). - D3 --
[package].binnames the binary; there is no[tool]table, because[tool]+[[tool]]is not TOML. Rejects:[tool](invalid),[tools]for dependencies (the owner said[[tool]]). - D4 --
[[permission]]rows with five kinds and four path forms, no absolute paths,dep_rel_okfor every<rel>; canonical sorted one-liners in the lock and the index;reasonis site-side only. Rejects: host/port onnet(unenforceable), globs, absolute paths. - D5 --
[package].testnames units the validator compiles and RUNS under the derived profile; the "second box over[project]" is retired; a tool's[project]is built in a box. Rejects: running a tool's binary with no arguments as its test. - D6 -- the derivation from permissions to box flags lives once, in the compiler (
mc tool box-args), and the sandbox gains only primitives (--rw --at-path --tmp --allow=net --bin --env). Rejects: a profile file format for the sandbox (a second vocabulary). - D7 --
--yesaccepts the printed permissions; the lock records the accepted set per package; a superset asks again; hosts without a box need--unconfinedat install and are silent afterwards. Rejects: a prompt (noisatty), a second accept flag,--yesimplying "no box is fine". - D8 -- tools:
mc tool install|list|remove|upgrade|run, staged and built under~/.mc/tools/<name>/v<ver>/in the vendored shape, one-line launchers in~/.mc/bin,runderives and spawnsmc sandbox exec; the LSP ismc_lsp/mc-lspin a new repositoryminicompiler/mc-lsp, depending onmclib. Rejects: a compiled launcher (noexec, no Windows--exe), launchers carrying derived flags, the LSP as a subdirectory of the compiler. - D9 -- two counters named for what they measure (index fetches at the origin per day; GitHub's asset download count), in two tables, never "downloads". Rejects: a per-version index count (the index is per package; the compiler asks for a name, not a version).
- D10 -- the dependency-registered rule: every
[deps]and[[tool]]name must be an active registered package of the matching kind with a published version >= the minimum. - D11 -- C0 (the driver globals diet) is the first commit of the milestone, and C1's state lives in
pk/dp, C2's insb_state, C3's in one new record.
9. Acceptance (runnable; no network anywhere in make check) #
Compiler (scripts/check-pkg.sh, scripts/check-tool.sh, scripts/test-sandbox.sh):
- C0:
scripts/check-inert.sh build/mc1.pre build/mc1identical over the corpus;check-limitsreportsglobals<= 446/512; goldens rewritten once after an empty--dump-asmdiff andcmp build/mc2.o build/mc3.o. - C1: a fixture
tool-0.1.0([project] kind = "exe",bin = "hello-tool",[[permission]]fs.read workspace+net) andnet-1.0.0(a library declaringnet);mc pkg sync tests/pkg/perm --registry $tmp/registry --libs-dir $tmp/lprints the permission table with the two fixed sentences andnothing was downloaded: re-run with --yes to fetch and to accept the permissions above, exit 0, no manifest written; with--yesthe lock is byte-identical tomc.lock.expectcarryingpermissions = ["fs.read workspace", "net"]sorted andkind = "tool"; a secondsyncwith nothing to fetch needs no--yes; replacing the index'snet-1.0.0row by anet-1.1.0row that addsexec shand raising the minimum makessyncstop without--yesand print only the changed package;mc buildof the project takes no extraopenand registers no root for the tool (the object iscmp-identical to a build with the[[tool]]rows removed). - C1: every
tests/pkg/*/mc.lockfrom before the change still builds (kindabsent = lib, nopermissions= empty accepted set);mc pkg checkof an index row carryingkind/permissions/tools/licenceexits 0 with--yesand the row is re-derived from its archive; a[[permission]]row withpath = "/etc"orpath = "workspace/../x"is refused at the key'sfile:line:col; a manifest with[project]and nokindis refused bymc pkg check --yeswitha package's [project] must say kind. - C2:
tests/sandbox/{rwtree,nettrip,binexec,envread}.mceach with an.expectper (allowed, refused) pair, 10/10 -> 18/18 on both architectures, both privileges;scripts/sandbox-trace.sh --checkclean with the net and spawn lists in both directions; two runs of every case byte-identical reports; the CIsandboxjob green in its four cells. - C3 (Linux cells; self-skip elsewhere with the reason):
mc tool install hello_tool --yes --registry $tmp/registry --libs-dir $tmp/l --bin-dir $tmp/binprints the plan and the table, stages$tmp/tools/hello_tool/v0.1.0/{mc.toml,deps/,mc.lock,build/hello-tool}, writesv0.1.0.tomllast, writes$tmp/bin/hello-tool(two lines);$tmp/bin/hello-toolprintshellofrom a file under the workspace and exits 0; the same tool asked to read../secretissandbox: refused: open ..., exit 125; without--yesnothing is staged;mc tool listmatches a golden;mc tool removeunlinks the launcher and the manifests; on macOS the same install without--unconfinedexits 2 naming the flag, with it installs and runs. - C3:
mc tool box-args tests/pkg/src/tool-0.1.0/mc.tomlprints exactly--ro <cwd> --at-path,--allow=net(golden); the string is whatcheck-tool.shfeedsmc sandbox execdirectly, proving the two roads agree. - M1:
mc pkg hash libequalsscripts/pkg-hash.sh libon macOS, Linux and Windows CI;lib/mc.toml'sfilesequals the generated list (drift fails);scripts/release-assets.sh --libs 0.0.0-dev disttwice givescmp-equal tarballs;mc pkg syncof a fixture project pinningmclib = "0.0.0-dev"against a directory registry whose row'surlis that tarball builds a program including<mclib/prelude.mc>and<mclib/float_rt.mc>, and the object iscmp-identical vendored and cached. - Inertness at every step:
check-obj32/32,check-inertidentical,check-standalone,check-parts(the<mc/core_pkg>row grows bytool.mc; a compiler without the part prints notoolusage line),check-docsgreen with every new key indocs/reference/toml.md(the gate counts them: 27 today) and every new flag incli.md.
Registry (make check: test-repos, test-job, test-pages, test-admin):
- 006 applies on a database at 005;
db/schema.sqlregenerated;versions_immutable()proved in SQL to refuse anUPDATEofkind,permissions,tools,bin,licence,job_id. test-job.shfixtures:fixtool(kind exe,bin, two permissions, atestthat reads its workspace) publishes withkind = tool; the same with atestthat opens/etc/passwdisrefused: the checks did not passwithsandbox: refused: open /etc/passwdin the report and nothing published (Linux cell; the stub cell asserts the derived flags reached the sandbox stub's argv);fixproj(a[project]with nokind) is refused with the sentence and anattemptsrow; a second version offixtoolwithkind = "obj"is refused for changing kind;fixdeprequiring an unregistered name is refused naming it;fixsub(subpath=lib,asset=x-{version}.tar.gz) hashes the subtree, is skipped with noattemptsrow while the stub answers 404 for the asset, and publishes with the asset URL once it answers 200.pages.py:/packages/mc_lspshows the kind badge, the permission table with the reason, dependencies and dependents (a fixture depending on it), the versions table with the report link, the two counters with their names;/packages?kind=toollists only tools;web/tests/registry/*.tomlgoldens carry the four new lines per row andmc pkg check(the pinned compiler) accepts them.reg_indexwrites onefetchesrow per package per day,nincrements across two GETs of the same file and the alias, and a 404 writes nothing.test-admin.sh:registerwithsubpath/assetcreates a second row for a triple already registered at the root; without them a second registration of the same triple is stillalready registered.
Implementation notes -- C1 (the manifest, the install table and the lock) #
Written after the work, from what was measured. C1 is § 1, § 4.2, § 4.3 and § 1.8 of the design above, plus acceptance § 9 items 2 and 3. C0 was PR #42; C2, C3 and M1 are not built.
1. Cost. git diff --numstat src/: deps.mc +291/-7, pkg.mc +497/-37; of the added
lines, 198 and 382 are neither a comment nor blank. The design priced +90 and +170. Three
things account for most of the excess and none of them is new mechanism:
- the lock writer was re-aligned:
permissionsis 11 characters, so every key of a row is padded to 11 and everydrv_putline of the writer is a changed line (~30); pkg_check_archivegained the five keys the row now carries (~45 code lines), which is acceptance 3's "the row is re-derived from its archive" and was not priced at all;- the permission block itself is 5 small functions -- the sentence per kind, the words for a path, the accept rule, the ask predicate and the printer -- because "fixed text per kind" means the text lives in one place (~90).
The rest is the manifest reader (dep_perm_line, dep_read_perms, dep_kind_of, dep_bin_of,
the two name rules) and the tool's exclusion from the root table. One thing got SHORTER:
pkg_name_lt is now dep_str_lt, so the order a lock's rows are sorted in and the order a
permission set is sorted in are one function and cannot disagree about what sorted means.
Globals: 443/512, unchanged. Every table added is a field in dp (PK_KIND, PK_NPERM,
PK_PERMS, PK_ROOT) or in pk (PKS_NACC, PKS_ACC, PKS_LONG, and seven VR_* columns).
2. The tool is excluded from the root table, not given an empty root. dep_read_lock used to
register one include root per lock row, index-parallel with the package table, and dp_set_dir(i)
wrote lex_set_root_dir(i). A tool row must have no root at all -- with one, lex_root_first
would match its NAME and libs_open would join a directory that was never resolved -- so the two
tables stop being parallel and PK_ROOT is the map, -1 for a tool. Edges whose end is a tool are
dropped. deps_apply skips a tool row whole: no dep_resolve, no dep_scan, no hash. Measured:
tests/pkg/perm builds to a byte-identical object with the [tools] table deleted and with
the tool's tree deleted from <libs> (check-pkg § 31e).
3. [tools] is validated by mc build and resolved by mc pkg. deps_apply checks the NAME
at its own position, exactly as it does for [replace], and does nothing else: a project whose
only dependency table is [tools] reads no lock and opens no file. pkg_read_deps is what turns
the rows into MVS requirements, with SL_TOOL marking which table asked -- that mark is the lock's
kind and the "tools required by this project" block, and nothing else.
4. Deviation: the permission block is printed only when something with a non-empty set is
unaccepted. § 4.2 says "one block per package whose set is not already accepted", and § 4.3 makes
a row absent from the old lock unaccepted. Read literally, the FIRST mc pkg sync of a plain
library project -- every existing project, every fixture -- would print (none: stdio only) for
every package and end with "to accept the permissions above", where there is nothing to accept.
So the rule implemented is: the block appears when at least one unaccepted set is non-empty,
and it then lists every unaccepted row, (none: stdio only) included, which is exactly § 4.2's
example. An empty set therefore never turns a silent sync into one that needs --yes.
Consequence, measured: tests/pkg/sync's plan is byte for byte what it was before this milestone.
5. The manifest is validated before the tree is blessed. pkg_fetch_one calls pkg_read_meta
-- kind, bin, permissions, all refusals live -- after the hash comparison and before
pkg_write_manifest. A refusal there leaves no cache manifest, so the tree is is not fetched and
not "a package that says something impossible". check-pkg § 31h asserts the absence of
v1.0.0.toml for all four refusals.
6. The kind is asked before anything else about a row. fs.delete was reported as
fs.delete takes no path in the first draft, because the per-kind rules ran before the vocabulary
check. It is now a permission kind is fs.read, fs.write, net, exec or env, at the kind key.
7. mc pkg check compares five more things -- kind, bin, licence, permissions,
tools -- because a row is what a consumer reads before fetching: an under-declared row asks for
consent to the wrong thing. Both permission sets are canonical and sorted, so equality is position
by position and needs no search. Proved in both directions in check-pkg § 31g: the fixture row
passes, and a row with permissions = ["net"] over an archive that asks for two is
the row's permissions are not the archive's, exit 1.
8. The two goldens, and why each line moved.
tests/pkg/sync/mc.lock.expect: every key of every row is re-padded from 8 to 11 columns (the
width of permissions), and each row gains permissions = [] -- the empty ACCEPTED set, which is
not the same claim as a row that predates the key. Hashes, versions, order: unchanged.
tests/golden/pkg-list.txt: each of the two lines gains one word, stdio. vendored is exactly
8 characters, so the new road column's padding adds nothing and the rest of the line does not move.
tests/pkg/perm/mc.lock.expect is new.
The four hand-written locks in tests/pkg/*/mc.lock were deliberately not re-recorded: they
are the compatibility case (§ 1.8), they still carry neither kind nor permissions, and
check-pkg asserts that they do not and that the chain builds with them.
9. What C1 does not do. It does not install, build or run a tool -- mc tool is C3 -- so a
[tools] row is fetched into <libs> like any package and recorded, and nothing more happens.
It enforces no permission: on this side a permission is read, printed, compared and recorded. It
adds no mclib (M1) and touches no sandbox (C2).
Implementation notes -- C2, the windows/x86_64 finding #
C2 was green on macOS, on all four Linux cells and on mc on windows/arm64 host, and red -- twice,
deterministically -- on mc on windows/x86_64 host: the cross-compiled compiler linked, answered
mc --host correctly, compiled a small program, lexed and PARSED its own source (--dump-tokens,
--dump-ast, exit 0 each), and then died with no output at all while COMPILING it. The defect is
not in anything C2 wrote.
1. What the static pass said. Cross-building the windows/x86_64 compiler object from
origin/main and from the branch and diffing --dump-asm --machine=x86_64-win of
src/mc_windows_x86_64.mc (normalising the l_strN indices, which renumber): 1712 functions in
common, 53 different, all of them sb_*, plus 33 new ones. Per-symbol disassembly of the two COFF
objects agrees: every core function's machine code is byte for byte identical. sub rsp, N maxima
identical (1152, under a page -- no __chkstk question). Section sizes, symbol counts and
relocation counts all far from any ceiling (14457 relocations against the 65535 sentinel). So the
compiler's own code did not change; only its data and the source it was fed.
2. The oracle. No Windows machine here, but wine under --platform linux/amd64 runs the
binary: the main compiler reproduces its CI success (exit 0, mc-windows-x86_64.obj 1388708 bytes,
the CI byte count) and the C2 one reproduces the failure in 1.1 s. WINEDEBUG=+seh named it --
EXCEPTION_STACK_OVERFLOW, c00000fd, stack 0x20000-0x21000-0x820000, i.e. all 8 MiB gone. Two
cross runs then settled where the fault lives: the C2 binary compiles main's source (exit 0) and
main's binary crashes on C2's source (exit 1). It is the INPUT, not the code.
3. The cause. fold (src/parse.mc) recursed on nd_next:
i64 nx = fold(nd_next(n));
set_nd_next(n, nx);
A sibling chain is a list, and a global array initializer is one node per element -- so the stack
depth was the LENGTH of the longest list in the program. src/bundle_data.mc is exactly that shape:
the M21.5 deviation keeps u64 bundle_blob[] = { ... } on disk, because the frozen seed's lexer has
no #embed. main: 71739 elements. C2, whose bundle carries the new sandbox sources: 73045.
fold's frame is 64 bytes of locals; per level that is 8 (return address) + 8 (saved rbp) + 64 +
32 (the Win64 shadow space, held across the recursive call) = 112 bytes, against 80 under
AAPCS64 and System V, which have no shadow space. 71739 x 112 = 7.66 MiB, fits; 73045 x 112 =
7.80 MiB plus the callers, does not. fold runs on the compile road only -- src/cli.mc returns for
--dump-ast on the line above it -- which is why the parse smoke passed.
Measured ceilings with a generated u64 big[] = { ... } and nothing else in the file: the
windows/x86_64 compiler took 74000 elements and failed at 75000; the linux/x86_64 one took 100000
and failed (SIGSEGV) at 120000; the macOS arm64 one failed at 150000. Every host had the same
defect; the 40% bigger Win64 frame is only what made it arrive first.
4. The fix, src/parse.mc, +30/-16 with the comment, 8 code lines: fold walks the SIBLING
chain with a loop and keeps the recursion for the CHILDREN, so the depth is the nesting and nothing
else. fold always answered the node it was given, so set_nd_next(n, nx) was a no-op and the
loop's shape is the same traversal in the same order. After it: 400000 elements compile on
windows/x86_64.
5. The gate. scripts/check-mc.sh generates a 150000-element chain, compiles it with the
host compiler, links it and runs it (exit 42). Generated and not committed because 150000 elements
is 300 KB of source; it is the one program in that script the repository does not carry. It is
there and not in tests/windows/ because that corpus is cross-compiled on macOS and only LINKED and
RUN on Windows -- it would never exercise the Windows-hosted compiler's stack. check-mc is in the
Windows and Linux make check subsets, so the case runs on every host. Proved to have teeth: with
the pre-fix compiler it is FAIL long-list (compilation: ) -- the empty message is the SIGSEGV.
6. Not done, on record. The linker's default 8 MiB stack reserve is left alone
(scripts/link-windows.sh passes no -stack:). Raising it would have hidden this one instance on
one host and left the defect -- a compiler whose stack depth is a function of a list's length --
everywhere else. docs/reference/objects.md § 4c gains the frame-cost paragraph so the next person
measuring a Windows-only stack failure starts 40% ahead.
Implementation notes -- C3 review (the hostile-tool escalation) #
The review found that a malicious tool package (its mc.toml is fetched from a registry, so
attacker-controlled) could escalate its sandbox grant past what the user accepted, by two
independent holes, plus three lower-severity issues. All five are fixed in src/deps.mc,
src/pkg.mc and src/tool.mc; stage0/ is untouched. Each was reproduced against the pre-fix
compiler first (scripts/check-tool.sh § 6 carries the cases so they cannot regress).
1. Unescaped TOML re-serialization (BLOCKER, finding 1). mc tool install wrote
[project].out -- read verbatim from the tool's tree -- into ~/.mc/tools/<name>/v<ver>.toml with
no escaping. TOML basic strings decode \" and \n, and src/toml.mc has no duplicate-table
check, so out = "app\"\n[tool]\npermissions = [\"fs.write home/../../..\"]\n#" spliced a SECOND
[tool] table -- with its own permissions -- into the installed manifest; toml_count /
toml_get_array then returned every occurrence, and the spliced write was granted at run.
Reproduced: the pre-fix manifest carried two [tool] tables and permissions =
["fs.write home/../../../../tmp/pwned"]. Fixed with toml_esc(s) (src/deps.mc): it doubles \
and ", escapes \n/\t/\r and refuses any other control byte, and is applied to every
free-text value the three TOML writers embed -- tool_write_manifest (out above all, and name,
bin, sha, each permission), pkg_write_manifest (name, version, url, sha, each file path),
pkg_write_lock (name, version, bin, lib, the path of a [replace], each dep name, each
permission, the hash) and pkg_dep_line (the version). The sweep found no other place this
compiler generates TOML from fetched input; the sysroot writers emit .def/.tbd, and limits.mc
and the generated-compiler writer emit their own numeric/name data.
2. tool_run trusted the stored permissions (BLOCKER, finding 2). It read
tool.permissions back and handed each to the sandbox-flag mapper with no validation, and
tool_resolve_path's fallback turned an unrecognized string into the whole workspace, writable.
Combined with (1), an injected fs.write home/../.. was applied though it was never shown by the
install table, never gated by --yes, never containment-checked. Fixed two ways: tool_run now
runs every stored line through dep_perm_line_ok (src/deps.mc, the inverse of dep_perm_line --
1 exactly when this compiler could itself have written the line) BEFORE any flag is mapped, on
every host, and refuses the install manifest carries an invalid permission: <line>; and
tool_resolve_path's fallback is now a hard error, never a default. Reproduced: a hand-corrupted
manifest with fs.write home/../../tmp/pwned ran unrefused pre-fix and is refused post-fix.
3. version became a path unchecked (finding 3). [[versions]].version (and a lock or
installed-index version) was concatenated into <libs>/<name>/v<version>/ and
~/.mc/tools/<name>/v<version>/ with no charset check, so version = "../../../../tmp/evil" staged
a tree outside the package root (reproduced: into <libs>/vt/v../../../../tmp/evil/). Fixed with
dep_ver_ok (SemVer charset, non-empty, no leading dot, no ..) enforced at the row in
pkg_index_read and in dep_read_lock (each toml_err_key, at the key), and a belt-and-braces
dep_ver_path guard the four path builders call the instant a version becomes a path.
4. Capacity mismatch (finding 4). DEP_MAXPERM (32) exceeds the box's per-kind ceilings
(SB_MAXRO/SB_MAXRW 16, SB_MAXBIN/SB_MAXENV 8), so a set of e.g. 20 fs.read rows was
accepted and locked at install and then refused at every mc tool run. tool_check_capacity
(src/tool.mc) counts the per-kind roots the set maps to and refuses at install, before the user
is asked to accept it (too many fs.read permissions for the box: 20 (at most 16)). The ceilings
are copied into tool.mc with a comment, not shared from sandbox.mc, because <mc/core_pkg> is
compiled before <mc/core_sandbox> and mc-slim omits the sandbox; scripts/check-tool.sh drives
a set past fs.read so the two cannot drift silently.
5. The (none) row (finding 5). A tool that declares no permission now boxes with its own
install tree bound read-only (--ro <tree> --at-path), matching § 4.1's row and
docs/reference/tools.md. Implemented in tool_run for the zero-permission case.
On record: mc sandbox exec always overlays the binary's own directory as /src, a
copy-on-write overlay, so the tool's current directory is technically writable; but every write
lands in an ephemeral upper layer on the box tmpfs, the host tree is never touched, and the writes
are discarded when the box exits. A literally read-only /src for exec is a future sandbox mode
and was not built here (it would need a new box mount mode, and would otherwise destabilize the
frozen M43 sandbox behavior). The security property a zero-permission tool must have -- it cannot
reach your files and cannot persist anything -- holds today; scripts/check-tool.sh proves boxed
that it cannot create a host-visible file, and the residual is documented in
docs/reference/tools.md § The one mapping.
NIT (finding 6). scripts/check-surface.sh silently fell back build/mc_seed -> build/mc1
when the seed compiler was absent. It now prints which compiler it used and warns loudly on the
fallback, since a missing mc_seed in CI is a regression, not a benign substitution.
Implementation notes -- C3 review, the consumer's four (0.16.1) #
Four defects reported by the consumer (teko) from a real run against 0.16.0, each with a pure
reproducer, each fixed at its root. scripts/check-tool.sh § 7 is one case per defect, 27 -> 31,
and all four FAIL against the pre-fix compiler (27/31).
1. mc tool install DIR ignored [tools] when the project had no [deps]. deps_apply
(src/deps.mc) counted deps. keys alone and returned on nd == 0 BEFORE dep_read_lock, so
dp_npkg() was 0 and tool_install_project printed no tools required by this project, exit 0 --
with a valid lock naming the tool sitting beside the manifest. Adding one [deps] row to the same
project made it install, which is what the consumer measured. The lock is the answer to BOTH
tables, so a tools. key counts towards nd and the lock is read when EITHER table has rows. One
root, every consumer: mc build, mc pkg list|verify|vendor and mc tool install DIR all reach
the lock through this one function (pkg_read_deps, the mc pkg sync road, already counted
tools). The consequence, documented: a [tools]-only project now needs an mc.lock for
mc build too, and without one says mc.lock is stale with its run: line, exactly as a
[deps]-only project does. Inert for every fixture in the tree -- tests/pkg/perm is the only
one with a [tools] table and it has [deps] beside it, so nd was never 0 there.
2. A [replace]d tool was half-applied. The tool was fetched from the registry and then
tool_stage's pkg_write_lock resolved the project's [replace] path against the STAGED tree --
tool_stage repoints cfg_file() at ~/.mc/tools/<name>/v<ver>/mc.toml so the vendored deps/
is found -- and died mc: cannot open: <tools>/<name>/<path>/mc.toml after the download.
Reproduced verbatim with [replace] hello_tool = "../toy".
The rule chosen is IGNORE, and the spec supports it: [replace] is Go's replace (M44 D11),
an override for a tree the BUILD compiles, and deps_apply step 4 already skips every tool row
before dep_replace is consulted -- a tool is not compiled into your program, it is a program
installed under ~/.mc/tools at the version the lock pins. Honouring it would have meant a second,
fetch-free install road for an unpinned tree with no hash to record, contradicting the row the
build already skips. So the whole mc tool road ignores the table -- for the tool AND for the
libraries staged beside it, which tool_stage copies from <libs> and not from a replace path --
and announces it once per replaced name in the build list, above the plan:
note: [replace] <name> = "<path>" is ignored by mc tool: a tool is installed from the registry.
Nothing dies after a download.
3. A constraint mc pkg sync refuses reached the install road. deps_apply validated a
deps. value with ver_parse (step 3) and a tools. value not at all, so [tools] x = "^0.1"
was accepted, fetched, staged and BUILT, and the install table printed the nonsense
x >= ^0.1. It is now the same ver_parse + ver_bad_msg() at the key's own file:line:col,
exit 2, in step 1 -- before the lock is read and before anything is fetched. It is in step 1 and
not step 3 because step 3 compares a constraint against a lock row and a tool's version comes from
the lock: the only thing to say about ^0.12 is that it is not a constraint.
4. The permission table lost its column gap. pkg_pad(s, w) pads to w and no further, so a
value at or past the width got nothing: a 24-byte fs.write workspace/build in a 22-wide column
ran into the sentence -- fs.write workspace/buildmay create, change and delete files under build
.... The what column has the same shape (<name> <version> passes 17 bytes on a long name).
Fixed where every OTHER caller already puts it: mc pkg list and mc tool list write their own
space after each column, and the permission table now pads to w - 1 and writes that space -- the
same total width for every value that fits, a guaranteed gap for one that does not.
The first draft forced the space inside pkg_pad instead and it moved an unrelated golden:
mc pkg list's hash column is xstrdup(h, 12) in a 12-wide column, exactly at its width, so
every row of tests/golden/pkg-list.txt gained a space (measured: check-pkg 192/194). The
helper is back to what it was and the goldens are byte for byte.
The seed's MAXFUNCS is what shaped the code. lib/mc_i128.mc is #include "../src/core.mc"
plus a module and sat at 2046 of the frozen seed's 2048 functions; the first draft added three
(pk_norepl, pk_set_norepl, tool_note_replaced) and build/mc0 --dump-asm lib/mc_i128.mc
became mc: too many functions, which check-asm compares byte for byte and stage0/ may not be
edited to fix. Collapsed to one: tool_ignore_replace() prints the notes and then sets the
flag itself (idempotent, so a two-tool project says it once), and pkg_replace_path reads
ld64(pk_state() + PKS_NOREPL) where the record is declared -- the shape pkg_list already uses
for PKS_LONG. 2047 of 2048: one function of headroom for the whole repository. (The seed
exhausting its 64 MiB arena on build/mc0 lib/mc_i128.mc -o x.o predates this change and no gate
takes that road -- check-lex/check-ast/check-asm only dump.)