Changelog
All notable changes to Mutant are recorded here.
All notable changes to Mutant are recorded here.
The format follows Keep a Changelog and this project adheres to Semantic Versioning. What "breaking" means for artifacts, builtins, the language and the CLI is defined in CONTRIBUTING.md.
Entries for v2.4.0 and later are written from the release notes and the commits. Entries before that are reconstructed from git history and are summaries, not exhaustive lists.
[Unreleased]#
[2.6.1] — 2026-10-13#
A workshop and documentation patch. The compiler, the VM, the builtins, the language server and the CLI are the same code as in 2.6.0; only the version they report differs. Every known issue listed under 2.6.0 still applies.
Added#
- The three DFRWS deep dives are standalone programs in
examples/workshop/dfrws/. Each imports nothing and names its input on one line, so it runs on the workshop's evidence or on evidence the room brings.1_timestompfinds a file whose$STANDARD_INFORMATIONcreation time is earlier than its$FILE_NAMEone, on every NTFS partition of an MBR or GPT disk or on a bare volume, and refuses an E01 by name.2a_ad_redirectswalks each Chrome redirect chain back to the visit that began it and labels server and client redirects.2b_ad_cookiesmarks each cookie whose site the history never visited.3_run_keysreads theRun*keys of an NTUSER.DAT or SOFTWARE hive, Wow6432Node included. The shorterstomp_two_clocks,ad_redirects,ad_cookiesandrun_keysstay beside the beats and read only the workshop's own evidence. - The workshop evidence builds one topic at a time.
build_evidence.ps1 -Topicandbuild_evidence.sh --topictake any oftimestomp,case,adsandregistry; with none they build all four.evidence/topics/holds one fsagen playbook per deep dive, each made of steps copied unchanged fromquilldrop.playbook.yaml, andsweep/workshop_topics_test.gofails if a copy drifts. When Defender refuses the case tree's generated PE, the ads and registry topics are still built, so the deep dives keep their input.
Changed#
- The workshop needs fsagen v0.4.0 or later, and pins no version. The build
scripts ask
fsagen features --jsonfor the eight features the playbooks use and stop, naming any that is missing. The disk image is now GPT with a change journal, the case playbook turns onsubsecond_jitter, and Chrome's files sit where a real profile keeps them (User Data/Default/HistoryandUser Data/Default/Network/Cookies). - The workshop's corpus counts are measured again, on fsagen v0.4.0: 100
files and 136 timeline rows on NTFS, and 100 and 135 on ext4, which has no
alternate data stream to record and no birth time to set. The run sheet,
evidence/README.mdand both rehearsal scripts said 98 and 131. - The workshop names 2.6.1 as the release attendees download, and the rehearsal scripts expect it. Every program still calls only builtins 2.6.0 has.
- Examples and guides name roles, not people.
alice,bob,caroland the examiners' names becameperson_a,analyst,example examinerand the like, in the detection, graph, text and workshop examples (their recorded output with them) and in GRAPH_DATABASE.md, MUTANT_LANGUAGE_REFERENCE.md and STRUCTURED_DATA.md. - The VS Code extension is 0.3.1. It names Mutant 2.6.1 and takes a development-dependency update (brace-expansion); it behaves as 0.3.0 did.
Fixed#
- Workshop beats 02, 03, 04, 07 and 09 stopped on a partial corpus with
index operator not supported: NULL. Each now checks that the file it reads is there before reading it. Their recorded output is unchanged. - Three of the shorter workshop programs no longer worked on the evidence.
stomp_two_clocksstopped withindex operator not supported: NULLon the GPT image, because it opened the first partition, the EFI system partition, as NTFS; it now reads every partition that opens as NTFS and skips a record with no$STANDARD_INFORMATIONcreation time.ad_redirectsandad_cookieslooked for Chrome's files where the profile no longer keeps them and always reported them missing. rehearse.shreported 2.6.0's once-per-run Linux notice,[security] process protection measured nothing on linux, as unexpected output. It now counts the notice and mentions it once at the end.
[2.6.0] — 2026-10-12 — "Sage"#
Every answer checked, or refused out loud.
Added#
A release gate you can run.
scripts/release_gate.shandscripts/release_gate.ps1run every check a release has to pass -- the toolchaingo.modpins, gofmt, vet on three platforms, the six build targets and the WebAssembly REPL, the tests, the race detector, fuzzing, the generated documents, the example sweep against its recorded output, module verification, the changelog date against the tag, and govulncheck -- and print one table. A reachable vulnerability fails a full run;--quick/-Quickskips that step unless--vuln/-Vulnasks for it. Everything it builds is what a release ships: the pinned toolchain withCGO_ENABLED=0, the race detector's test binaries alone excepted, and the sweep runs a binary the gate built rather than one built by whatevergois first on the PATH. Every choice is a parameter; nothing is read from the environment and nothing is written into the working tree -- a log directory inside the repository is refused, since the Go files the gate writes there would join the module it is checking.scripts/coverage.shandscripts/coverage.ps1measure unit and cross-package coverage per package through a newcmd/covreport. There is still no CI: this is the command a contributor runs, and the one the owner runs before tagging.Every limit is named, and a guard keeps it that way. A value that bounds what Mutant will do -- how much it reads, how deep it recurses, how long it waits, how many entries it keeps -- is a constant with a
//mutant:limitdirective and a written reason, anddocs/LIMITS_REFERENCE.md, a third artifactgo run ./cmd/gendocsgenerates, lists each with its value and the flag that overrides it, if one does.go test ./policy/fails on a limit written as a bare literal in any of ten recognised shapes; the 174 already in the tree are held in a per-file budget that can only shrink. See section 6 of CONFIGURATION_POLICY.md.The documentation is checked against the tree. Every
mutantfence compiles, allowing for names an earlier fence defined; everyname(in prose is a registered builtin; everymutantcommand line uses flags the binary spells, and none teaches the retiredrun,-pwdor--passwordforms; every path a document or a Go comment cites exists in a clone; every relative link and anchor resolves. The examples check walks every directory now, and also fails when a README names a program that is not there. The version the documents state is held toglobal.Version: the tutorial's sample output, SECURITY.md's supported-versions table, this file's first release and compare links, and a newmutantLanguageVersionfield in the extension's manifest.Examples can record what they print.
go run ./cmd/sweep --goldencompares each reproducible example's output with a.goldenfile beside it, and--update-goldenwrites them; output that varies between two runs of the same bytecode gets no golden, because one sample of it is not its value. A golden recorded on one OS checks a run on another: the separators in a source position and the few system errors Windows words differently are normalized, and the evidence fixtures inexamples/dataare checked out byte for byte. A new// mutant:sweep needs-input -- <what>marker compiles an example that needs evidence, a network peer or a passphrase, or that reports on the machine it runs on, and counts it separately, so the number of examples whose output was actually checked is never inflated. A difference between two mutation levels fails the sweep only if it repeats when both are run again, so output that carries the time is no longer reported as the engine changing a program.db_compact, and a memory budget fordb_open_disk. Graphene v0.9.0 compacts a disk store's write-ahead log on request, and caps the memory a disk store may use; v0.4.0 had no memory configuration at all.Integrity that is checked, not only reported.
ewf_metadatahas always returned the MD5 and SHA-1 an acquisition tool stored inside an E01 image, and nothing compared them with the data.ewf_verifydoes: it re-hashes the image, reports each digest's match beside whether one was stored at all, and lists the bad ranges. An image that stores no digest is not verified, rather than verified by default.ewf_segmentsfinds a segment set from its first file and names any segment missing from it, andewf_open_partialopens an incomplete set on purpose rather than by accident.Six
*_verifybuiltins --ntfs_verify,fat_verify,xfat_verify,ext_verify,hfs_verifyandxfs_verify-- run whatever integrity checks each filesystem library carries. Every check reportscheckedbesidepassed, and a volume isverifiedonly when at least one check ran and every check that ran passed; one boolean would have an HFS+ volume assert a check its format does not contain.fat_verifywalks the directory tree before it reads the FAT mirror comparison, because that comparison happens only as entries are looked up, and a volume holding only root entries would otherwise report verified with no comparison made.Security events are recorded, not only counted.
security.auditEventwas a no-op at fourteen call sites, so a manifest could say the debugger check tripped eleven times and nothing could say when. It now feeds an append-only, length-prefixed hash chain, read throughaudit_head,audit_writeandaudit_verify. The head is bound into the case manifest inside the existing seal, andaudit_verifytakes the head as an argument, because a log checked against the head stored inside it has been checked against a value its own writer chose. At most 4096 entries stay readable; the head still commits to every event, andentries,retainedanddroppedare three numbers. Every document says what it does not cover: an edited entry breaks every link after it, and no document can show that a whole document is missing.A partition opens where it lies. The second argument to
ntfs_open,fat_open,xfat_open,ext_open,hfs_openandxfs_openis a byte offset or, better, a row straight fromtable_list_partitions, which carriesstart_byteandlength_bytetogether so that no line of a script does arithmetic on an offset. Nothing is carved out first. The offset goes to the library rather than to a wrapping reader, so every offset a filesystem reports stays image-absolute: a partition-relative offset looks exactly like an absolute one, and intersecting the two succeeds and is wrong. Every open the case manifest records now carriesvolume_offsetandvolume_length, so two partitions of one image no longer read as the same open twice.Files stream rather than load whole. Eighteen builtins, three verbs across the six filesystems:
*_extract_filewrites a file out a megabyte at a time and digests it on the way past,*_hash_filedigests it where it lies, and*_read_file_atreturns one window of it. A file larger than memory is reachable, including a disk image inside a disk image. Each reports the recordedsizebesidelocated_bytesand never reads past the latter: a FAT entry whose chain broke at deletion still claims its original length, and the clusters past the recoverable prefix now belong to some other file. A short chain extracts its prefix and saystruncated: true, and the digest covers what was read, never what was claimed. Extraction refuses a destination that exists and removes its partial output on failure.Which partition table is true can be the script's decision. A disk that two schemes both describe was resolved by a preference the script never saw.
table_openstill resolves by preference and now lists every candidate it passed over;table_open_strictrefuses and names them;table_open_asforcesmbr,gpt,bsd,sunormac, so an examiner records having decided; andtable_open_allreturns a handle per scheme -- on a hybrid disk the only route to the MBR's real entries.table_detectasks without opening anything, andtable_nestedreaches a disklabel inside an MBR slice with offsets computed by the inner table.table_opengainedgpt_backup: the two GPT copies are written together, so a mismatch is a tamper indicator rather than a health check. Every partition row now says what it is: only anallocatedrow is a volume, and the rows markedoccupies_spacetile the device exactly once.What the filesystems remember about files that are gone. Seven builtins --
ntfs_deleted,fat_deleted,xfat_deleted,ext_deleted,hfs_deleted,xfs_deletedandxfs_unlinked-- give every supported filesystem the deleted-entry listing that existed only for NTFS resident data. The field that matters iscontent_state, not arecoverableflag, because six formats destroy six different things at deletion: ext4 zeroes the extent tree and leaves the file fully described and wholly unlocatable, while exFAT'sNoFatChainkeeps a layout the volume stated while the file was live. Eight*_recover_filebuiltins write what remains back out, addressed by the index the scan gave the entry, and count three kinds of zero apart: bytes read from the image, sparse holes whose zeros are the content, and unlocated ranges written only so that later offsets land.fat_recover_file_assuming_contiguousand its exFAT twin are separate names rather than a flag, so the hypothesis is written at the call site.Journals. Nine builtins read the one structure in these formats that records the past: NTFS's USN journal and
$LogFile(ntfs_usn_journal,ntfs_log_records,ntfs_log_transactions), ext's JBD2 (ext_journal,ext_journal_block_copies,ext_journal_inode_versions,ext_recover_journalled_file) and the XFS log (xfs_log_records,xfs_log_transactions). All three are written round and round, so every scan says whether it crossed the wrap, and where it did the array is not a timeline and nothing here reorders it.$LogFileand the XFS log carry no clock, so what they give is an ordering, not a timeline.ext_recover_journalled_fileis the one route here to an ext4 file the filesystem itself can no longer locate. ext's fast-commit records are deliberately not read: they carry no sequence and no timestamp.Slack, and the other thing called slack.
ntfs_slack,fat_slack,xfat_slack,ext_slack,hfs_slack,xfs_slack,ext_dir_slackandhfs_unallocated. File slack lies past the recorded size; unwritten space lies inside it and was never written, and only four of the six formats record that second boundary. Every range carries its class rather than merging the two. These address a live file by path, so the slack of a deleted file is out of their reach, and the reference says so.Content and labels a path does not address.
ntfs_streams,ntfs_read_stream,ntfs_extract_stream,ntfs_security,ntfs_security_descriptors,ntfs_reparse,ext_xattrs,xfs_xattrs,hfs_xattrsandhfs_resource_fork. An alternate stream or a resource fork is a second body of bytes; a descriptor or an attribute asserts something about the file and holds none of its content, and the two are never summed. A descriptor with no DACL grants everyone access and one with an empty DACL denies everyone, sodacl_presentis its own field.Virtual disks as the three things a byte stream hides.
vhdi_extents,vhdi_chain,vhdi_changed_extents,vhdi_changed_since,vhdi_probeandvhdi_discover: an address space that is mostly absent, a device spread over several files, and a change record answerable at block level. A range with no bytes behind it reportsfile_offset: -1rather than the zero the library returns, because on a dynamic VHD byte zero is the footer's mirror. A VHD chain cannot record a deletion at all, anddeletions_expressiblesays when that applies.vhdi_proberegisters the image it names as evidence;vhdi_discover, which is asked about a directory, registers nothing.What a filesystem can record, and what this one did. Six
*_capabilitiesbuiltins report each library's own capability set in three states -- supported, unsupported and unanswered -- because a question the library never answers is not a no: HFS has recorded creation dates since 1985 and libhfs does not declare it. Each answer says whether it came from the format or from the volume in hand. Six*_reportbuiltins walk a whole volume into one row shape, andevents_from(report, "fs_report")carries any of them into the ECS, OCSF and Timesketch emitters.completemeans the walk had no known gap and nothing more; onlyxfs_reportcan reconcile against the volume's own inode counters, socompleteness_checkedandcompleteness_provenare separate fields.A forensic ledger. Thirty-nine
ledger_*builtins, in a newforensic ledgercategory, put graphene's signed-store layer within reach of a script.ledger_openhas a fixed strict posture and no options hash: every commit signed and attributed, and a retention policy that keeps every commit's actor, timestamp and signature through compaction.- Proofs:
ledger_prove_node,ledger_verify_proof,ledger_proof_describe,ledger_root_exportandledger_verify_chain. The root is always an argument, never read from the proof. - Custody and anchoring:
ledger_custody,ledger_custody_anchored,ledger_checkpoint,ledger_checkpoint_history,ledger_verify_anchorandledger_verify_store. The witness is an argument, and graphene's insecure local anchor is not reachable from the language. A write made since the last compaction moves none of the six heads a checkpoint binds, and graphene reports that as clean;ledger_verify_anchorreports it as a gap. - Attributed redaction: four
ledger_redact_*scopes, each requiring a reason,ledger_redaction_impactto preview a cascade,ledger_redactions, and threeledger_prove_*redactionproofs. A redaction does not remove the content from the ledger directory -- the retired log segment that retention keeps for attribution still holds it -- and every redaction reportsretained_segmentsrather than implying otherwise. A reason containing one of the redacted values is refused, because graphene publishes the reason where it cannot be redacted. - Reads:
ledger_node,ledger_edge,ledger_provenance,ledger_path,ledger_subgraph,ledger_patterns,ledger_query_nodes,ledger_explain_query, fourledger_declare_*index declarations andledger_indexes. Declaring a key ordered changes its range comparisons from numeric to byte-wise -- over9, 10, 1x, 100, 2, abetween "2" and "100"goes from four records to none -- soledger_declare_orderedreportsorder_differsbefore it declares, andledger_query_nodesreports which comparison answered.
db_open_diskrefuses a ledger directory: one unsigned write through it would leave a store the strict open then refuses entirely.- Proofs:
mutant graph exportandmutant graph query.exportwalks an entry file's whole import closure and writes a graphene store of its symbol graph: a node per module and per declaration, joined byDECLARES,ENCLOSES,REFERENCESandIMPORTS, withUSES_TYPE,CONSTRUCTSandMATCHESas further labels on a reference that names a type, builds a struct or is compared against in a match arm. A struct or enum another module declares is resolved the way the compiler resolves it, against the modules compiled first; a use the compiler would refuse has no edge, and the export says how many.queryreads the store back through eleven named questions --summary,modules,where,callers,callees,outline,exported,types,type,depsandrdeps-- rather than a filter surface, because in this engine a filter on an unindexed key matches nothing and returns no error.depsandrdepsfollow each import in its own direction and keep every one, which graphene's own walks do not. The store is identified by its own label table before anything opens it, and a store with no lock file is read live rather than have one created for it, so the first question put to a restored store does not modify it. Each answer says what it does not cover:callersis complete within a module and a floor across one, and a question that read every record to answer says so.Classified records.
case_key_create,case_key_open,case_key_rotateandcase_key_fingerprinttake a path, never key material; the passphrase is asked for at the terminal, and an options key namedpassphrase,password,secretorkeyis refused by name.class_defineandclass_listpre-declare the labels a byte range may carry, so a typo is an error rather than a new secret class. Tenrecord_*builtins, in a newclassified recordscategory, seal a file into a.mreccontainer: ranges of uniform classification, split into segments of at most 64 KiB, each segment its own XChaCha20-Poly1305 unit under a key derived from its record, index, offset, length and class, so a segment moved anywhere else fails to open rather than only failing its tag.record_verifyauthenticates the whole file holding no key.record_readerrors when a requested span overlaps a segment it cannot decrypt, andrecord_read_partialreturns{bytes, holes}as a separate contract.record_seal_quantisedrounds boundaries outward so a short secret does not advertise its length, and requiresrounds_to, the one class rounding may grow, because every byte it grows over changes class. Rotating the case key invalidates no grant already issued.Disclosure: a grant of segments, sealed under a passphrase.
view_define,view_listandview_preview, in a newdisclosurecategory, name a set of classes. A view cannot negate, because "everything except restricted" widens by itself each time a class is declared after it.view_previewis arithmetic over the public header -- no key, no plaintext -- and states a rounding in the terms of the view in front of it.disclose_to_passphraseissues a grant of exactly the segments a view covers, 56 bytes of key and nonce per segment, sealed under a passphrase, and commits it to the ledger before handing it over; a ledger failure means nothing was issued.disclose_bundlewrites one disclosure's package: the record byte for byte, the sealed grant, the ledger's inclusion proof, a report, a manifest sealed and signed over all four, andSHA256SUMSlast. The snapshot root is returned and not packaged, anddisclose_verifyrequires it as an argument; a null root is recorded as a finding, never read as a pass.disclose_withdrawreturnsbytes_recoverable: false, because a recipient holding the ciphertext and a key holds both halves, and no builtin is namedrevoke.disclose_history,disclose_for_segmentanddisclose_reclassifiedanswer who holds what, and who holds segments whose classification has since changed.A grant is sealed under a passphrase and nothing else. That is a decision, not a gap: there is no public-key
disclose_to, and should one ever be added, the holder of a private key is trusted to keep it secret, as the examiner keeps the case-key passphrase. See DISCLOSURE_POLICY.md.Plaintext read from a classified record does not leave the process by accident. A buffer from
record_readorrecord_read_partialcarries its record and class,bytes_sliceand+of two buffers carry it on, and thirteen builtins refuse it anywhere in their arguments:putln,putf,fs_write,fs_append,http_post,http_request,report_write,report_render,case_note,cache_put,ledger_add_node,ledger_add_edgeanddb_add_artifact.record_release(buffer, reason)is the recorded way out: it needs a reason and an open case, records the release in the case timeline, and returns an unmarked copy. The mark is deliberately not consulted byInspect, which is the identity function for equality,unique,containsand hash keys. It catches accidents, not adversaries: a loop that rebuilds the buffer a byte at a time launders it. A policy guard keepsInspectand runtime format strings out of the files that handle plaintext.The editor says so first. A new
classifiedPlaintextlint rule -- a warning by default,mutant.lint.rules.classifiedPlaintext.severityin the extension -- reports a read handed to one of those builtins, directly, through a name bound once, throughbytes_sliceorrecord_read_partial'sbytes, or inside a literal array, hash or struct, at the line and before the program runs and asks for a passphrase. Its lists are the runtime's own,builtin.ClassifiedSinksandbuiltin.ClassifiedSources, and tests that read the builtin package's source hold both to the code that refuses and marks, and hold every sink to numbering arguments the way the script wrote them. A value from a user function, a name bound twice anda + bare not followed, so the rule never reports what it cannot be sure of.An example that runs the whole path.
examples/forensics/record_disclosure.mutopens a seized USB stick image, finds its one volume, verifies it and extracts a phishing email from it where it lies, seals the email with the victim's address classifiedpiiand the attachmentrestricted, discloses it to acounselview and amalware-deskview, bundles both, and re-measures custody at the end. It also tries once to write the address out, to show the refusal.examples/data/usb_stick.imgis a 100 KiB MBR disk holding one FAT12 partition, rebuilt byte for byte bygo run examples/data/make_usb_stick.go, andTestTheRecordDisclosureExampleRunsEndToEndruns the program with its seven terminal prompts scripted and checks that the email taken off the image is identical toexamples/data/phish.eml.A
db_*store can be read back. Everything the family wrote, nothing could read: properties went into graphene's index with no builtin to look a node up by one, and the only way to learn what a store held was to have been the program that wrote it. Five builtins read it (659 → 664):db_find(db, key, value, nodeType?)looks nodes up by one indexed value, and says inkey_indexedwhether the key was ever indexed at all -- graphene answers a lookup on an unknown key with nothing and no error, so an empty list alone could not tell "no host has that address" from a misspelled key;db_node(db, id)reads a node's type and every indexed property, and shows a key indexed twice as both values rather than picking one;db_relations(db, node, direction)lists a node's edges with the relation each carries;db_schema(db)counts types, indexed keys and relations; anddb_verify(db)cross-checks the indexes against the records, reportingcheckedandconsistentapart so a store that cannot check itself never reads as a pass.db_open_disktakesread_only, which opens a store without changing it -- never creating one, refused while a writer holds it, and read without a lock when it has nographene.lock, whichdb_stats' newlock_freefield then explains.db_compactreports thesnapshot_rootof the image it wrote and theprev_rootit replaced.The editor knows the words a builtin takes. A parameter can now declare the closed set of words it accepts --
db_bfsanddb_relations' direction,ledger_path's cost model -- from the builtin's own list, and a newbuiltinArgChoicerule warns about a string literal outside it before the program runs. It compares the way the builtin does, ignoring case and surrounding space, and looks only at literals.mutant.lint.rules.builtinArgChoice.severitysets it.The editor knows what the case builtins refuse. Four new lint rules, each a warning by default, report a call the run time refuses, in its own words, before the program runs and asks for a passphrase.
roleLiteral: a rolecase_openorledger_openrefuses -- a word that is not a role, a recipient's role such aslegal, orunasserted.filteredLedgerHandle: the handleledger_under_viewreturns, passed to a builtin that does not read under a view.secretOption: an option namedpassphrase,password,secretorkey, which those builtins refuse by name.lifecycleState: a statecase_transitionnever moves a case to, naming the builtin that does. Each reads what the builtin package exports --RoleOptionBuiltinsandExaminerRoleRefusal,RefusesLedgerViewHandle,SecretOptionRefusers,CaseMoves-- and tests that read the package's source or call every builtin concerned hold each to the code, so none can warn about a call the run time accepts.mutant.lint.rules.<rule>.severitysets each.A record's key is erased, and so is the case's, and a case is disposed of.
record_erase(ledger, path, reason, options?)overwrites the record key in one copy's header with zeros as wide as it, forces the write to the disk, reads it back, and records the erasure in the case's ledger with the file's digest before and after;{"preview": true}checks everything and writes nothing. It destroys that copy's key and nothing else, and itsdoes_not_erasefield says so: every other copy still opens with the case key, and a grant already issued still opens the segments it names.case_key_erase(ledger, key_path, reason, options?)is the erasure that reaches every copy: it overwrites the key file and removes it, or with{"generation": n}erases one earlier generation in the file. Both are recorded by an examiner acting as a case_owner or an administrator, and both are refused under a legal hold.case_transition(ledger, "disposed", reason)disposes of a retained case once no hold is in force, the retention period has run, every exhibit is returned or disposed of and the case key is erased, and a refusal names everything still missing.erasure_listreads the erasures back with the ledger alone,record_verifyreports an erased copy aserased-- its signature no longer holds, because it covered the bytes the erasure overwrote -- andcase_key_fingerprintsays which generations are erased. The lifecycle reader refuses a move the lifecycle does not have.A case is reviewed, and kept.
review_request(ledger, subject, note, options?)asks for a review of the attached case, of one of its records or of one of its redaction versions, and binds what it asks about by a hash: a record by its file's SHA-256, a redaction version by the digest of what it releases, and the case by a manifestcase_writewrote, which must still hash to its seal and have been written with the case where it stands -- the manifest a run renders in memory changes each time it is looked at. A review of the case moves it from active to in_review, andreview_decide(ledger, request, decision, reason)concludes it or sends it back; a decision is recorded by an examiner acting as reviewer who did not ask for the review, and a request is decided once.retention_set(ledger, until, basis)retains a concluded case until a date, andretention_hold(ledger, reason, options?)places a legal hold, on the examiner's own authority or one they name: while a hold is in forceevidence_disposerefuses, untilretention_releaselifts it.review_listandretention_listread both back with the ledger alone, andcase_transitionrefuses the moves the review and retention builtins make, naming the one that makes each.A ledger's older redactions can be reviewed.
ledger_redactions_review(ledger, through_seq, reason)records that an examiner has answered for the ledger's redactions, up to a sequence number, that were recorded with no role, by a build that may not have refused a redaction of a disclosure record. Each review is the next event of the ledger's one hash-linked chain of reviews.ledger_redactionsmarks each redaction still in questionunguardedand reportsreviewed_through,unguarded_redactions,reviews_intactandreviews_reason;disclose_history,disclose_for_segment,disclose_reclassifiedandredaction_versionsreportunguarded_redactions. See Security.A record can be searched as one of its readers could read it.
record_search(record, pattern, options?)finds a literal -- BYTES, or a STRING's UTF-8 bytes -- in what one reader of a record could read. Opened under a grant, that is the segments the grant opens; opened with the case key, the search must name a view and reads exactly the segments a grant under it would open, the partitionview_previewreports. No other segment is read from the file. A match that would need even one byte the search did not read is never reported, and every offset the pattern occurs at is, overlapping ones included and across segment boundaries, in time proportional to what was read. Each hit carries its offset, the segments and classes it lies in, and acontextof up tocontextbytes either side (default 32, at most 4096), clipped where the readable bytes stop and never zero-filled, and marked like a read's output: the sinks refuse it andrecord_releaselets it go.countis every match,hitsthe firstmax_hitsof them (default 100, at most 1000),completewhether every segment was searched, and a segment the reader should open and cannot is listed infailuresrather than counted as withheld.ignore_ascii_casefolds ASCII capitals and nothing else. The open case's timeline records every search, with the pattern only as a digest keyed to the case key -- a bare SHA-256 of a guessable term would confirm every guess -- and with no case key open, in no form at all. Literal only: a regular expression read a segment at a time would miss matches without saying so. The editor'sclassifiedPlaintextrule warns about a hit, or its context, handed to a builtin that refuses it.A script's ledger nodes can be classified, and the ledger read under a view.
ledger_classify(ledger, node, classes, reason)records which of the open case's declared classes a node a script wrote holds -- one, several, or none, which no view shows -- as a hash-linked chain of that node's classifications in the ledger. The chain names the node by id and no edge joins them, so a classified node can still be redacted.ledger_classifications(ledger, node?)lists them with the views that show each node, and with the ledger alone lists every case's.ledger_under_view(ledger, view)returns a second handle through whichledger_node,ledger_edge,ledger_provenance,ledger_path,ledger_subgraph,ledger_patterns,ledger_query_nodesandledger_prove_nodeshow only what the view shows: the script nodes classified within the classes it grants, the case's Case and Record nodes, the Classification nodes it grants, and the edges among them. No walk crosses a node the view withholds, a withheld node and one the ledger never held get the same answer, and nothing counts what was withheld. Every other builtin that takes a ledger refuses the handle by name, andledger_closedrops it. It is not access control: the program holding it holds the ledger's own handle too.A disclosure is issued against the recipient's role.
role_define(role, views)gives a recipient role --legal,external_partner,restricted_viewer, orreviewerorauditor, who are on both sides -- its bundle: the views it may be granted, possibly none, which it then says ingrants_nothing.role_assign(ledger, recipient, role, reason)records that a named recipient holds one, or ends it withnone, as a hash-linked chain of their assignments kept apart from the examiners', androle_list(ledger?)reports the bundles and who holds which role -- with the ledger alone, every case's. A bundle defined while the case is attached is written into its ledger and read back by a later attach. Roles are asserted, like an examiner's: a refusal a role causes keeps the record consistent, and the passphrase on a grant is still the only thing that stops anybody reading.Redaction versions. Every disclosure names the version of its record's redaction under its view -- what the view releases from the evidence, as plaintext byte ranges -- in a hash-linked chain per view that follows the evidence through its reclassifications. A version is written only when what the view releases changes, by the disclosure that first issues it or ahead of one by
redaction_commit(ledger, record, view, reason), so that it can be reviewed first.redaction_versions(ledger, record_uid)lists them with the disclosures issued under each, and calls every disclosure issued under a version since supersededstale. It needs no case open.An exhibit has a custody chain.
evidence_intake(ledger, exhibit, path)takes a file into the attached case: it is hashed with SHA-256 whatever the case's hash policy, written into the ledger as an EvidenceFile node that belongs to the case, and held by the examiner who took it in.evidence_release(ledger, exhibit, to, reason)hands it to another examiner the case assigns, andevidence_accept(ledger, exhibit, path)records them taking it after hashing what they were handed: a file that does not match the intake is refused, and nothing recorded, unless they state what happened with{"discrepancy": ...}.evidence_returnreturns an exhibit out of the case; if it comes back,evidence_intaketakes it in again under its own name, as the next step of the same chain, and holds what came back to the same test against the first intake.evidence_disposerecords a disposal, by a case_owner or an administrator; a disposal deletes nothing and is final. The steps form a hash-linked custody chain every reader checks -- down to each hand-off being recorded by the holder the ledger names, each accept by the person it was released to, and each hash check agreeing with its own digests -- andevidence_history(ledger)reads every exhibit's custody back with no case open. A new exhibit is refused while the case is in review, concluded, retained or disposed; the movement of one already taken in, a returned one coming back included, is refused in no state.A case keeps its state in its ledger.
case_attach(ledger)binds the open case to a ledger. The first attach registers it -- a Case node, aregisteredlifecycle event and the examiner's assignment -- and a later run's attach reads back its lifecycle state, every examiner's latest assignment, and the classes and views declared under the open key generation, tagging each class again and refusing a ledger whose tag the key does not give. While attached,class_defineandview_definewrite through to the ledger, answer a definition read back withalready_defined: true, and reportin_ledger;class_list,view_listand the manifest say where each came from, and the manifest gains aledger_stateblock.case_transition(ledger, to, reason)moves the case from registered to active and reopens a concluded one, and each state refuses what it cannot take: in review, sealing, definitions, reclassifications and disclosures; concluded or retained, sealing; disposed, everything but a withdrawal and the movement of an exhibit.case_assign(ledger, examiner, role, reason)records a role in the case or ends one withnone, andcase_attachrefuses an examiner the ledger assigns no role or a different one. Lifecycle events and assignments are hash-linked chains, and a fork is refused by every reader. The ledger's label table now also names the labels the rest of the case builtins will write, so their numbers are fixed before anything uses them.Roles are recorded.
case_openandledger_opentake{"role": ...}: one of administrator, case_owner, lead_investigator, investigator, reviewer or auditor. A ledger opened by the case's examiner takes the case's role unless it names its own, andledger_openreportsrole,role_idandrole_source. The role is stamped on every case timeline entry, written into the manifest besiderole_authenticated: false, kept by graphene on every redaction record, and written on every Disclosure, Withdrawal and ReclassEvent and on their PERFORMED_BY edges. It is never checked. Three refusals keep the record consistent with it, and each says it is not access control: a recipient role (legal, external_partner, restricted_viewer) cannot be asserted by an examiner, one examiner cannot hold two roles in one process, and a ledger opened as auditor refuses every write -- the four redactions,ledger_add_node,ledger_add_edge,ledger_compact,ledger_checkpoint,disclose_to_passphrase,disclose_withdrawanddisclose_reclassified-- and cannot create a ledger that does not exist. A script's ownledger_add_nodeorledger_add_edgecommit records the actor and not the role: graphene v0.9.0 keeps no role on a commit, and Mutant passes it anyway so a graphene that does will record it.The editor says what a macro body may not do. A new
macroSafetylint rule, a warning by default, reports a builtin a macro body calls that the compiler refuses while expanding it -- one that reads or writes a file, reaches the network, starts a process, draws entropy, reads the clock, decodes cryptographic material, or answers from state something else set. It repeatsbuiltin.MacroRefusal, the sentence the expander itself prints, so the two cannot come to say different things, and it names the spelling the author wrote:fs.writefor the dotted form,fs_writefor the flat one. Nothing inside aquote(...)is reported, because that is the source the macro emits rather than code the compiler runs -- and anunquote(...)inside one is, because that is the compiler running again. Expansion stops at the first refusal, so a build names one of them where the editor names them all.mutant.lint.rules.macroSafety.severitysets it.exec_argv(argv)runs a program with exactly the arguments given, and no shell in between. The first element names the program and the rest reach it as they stand, so a value in one is a single argument however it is spelled -- a space does not add a word and a;starts nothing. It is where a shellexec_stringdoes not know the flags for is reached, by writing the invocation out:exec_argv(["zsh", "-lc", cmd]), orexec_argv(["fish", "-c", cmd]). It is also the way to run a program with no shell at all, which is the better choice wherever a value has to reach a command line. A program that is not installed is reported in the result'serrorfield with an exit code of -1; an argv that is not an array of strings, or that names no program, is a caller mistake and comes back as an error object. The same attempt record, the same timeout and the same output cap apply as toexec_string, so it is not a way around the command controls.commandInjectionknows about it, and reports the one shape where an element is not just an argument: a value spliced into an element that follows-c,-lc,/Cor-Command, which tells the program to read a command from the next element. It looks at the flag and not at the program, because there is no closed set of shells left to recognise. A spliced element with no such flag before it is not reported, because that is the whole point of the builtin.
Changed#
A bare
--passwordis refused. 2.5.0 warned on it and promised the next minor release would require the explicit opt-in; this is that release.--passwordand--pwd, in every spelling and on every command, stop the run before anything compiles, runs or is written, and the refusal names what to use instead: the prompt (the default),--password-file,--password-stdin, or--password-insecure <value>for someone who accepts that argv is readable by every local user. The--devrefusal onreleaseno longer recommends the flag. The example sweep passes--password-insecure, and the VS Code extension's taskpasswordfield now does too.The language server reports the language release it teaches. Its
serverInfo.versionwas the constant0.1.0, a version nothing here ever had; it is nowglobal.Version, the same stringmlsp --versionprints.object.LuaPatchno longer carriesDecryptedChecksum. The field was documented as set after decryption for validation, but nothing set it or read it, and storing the value in a variable would have dropped it anyway. Validation happens where a patch runs, againstChecksumExpected. A new test stores and reloads every object type with every exported field set, which is how this was found and how the next dropped field will be.Every
github.com/aoiflux/*dependency is on its latest release.libextv0.2.0 -> v0.3.0,libfatv0.2.0 -> v0.3.1,libhfsv0.2.0 -> v0.3.2,libntfsv0.3.1 -> v0.3.3,libvhdiv0.2.0 -> v0.3.0,libxfatv1.2.0 -> v1.4.0,libxfsv0.3.1 -> v0.4.1, andgraphenev0.4.0 -> v0.9.0, which moved on its own with the namespace-fold fix below.libewfv0.2.1 andlibtablev0.2.2 were already current. No transitive dependency moved.Six of the seven needed no code change: nothing exported was removed from any of them, and the four documented breaking changes land on API Mutant does not call --
libntfs'sOptions.BaseOffset(never set here), thelibvhdivhdimap.ByteRangecoordinate space,libfat'sFATReportoffsets, andlibhfs's report keys and package clause, which an aliased import is immune to.xfat_read_fileno longer writes the file it is reading to a temp directory. libxfat extracted only to a path, so an exFAT file's content round-tripped throughos.CreateTempto be read straight back -- the bytes of an evidence file, written to shared storage, to answer a read. v1.3.0 added a reading API and the round trip is gone.The error behaviour is deliberately unchanged: a chain that runs out before the entry's recorded length is still an error rather than a silently shorter file, and it still wraps libxfat's
ErrTruncatedChain.ReadEntryalone would have returned the short content without saying so, which is why the entry is opened andLocated()checked instead.This retires one of the two entries in
policy.EvidenceWriteAllowlist. The guard failed the moment the write went away, which is what the allowlist is for; see docs/EVIDENCE_HANDLING_POLICY.md.A deleted entry's
namefromxfat_list_filesandxfat_metadatano longer ends in" (deleted)". libxfat stopped decorating the name in v1.3.0. The decoration was presentation inside the parser: it produced a name that could not be compared against the same file seen live, against another tool's output, or against an earlier reading of the same volume, and it landed in every composed path. Thedeletedkey already carries the fact, and a script that wants the old rendering appends the string itself.The
has_fat_chainandindexedkeys keep both their names and their meanings, although the methods behind them were renamed and one had its sense inverted upstream.table_open's warnings are records, not sentences. Each warning is now{code, message, lba}, beside a deduplicatedwarning_codes, where it was a string. The prose is written for a report and reworded between library releases, so a script that matched on it stopped matching without being told. A script that printed the old strings readsw["message"]; one that matched on them should branch onw["code"]instead.The editor resolves names the way the compiler does. Deciding what a name refers to moved into one walk in
semathat the compiler, the language server andmutant lintshare, replacing five separate resolvers in the server and a sixth scan that patched the gap they shared.stats.meannow has go-to-definition, hover, completion, find-references and rename across every importer, under whatever alias each file chose, and call hierarchy finds callers in files nobody has opened. Cross-file answers are scoped to the import closure rather than to every file in the workspace, so go-to-definition no longer jumps into a module nothing imported. A newunusedImportrule reports at information severity, since an imported module's top-level statements run whether or not its namespace is read.mutant lintbuilds the same workspace the editor holds rather than reading one file at a time.case_open,case_evidenceandcase_writerefuse an option they do not take. They used to read their one option (hash, orsign) and ignore everything else, socase_open(id, examiner, {"hash_policy": "sha256"})opened a case that digested nothing, and{"signed": false}signed. An unknown key, an options argument that is not a hash, and a value of the wrong type are now errors. Everycase_*,class_*,record_*andview_*builtin with an options hash also refuses a key namedpassphrase,password,secretorkeyby name, before it looks at any other argument, as DISCLOSURE_POLICY section 8 says; onlycase_key_*did before.sandbox_statusreports only probes that measure something. Three of its probe signals measured nothing:acpi_pciandgpu_featureanswered "not implemented yet", andld_preloadanswered "env-based preload checks disabled". They are no longer probes.acpi_pcire-reported the sandbox detector's verdict, whichsandbox_statusreports in its own fields, andld_preloadcould never see anything in a statically linked build, which never runs the dynamic loaderLD_PRELOADworks through.debug_statussays which checks found a debugger, and how sure that is. Itsconfidencewas 100 for any detection and itsindicatorsalways empty, so a parent process that happened to be namedgdbread exactly as a debugger the kernel says is attached.indicatorsnow lists the checks that fired, andconfidenceis 100 only when one of them reads the operating system's own record of a debugger --IsDebuggerPresent, the debug port,TracerPid-- while heuristics alone give 35 for one and 70 for two or more. Four probes named measurements they never made.hardware_breakpointandsyscallre-reported the debugger detector -- thatgdbparent came out as a hardware breakpoint -- andcpuid_hypervisorthe sandbox detector, so they are no longer probes;rdtsc_drift, which times short sleeps on the OS clock, is calledsleep_drift. See ANTITAMPER_PROBE.md.A probe that did not look says so, and so does process protection. The six probes that read Windows process structures answered "not supported on this platform" elsewhere with
detected: false, the same shape as a clean result. All five probes of the runner's process protection are among them, so on Linux and macOS that stage looked at nothing and passed without a word. Every probe signal insandbox_statusanddebug_statusnow carriesmeasured, which is false for a probe that could not run or whose check failed, and a run whose process protection measured nothing says so once on stderr. Process protection runs on Windows only, and the docs now say so.db_add_relationstores its relation. It used to add a plain edge and send the relation only todb_timeline's journal, which lives in the process and is cleared bydb_close, so a reopened store -- or any reader but the program that wrote it -- held the edges with no relation at all, while the reference said they were labelled. The edge and its relation now land in one transaction, the relation indexed on the edge wheredb_relationsanddb_schemaread it back. It takes an optionalattrshash, stored beside the relation asattr_<key>and refused if it holds classified plaintext, and it refuses an empty relation, one that is not valid UTF-8, and a node that does not exist. Stores written by earlier releases cannot be repaired: the labels were never in them, anddb_relationsreports those edges withrelation: nulland counts them asunlabelled.db_bfsrefuses a direction it does not know. Any word other thaninoroutused to meanboth, so a misspelled"outbound"walked every edge both ways and reported nothing wrong; a negative depth was walked as zero. Both are errors now, andOUToroutare read asout. It returns every edge it crosses: graphene's walk keeps one edge per neighbour, so of three relations from a process to one file it returned one. It runs under the same fixed traversal budget as theledger_*walks, and is refused rather than cut short when it reaches it.db_shortest_pathreturns an empty array for two nodes that are not connected, as its documentation always said; it returned an error. A node that does not exist is still an error, and now says which argument named it. It runs under the traversal budget, and its documentation says what it always did: it walks edges in either direction.An attribute key that is not a string is refused.
db_add_artifactused to skip such a key, and reported the node with fewer properties than the script gave it and nothing to say which had gone.ledger_opentakes an options hash, and the one option is the role. The posture stays out of reach:sign,strict,retentionand every other key are refused before anything is opened.ledger_redactionsand the four redaction builtins report each record'sroleandrole_idand where its actor's name came from (actor_source), anddisclose_historyreports the role each disclosure and withdrawal was made under.disclose_to_passphraseneeds the recipient to hold a role whose bundle holds the view. A disclosure to a recipient the ledger assigns no recipient role in force, whose role has no bundle, or whose bundle does not hold the view is refused -- before any key material is derived or any passphrase asked for, and again under the ledger lock before the write. A script that disclosed before 2.6.0 needs arole_defineand arole_assignfirst;examples/forensics/record_disclosure.mutshows both. The grant is still exactly the view. The recipient's role, the assignment, the bundle and the redaction version the disclosure carries out are recorded in the ledger, the case manifest, the package anddisclose_history, anddisclose_verifychecks the package's role and version against the ledger's record.The ledger's walks, matches, queries and proofs say which view answered.
ledger_provenance,ledger_path,ledger_subgraph,ledger_patterns,ledger_query_nodesandledger_prove_nodereturnview: empty on the ledger's own handle, and under a handle fromledger_under_viewthe view's name, because therecomplete,foundandstopped_atare claims about what the view shows.ledger_closetakes such a handle and drops it, leaving the ledger open.A Rego policy is no longer offered the builtins that reach the network:
http.send,json.match_schemaandjson.verify_schema.policy_load,policy_eval,policy_allow,policy_rulesandpolicy_tracerefuse a policy, or a query, that calls one, before anything is evaluated.http.send's client took its proxy fromHTTP_PROXY,HTTPS_PROXYandNO_PROXYand its timeout fromHTTP_SEND_TIMEOUT, andpolicy_loadevaluates a policy to validate it, so loading one was enough to send a request wherever the environment pointed it (M26-DAT-030). The two schema builtins fetch a schema's remote$refthrough the same proxy (M26-DAT-032), and cannot be offered without it. Fetch withhttp_getand pass what it returns in the policy's input.An artifact's header may ask for no more Argon2id work than the costliest derivation in the tree. A password-encrypted artifact carries its key's Argon2id cost, and the runner pays it before anything in the artifact has been authenticated; the header could ask for eight passes over 4 GiB in sixteen lanes. It may now ask for at most three passes over 256 MiB in four lanes -- what a case-key or grant file costs -- and for no less memory than the 64 MiB the generator writes. Every artifact a Mutant build has written asks for one pass over 64 MiB, and still runs (M26-LIM-010).
The limit guard's budget lists each unnamed limit, and the limits of
security/andrunner/are named.policy/limit_budget.goholds the findings the tree still carries by rule, function and expression rather than as a count per file, so naming one limit can no longer make room for a new one beside it (M26-LIM-005). A value counted in kibibytes, milliseconds or microseconds says so, and the limits reference prints it as the size or time it is. The record format's bounds, the anti-tamper thresholds, the Argon2 band and the command and remote-scan scores are named with their reasons, the record format's fixed sizes are marked as format facts, and two remote-scan fields and two tamper-delay bounds that nothing read are gone. The names change no compiled instruction.mutant fmtand format-on-save keep the brackets you wrote, and add none. The formatter printed every operator expression inside its own pair of brackets, soputf("a=" + b + "\n")came back asputf((("a=" + b) + "\n")), and once a file was formatted the brackets it had added could not be told from the author's. The parser now records which expressions were written inside brackets, and the formatter prints those and no others; a doubled pair prints as one. Six examples that format-on-save had bracketed in 7e7ca13 lost the brackets it added, and parse to the same trees as before (M26-LSP-030).A block is a scope, and a
lethas to declare something new. BREAKING. A name declared inside{ ... }now means nothing after the closing brace, and may shadow a name from outside it. Every brace-delimited region is one: a bare block, aniforelsebody, a loop body, amatcharm. A loop header is a scope with the body nested inside it, so a body may shadow the name the header declared; a function's parameters and its body share one scope, so aletat the top of a body cannot take a parameter's name. The rules are Go's, measured against the Go compiler rather than read off the specification. Within one scope, aletis refused unless at least one of the names on its left is new to that scope — which is Go's rule for a short declaration, and is what makes the(value, err)idiom work rather than a special case for it:let head, err = read(a); let tail, err = read(b);is fine becausetailis new, whilelet x = 1; let x = 2;is refused andx = 2is what was meant. A parameter list naming one parameter twice is refused for the same reason. The blank is always allowed, however many times. One deviation from Go, and it is deliberate: in Go,_, err := f()witherralready declared is an error and the remedy is_, err = f(). Mutant has no multi-target assignment, so refusing it would offer no legal way to write "call it and keep the error" — 27 lines of one shipped example. A blank on the left therefore counts as new. Adding_, err = f()would close the gap and is the only part of Go's rule Mutant cannot yet express. What this costs: a program that declared a name inside a block and read it after the block closed no longer compiles, and neither does one that threads a single handle through a pipeline by rebinding it. Measured across the 123 shipped example programs, two needed a change — each declaredok, errtwice at one file's top level — and both are clearer for it, since oneokfor two different keys discarded the first answer before anything read it. The editor agrees with the compiler because it reads scope out of the same walk: a block shadow is no longer reported as a duplicate declaration, and a real duplicate now is. Nothing about the bytecode, the opcodes or a frame's layout changes. One change still compiles, and gives a different answer: aletinside a block that reuses an outer name now declares a new variable, where before it changed the outer one.let x = 1; if (true) { let x = 2; } putln(x);printed 2 and now prints 1.mutant lintreports the innerxas an unused declaration; a compile-time warning is planned (M26-CMP-022).Assigning to a name nobody declared is refused. BREAKING.
y = 1with nolet ybefore it defined a global in a compiled program, while the editor calledyundefined. The compiler and the evaluator, and so the REPL, now refuse it: "cannot assign to y: y is not declared -- declare it withlet y = ..." (M26-LSP-035).An import names the same file on every system. BREAKING on Windows. A backslash in an import path is a separator everywhere. A spelling that Windows and other systems read differently is refused with the reason: whitespace at either end, a segment ending in a dot or a space, and on Windows a path that begins with a separator but names no drive, such as
import "/lib/x.mut". Write a path relative to the importing file, or an absolute path with a drive (M26-RUN-012).Values of different types are not equal. BREAKING.
==fell back to comparing printed text, sonull == "","0" == 0and"true" == truewere all true. They are false now, in both engines; an integer and a float still compare by value. Arrays, hashes and structs compare element by element rather than through a float render rounded to six decimals, so[7.9999991] == [7.9999994]is false, andunique,contains,index_ofand hash-key order no longer merge such floats.assert_eq,assert_neandassert_containsgive the answer==gives, for errors too, where they used to compare the line an error was raised on (M26-VM-010, M26-EVL-006, M26-VM-016).A function value prints its signature, and is equal to itself. A function printed as its heap address, so a program that ended on one never printed the same thing twice; it now prints
fn mean(values). On the VM a function was never equal to itself, while the evaluator said the opposite; both now compare functions by identity, inside arrays, hashes and structs too (M26-EVL-002, M26-EVL-005).json_stringifywrites a whole float as a float.5.0came out as5, so a parse read it back as an integer; it now comes out as5.0(M26-DOC1-004).exec_stringandcmd_builderaccept any shell the host has, by name.powershell,pwsh,cmd,batch,bashandshkeep their own invocations, where-cwould be wrong or where the executable is not the name. Any other bare name --zsh,dash,ksh,mksh,fish,nu-- is given the command with-c, which is the one convention every POSIX shell shares and which fish, csh, tcsh, nu and xonsh share with them, so no list here has to be kept correct for a shell nobody here has tried. A shell whose flag is not-cis reached throughexec_argvinstead. A shell that is not installed is reported in the result'serrorfield, as it was before.What is refused is a value that is not a shell name: a path like
/bin/sh, or a command line likesh -c. Either would have been looked up as one long file name and come back as not found, which reads as "that shell is not installed" when the argument was never a shell name -- so it now says which of the two went wrong. This widens the-cpart of the fix recorded under Fixed below, which reachedbashandshonly.
Fixed#
mutant.version()in Lua reports the running version. It answered2.1.0, the version it was written in, on every release since.A program compiled by 2.3.x calls the builtin it was compiled for. On this runtime its
help()call ranpmap. The runner now tells a 2.3.x artifact from a 2.4.x one by the type its bytecode declares and looks builtins up in that release's table; an artifact whose type cannot be read is refused (M26-BLT-014).Builtin answers keep their types and order.
cache_putandcache_getkeep the types of integers, bytes, structs, enums and integer-keyed hashes, where an integer came back as a float and bytes as a hex string.keys,valuesandentriesshare one order, sokeys(h)[i]pairs withvalues(h)[i].task_wait(h, 0)returns a finished task's value instead of reporting it still running.fs_hashon a directory says a directory has no digest. An error's context names the builtin that raised it, where 23 named a wrong or misspelt one (uuid_v7asbuiltin.uui_dv7). The editor accepts bytes as a hash key, as the runtime does, and the help fortext_indexandlensays they count bytes (M26-BLT-002, M26-BLT-007, M26-BLT-009, M26-BLT-010, M26-BLT-012, M26-BLT-015, M26-BLT-017).Disclosure checks what it discloses, and to whom. A withdrawn recipient could be disclosed to again by respelling the name in another letter case or with an invisible character; recipients are now compared case-folded.
disclose_to_passphraserefuses a record whose signature fails or which changed sincerecord_open.record_sealrefuses a range whose label and class tag do not name one declared class, anddisclose_reclassifiedrefuses two records that would reclassify each other. A role bundle read back from the ledger is checked against the role's definition and id, and a record footer that says unsigned while carrying a signature is refused (M26-REC-009, M26-REC-011, M26-REC-012, M26-REC-024, M26-REC-025, M26-SEC-013).Event and report output keeps what the evidence said.
events_fromkept only the whole seconds of ISO, Unix-millisecond, FILETIME and WebKit times; ECS, OCSF and Timesketch output now keep the fraction.ocsf_eventfiled a deletion under activity 99 (Other) instead of 4 (Delete). An EVTX Verbose event ranked above an Information one. A line starting~~~turned the rest of a Markdown report into a code block; such fences, indents, list markers and bare carriage returns are now escaped.json_parsegives the same depth refusal whichever Go built the binary (M26-DAT-020, M26-DAT-021, M26-DAT-022, M26-DAT-026, M26-DAT-041).The editor,
mutant lintand the build agree.mutant lint .run from a file's own directory resolved no import, and run inside a project missed import cycles the build refuses; both now resolve imports as the build does.mutant fmtdeleted the;after aniformatch, so a next statement starting with(or[became a call or an index on it; the;is kept (M26-SEM-028, M26-SEM-033, M26-LSP-049).Artifact parsers stop inventing and dropping evidence.
email_urlsstopped at the first body line over 64 KiB.extract_iocsturned " at " and " dot " in prose into@and., inventing email and domain indicators; it now refangs only the bracketed andhxxpforms.mft_parsefiled a deleted file under whatever directory now holds its parent's MFT slot; a reused parent is reported as unknown (?).detect_injectionscored random bytes as an injection; a PE header now needs a valide_lfanewand signature.bin_yara_scanwithcaseInsensitivereported offsets into a lower-cased copy, not the file. The live registry dropped the data of value types it does not decode; it returns it hex-encoded withrawset (M26-ART-014, M26-ART-015, M26-ART-023, M26-ART-026, M26-ART-027, M26-NET-006).The evaluator runs macro bodies as the VM runs programs. A loop stopped at the first iteration whose body ended in an error value. A write through an alias changed the original container, where the same lines at run time did not; the evaluator now copies on read, as the VM does.
sort_byordered integer keys through a rounded float copy; one exact comparison now serves both engines (M26-EVL-008, M26-EVL-021, M26-VM-019).Four examples reported false results.
network_service_recon_graphcalled every probed port open, timeouts included;net_exampleprinted "connected" after a dial with no port;service_reconnever noticed a missing banner;dependency_version_auditorreported every dependency not vulnerable. Each now reads the result'sokfield or the response body. The examples are also rewritten shorter and plainer, and print what they printed.tld_extractanswers every name with a hash. A cookie's host (.example.com), a name with its trailing dot, a bare public suffix,localhostand an IP address each returned an empty string, sotld_extract(h)["etld1"]stopped the program. The leading and trailing dot are dropped, and a name with no registrable domain answers an emptyetld1.A refused comparison names its operator.
1 < h["x"]on a missing field failed asunknown operator: 59 (INTEGER NULL), the opcode's number; it now readsunknown operator: INTEGER < NULL.An editor's cursor and the language server's own columns now mean the same thing in every direction, and a document the server cannot keep up with stops handing out edits.
LSP 3.16 measures
Position.characterin UTF-16 code units. Mutant's lexer measures columns in bytes, deliberately: the CLI prints those columns, the parser's messages quote them, and the example sweep's recorded output pins them. The conversion between the two happens once, at the protocol boundary. Three rows here were the places that conversion had not reached, or had never been checked, and a fourth was the same arithmetic going the other way -- from a position the client sent to a place in the text -- which nothing owned at all.A change the editor sent could land in the wrong place, and then every change after it. The document store asked glsp's
Range.IndexesInwhere an incremental change goes. That answers offset 0 for any position past the end of the content, and a client is entitled to send one: the protocol says a character greater than the line's length means the end of the line, and several clients spell "the end of the document" as the line after the last one. Measured: insertingXat line 0 character 10 ofabcgaveXabc, where the protocol saysabcX-- and the store accepted it without complaint. A range ending at(lineCount, 0)came back as11..0, an end before its start, which could only be refused; the store then kept the old text at the old version while the client moved on, so the next version's change was applied to stale text and accepted. From that point the server and the editor disagreed about the file for as long as it stayed open, and since the server answers a formatting request with one edit replacing the whole document, the next format-on-save replaced the user's work with the server's copy. The offsets are now computed here rather than asked for, clamping the way the protocol says to; and a change that still cannot be applied -- a range whose start is after its end, a payload of a kind this store does not know -- marks the document as out of step instead of advancing a version over text the change never reached. While it is out of step the store refuses further incremental changes rather than compounding the divergence, the diagnostics are taken down rather than left describing text the client does not have, and the four handlers that answer with aTextEditbuilt from the stored text -- formatting, range formatting, on-type formatting and a quick fix -- answer with nothing. A whole-document change or reopening the file puts it back in step, which is all the protocol offers: there is no request that asks a client to send a document again (M26-LSP-004).Three rows that measurement showed were already fixed, and are now pinned so they stay fixed. Member completion once added the client's character to the line's byte offset, with a comment saying characters were treated as bytes, so any non-ASCII text earlier on the line put the offset short of the dot and
x.completion silently stopped working (M26-LEX-014's inbound half, filed as M26-SEM-010). Whole-document formatting ended its edit range at a rune count, so an unterminated last line holding an emoji -- one rune, two UTF-16 units -- left the tail of the old line behind after the formatted text: the row's own repro produced a stray;on a line of its own (M26-LSP-006). Both were corrected by the commit that fixed their root cause and neither had a test, so neither had anything stopping it coming back. They do now, and the tests were checked the only way such a test can be: by rebuilding the defect in a scratch copy and requiring them to fail against it. They do, with the stray;and the missing completions exactly as the rows describe.And the doc comment that started all of it.
token.Positionsaid its columns "count runes, not bytes" and called that LSP-friendly. Both halves were wrong -- the lexer counts bytes, and the protocol counts UTF-16 units, which is a third unit again -- and the three agree on ASCII, which is why it went unnoticed and why every test that asserted a column used ASCII text. The comment now says bytes, says why bytes are the deliberate choice, and says where the single conversion lives. A choice nothing asserts is not a choice, so the lexer now has tests that measure a column after a two-byte character, after an emoji, and on the line following each; the protocol package has tests that take a real token from the real lexer through the real conversion, in both directions, which is the only place the two units meet and the only test that would notice the lexer being changed to count something else (M26-LEX-014).Deliberately left: on a document that is out of step, hover, completion and navigation still answer from the stored text. Being wrong about what a name means costs an answer, where being wrong about the text costs the file, and they come right the moment the client sends a whole document. It is filed rather than buried in a comment (M26-LSP-034).
A multi-step command now runs every step, and the command reference no longer promises a gate that does not exist.
cmd_buildercollects lines andcmd_runran them; it joined them with a newline and handed the result to whichever shell was named.cmd.exereads its command from a command line, and a command line ends at the first newline -- so a builder made withcmd_builder("cmd")ran its first line, silently dropped every later one, and came backokwithexit_code0. Measured: the linesecho FIRSTandecho SECONDgave the stdoutFIRST\r\nand nothing else. The same builder underpowershellprinted both. A multi-step collection therefore performed step one and reported success for all of it.The lines are now joined with what the named shell reads as the end of one line: a newline for
powershell,pwsh,sh,bashand any other name, and&forcmdandbatch.&and not&&, because&&runs the next line only if the one before it succeeded and a newline in the other shells does not work that way -- measured,sh -c "false\necho SECOND"printsSECONDand exits 0. A builder is a list of lines to run, and the shells that already worked run all of them, so this one does too. The separator carries no spaces, which is not cosmetic either:cmd'sechoprints everything up to the separator, so&put a space on the end of every line's output but the last. With a bare&the join contributes nothing to any line's own text. A blank line is left out of acmdcommand, because a blank line does nothing in every other shell and in this one&with nothing beside it is a syntax error that costs the whole command; a builder holding nothing but blank lines is still refused rather than run. The exit code is the last line's, in every shell, so a line that fails does not stop the rest and does not appear in the result on its own -- that is the shell's rule and it is now written down, in the reference and in the code. Whatcmdstill cannot do is carry state between lines: it expands%VAR%when it parses the command line, before any of it has run, so a line reading a variable an earlier line set gets the unexpanded name. A run that needs that belongs in a script the caller controls, started withexec_argv(M26-NET-018).Found while measuring that one, and fixed with it. The capability reference said command execution was "subject to the
command_execcapability policy". There is no such policy: the name appeared nowhere in the tree but in that one sentence, no builtin has a runtime capability gate, and this project's own traceability matrix says so -- SEC-017, "Builtin capability configuration", status under design. The canonical catalog of the builtins was the one document asserting a security control the low-level design says is not built, and a reader deciding whether to run a program they had not written would have been reading it. That category now says what actually guards a command -- a fixed timeout, a cap on the output kept, a kill that reaches the process tree, a shell named rather than written out -- and says plainly that there is no per-builtin gate and that thecommandInjectionlint is an editor-time warning rather than an enforcement. The same wrong claim in the analyzer's own comment is corrected with it (M26-DOC1-017).And
examples/registry/persistence_triage_commands.mut, the one example that uses the builder, testedstartup["allowed"]-- a field no command result has ever carried. The hash is{ok, exit_code, stdout, stderr, timed_out, error, schema_version}, so the comparison was always false, the branch that was to report a failed collection could not run, and the example printed an exit code and wrote a report whether or not the collection had happened. It also told the reader to change a command-execution policy, which is the policy that does not exist. It now testsokand printserror, which are the two fields that answer the question (M26-EX-024).A relay now reproduces what it read, and a request sends the headers it was given. Three defects, one subject: a message was not relayed as it arrived, or not sent as it was asked for.
ws_read_framedropped the three RSV bits -- there was no field for them -- andws_write_framealways set FIN, so the documented use of the pair, read one frame and write it to the other side, could not reproduce the frame it had just read.RSV1is permessage-deflate, which Chrome and Firefox negotiate by default, so an intercepted WebSocket relayed every compressed frame with the compression flag cleared and the far side read the payload as text; and the first fragment of a message was relayed as a whole message, after which the continuation frame that followed was a protocol error.ws_read_framereportsrsv1,rsv2andrsv3now, andws_write_frame's fifth argument is either the write deadline, as before, or a hash of the frame's ownfin,rsv1,rsv2,rsv3andtimeout_ms. Every other key is ignored, so the hashws_read_framereturned can be handed straight back and the frame goes out with the byte it came in with.maskedis deliberately not read from it: a client masks and a server must not, so masking belongs to the direction the frame is being written in and stays the fourth argument (M26-NET-017).Found while measuring that, and fixed with it:
ws_write_framemasked the opcode with0x0f, so an opcode outside the four bits a frame has for it became a different kind of frame --16went out as opcode 0, a continuation frame, which a peer with no message open fails the connection over. It is refused now, and the refusal names the opcode that would have gone out. Beside it, the two things RFC 6455 says about a control frame were unchecked: a close, ping or pong carrying more than 125 bytes of payload, or now that FIN is settable, one withfinfalse. The standard requires a peer to fail the connection on either, so a close frame with a 200-byte reason was a dropped connection and not a close anybody read. Both are refused. A reserved opcode that does fit the field is still written, because reproducing one is a thing an examiner may need to do (M26-NET-040, M26-NET-041).http_requestapplied a caller's headers withreq.Header.Set, and net/http's client takes three fields off its own request structure instead and ignores the header. AHosttherefore went nowhere: virtual-host probing and a Host/SNI mismatch check got the default host's answer, with no error and nothing in the result to say which host had answered. It is sent now. The other two are refused rather than dropped: aContent-Lengththat disagrees with the body, naming both numbers, exactly ashttp_build_requestrefuses the same disagreement -- sending the correct length under a caller who believes a different one went out measures a desynchronised connection as a working one -- and aTransfer-Encoding, because the body given there is framed for you, and a request framed by hand is written withnet_conn_write. One that agrees with the body is already true and is dropped. Two header keys that differ only in case name one field once net/http has canonicalised them, so one value was sent and the other silently discarded; that is refused too, naming the field (M26-NET-023, M26-NET-042).Neither
http_parse_responsenorhttp_conn_read_responsecould read the answer to aHEADrequest. AHEADresponse carries theContent-Lengthof the representation that was asked about and no body at all, and nothing in the message says which of the two a length is -- only the request's method does, and neither builtin took one, so the parse waited for a body that is never sent and failed with "unexpected EOF" while the socket read waited until its deadline. An interception proxy could read theHEADrequest and not its answer, andHEADis how a client checks a file's size or freshness before downloading it. Both take the method as an optional last argument now, as dohttp_conn_read_response_head, which reportscontent_length0 andchunkedfalse after aHEADbecause those two fields say what to read off the connection next and the answer is nothing -- the declared length stays inheaders, where the evidence belongs -- andhttp_build_response, without which the relay was still broken: the length check added for M26-NET-016 refused to rebuild a message declaring 50000 bytes beside an empty body, which is exactly what aHEADresponse is. That was measured after the read side worked, not predicted, and it is the second time in two changes that a length check has had to learn about a message whose length describes something other than its body. With no method given the old refusal stands, because without it the body genuinely is unaccounted for and guessing would be the same silent framing in the other direction (M26-NET-039).docs/SECURE_NETWORKING.mdgained a section on WebSocket frames, which it had never described although frame relay is half of an interception proxy, and a paragraph on each of the two things above. It also now states a limitation it had never stated: field names are canonicalised bynet/http, so a server'sETagarrives asEtagand a rebuilt message writes the canonical spelling. No meaning is lost, since a field name is case-insensitive, but the casing a peer chose is a fingerprinting signal, so a proxy built on these builtins is detectable by the server it relays to. Preserving it would mean replacingnet/http's parser, which this project is not going to do; the doc says so, and says to read the bytes withnet_conn_write's counterpartnet_conn_readwhere byte-exact header names are what an examination needs.An HTTP answer now says where it came from, and an HTTP message no longer declares a length it does not have. Four defects, one subject: the answer or the message said something other than what arrived.
http_get,http_postandhttp_requestfollowed up to ten redirects, across hosts and out ofhttpsinto plainhttp, and the result held only a status, a body and headers -- no URL anywhere. So a fetch answered by a parked domain, a captive portal or an attacker's redirect read exactly like the answer for the URL asked, and the provenance of an indicator feed or a downloaded artifact was lost. The result now carriesfinal_url, the URL that produced this status and this body;redirects, the number of hops,0for none; andfinal_method, the method that URL was asked with -- which is aGETwhen a 301, 302 or 303 redirected aPOST, as the RFC and every browser do, and which nothing in the old result showed. A cross-host hop is still followed, because redirecting to a CDN is how most of the web serves a file and recording the hop is what makes it reportable. A hop that leaves TLS is refused: anhttpsURL is an instruction to fetch over a channel nobody on the path can read, and a 302 to anhttpURL discarded it quietly. The ten-hop budget is now this project's own named limit, because setting a redirect policy replaces net/http's default outright rather than adding to it (M26-NET-015).The same three builtins read the response body with an unbounded
io.ReadAll, while the interception builtins in the same package refused at 32 MiB. A hostile or misconfigured endpoint could hand back as much as it liked -- 48 MiB arrived whole when this was measured, and an endless response was read until the 30 s timeout -- buffered once and then copied again into a string. The read is bounded by the same named constant, at the cap plus one byte so that a body which fits is not confused with the first 32 MiB of one that does not. Past the cap the status and headers are still reported, because they arrived and are not in doubt, and the body is dropped rather than returned short: a whole answer cut at an arbitrary boundary invites a script to parse 32 MiB of a 48 MiB feed as the feed (M26-NET-021).http_build_requestandhttp_build_responsewrote whateverContent-Lengththe caller's hash held, which broke the one thing they exist for: parse a message, change the body, rebuild it. The parsed hash carries the original length, so a body of any other size produced a message declaring the old one -- a twelve-byte body underContent-Length: 5. A receiver believes the header, so the next hop reads five bytes and begins parsing the remaining seven as a new message, which is a desynchronised connection and the shape request smuggling is built from. A length that disagrees with its body is refused now, naming both numbers and saying that dropping the header gets a correct one written; two of them, or one that is not a number, are refused too. A length that agrees is written through unchanged, so relaying a message untouched still works (M26-NET-016).Found while measuring whether that length check was too strict: a 304 Not Modified could not be rebuilt at all. A 304 carries no body and may still declare a
Content-Length, which describes the cached representation the recipient already holds and which an interception proxy never sees, so comparing it with the body was the check's error and not the message's -- relaying the most ordinary response there is would have broken. Beside it, a defect that was there before: the builder added aContent-Lengthwhatever the status, so a 101 Switching Protocols relayed through a proxy went back to the client carryingContent-Length: 0, a header RFC 9110 forbids in a 1xx or a 204 and a malformed upgrade handshake emitted by the builtin rather than the server. Any 1xx, 204 or 304 is now never given a length, keeps one the caller supplied, and is refused if a body comes with it -- bytes after a status that says there is no body are read as the start of the next message (M26-NET-038).Found while measuring those, and not fixed here: neither
http_parse_responsenorhttp_conn_read_responsecan read the response to aHEADrequest, because only the request's method says that aContent-Lengththere describes a representation rather than a body that follows, and neither builtin takes a method. It refuses rather than answering wrongly, so nothing false is recorded, but it needs an argument added rather than a line changed and is filed for its own change (M26-NET-039).Found while measuring those three, and fixed with them: two
Set-Cookielines were folded into one comma-separated value. RFC 9110 permits combining a repeated field only where its value is defined as a comma-separated list and namesSet-Cookieas the exception, and the fold is not reversible either -- a cookie'sExpiresattribute carries a comma of its own, so nothing can split the joined value back apart. A response that arrived with two cookies was rebuilt with one line holding both: one malformed cookie, the second silently gone, which for a relayed response is a session the client never receives and for a recorded one is evidence of something the server did not send.Set-Cookieis a list now, one element per line and a list whether one cookie arrived or five, so no script has to handle two shapes; every other field still joins, which is both legal and reversible. The ordering is stable, because two lines of one field sort equal and an unstable sort decides which of two same-named cookies the client keeps. The fetching builtins built their header hash inline, separately from the interception builtins, so the same response was described two ways; they share one reading of a header block now (M26-NET-037).The
process_*builtins no longer answer about a process other than the one they were asked about. Seven defects with one shape: the answer was real and the label on it was wrong, which in a forensic record is the one thing that cannot be unsure.process_memory_scancould not answer the question it exists for. It scanned only the calling process, and the pattern argument, the copy handed to the scanner and whatever the program built it from all sit in the memory being scanned -- so a pattern that exists nowhere on the machine still matched, twice when this was measured and six times when it was first reported. "Is this indicator resident in memory?" could only ever come back yes. It scans another process now, which does not hold the query:OpenProcesswithPROCESS_VM_READplusVirtualQueryExon Windows,/proc/<pid>/mapsand/proc/<pid>/memon Linux, and a refusal that says what it could not open rather than a scan that found nothing. The calling process is the one pid refused, and the refusal says why (M26-ART-007 is unrelated; this is M26-NET-007).process_modules(0)on Windows listed Mutant's own DLLs.CreateToolhelp32Snapshotdocuments a process id of 0 as meaning the current process, andprocess_listreturns the System Idle Process as pid 0 first, because the list is sorted ascending -- so a script walking the list and recording each process's modules filed the examining tool's fourteen libraries, its own image among them, under pid 0 in its report. Pid 0 is refused at that API, rather than in the pid range the other builtins share, because 0 is a pid they answer for legitimately (M26-NET-019).On Linux every one of the six builtins that takes a pid answered for a thread id. The kernel puts a thread at
/proc/<tid>as well as under/proc/<pid>/task/<tid>, and nothing there says which it is except theTgidline, soprocess_hash,process_env,process_tree,process_threads,process_open_filesandprocess_modulesall answered -- and labelled the answer with a numberprocess_listcan never return, because/procdoes not list thread directories. The clearest case wasprocess_threads(tid), which returned a thread list with the real pid in it and the reportedpidnot even first. The data under those answers is the thread group's and is not wrong; the label is, so a thread id is refused, in one place, with the pid to ask about instead (M26-NET-033).A Windows environment's hidden per-drive entries were folded into one nameless variable. Their names begin with the separator --
=C:holds the working directory on C:,=ExitCodethe last exit status -- so splitting at the first=gave all of them the empty name and each overwrote the last, leaving one variable called""with a garbled value. A leading separator is part of the name now (M26-NET-022).process_kill's platform note said other signal numbers were ignored on Windows while the code refused them, so a script written from the documentation failed. The code's behaviour is the safer one, so the note moved to it (M26-NET-024).Two more were found while measuring the five above, and fixed with them.
process_envreported an empty environment for a process whose environment it could not read: the Windows reader returns no variables and no error when the read is denied, and 189 of the 391 other processes on the machine this was measured on came back that way -- "this process carried no environment" as a statement of fact about most of the machine. It now asks Windows for the same access and refuses with the reason, while a genuinely empty environment still answers as one (M26-NET-035). Andprocess_treeanswered{root_pid: N, descendants: []}for any pid in range, never asking whether the root was in the table it had just listed, so "that process had no children" and "that process is not running" were the same answer; it is the one of the six that walks a snapshot instead of opening the pid, and it looks its root up now (M26-NET-036).Six artifact parsers no longer name the wrong thing and report it as fact. Each had an answer for every input and no way to say it was unsure, so the wrong field, the wrong flag and the wrong Windows version all arrived as evidence.
Windows shell link flags were shifted one bit from
0x1000up, and eleven of the twenty-seven flags were not in the table at all. A link that runs as a different user reportedHasDarwinID; a link with a Darwin ID reported nothing; and every flag above0x10000-- shim layer, link-track suppression, the known-folder controls -- decoded to nothing whatever the file said. The names and bits are MS-SHLLINK 2.1.1 now, including the two it reserves, and a bit the specification does not name is reported as that bit rather than passed over, sincelink_flagssits in the same result and a list that accounts for less than the number beside it is the defect being fixed (M26-ART-012).Prefetch volume entries were stepped at the wrong distance on XP and Windows 8.1. The strides are 40 bytes for v17, 104 for v23 and v26, and 96 for v30 and v31; the code used 104 below v26 and 96 from v26 up, so two of the five versions were wrong. What that produces is worse than a gap: at v26 the second volume's serial number was read eight bytes early, out of the middle of that volume's own creation timestamp, so a prefetch file with two volumes reported a plausible eight-digit serial that was half a FILETIME. A version with no stride of its own now reads no volumes instead of borrowing a neighbour's (M26-ART-003).
A Sleuth Kit bodyfile row was split on every
|and read by fixed position from the left, but only the name may contain the separator -- it is legal on ext4 and HFS+, and an NTFS alternate data stream reaches one. So a file namedx|0|0|0|0|0|999|999|999|999supplied its own inode, size and all four MAC times, and an attacker who chose a filename chose what the super-timeline said about that file. The first field and the last nine are read from their own ends now, and the name is what lies between them. A file in which no line held eleven fields is refused rather than parsed to an empty array and no error (M26-ART-020).shimcache_parserefused the two Windows versions its own summary and its own error message said it supported: Win8 and Win8.1 write a 0x80-byte header and only 0x30 and 0x34 were accepted, so every Win8 and 8.1 SYSTEM hive failed with a message naming them as supported. Accepting the header alone would have been worse than the refusal, because the layout differs with it and not only the length -- both 0x80 formats put a flags word and an unknown word between the path and the timestamp, and Win8 puts package data before those. Read as a Win10 entry, a Win8.1 record yields a last-modified time assembled out of its flags. Three layouts are read now, and the version reports which one it was rather than the single string it used to return for all of them (M26-ART-022).go_symbolsclassified any module path without a dot in its first segment as standard library, on the reasoning that third-party code is domain-qualified.go mod init agenthas no dot, which is ordinary for a small tool and common in malware, so every package of the program being examined answered as standard library -- andmode: "user", whose whole purpose is to show the program's own code, listedmain.mainand nothing else while the capability functions were filed under the standard library. The binary records which module it is, and a package at or under that path is the program's own code however it is spelled; what remains is the actual list of standard library names, which is closed, rather than a shape a name can accidentally fit (M26-ART-021).browser_downloadsreported the initiating tab's URL as the download'surl. For a file fetched from a link in webmail that is the inbox, so the origin of the file -- the indicator the record exists to carry -- was the page the user happened to have open. Chromium keeps the real source indownloads_url_chains;urlis the last hop of that chain now,url_chainis every hop in order, and the page keeps its own name astab_urlalongside the referrer and the site. The state map hadinterruptedat 3, which Chromium retired, and not at 4, which is what it uses, so an interrupted transfer came back asunknown(M26-ART-019).A Sigma rule no longer reports a clean no-match over the thing it was written to catch. Four defects in the detection engine, all of them false negatives, which is the case a detection tool cannot have: a rule that finds nothing and a rule that is not working look identical from the outside.
|windashsubstituted one dash character everywhere instead of permuting the positions, and only ever from-. The specification names five interchangeable characters and says "all possible permutation" twice, so a command line mixing two spellings walked past the rule --pwsh -nop /enc AAAAdid not match a rule written for-nop -enc, and a rule whose author happened to write the slash form matched nothing but the slash form. The modifier now permutes the interchangeable characters at each position a switch starts, and refuses a value that would expand past 625 spellings rather than building them (M26-ART-016).of themcounted the underscore-prefixed search identifiers the specification excludes from it, soall of themdemanded conditions the rule's author never put in the rule and1 of themcould be satisfied by one they had excluded.N of themwas then unvalidated in the other direction as well: a count larger than the number of eligible identifiers compiled into a condition no event can satisfy, which is the same silence from the opposite side. Both are refused, the second mirroring thecount < 1refusal three lines above it (M26-ART-017).A search that tests nothing compiled and then held for every event or for none, depending on where it sat in the condition -- an empty mapping, an empty list of values. A
nullinside a list of values silently became the empty string, so a rule looking for an absent field matched a present and empty one. Both are refused at compile time, where the rule's author is still looking at the rule (M26-ART-018).A keyword search stopped at 24 levels of nesting and reported the result as an answer about the whole event. The bound is now a named limit with a written reason, raised to 256, and an event deeper than that is an error rather than a no-match it did not earn. The row's claim that nothing could build an event that deep was checked and is false: the file parsers will not, but
json_parseandndjson_parserecurse with no depth bound of their own, and 257, 300, 1000 and 10000 levels all decode and are now all refused by name (M26-LIM-001).exec_stringnow bounds what it starts, what it buffers, and what it quotes. Four defects in one file, each measured on this host before it was designed.The three-second timeout bounded no part of the call.
ping -n 30 127.0.0.1throughcmdran for 29.3 seconds and then reported that it had timed out: the deadline decided what the result would say afterwards and nothing else, because a grandchild holding the output pipe open kept the call alive for as long as it liked. The call is now bounded, with half a second past the deadline for a killed process tree to die and let go of its handles, and a command that exits while something it started still holds its output does not come back ok -- the exit code is the command's own, but whether the output is all of it is not known (M26-TMP-006).The whole of a command's output was buffered and then cut to the 8 KiB the caller gets. The cost is five times the output rather than one: a
bytes.Bufferdoubles as it grows and then the final copy into a string has to be reachable at the same time as the buffer, so 64 MiB of output allocated 320 MiB with a live heap peak of 257 MiB, to keep 8 207 bytes. Output is now written through a capped writer that stops at a rune boundary: 100 MiB fed through it allocates 9 472 bytes (M26-TMP-007).cmd.exewas handed C-runtime escaping, which it does not use, so a quoted argument reached the command with the quoting still in it --"a b"arrived as\"a b\". The two are quoted by their own rules now (M26-TMP-008).An empty command ran the shell and reported success with exit code 0. It is refused (M26-TMP-009).
The lexer no longer decides a program has ended and says nothing about it. Two ways it did, both of which truncated a file in silence, and in one case wrote the truncation back over the author's source.
A NUL byte in the file was end of input.
l.chis 0 past the end of the input and a NUL byte decodes to the same 0, so one test answered for two different facts and every scan asked it that way:let a = 1<NUL>let b = 2lexed as the first statement alone, with no error anywhere. Two consequences are worse than that one. A NUL inside a//comment ended the comment scan, which the lexer then read as end of input, so a comment followed by a whole program lexed as nothing at all -- andmutant fmtwrote those six bytes back over the file, which is a stray byte in a comment deleting a program by formatting it. A NUL inside a string literal ended the literal at the byte and the rest of the file was re-lexed as code, turning one statement into three. End of input is now a position rather than a character, tested separately from the byte, and a NUL is reported as what it is: one illegal character, at its own line and column, with the rest of the file still lexed (M26-LEX-006).A
${with no closing brace was closed at the end of the literal, and the comment on the function doing it said "the resulting error points inside the string, which is where the missing brace is". There was no such error.let a = """v=${1+2""";lexed as a finished template, parsed with no errors, and setatov=3-- an expression whose end the author never wrote, evaluated as though they had, and reprinted byte-identical bymutant fmtwith the linter reporting nothing. The hole scan now reports whether it found its brace and the literal is refused, naming the${rather than the end of the literal, which is only where the scan gave up (M26-LEX-017).Both refusals travel as the lexer's own sentence on the token, which the parser prints in place of its own weaker one: the lexer knows that a
${has no}and that a NUL is in the file, where the parser knows only that no rule begins with an illegal character. Surfacing them found one more defect, which is none of these rows: a function's parameter slot took the current token's text as the parameter's name without asking what the token was, so it was the one position in the grammar where an illegal token never became an error -- andfn(1)andfn("ok")were accepted there too, the formatter reprinting the second asfn(ok)and dropping the author's quotes. A parameter must now be a name.An artifact parser no longer trusts a length or a depth it read out of the artifact. Four defects in the readers for property lists, shell links, memory images and compressed prefetch files. Each one came from a number in the evidence being believed about the evidence, and three of the four answered rather than refused.
XML property lists were parsed with no bound on recursion, while binary ones had always been bounded at a hundred levels. The two parsers call a function per level of nesting, so a file of nested
<array>elements grew the stack until the process died -- and a stack overflow is fatal, norecovercatches it, so the recover the builtin already had was no help. Measured in the row at 168 to 839 bytes of stack per level, which the default gigabyte reaches at a file of 9 to 45 MB. Both paths now share one named limit, and the refusal is propagated rather than returned as a short answer: both container parsers answer a child's error by keeping the part of the tree they already hold, which is how a plist cut short still parses, and a depth refusal travelling that way would have made the parse succeed and hand back only the tree above the bound (M26-ART-009).A shell link whose
LinkInfowas cut short was read past its own end. The parser established sixteen bytes and then read a four-byte offset at the sixteenth, and a slice expression may reach past the length as far as the capacity -- so where the allocator had left slack it read it and reported it aslocal_base_path, a file path shown to an examiner that was never in the link, and where there was no slack it panicked. Which of the two happened was decided by how the bytes had been obtained and not by the file: reading a file leaves slack, reading a jump-list stream does not. The read is now guarded, and the parser is handed a slice whose capacity is its length, so nothing else in it can reach past the end either. The panic also escaped to whichever builtin's recover was outermost, which for a jump list discarded every entry: one damaged stream out of twenty, and theDestListand the other nineteen links went with it. The recover now sits in the link parser, which is the only place that knows the failure is one link's.jumplist_parsereportsunreadable_entries, because a stream it cannot read was skipped in silence, and a short answer that does not say it is short cannot be told from a complete one (M26-ART-011).mem_readandmem_read_bytesformed the sum of the offset and the length before anything measured it, so a length nearMaxInt64-- which is how a caller asks for the rest of the image, and which the pair already answers correctly as a short read -- wrapped negative, and a test for a sum past the end of the image cannot fire on a negative number. The slice expression then panicked, and a builtin panic is not contained in the builtin: the VM turns it into a runtime error that ends the program and records avm-panicintegrity failure in the audit chain a case manifest seals, so an ordinary argument wrote a tamper record into the evidence of the case being worked. The length is now measured against what is left before it is added. The offset had to be past zero to reach it, so the one case anybody would have tried by hand worked (M26-ART-024).The Xpress-Huffman decoder never noticed that its input had run out. The helper that reads the bitstream answers zero past the end rather than refusing, and the decode loop ran until it had produced the number of bytes the header declared, so a stream cut mid-block refilled its lookahead with zeros and went on decoding symbols out of them. The embedded test vector, 382 bytes compressed to 768, returned a full 768 bytes and no error when cut to 366, 319 and 264 bytes, with 626, 296 and 16 of them right -- so a carved or partly recovered Windows 10 prefetch file produced file names and volume paths it does not contain, under a parse that reported success. The reader now counts how many of the bits in its window were invented, which is what this has to be measured in: the window is 32 bits and refills 16 at a time, so a valid stream also reads past its own end, and the thing that distinguishes a cut one is a symbol decoded out of bits that did not come from the file. Every cut of the vector is now tried, and the error says which of the two it saw -- a stream that ran out, or bits that are not a code in the stream's own table -- because those point at different things (M26-ART-028).
The front end no longer reads, or silently accepts, something other than what the program says. Five defects in the path between source text and a tree. Four of the five were silent.
A leading zero on an integer literal was read as octal:
010was 8 and0100 + 1was 65, with nothing reported. It is now a compile error naming both readings -- write755, orparse_int("0755", 8)-- because the text does not say which was meant, a zero-padded field pasted out of a date or an offset listing meaning ten and a hand-written0755meaning a permission mask. Nothing was lost with the base: a prefix never reached the literal parser in one token, since the number scanner loops on digits and., so0x1Flexes as0followed by the identifierx1Fand did before this change. A float was never read as octal, so010was 8 while010.5was 10.5, and the same spelling meant two things depending on whether a.followed it (M26-LEX-008).to_intmade the same base-0 call on runtime data, and gets the opposite answer, because a string is not authored: a zero-padded month read as octal, soto_int("010")was 8 andto_int("0755")was 493, while the two months that are not octal digits failed outright --to_int("08")andto_int("09")could not be parsed at all. One timeline field could be wrong, refused or right depending only on which month it named. It now reads base 10.parse_int(s, 0)still detects a base and is whereto_int("0x1F")went; it answered 31 until now, which no summary ever said it would (M26-BLT-008).An unterminated string literal was accepted in all four spellings and swallowed the rest of the file as its own text. The program compiled, ran the statements before the stray quote and reported success, and everything after it -- in the reported case a ledger verification and an
exit(3)-- was inside the string and never ran. A{with no matching}was accepted the same way and for the same reason, and that parse reported nothing at all: inlet f = fn() { return 1;followed byputln(f());the call became part off's body. Both are now refused, each naming the line and column the literal or the brace opened at, which is the position the swallowed text hides (M26-LEX-005).Parser recursion had no bound. A file of 1,000,000
(characters -- under the language server's 1 MiB workspace-scan ceiling, so the scan reads it -- ended the process withfatal error: stack overflow, whichrecover()cannot catch: the server died and came back to read the same file again on every start. Nesting is now bounded at 1,000 levels and the error list at 100 entries, the second because one unclosed paren produced one error each and a 700 KB file of them produced 700,001. The three inputs that killed the CLI are each refused in under 1.5 seconds, naming the column (M26-LEX-003).String()on the tree a failed parse returns panicked on a nil child, and the language server died of it -- the whole process, rc=2, onf()[-,] = 1;-- because it callsString()on an assignment's target to lint it and nothing between that call and the transport's reader goroutine recovers. Ten of the package's methods dereferenced a child with no check at all; the other nine skipped it, which is the quieter half of the same defect:f(1, , 2)rendered as(1, 2), an honest-looking call of two arguments. EveryString()now answers on a nil receiver and every child one prints goes through a single renderer that names a hole<missing>(M26-LEX-010).The compiler refuses to finish a stream that still holds a jump nobody patched. A forward jump is emitted before anyone knows where it goes, with a placeholder standing in for the target, and something later has to come back and write the real one. Nothing used to check that anything did, and two of the three defects in the entry below were exactly that failure: both compiled without complaint and ran.
Every placeholder is now recorded as it is emitted, cleared when it is patched, and the set is required to be empty at the two moments a stream stops being editable: when a function's scope closes, and at the end of the program. One left over is an internal compiler error naming the opcode, its offset and the line it came from -- no source text can ask for an unpatched jump, so it is a defect in the compiler and says so, rather than becoming a wrong answer in whatever program happened to trip it.
Measured against the commit before those two were fixed, with nothing but this guard applied: both are refused, each naming the one jump and the column of the
forheader it came from, while the control program that always compiled correctly is untouched. All 123.mutprograms underexamples/compile to byte-identical instruction streams, so the guard is inert on everything that already worked.A
breakor acontinuereached from inside an expression no longer leaves that expression's operands on the compiler's stack, and the compiler no longer emits a jump that nothing resolves. Three defects in the same lines ofcompiler/compiler.go, landing together because separating them would mean writing the same statements twice.The first is the companion to the evaluator fix below, and the shape is the same: a block is a value, so a
breakcan be reached from somewhere a larger expression was being assembled. The compiler emitted a bare jump. The operands already pushed for that expression -- an infix left operand, the elements of an array written so far, a callee and the arguments ahead of this one, a container and an index waiting for a store -- were left behind, and every target a jump can reach expects the stack at the depth the loop was entered with. In afor…inthe pop meant for the loop's cursor took a leaked value instead, the cursor stayed put, and the next advance read whatever had piled on top of it.The compiler now counts what an expression holds across a nested compile, at the fourteen places that hold anything, and a
breakor acontinuedrops exactly its own operands before it jumps. The count is per loop, not absolute, because a loop can sit inside an expression itself:[0, if (c) { for (v in xs) { break; } 1 }]enters its loop with a slot pending that belongs to the array, and popping to zero there would steal it.Measured. Of twenty-two programs that put a loop control statement in an operand position, twenty were wrong before: eleven died with
loop cursor was replaced on the stack, which names a VM invariant rather than the program, and nine answered a plausible wrong number with no diagnostic at all -- the reported case iterated[1, 2, 3]and printed[1]. A twenty-first did not terminate. The nine programs that were already correct -- a break with nothing pending, the right operand of&&, a prefix operand, a closure's own loop -- are correct before and after, because over-popping is the quieter failure this kind of fix invites: the slot it steals belongs to whatever contains the loop. Outside afor…inthe leak was entirely silent, and the stack-depth probe is what shows it: the same program held 2 slots after 2 iterations and 60 afterNothing shipped changes. All 123
.mutprograms underexamples/compile to byte-identical instruction streams before and after -- same length, same constant count, same SHA-256 -- and the 14 that need the linker fail identically on both sides. Since a pending operand would have shown up as an extraOpPop, identical bytecode also proves no shipped example uses the shape.The second and third were found while rebasing the first, and both end in the same instruction:
OpJump 9999, the placeholder emitted before anything resolves it, still there in the finished program. Acontinuein a countingfor's step was never patched at all, because the back-patch runs before the step is compiled. And abreakin a step was patched in the wrong copy of a slice: the patch read a pointer taken before the step, and a loop inside the step growsloopContextsfrom one element to two, which reallocates it -- the shallowest nesting there is, so there was no depth at which it was safe.What an unresolved jump does is decided by the program's size rather than by anything its author wrote, and all three outcomes were measured. Under 9999 bytes of instructions it runs off the end, the run is treated as finished, and the program answers whatever was last popped: a boolean, for a program whose value is an integer. Over 9999 bytes it decodes whatever that byte happens to be, which reported
unknown opcode 225: the bytecode may be damaged or decrypted with the wrong key, or it may have been built by a newer version of mutant-- three claims that were all false, for a mistake in aforheader. And when byte 9999 falls on an instruction boundary, the VM resumes in unrelated code and does not come back.A
breakin a step is legal and still means the loop ends. Acontinuethere is now refused, because no reading of it terminates: going on to the condition skips the step that advances the loop, and jumping to the start of the step -- where every othercontinuein that loop is patched -- reaches the samecontinueagain. The sentence lives insemaand both engines raise it, so one program cannot be refused in two wordings.The engines now agree on all forty-two shapes in
parity/expression_signal_parity_test.go. The four it recorded as divergent were the compiler's, and it named all four and refused to pass until they were moved, which is what that table is for.A
break, acontinueor areturnreached from inside an expression is no longer used as a value by the tree-walking engine. A block is a value in this language --let v = if (c) { break; } else { 1 };is ordinary source -- so evaluating a block hands the signal back as the block's result, and expression evaluation only ever guarded against errors. A signal is not an error, so it was treated as a value: appended to an argument list, stored as a hash value, used as an array element, or added to an integer. The loop or the function it was meant for never saw it.Measured over forty-two shapes across both engines. The tree-walker was wrong on twelve of thirteen loop-control shapes and five of seven
returnshapes. Nine surfaced as errors naming the control object as a type --type mismatch: INTEGER+BREAK,unknown operator: -BREAK,index operator not supported: ARRAY-- which at least said something was wrong. Thirteen answered an ordinary number with no diagnostic at all: abreakstored in an array, a hash or an argument list, the loop running to completion, exit 0. Andwhile (if (i == 2) { break; } else { true })did not terminate, because a Break object is truthy, so the condition read it as "keep going" -- a program the compiler refuses outright made the engine loop for ever.It matters beyond the tree-walker's own entry point, which is what made it worth fixing before the compiler half:
mutant genrunsDefineMacrosandExpandMacrosfor every module with no flag behind it, so a macro body of one of those thirteen shapes spliced a wrong value into the generated program and said nothing.The fix propagates the signal instead of consuming it, at every position where a sub-expression's result is used as a value, and refuses it where it has nowhere left to go -- the end of the program, next to the end of a function body, which the previous fix in this family already guarded. A loop's header follows the compiler: a condition, an init section and a
for…in's collection are outside the loop, so a signal in one belongs to an enclosing loop or is refused; a countingfor's step is inside it, so abreakthere ends the loop. Acontinuein a step is refused, because neither reading of it terminates -- going on to the condition skips the advance, and re-running the step reaches the samecontinueagain. The sentence for that refusal lives insemawith the others, so the compiler cannot phrase it differently when its half lands.The compiler still gets four of these shapes wrong in its own way, by leaving a half-built operand on the stack, and answers a boolean for the step case. That is tracked separately, and
parity/expression_signal_parity_test.gorecords which four rather than leaving them out of the suite: it fails if one of them starts agreeing, so the compiler fix has to update the list rather than discover it.go run ./cmd/gendocs -checkanswers for the hand-written documents too. It compared the three artifacts it generates and nothing else, and reporteddocs/CAPABILITY_REFERENCE.md is up to datein the same words whether or not the documents around it agreed with the registry. Those go stale separately, and regenerating the artifacts is the act that makes them stale: on 2026-10-06 a new builtin took the count to 694, all three artifacts were regenerated and current,-checkreported all three up to date, andgo test ./...failed three tests incmd/gendocson the commit itself. Seven sentences across six documents still said 693, and the category index still gave Command Execution 4 builtins and linked to#command-execution-4, an anchor that stopped existing the moment the heading it names was regenerated as## Command Execution (5).The prose counts, the per-category index and every document link were already checked, by tests in
cmd/gendocs. They are no longer written there: they live incmd/gendocs/consistency.go, and the tests and-checkare two callers of one implementation, so the treescripts/release_gate.shandscripts/release_gate.ps1call consistent is the tree the suite calls consistent. Each check also reports what it examined -- so many claims, rows and links -- because a check whose scope nobody can see is one a reader reads too much into, which is how "the generated reference is up to date" came to be heard as "the documentation is up to date". A check that matches nothing now fails instead of passing, and eight tests point each check at a tree that is wrong on purpose, because "the check runs" is not the claim that mattered.Writing mode reports them as well, and exits non-zero when any is wrong. That is where the information is worth most: the count gendocs has just generated is the count a sentence elsewhere now contradicts, and it can name the file and the line to edit rather than leaving it to a five-minute suite run, or to a reader. Adding or removing a builtin is a documentation change in about eight places that nothing generates, and this lists them.
A
letinitializer no longer reads the binding it is about to create, and a loop whose body redeclares the loop's own name no longer hangs. Both were one cause: nothing in the language was a scope except a function literal, soDefineallocated a fresh slot and overwrote the name, and the name meant the new slot from the first line of the statement that declared it. Inside an initializer that read a slot the statement had not yet written — null at the top level, and whatever an earlier call left in the frame at that offset inside a function, which is why the wrong answers were plausible numbers.let total = 10shadowed in a closure printed 301 after an unrelated call,let len = len(xs)printed the argument array and then%!d(string=), andlet s = s + 1over a parameter faulted in the VM. Worse, aforstatement compiles its post section after its body, so once the body redeclared the counter,i++incremented the body's slot while the condition went on reading the original:for (let i = 0; i < 3; i++) { let i = 9; }never terminated, and nothing reported it. The tree-walking engine answered 1 for the same program, from one name-keyed environment. Both are fixed by scoping blocks correctly rather than by refusing them, so that program now terminates and answers 3, which is what Go answers. The initializer reordering is also Go's rule — a declared name's scope begins after its declaration — and it takes one thing away: a brand-new name can no longer reach itself through a call, solet step = wrap(fn(n) { return step(n - 1); });isundefined variable: step, which is what Go says about the same shape. Direct recursion is untouched. See the entry under Changed for the scope rules. (M26-CMP-001)A
breakor acontinuewritten inside a function no longer escapes the call and drives the caller's loop. The compiler refuses such a signal — a function body is a loop boundary — but the tree-walking engine ran it and let the signal leave the call as the call's value:unwrapReturnValueunwraps only a return, so a break fell straight through and the loop around the call obeyed it.for (let i = 0; i < 3; i++) { let f = fn() { break; }; f(); out = out + 1; }answered 0 where 3 is right, and the same held forcontinue, forwhileand forfor ... in; with the closure never called the answer was 3, which is what showed the signal only escapes when the call runs. That mattered beyond the one engine, becausemutant genruns macro definition and expansion for every module with no flag behind it, so anunquoteargument is user code the tree-walker executes before anything is compiled: a macro whose argument ran that shape spliced the literal 0 into the program, which then built and printed it with exit 0 and no diagnostic. A function body is now a boundary in both engines, the escaping signal is reported rather than returned, and macro expansion names the macro it happened in. The sentence itself moved intosemabeside the other refusals, so the two engines cannot drift into two phrasings of one rule again — which is what this defect was. (M26-EVL-023)The allocation bounds in
builtin/measure the parser rather than the process. Six tests assert that a count written in a file's header cannot size an allocation, and each measured it by readingruntime.MemStats.TotalAllocacross the call. That counter is process-wide and cumulative: everything allocated between the two readings lands in the difference, by any goroutine and by the runtime itself. Against bounds as small as 1 MiB it was mostly other people's memory, so the same tests passed when run alone and failed in a fullgo test ./builtin. That is the worst way for a test to fail — a gate that is red on a correct tree teaches people to ignore red — and two staged fixes could not be gated at all while it was.How far off the figure was is worth recording.
TestABinaryPlistIsNotSizedByItsCountsfailed at 1,998,144 bytes on a tree where the fix was correct, and the parse it measures allocates 8,312. More than 99% of what it asserted on had nothing to do with the code under test.Each one now runs the same parse over two fixtures differing only in the number written in the header, and bounds the difference rather than the total: the difference cancels the parse's own cost, which turns the assertion into whether the declared count decides the size, and the lowest of several readings is taken because contamination can only ever add. Measured under a full package run, four of the six differences are exactly zero and the largest is 31 KiB against a 4 MiB allowance, where the defects these tests exist for cost between 16 MiB and 900 MiB.
The twin is a wrong count too rather than a valid one — two objects where the file holds one, two array references, one entry rather than 2^20, sixty-four bytes of record in a forty-byte capture, a header field of twice the budget — so both measurements are of the same refusal and the difference between them is the cost of the number alone. Each test fails loudly if its twin is ever accepted, because from that point the difference means nothing.
builtin/allocation_gap_test.gocarries the reasoning, and the DestList bound was found by this pass: it had the same defect and was in neither inventory.plist_parseno longer returns a value a binary plist does not contain. The declared object count was checked against the bytes from the offset table's start to the end of the file, and the last thirty-two bytes of a binary plist are the trailer, which is not table. The check therefore handed the table the trailer's own bytes as room for offsets, so a file could declare up to32/offsetIntSizeobjects more than its table holds and the offsets for those were read out of the trailer. On a forty-five byte file with four-byte offsets the table holds exactly one entry and nine objects were allowed; with the top object pointing at one of the eight phantoms,plist_parsereturned a two-character string decoded from the trailer's bytes and no error at all.A refusal would have been fine and a dropped value would have been bad. A confident answer about an artifact that the artifact does not contain is the one outcome a parser here may not have, and the input is exactly the kind an examiner is handed rather than writes. The bound is now the room the table really has, which refuses nothing real because a well-formed plist puts its offset table immediately before the trailer. (M26-ART-035)
It was found by the entry above, and that is the useful part. The small twin for the differential measurement was a count of two, chosen because it should have been refused for the same reason 2^24 is; the test asserts that its twin is refused, because otherwise the two measurements are of different code paths and their difference means nothing. That assertion failed, which is how a parser that fabricates data came to light from a change to how memory is measured. The field had already been reviewed once, under M26-ART-005, and this half was missed: the check's own comment stated the flawed premise.
Calling a name that has no value is reported instead of crashing the tree-walking engine. Macro definitions are removed before anything runs, but only the top-level ones, so a
macroliteral nested inside anunquoteargument survived into code the tree-walker executes — and that engine has no case for a macro literal, so the name was bound to nothing and calling it dereferenced it.mutant gendied with a nil pointer dereference on a program that is only a few lines long. It now says that a macro definition must appear at the top level, where it is expanded before anything runs, which is both what went wrong and what to do about it. The compiler already refused the same nesting, so only the expansion path was exposed. This is the tree-walking half of M26-VM-003, which was fixed in the VM.The disclosure policy promised an erasure nothing performed. It said crypto-erasure was available and listed it among the operations, and no builtin erased or rewrapped any key.
record_eraseandcase_key_erasedo now, and sections 3 and 7 say what each destroys and what it cannot reach: destroying one record's key is not enough while copies of the record exist.A withdrawal could leave its examiner unable to write to the ledger. A disclose_* write finds an Actor, Record, Class or View node the ledger already holds before adding one, and asked only the ledger -- which cannot see a node added earlier in the same transaction. So
disclose_withdrawon the examiner's own authority, its default, by an examiner the ledger held no Actor node for wrote two, and every later disclosure-family write naming that examiner was refused as a ledger written to by something else. A transaction now finds what it has itself added.fat_deletedreported a live file's content as an orphan's, andfat_recover_filewrote it. A record the orphan sweep finds sits in a deleted directory's freed cluster without its own 0xE5 marker, so libfat walked the current FAT from its first cluster -- and when that cluster had since been given to a live file, the walk returned that file's chain. The scan saidpreserved,allocation_checked: true,reallocated: false, and the recovery wrote the live file's bytes under the orphan's name with no caveat. An orphan's chain is as freed as a deleted entry's: it is now located for its first cluster alone, as the scope has always said, andreallocatedis read from the allocation table, so a recovery over a reassigned cluster carries the caveat that it is most likely another file's content.allocation_checkedis true only when that read succeeded.A
whileor C-styleforinside afor…inbroke the outer loop. The compiler stripped the pop after an expression statement that ended a condition-driven loop's body, post or init section, so each iteration leaked one stack slot.for…inreads its cursor at a fixed offset from the top of the stack, so its next advance read the leaked value and failed with "loop cursor was replaced". Every loop body is stack-neutral again, andvm/loop_nesting_test.goruns each nesting shape as its own case.rand.*,sort.*,assert.*andgunzip.*failed in compiled programs. The compiler's shadow test forns.memberalso found builtins, so the four families whose namespace is itself a builtin's name never folded, and compiled a field read on a builtin that the VM can only refuse. Nine builtins ran under the evaluator and in macro expansion, failed under the VM, and were offered by the editor throughout. The test ignores the builtin scope now, andparity/holds the two engines to each other on it.db_add_artifactwas N+1 unchecked writes. It is one transaction now, and a store handle the timeline kept is no longer leaked.Documents that pointed at nothing, taught retired commands, or did not compile. SECURITY.md named 2.4.x as the supported release; three example READMEs taught
mutant runwith-pwd, which puts the password in the process table; four source comments cited a design note that exists nowhere, in a directory git ignores; a MODULES.md example elided its bodies with a comment syntax Mutant does not have; two links pointed at capability-reference anchors that had moved; CONTRIBUTING named the packaging script's old path; and four example programs were named by no README. The checks that found them are the ones added above, so none of these can come back unnoticed.This file dated 2.5.0 three days before it was tagged, and listed half of it as unreleased. Fourteen entries that shipped in the v2.5.0 tag sat under
[Unreleased]; they are under[2.5.0]now, and 2.5.0 is dated 2026-09-17, the day the tag was made. The release gate compares the two from here on.NTFS evidence was opened writable. libntfs promotes any
io.WriterAtto a writable volume, and*os.Fileimplements it whatever mode the file was opened in, sontfs_openhanded the library a volume it could write to. It is opened read-only now.Lint rules disagreed with the compiler about which names were taken. The duplicate-declaration rule reported every shadow as a duplicate -- a parameter shadowing a
let, or a value named like a struct -- and offered a quick fix that deleted one of two lines the program needed. The builtin rules went silent where a struct or enum shared a builtin's name, sostruct rand { x; }; rand.int(1, 5);drew no arity, argument or(value, err)check.platformSupportreported a local value named like a namespace as an unsupported builtin. All of them now ask the same graph the compiler builds.Undefined names the build refuses drew no diagnostic, and names it accepts drew four.
struct Point { x }; Point;,len{x: 1}and an enum used above its declaration failed the build from files the editor showed clean, while a four-line program using a struct, an enum and a macro from an import reported all three undefined and the import unused. The evaluator also foldedstr.upper("a")to the builtin underenum str { x }, where the VM refused it; both refuse now, in the same words.The
hardcodedSecretrule flagged the idiom it recommends. A name ending_file,_pathor_dirholding a real path matched both its name pattern and its looks-issued check, so an author who had just moved a secret out of the source was told to move it out of the source. Those suffixes are exempt.A bad hash algorithm in a
case_opencustody policy was reported as anfs_hasherror, naming a builtin the script never called. The error names the builtin that was.Running a program bounds what it reads. The runner read the program's file whole whatever its size, and inflated its bytecode to whatever size the zstd frame declared, so a disk image named by mistake was read into memory and a payload of a few hundred bytes could ask for gigabytes. A file over 1 GiB is now refused before it is read, and bytecode that inflates past 256 MiB is refused while inflating. Both limits are in
docs/LIMITS_REFERENCE.md.A killed
record_sealno longer leaves part of a record under the record's name. The record was written in place and removed only when the seal saw an error, so a process killed mid-seal left a file with a valid header and some of the segments, where a complete record was expected. The record is now written beside its name and renamed into place once complete; a seal that is killed leaves an empty file, whichrecord_openrefuses as an interrupted seal, and a.mutant-record-*file of partial ciphertext.Two ledger reads said less than they knew.
ledger_query_nodeson a key the ledger's index never held returned an empty answer indistinguishable from "no such record" -- a misspelt key, or any of thedisclose_*family's own properties, which that family keeps unindexed. The result now carriesunindexed_keysandindex_keys_known.ledger_pathwalks edges in either direction and did not say when a hop ran against its edge; it now returnsreversed_hopsanddirected.mutant graph exportreports a store it could not close. The export discarded the store's close error, so an export whose store was never marked cleanly shut down still reported success. The close error is now the export's error.A ledger or disk graph store the program forgot to close is closed at its end. A store still open when the process exited was recorded by its next open as a restart after an unclean shutdown -- in a custody ledger, a permanent audit entry for a crash that did not happen. Running a
.mu,mutant test, the debugger and the REPL now close everyledger_openanddb_open_diskstore the program left open, and name each on stderr.db_open_diskreads a store's label table before opening it. graphene registers a store's label names for the whole process, anddb_open_diskopened any directory. Opening an exported symbol graph letdb_add_nodewrite nodes under the label the graph namesModule, whichmutant graph querythen could not decode; opening a store that named a disclosure-ledger label differently made every later disclosure, withdrawal and reclassification in the run fail. A table that names the custom labels 0-127db_*writes, renames a disclosure label, or is torn is now refused before anything is opened.db_index_propreported success on a node that does not exist. graphene does not look for the node before indexing it, so a mistyped or stale id wrote an entry pointing at nothing. It now looks the node up first and refuses by name.Every disclosure write rewrote the ledger's label table. The disclosure family's label names were written and fsynced beside the ledger on every disclosure, withdrawal and reclassification. They are now written on the first of these after each
ledger_open. Asking about a reclassification that is already recorded went from about 4 ms to about 14 µs.Four examples reported the wrong numbers, and their recorded output locked them in.
mini_timeline_builderindexedfs_stat's error field for every file, which is null for a file that stats cleanly, so its output was a run of argument errors; it now indexes an error only when there is one.incident_graph,binary_triage_sections_entropyandmemory_scan_to_detectionprintedlen()of a result hash -- a count of its keys -- as the number of nodes reached, sections and PE headers: 2 nodes for 3, 3 sections for 15, 3 PE headers for 1.incident_graphnow also looks the process up withdb_findand lists what it touched withdb_relations.A withdrawal recorded who made it and not on whose authority, though the disclosure policy promised both.
disclose_withdraw(ledger, disclosure, reason, {"authorised_by": name})records the authority; left out, it is the examiner's own. The Withdrawal carries the name andauthority_basis(selfornamed) and an AUTHORISED_BY edge to that name's Actor node, anddisclose_historyshows both. A withdrawal recorded before this release reports its basis asnot recorded.ledger_redactionsnamed whoever was reading the ledger as the actor of every redaction. The name came from the reading session, not the record, so a redaction one examiner made was attributed by name to the next one to open the ledger. The name is now the one behind the record's own actor id: this session's, or the one a disclosure-family Actor node pairs with it, and otherwise empty, withactor_sourcesaying which.A case could be written through a ledger it was not attached to. A builtin that takes the ledger asks it for the case's state, and another ledger records no lifecycle for the case, so a disclosure or a reclassification written through one went ahead while the case was in review in the ledger it was attached to.
disclose_to_passphrase,disclose_reclassified,role_assignandredaction_commitnow refuse a ledger other than the one the case is attached to.ledger_patternscould report a match naming a node the ledger does not hold. It refused a pattern with no edges, since every node then matches on its own, but accepted one with a single node on no edge, which matched every candidate on its own -- and over a scope, graphene takes the scope's ids as an unlabelled node's candidates without reading them, so a match could name any id in the scope. A pattern node on no edge of the pattern is now refused, and the refusal names it.The documentation names every builtin that refuses classified plaintext.
record_release's documentation, the disclosure policy and the classified-records source all stopped their list of the builtins that refuse a marked buffer atdb_add_artifact, althoughdb_add_relationrefuses one too;record_release's also left outreport_tableandreport_list. A test now holdsrecord_release's documentation to the list the run time is held to.A lint report on a call inside a
returnwas given twice. The parser records a return's first value twice over, and the walk most of the lint rules share followed both, soclassifiedPlaintext,commandInjection,evidenceMutation,pathTraversal,tlsVerificationDisabled,unboundedResourceandweakCryptoeach reported a call written in areturntwice, in the editor and inmutant lint. The statements inside aniformatchthat a function returns were walked twice too. Each is walked once now.Every analysis built the set of builtin names again for each rule that asks. Seven lint rules built a set of every builtin's name, about 27 KB, each time a document was analysed -- on every keystroke -- although the language server builds that set once when it starts, and the four new case rules would have made it eleven. They read the one set now: analysing a 50-declaration document allocates about 160 KB less than before, the four new rules included.
The HTTP builtins went through whatever proxy the environment named.
http_get,http_post,http_requestandlua_run_httpsent every request through net/http's default transport, which takes a proxy fromHTTP_PROXY,HTTPS_PROXYandNO_PROXY, so whoever set the examiner's environment saw every fetch and upload and could rewrite a plain-HTTP answer. On Linux,SSL_CERT_FILEandSSL_CERT_DIRlikewise chose the certificate authorities those requests,net_tls_connectandnet_tls_upgrade_clienttrusted. The builtins now send through a transport that names no proxy, and on Linux read the system's certificate authorities from where Go reads them when neither variable is set (M26-NET-010). The configuration guard now refuses net/http's default client and transport, anhttp.Clientwith no transport of its own and, in the Linux build,x509.SystemCertPool.On Linux the
process_*builtins read/procfrom whereverHOST_PROCnamed.process_list,process_tree,process_env,process_hash,process_modules,process_open_filesandprocess_threadsgo through gopsutil, which builds every path it reads fromHOST_PROCunless it is told the directory, and decides that a process exists by signalling it when that directory is not a mount, so the environment decided what they reported (M26-NET-025). gopsutil is now told/proc, and every other directory it would look up.The limits reference and the configuration policy claimed more than was true.
docs/LIMITS_REFERENCE.mdsaid every value that bounds Mutant was a named constant and that it was the list of them, while the guard's budget held 167 that were not, and it listed a test helper's settle timeout beside the limits of a run. It now says how many limits the guard finds unnamed, and lists the limits of the tools around Mutant -- the test helpers, the guard itself -- after those that bound a run (M26-LIM-003). The configuration policy excused binary-format offsets from the guard because "their bounds are tested by fuzzing instead"; the tree has no fuzz targets yet, and the policy says so (M26-LIM-002).A count an examiner mistyped was charged to the host.
str_repeat,str_pad_left,str_pad_rightandrand_bytesallocated whatever size the script asked for, and so didrandom_hexandnanoid. The result was not an error and was not a crash: the process stayed alive holding tens of gibibytes of committed memory, printing nothing, surviving an ordinary kill, so the case it was opened for was neither finished nor released and the machine an examiner shares was the thing that degraded (M26-BLT-013). All six now refuse a result over 32 MiB, naming the builtin, the size asked for and the limit, in the same shape as every other argument refusal. The refusal also happens at expansion time, so a macro body that asks for one can no longer wedge a compile. The bound ismaxBuiltinResultBytesinbuiltin/result_limit.go, the language server shows it on the parameter before the call is run, anddocs/LIMITS_REFERENCE.mdlists it with its reason.The formatter could write an
if,whileormatchcondition that did not parse. It left out the brackets the statement needs whenever the printed condition began with(and ended with), which a call on a bracketed callee such as(-f)(1)does. It now always writes them (M26-LSP-008).The five disk-image examples failed on any real image.
vhdi_example.mut,hfs_example.mut,xfs_example.mut,ewf_example.mutandraw_example.mutbuilt their lines with+, and an image's sizes and numbers are integers, so each stopped at the first one withunsupported types for binary operation: STRING, INTEGER. They also ran their lines together and passed the data they read toputfas the format, andraw_example.mutprintedsector_size, whichraw_metadatacallsassumed_sector_size. They now print throughputf's verbs. The sweep cannot supply an image, so tests run the VHD and raw examples against generated ones, and a sweep test refuses aputfformat any example decides at run time (M26-EX-020).Calling something that was not a function did not fail. The VM tested the callee slot for a closure or a builtin, and when it held neither it called whatever was at the bottom of the stack instead -- which is usually the function currently running. A dispatch table with no entry for the value it was keyed on therefore re-invoked its own caller and handed back that result, so a program that looked up a handler by a string from the evidence got an answer it never computed and nothing was reported anywhere (M26-VM-003). When the value at the bottom of the stack was the caller, the call recursed on itself:
fn() { let x = 5; return x(); }did not return a wrong number, it ran until something stopped it, and in a test what stopped it was the ten-minute timeout. The fallback is gone, and a callee that cannot be called is refused by name --calling non-function and non-built-in: INTEGER, naming the type the author wrote and not the encrypted form it is stored in. The tree-walking evaluator always refused this, so the two engines no longer disagree about what the same expression means.A failing task named the instruction that failed, and that name changed with the build.
task_waithanded the waiting program an error readingvm_runtime_error ip=6 op=OpDiv: integer division by zero, and the same source built at another mutation level saidip=12. A program that printed a failed task's error printed different output depending on how it was built, which is the one thing mutation promises not to do, and the number described the mutated instruction layout to whoever read that output (M26-VM-002). An error a program receives as a value now carries a source position instead of a bytecode one -- and the position is the line the task died on, not thetask_waitthat collected it, which is what a reader was looking for in the first place. The failure also brings the task's own call stack with it, readable aserr.stack. An error that ends the run is unchanged: there the instruction pointer and the opcode are what a bug report needs, and they are still printed. Awith_resourcecloser that fails and a test that dies rather than asserting were the same defect and are fixed with it.A
breakor acontinueinside a function literal is refused at compile time instead of rewriting an unrelated instruction. The compiler's record of which loop a jump belongs to hung off the compiler rather than off the scope, and starting a function body neither saved nor cleared it — so abreakinside a closure was recorded against the enclosing loop, as a position in the closure's own instruction stream. The loop then patched that position in its own stream, overwriting the first operand of whatever instruction sat there, and the closure kept the placeholder jump nobody had patched. Nothing was reported, and what went wrong depended on where the offset landed: abreakin a callback inside aformade an unrelatedlet marker = 1;print 1053, acontinuein one inside afor ... infailed with a stack underflow blamed on the program's first line, and one shape tookmutant gendown with an index-out-of-range panic. A function body is now a boundary, so such abreakis what it always was — one with no loop to leave — and says so. A loop inside the closure is unaffected and still patches its own stream. No shipped program uses the refused shape. (M26-CMP-002)The workshop's evidence corpus can be built, and the ten beats have now actually run.
build_evidence.{sh,ps1}could not produce the QUILLDROP corpus with the generator their own install line installs: fsagen v0.1.0 refused the playbook, first for pairingtemplate:withcontent:and then, with that removed, for adeletewhose path template redrew its random component and so named files that had never been created. Nothing had ever got past that, which is why all ten.goldenfiles record only the “missing evidence” line — the code insidequilldrop.ready()had never executed on any machine. The lure is now built byaction: email, so beat 2 parses a real RFC 5322 message; the staging tree is deleted by reference; and a third refusal that no report had recorded is fixed —Finance/*.xlsxfilled with random bytes is refused, because the extension promises an OOXML spreadsheet fsagen will not fake, and both escapes it offers write text, which cannot reach beat 1's 7.9 entropy floor. Those exhibits are.xlsx.enc, which is what an encrypted spreadsheet looks like on disk, measured at 7.997, and the contract document is corrected to match the generator. All ten beats then ran end to end, every one exit 0, with beat 10 verifying beat 9's seal. Both scripts and both attendee-facing install lines now pinfsagen@v0.1.0;@latestbecame v0.2.0 on 2026-10-02. (M26-EX-018)What the workshop said about ext4 and APFS was wrong in both directions. It said the corpus “still builds” there and that only beat 3 was affected. Measured: without
--on-unsupported=skipthe run exits 1 and writes no corpus at all, because Mark-of-the-Web needs an alternate data stream; and with that flag the modelled timeline comes back with everycrtimeat 0, so beat 6 — which asks whether a file's modification time precedes its creation time — reports nothing on any row and its entire anti-forensics finding silently disappears. One fewer line of output and no diagnostic. Both build scripts now pass the flag, which is a no-op on NTFS, and then put the question beat 6 asks to the timeline they have just written, saying plainly when the finding cannot fire. That is a measurement of the output rather than a guess about the filesystem, so it is correct on ReFS, on FAT32 and on a network share as well. The documented platform table is now the measured one, and what it measures is NTFS against WSL ext4; APFS is a separate correction, below. (M26-EX-019)The workshop told six readers to take the pre-built corpus and never said where it comes from. The scripts and the docs offer it in six places --
build_evidence.sheven prints "see evidence/README.md" at the moment beat 6's finding cannot fire -- andevidence/README.mdwas the one file that did not answer. The run sheet told the presenter to hand it over without saying what they were handing over. The owner decisions of 2026-10-04 make a pre-built, checksummed corpus the default for every attendee rather than a fallback, so the gap sat on the primary documented path, and the packaging plan existed only underplans/, which is gitignored and which no attendee can read.evidence/README.mdnow has a section saying what travels together -- the 98-file case tree, the 131-row modelled timeline, and aSHA256SUMSin the formatsha256sum -creads, which is the conventioncase_bundlealready writes -- that the presenter supplies it, and how to check it on either platform; the other five references name that section. No filename or URL is printed, deliberately: the asset is named in the release it travels with, and a name written here would be the same dangling promise one level down. (M26-EX-021)A measured ext4 result was explained by a mechanism that is wrong, and then extended to a platform nobody had run.
evidence/README.mdsaid that Mark-of-the-Web, the alternate data stream,crtimeandctimeare "Windows + NTFS only".ctimeis POSIX and fsagen reads it on Linux unconditionally throughstatx; and a birth time belongs to the volume rather than to the operating system -- APFS keeps one, and fsagen's darwin path reads it fromBirthtimespec. The single measurement behind that sentence was WSL ext4, and the table it introduces has no APFS column, yet four places generalised it to "ext4 or APFS", so a Mac presenter read an ext4 number as a macOS number and was told to hand out a corpus they may not need. Every measured number is kept; the prose is narrowed to the volume it was measured on, APFS is named as unmeasured, and the reader is pointed at the check both scripts already run against the timeline they have just written -- which was always the right authority, since it measures the output instead of predicting it from a filesystem name. Measured while checking this: fsagen v0.1.0 does cross-build for darwin on both architectures, so macOS is not shut out of generating at all. Separately,evidence/README.md's hand-runfsagencommand omitted--on-unsupported=skip, which both build scripts pass and which is a no-op on NTFS, so the one command the document spells out was refused on exactly the volumes the surrounding text is about. (M26-EX-022)A Windows 10 or 11 jump list lost every entry after its first.
jumplist_parseread a DestList entry's path size at 0x74 from version 3 on. The format keeps it at 0x80 from version 2 on, and 0x74 is the entry's access count, so the path came out of the wrong bytes and the next entry was looked for in the wrong place: on a real jump list the walk stopped after one entry, and the hostname, time, pin and path of every other were lost (M26-ART-001).prefetch_parseread the run count from the wrong field on current Windows. Version 30 prefetch files come in two layouts. The one later Windows 10 builds and Windows 11 write keeps the run count at 0xC8, where 0xD0 is another field. The file says which layout it is, by where its file metrics array starts, and the run count is read from that layout now (M26-ART-002).A registry value over 16 KiB came back as 12 bytes. On a hive of version 1.4 or later, a value longer than one 16344-byte segment is stored through a big data record, and
hive_get_value,hive_list_valuesand every hive-backedreg_*read returned the record instead of the value. They follow it now, and only where the format puts one: a short value that happens to begin "db" is data, which the reader behindshimcache_parseused to get wrong the other way (M26-ART-010).Nested email parts were dropped with everything in them.
email_parse,email_attachmentsandemail_urlsread a message's top level only, so a multipart/alternative inside a multipart/mixed -- how most mail is built, phishing included -- lost its text and HTML bodies, its links and any attachment inside it. Nested parts are walked with their own boundaries now, down to 16 levels, past which the message is refused by name (M26-ART-013).A crafted artifact could hang its parser or exhaust the host. A registry index root naming itself cost 2^33 calls, and a chain of them returned a million copies of one subkey; each list is now read once (M26-ART-004). A binary plist of forty arrays, each holding two references to the next, expanded to 2^40 values; a plist is now refused once it would describe more values than it has bytes, which only shared containers can make it do, and a container holding itself is named as one (M26-ART-008). An EVTX chunk declaring 2^64 records over one that states a size of zero parsed that record until memory ran out; a chunk's records are now walked first and the parser told how many there are (M26-ART-006). And a count read out of a file -- DestList entries, registry values and big data sizes, plist objects and arrays -- no longer sizes an allocation before the bytes it promises are there to read: one header could ask for 240 GB, which no
recovercatches (M26-ART-005).The language server pointed at the wrong place on any line holding a non-ASCII character. LSP 3.16 defines
Position.characteras an offset in UTF-16 code units and offers no way to negotiate another encoding:positionEncodingarrived in 3.17 and this server is built onprotocol_3_16. Mutant's lexer counts columns in BYTES, and the server equated the two numbers. They agree on an ASCII line and on no other, so from the first non-ASCII character of a line onwards every range sent out was too far right and every position read in was taken too far left, by one unit per extra UTF-8 byte.The trigger is not a non-ASCII identifier. A non-ASCII character anywhere earlier on the line does it, inside a string or a comment included, so this was live for ordinary ASCII-named code -- 18 of the shipped
.mutfiles carry non-ASCII in a string or a comment. Everything that carries a range was affected: diagnostics underlined the wrong span, go-to-definition and hover resolved the wrong token or none, inlay hints landed inside words, document links missed -- and the server advertisesRenameProvider, so accepting a rename applied its edit at the wrong offset in the user's own file.The conversion now happens once, at the protocol boundary, in
protocol.Mapper. Byte columns are deliberately NOT replaced with character columns: the CLI, the parser's own messages and the sweep goldens all readtoken.Positionas bytes and the goldens pin it, so nothing in the lexer, the parser or the AST changes. Seventy-five conversion sites across thirty files now go through two functions, and the three package-level converters that did the arithmetic without a document in hand are deleted rather than corrected, so a new call site cannot quietly reintroduce it.Three surfaces beyond the ones reported. A semantic token carries a start and a length, and both are in the protocol's units: the length was either the byte difference
end-startorlen([]rune(text)), and a rune count is wrong too for any rune outside the basic multilingual plane, which needs two UTF-16 units. That same rune count gave a document's last position in three more places. Andlsp/apinow converts back to a byte column, somutant lintkeeps reporting the column the parser reports.On an ASCII document every number is unchanged, and that is asserted rather than argued: the existing suite passes untouched and
TestAnASCIIDocumentIsUnaffectedpins it. Each new fixture was proved to fail on the old arithmetic first. (M26-LSP-027)The sandbox detector named WSL on a machine that had no WSL. On Windows a parent process named
bash.exescored 90 under the typeWSL, over the 70 that counts as detected. Git for Windows, MSYS2 and Cygwin all ship abash.exe, so a program started from any of those shells was halted and told it was running under WSL. The halt is intended and is unchanged -- an emulated shell is one of the environments the detector exists to notice -- but the type is nowMSYS/Cygwin or legacy WSL, which is everything that name can prove: the legacy WSL launcher is calledbash.exeas well, and the two cannot be separated there, because the parent is read fromtasklist, whose image-name column is a basename and never a path. A run that is really under WSL is still reported asWSL, since it scores from its context environment and its UNC working directory too, and the tie-break prefers the specific type.The parent name was also compared as a substring, so a name that merely held one of the list's entries scored the same 90:
notwsl.exeandmywsl.exeholdwsl.exe, andgitbash.exeholdsbash.exe. None of those is a shell or a launcher and none of them scores at all now -- a single name is compared whole. The three other callers of the substring helper search a blob -- atasklistdump, the contents of a cgroup file -- where a substring is the right match, and they are unchanged.docs/TUTORIAL_30_MIN.mdanddocs/SANDBOX_DETECTION.mdrecord the type and why it names two things. (M26-TMP-003)A longer process name was read as a shorter one, and in both directions. The Windows sandbox detector ran
tasklistand searched the whole dump for each process name it knows, so a name that merely held one of them was read as being it. Forwards, that halted a run with nothing wrong with it: the guest tools and integration services are worth between 70 and 85 each, all at or above the 70 that counts as detected, so one process calledmyvmtoolsd.exewas enough on its own. Backwards it did the opposite and turned the detector off -- a name holdingvmwp.exemade it decide this machine was a Hyper-V host, and the host role gates the CPUID vendor, the device listing and the SMBIOS table off, so a Hyper-V guest went undetected. The listing is now asked for as CSV and read into a set of image names, each compared whole. The format mattered too: the default table cuts the name column at 25 characters, which 13 of the 312 processes on the machine this was written on exceed, and one of its own rows isSystem Idle Process, whose name holds spaces. (M26-TMP-020)USERPROFILEwas searched the same way for the Windows Sandbox account's profile folder, and that signal alone is worth 95, so a directory that merely held the name -- a copy of that profile kept elsewhere -- halted the run. A profile variable is that directory, not something containing it, so the path now has to end there. The neighbouringUSERNAMEcheck already compared whole. (M26-TMP-021)This corrects the entry above. It reported that the three remaining callers of the substring helper all search a blob where a substring is the right match; two of those three were these
tasklistreads, and a listing of names is not that kind of blob. The one that is -- the contents of a cgroup file on Linux -- is unchanged, and is now the only caller left.On Linux the parent-debugger check searched the parent's command line, so ordinary words were read as debuggers. It read the whole of
/proc/<ppid>/cmdline, arguments included, lowercased it and looked for each name in its list as a substring.rris in that list and is insidecurrent,terraformandmirror;idais insidevalidateandcandidate. Run on real Linux, a parent whose arguments heldcases/current/triage.mu,terraformorvalidate the imagewas reported as a debugger in all three cases, and in secure mode that ends the run. The check now reads/proc/<ppid>/comm-- the executable's name, not its arguments -- and compares it exactly; all three report clean, and a parent whose executable really is namedgdbis still detected. No names were added to the list: every candidate setsTracerPidwhile it is actually tracing, which the other check already catches, so more names would only widen the collision set for no gain. An unreadablecommfails open, because not knowing what started a program is not evidence that a debugger did. (M26-TMP-002)exec_stringandcmd_runcould not run a command on Linux or macOS at all. The shell defaulted topowershellon every platform, and the non-Windows shells were handed the command as a file path instead of after-c, sobashexited 127 with the whole command treated as a script name andshwas not reachable by any spelling. The default is nowpowershellon Windows andsheverywhere else,bashandshare given-c, andpwshruns the realpwshbinary. Three deliberate behaviour changes go with that. On WSL,exec_stringwith no shell argument used to reach PowerShell through Windows interop and now runssh -c, so a script that relied on that must namepowershellexplicitly.exec_string(cmd, "bash")on Windows goes from doing nothing to running the command inside WSL. Andpwshstops silently running PowerShell 5.1. Windows' own default is unchanged, andpowershell,cmdandbatchkeep working by name on every platform. (M26-TMP-005)On macOS the parent-debugger check had the same defect with a worse list. The output of
ps -o comm=was substring-matched, and the list holds the bare wordstrace, which is insideltrace,dtraceandpytrace, andheap, which is insideheaptrack;xcodematched anything invoked by path under/Applications/Xcode.app. The base name is now compared exactly, through the same matcher and the same lists as the Linux side -- both lists moved into the untagged file, so one function serves both platforms and both platforms' rules are testable from any host. This was found while fixing M26-TMP-002, and neither row mentioned it. Alongside it,go buildandgo vetnow pass forlinuxanddarwin: nothing had ever compiled these paths, which is how two platforms' worth of this check went unexamined. (M26-TMP-019)The lexer reads source as the runes it is written in, so a program that names a variable
grün,caféorводаcompiles and runs instead of hanging the tool that read it. The cursor was declared a rune but filled from a single byte, and the first byte of a multi-byte letter is itself a letter in Latin-1, so a name was taken to end one byte into itself: every letter whose UTF-8 form begins0xc3read as the same identifier, which madexàandxÅone variable, andmutant fmtwrote the truncated halves back over the author's file as bytes that were not UTF-8 at all. Separately, the scan dispatched onunicode.IsNumberwhile the number reader looped onunicode.IsDigit; a superscript two or a vulgar fraction is the first but not the second, so the reader took nothing and the token had no width, and a lexer that hands out the same token for ever is read for ever —mutant,mutant lintandmutant fmtall spun instead of answering, and so did the language server. Both predicates are nowIsDigit, andNextTokenguarantees that a token which is not the last one consumed at least one rune, so a disagreement of that kind can cost a wrong token but not the process. Unicode identifiers are supported rather than refused, and are not normalised, which is the rule Go uses for its own. Invalid UTF-8 is reported one position at a time instead of being silently misread. (M26-LEX-001, M26-LEX-002)A struct now prints its fields in the order its own declaration gave them, and two structs are compared field by field rather than by their printed text.
Struct.Inspectranged a Go map, which yields a different order on every CALL rather than merely on every run, and Inspect was the identity both engines compared structs by. Sop == qfor two records built from the same literal answered true about one time in six for a six-field struct,p == pwas usually false,p == qandp != qcould both answer true in a single statement, and printing one record twice printed it two ways — all without a diagnostic.contains,index_ofanduniqueasked the same question through the same identity, so a search for a record that was present found it about half the time and a dedup kept both copies of one record. A struct holding another struct, or held in an array, was wrong for the same reason. Equality is nowobject.ValuesEqual, one definition shared by==in both engines and by the collection builtins, so a search cannot answer differently from the comparison written by hand; it also means a string that spellsP { a: 1 }is no longer equal to the struct, which it was in both engines, as a buffer, an error and an enum value already were not. A struct with a field that no declaration named — which onlyOpSetFieldcan produce — prints it last, in sorted order, rather than hiding it. Ordering operators on structs are still refused, and the editor already said so. (M26-EVL-004)A struct's fields are fixed by its declaration, in both engines and in the editor. Reading a field no struct declared gave null, and writing one added it. So a mistyped read was an absent value that compared unequal to everything, printed as nothing and flowed on into whatever used it; and a mistyped write left the field the author meant to change holding its old value while the record gained one its type never declared, the write appearing to succeed. Both are refused now. The compiler refuses them before the program runs wherever it can prove what the receiver holds -- a name bound once from a struct literal and never assigned again -- both engines refuse the rest in the same words, and the language server says those words first. An error's unknown field is still null, for its own reason: a field a stripped build stamps nothing into still has to read, which is now written where it is relied on rather than deferring to a struct rule it no longer shares. (M26-CMP-017, M26-CMP-018)
P { a: 1, c: 3 }said "missing field b" and never mentionedc. One mistyped field name makes two faults -- a name the declaration does not contain, and a declared field nothing set -- and only the second was reported, so the author was sent to look at a field they had not touched. Both are reported together now, which is what identifies the pair as one typo:struct P has no field c and needs a value for field b: it declares a, b. The separate field-count refusal is gone: a count that differs means the names differ, and the names are what has to change either way. The one exception is a field name written twice, which is why a repeat is now refused where it was written --struct P declares field a twice,struct P sets field a twice-- rather than two steps later as "expects 2 fields, got 1", which named neither the mistake nor its line. (M26-CMP-019)A struct literal meant something different in each engine. The tree-walking evaluator checked none of the three things the compiler checks, so a literal of an undeclared type, one missing a declared field, and one naming a field the type does not declare were all values there and all refused by the compiler. That engine is what computes
unquote(...)during macro expansion, so it is not dead code. It makes the same three checks now, and macro expansion is given the program's struct declarations, so a type the program plainly declares is not refused as undeclared inside an unquote. Nothing a program can write reached the old laxness: a struct has no source form, so it could never be spliced back out of an unquote in the first place. (M26-EVL-019)toml_parseandmsgpack_parsecrashed the process on a deeply nested document. Both libraries recurse once per level, and nothing bounded them: convertNative's depth check runs on what they return, after the recursion. Two million levels overflowed the stack, a fatal error norecovercatches. A document's shape is now measured from its bytes first, and one nested past 256 levels is refused (M26-DAT-004). The same measurement refuses a TOML key of more than 256 dotted parts, which the library re-walked once per part -- 16,000 parts took 7.9 s (M26-DAT-033) -- and a MessagePack container declaring more values than its bytes can hold, which the library allocated before reading one: five bytes asked for 64 GiB (M26-DAT-034).toml_parsemoved local date-times by the host's UTC offset and labelled them Z. A TOML local date-time, date or time names no instant, and is now rendered as it was written; an offset date-time is still rendered in UTC (M26-DAT-015). An array of tables,[[name]], is read rather than refused (M26-DAT-016).Three decoders let the last copy of a repeated key win without a word.
json_parse,ndjson_parseandmsgpack_parserefuse a key written twice in one object, as the CBOR and YAML readers already did (M26-DAT-035, M26-DAT-036).msgpack_parsereads integer map keys, which it could not, andcbor_encodeandmsgpack_encodewrite INTEGER and BOOLEAN keys as what they are rather than as text: COSE's{1: -7}round-trips, and{1: v}no longer encodes the same as{"1": v}(M26-DAT-014).json_parseread a document's first value and dropped the rest. It requires exactly one now, and so does everyndjson_parseline, where a stray}used to pass (M26-DAT-017). An integer too wide for INTEGER comes back as its exact decimal text, not a rounded FLOAT (M26-DAT-018), and text that is not UTF-8, or half a surrogate pair, is refused rather than rewritten to U+FFFD (M26-DAT-037).protobuf_parserefused every group, and could hold hundreds of times its input. A group is read with its own field number (M26-DAT-011), one field budget covers every nested reading (M26-DAT-012), and past the 32-level limit a group is refused rather than skipped while a length-delimited field saysmessage_unchecked: true(M26-DAT-038).der_parseread a UTCTime from 1950-1968 as 2050-2068, and took superlinear time over a huge INTEGER. Two-digit years follow RFC 5280 (M26-DAT-013), and an integer longer than 4 KiB is rendered as exact hexadecimal, incbor_parseas well (M26-DAT-029).xml_findreturned one element several times, out of document order, and four**over a chain of sixty elements returned 595,665 results. Each element comes back once, in document order (M26-DAT-019).An HTTP message head is bounded in fields as well as in bytes. Nothing bounded how many header fields a head could carry, and the cost of a field is not its length: each one becomes an entry in an
http.Headerand then a pair in the hash the builtin hands back, which is held as a VM variable and re-encrypted on every store. With a 1 MiB head bound in place, 87,964 one-line fields fitted in 1,044,485 bytes on the wire and grew the heap by 33,246,576 -- 31.8 times what arrived. A head is now capped at 100 fields, and a head past the cap is refused rather than trimmed: a hash holding the first hundred fields of a larger head would be a wrong answer with nothing in it to say so. The refusal says how many fields arrived as well as how many are allowed, because the difference between 101 and 90,000 is the difference between a verbose client and an attack. The cap counts the lines that arrived, not the distinct names, so a head spelling one name ninety thousand times is ninety thousand fields and not one. It applies to every builtin in this build that turns an HTTP head into a hash:http_conn_read_request,http_conn_read_response, their two_headvariants,http_parse_request,http_parse_response(M26-NET-028), andhttp_get,http_postandhttp_request, whose response head had no byte bound of its own beyondnet/http's 10 MiB transport default (M26-NET-032). This is a behaviour change: a script reading a head of more than 100 fields used to get a hash and now gets an error. 100 is Apache httpd'sLimitRequestFieldsdefault and Tomcat'smaxHeaderCountdefault;net/httphas no field count of its own. Email headers are deliberately not capped at this figure -- a real message accumulates aReceivedfield per hop -- andemail_parseand its four siblings are unchanged.A recursion with no stopping case ran until the host gave out, and through
mapit killed the process outright. Nothing bounded how deeply calls could nest. The constant that looked like the bound,global.MaxFrames, sat one line below a comment saying the VM grows these slices dynamically, and it was read as a limit by two comments elsewhere in the VM that describe reporting a runaway recursion as an error. Measured:let f = fn(n) { return f(n + 1); }; f(0);reached 73,298 frames in eight seconds and returned nothing at all, and the same recursion written throughmapreached 82,366 while also growing the Go stack, becausemap,filter,each,reduce,sort_by,with_resourceandtestcall their callback by re-entering the engine. Past the Go stack's own default that form ends infatal error: stack overflow, which norecovercontains, so the process dies with the run's cleanup unrun -- noCleanupSensitiveData, noWaitForTasks. A call may now be 10,000 deep and the next one is refused withthis call would be 10001 deep, over the limit of 10000 nested calls. The ceiling is on frames and not on calls, so a program that calls a function sixty thousand times in a loop is unaffected, and it is checked in the one place a frame is entered, so the plain path and the callback path are bounded by the same number. 10,000 nested callback re-entries were measured to fit in 32 MiB of Go stack against a 1 GiB default, which is why one ceiling is enough for both.global.MaxFramesis gone rather than renamed: the frame capacity isvm.initialFrameCapacitynow, in the package that grows it, next to the limit. (M26-VM-006, and in part M26-VM-021)An ext volume inside a disk image could not be opened.
ext_openat a partition offset refused every ext volume as a corrupt superblock. libext reads relative to the reader it is given and adds its base offset only to the offsets it reports, so handed the image from byte zero it looked for the superblock in whatever preceded the partition. It is handed a reader that starts at the volume now, and the offsets it reports stay image-absolute (M26-FS1-001).An NTFS file's size came from the copy NTFS keeps least current.
mft_parse,fs_deletedandntfs_deletedtook a record's size from its$FILE_NAMEattribute, which NTFS updates lazily and which is routinely stale or zero, andntfs_recover_filecut a recovery to that figure -- or, when it was zero, padded it out to the whole of its clusters, with no caveat either way. The size is now the default$DATAstream's wherever the record holds the start of that stream, and$FILE_NAME's only where it does not (M26-FS2-002).A partition's byte range could wrap round to another partition's. Every partition table's
start_byteandlength_bytewere LBAs times the block size in unchecked arithmetic, so a crafted entry reported a negative length or the start of a different partition, andfat_openon it opened that partition. The fields now come from checked arithmetic and read -1 when they do not fit, and opening such an entry is refused (M26-FS1-002). A region whose offset plus length passes the largest offset an image can hold is refused too, where it was opened "bounded" with a reader that ran on without end (M26-FS1-014).ewf_open_partialmisplaced the segments after a hole. libewf concatenates the chunk tables of the segments it is handed, so with a segment missing every chunk after the gap decoded at the wrong offset: a set of segments 1 and 3 read segment 3's data where segment 2's belongs. A set with a hole now decodes segments 1 up to the first gap, lists the rest in a newundecoded_segmentsfield, and is refused outright when segment 1 is missing; an explicit list must be 1..k by each file's own header (M26-FS1-010).partialis true whenever the image is not whole -- a hole, a last segment with no done section, a first segment that is not number 1, or fewer chunks than the volume declares -- where it said false for a list with a hole (M26-FS1-009). A list of one path is opened as given, never expanded by discovery (M26-FS1-018).vhdi_discovergave its duplicate-identity warnings in a different order on every run. They came out in map order; they are sorted by identity now (M26-FS1-016).A read that came up short was a whole file. libfat, libxfat and libntfs end a read with
io.EOFwhere the image ends inside a file, and libntfs and libhfs where a file's own map runs out, and the*_extract_file,*_hash_fileand*_read_file_atbuiltins took that for the end of the file: a prefix was extracted, hashed and reported as the file. A read that stops short of the bytes the volume located is an error now, and an extraction that fails removes what it wrote (M26-FS1-003). A deleted file's recovery reads nothing past the end of the image, counts the bytes its runs place there apart from the ones it could not place, and refuses an output that would be nothing but stand-in zeros (M26-FS2-005).fs_hashand the*_hash_filebuiltins report the algorithm they computed under its canonical name (M26-FS1-017).The NTFS walks trusted a count the image does not hold.
ntfs_verify,ntfs_reportandntfs_deletedwalked every record the$MFTdeclares about itself, unbounded by the image, so a crafted 128 KiB image could run the process out of memory; andntfs_verifytook a record it could not read for an unallocated one, so a truncated MFT verified. The walks now follow record 0's own run list, checked against the boot sector, and cover only the records the image holds; the rest are named, a record that will not read is a finding and fails the new structural checkmft_readable, andntfs_deletedsays it is incomplete (M26-FS1-005, M26-FS1-006, M26-FS2-003).ntfs_reportrefuses an image that does not hold its whole MFT, because libntfs's report walk cannot be bounded from outside.verifiedcould rest on no check at all, and on checks that never compared anything. Every*_verifycheck now has akind,integrityorstructural, andverifiedneeds an integrity check to have run, so ext2, HFS+ and XFS v4 -- formats with nothing to compare -- are no longer verified (M26-FS1-004).hfs_verifyno longer fails a volume for an attributes B-tree its format does not have (M26-FS1-011).ext_verifynever compared an inode's checksum -- libext compares one on every read and keeps the answer only on a handle opened to refuse a mismatch -- so an ext4 volume with an edited inode verified, and the summary said verifying again after a walk would widen the check; it compares the superblock, the group descriptors, the allocation bitmaps and every allocated inode now (M26-FS2-027). libxfs v0.4.1 reads the stored superblock and inode CRCs in the wrong byte order, soxfs_verifyfailed every healthy v5 volume with a critical finding andxfs_reportcarried a mismatch anomaly for every inode; the comparisons are made here now, and a real mismatch is still reported (M26-FS2-030).A path named a different file. Paths are no longer trimmed:
" a.txt"and"a.txt"are two names on every filesystem that allows both, and ext, XFS and HFS+ keep a backslash as the character it is. HFS+ refuses a path ending in a space, since libhfs trims it. An exFAT name held by a live file and a deleted one finds the live one, and two deleted files of one name are refused rather than one chosen (M26-FS1-007, M26-FS1-008).Allocation was "checked" where nothing asked. A deleted FAT file's assumed run, and every cluster of an exFAT recovery, are asked about cluster by cluster, and the first in use is named (M26-FS2-004, M26-FS2-017). An ext inode on the orphan list owns its blocks and is no longer reported reallocated, and an ext or HFS+ entry whose allocation could not be read is no longer reported checked (M26-FS2-006, M26-FS2-016). A FAT or exFAT record seen in a deleted directory and again by the sweep is one row (M26-FS2-015), and libxfat's virtual
$OrphanFilesroot is not a deleted file (M26-FS2-026).Warnings were lost, and their loss was a clean result. libext keeps 256 warnings per handle and libhfs one of each kind; a scan after the list filled, or a second file with the same damage, said complete with no warnings. Both now say so (M26-FS2-008, M26-FS2-021). The ext scans count the inode-table slots they read, and a slot past the end of the image -- which libext passes over without a word -- makes them incomplete (M26-FS2-018, M26-FS2-028). The
$LogFilescans count the record pages libntfs drops for a failed update sequence -- a torn write -- and say they are incomplete (M26-FS2-029).Reports and scans put things in the wrong place.
ext_report's fragments end exclusively, like every other format's, and sit at their real offset in the file, so a run after a hole is placed after the hole; a row whose block map could not be read says so and the report is incomplete (M26-FS1-012, M26-FS1-013).xfs_reportbounds its volume (M26-FS1-015).xfs_unlinkedsays it is incomplete when an inode would not read, and an XFS time of zero is no time rather than 1970 (M26-FS2-013, M26-FS2-014).xfs_slackno longer counts a directory's live index blocks, 32 GiB into it, as slack (M26-FS2-020). A fragmented HFS+ attribute value reports every range it occupies (M26-FS2-022).ntfs_log_transactionsgroups records in LSN order, so a transaction across the wrap is one transaction, and a record met twice is reported once (M26-FS2-023, part of M26-FS2-024).fs_carvereports where a signature's file begins, not where the signature is, andfs_walkmeasures depth from the root it was given (M26-FS2-010, M26-FS2-011). An inode number past the volume is refused rather than read as another inode's (M26-FS2-007).mutant fmtrewrote a file it could not parse, and shortened the strings inside it. With no tree to read, the formatter fell back to stripping trailing whitespace from every line, and that fallback cannot tell a line of code from a line inside a triple-quoted string: a banner whose first row ended in three spaces came back three bytes shorter. The CLI wrote that result over the file and exited 0,--checkexited 1 for the wrong reason -- it was reporting a whitespace diff it had invented against a file it could not format -- and--stdoutprinted the invented text. A file that does not parse is now left exactly as it is:mutant fmtprints its parse errors inmutant lint's shape and exits non-zero in all three modes, and the editor's formatting, range-formatting and on-type-formatting requests answer with no edits at all. There is deliberately no whitespace-only fallback, which is whatgofmt,rustfmtandprettierall do here: the diagnostics already say what is wrong, and nothing can be formatted until it is fixed (M26-TOOL-014).A sandboxed Lua script could run past its timeout without bound. The five-second cap was a
contexthanded to gopher-lua, which consults it only between VM instructions, so a single call into a Go library function never looked at it. A backtracking pattern search is exactly that one call:string.find(string.rep("a", 400), ".-.-.-b")ran for 43 seconds and noticed its deadline only once it had finished, and the cost rises about fifteenfold for every doubling of the subject. The chunk now runs on its own goroutine and the calling builtin waits on the clock, solua_run_string,lua_run_fileandlua_run_httpalways return within the cap and report the script as abandoned.string.repis bounded at 8 MiB in the same change, because it is the one call a sandboxed script uses to manufacture a subject large enough for the search to matter, and it refuses rather than truncating. What the deadline bounds is the caller's wait, not the host's CPU: an abandoned chunk's current library call still runs to its own end, anddocs/RUNTIME_INTEGRATION.mdsays so (M26-TOOL-010).An index one past the start of an array or a string took the interpreter down, and the audit chain recorded it as tampering.
xs[-1]is the last element, soxs[-4]of a three-element array reached element -1 of a Go slice: the panic was recovered, reported asvm_runtime_error: recovered panic: runtime error: index out of range [-1], and counted as a VM integrity failure -- which means "the program running is not the program that was compiled". On the CLI an ordinary mistake in a script therefore left a tamper entry in the one log that is meant to be worth trusting. Every ordered container now answers null to an index that names nothing, from either direction, and the audit chain does not move. The tree-walking evaluator had the opposite fault on the same expression -- it answered null to any negative index on an array -- soxs[-1]meant the last element when a program was compiled and nothing when the same program was interpreted. Buffers were the one container that was already right in both engines, and they are what the shared rule is taken from. (M26-VM-008)A string index returned a character the string does not contain.
s[i]indexed by byte and then converted that byte to a rune, which is not the byte and not the character: in a string holdingh,e-acute,l,l,o,s[1]was U+00C3 ands[2]was U+00A9, neither of which is in it, and the string appeared to have six characters where it has five. It is indexed by rune now, which is what the for-in iterator,str_char_at,str_substrandstr_reversehave always done --object.NewIteratorstates the rule and the reason. A byte that begins no valid sequence reads as U+FFFD, as it already did in those four; a program that means the bytes should hold a buffer, which is whatBYTESis for.len(s)still counts bytes. The evaluator did not index strings at all before --s[0]was "index operator not supported: STRING" -- and does now, in the same words and with the same answers. (M26-VM-009)!xwas not the negation of truthiness, soif (x)andif (!x)could both skip their branch.0,0.0,""and an empty buffer are falsy -- that is whatif,while,for,match,&&,||andfilterhave always meant by them -- but!consulted none of that. On a compiled program it compared the operand against three singleton pointers and answeredfalseto anything else; in the tree-walking evaluator it compared rendered text. So!0wasfalse, which is the same answer as!true:0was falsy and!0was falsy too, andif (!count) { putln("no hits"); }never fired for zero hits.!!xwas notx. There is one implementation of truthiness now,object.IsTruthy, and!is its negation in both engines, so!xis always a boolean, exactly one ofxand!xis truthy, and!!xhasx's truthiness. Two of these were also disagreements between the engines rather than only wrong:!""and!<empty buffer>were true when a program was interpreted and false when it was compiled, and!"false"was the other way round -- the evaluator was comparing the string"false"against the rendering offalse. The rule is now stated in the language reference, which never stated it, and corrected indocs/WASM_REPL_REFERENCE.md, which listed a whitespace-only string as falsy and left out0.0and the empty buffer. (M26-VM-007)A function whose body produced no value crashed the compiler. The tree-walking evaluator returned a Go
nilrather thannullfor a call to a function whose body yields nothing -- an empty body, or, far more commonly, a body whose last statement is alet, which is how a function that works by side effect is written. Thenilwas handed to the program, and whatever touched it next dereferenced it. Because that engine is what computesunquote(...)during macro expansion, the result wasmutant prog.mutending withpanic: runtime error: invalid memory address or nil pointer dereferenceand a goroutine dump, at compile time, from three different ordinary spellings:type_of(f()),!f()andf() == 1. Where it did not crash it was wrong instead --if (f())took the true branch here and the false branch on the VM, andwhile (f())never terminated here and ran zero times there. Such a call isnullnow, as it always was on the VM, which is also what the builtin path in the same function had always returned for the same case. (M26-EVL-020)A
pmap,peachorspawnworker could write the caller's variables. A worker has always been given its own copy of the variables its callback captured directly, and the language reference promises that "an assignment to either stays local to that worker". That promise held for one route to a variable and failed for six: a function the callback captured, a function held in a global, a function inside a captured array, hash, struct, enum or multi-value, a function handed in as apmapelement, and the argument ofspawn(fn, arg). Along any of those, every worker and the caller wrote one storage location with no synchronisation --go test -racereported six races, and the observable effect was lost updates: eight workers bumping a counter fifty times each left the caller's counter reading 432, 503 and 422 on three runs of the same program. A worker now gets its own copy of every variable it can reach, found by following the same paths the storage seal already follows, and a variable reached two ways is still one variable inside the worker, so a program's own aliasing is untouched. A recursive function, whose storage holds the function that holds the storage, terminates. The walk costs 34us per worker where there is nothing to detach -- against 782us to build a worker and 244us to copy its globals -- and the globals are examined once per call rather than once per worker, so a program with no function in a global pays nothing (M26-VM-004).Every variable read cost time in proportion to the value's size. Reading a variable moved it from a slot, a global or a cell onto the stack, and the move opened the value and sealed the result again with the same key -- two ChaCha20 passes and two allocations over the whole payload to arrive back at the bytes it started with. Sealing also derived its key per call, two SHA-256 hashes for every eight bytes: building an array of 800 elements in a loop made 2,890,011 derivations for 22 MiB of payload. A value that is only moving is now moved, and the key is derived once per run -- the VM already derived it for its instructions. A stored value still records the seed it was sealed under, and one sealed under a different seed is still opened with a key derived for it, which is what the REPL does on every line. Nothing in the VM mutates a container in storage form, so the two slots a moved container is now reachable from are safe: every write takes its target from the stack, which rebuilds it.
examples/binary/static_bin_analysis.mut, which reads a 3 MiB buffer out of a struct field in a loop, went from 40.8 s to 22.2 s against the release sweep's 60 s limit (M26-VM-001).Reading a
.tar.zstcould return the wrong bytes. Its zstd decoder was never closed, and went on reading the archive from its own goroutines after a read returned, racing the next read's seek: one or two reads in twenty failed or came back wrong, and dozens of goroutines outlivedtar_close. Every decompressor is closed when its walk ends now, and zstd decodes as it is read (M26-DAT-005).tar_opencould not open an archive that compresses past 1000:1, and nothing could make it. It takes an optionalmax_bytesnow, which every later read through the handle keeps (M26-DAT-006). A plain tar whose first member's name begins with a compression magic -- a file calledBZhistory.txt-- opens as the plain tar it is (M26-DAT-008). What a handle holds is the listing, at most 128 MiB of names, link targets and owners, where it kept every header whole (M26-DAT-010).tar_readreturned a hard link as an empty buffer. A hard link reads as the member it names; a symbolic link, a directory or a device holds no content and is refused, saying which (M26-DAT-009). A name two members share -- a tar appended to holds every version of a file -- is refused rather than read as the first, the stale one, intar_readandzip_readalike, and either reads a member by its position in the listing, which the second argument now accepts (M26-DAT-039).unsafe_pathjudged a symbolic link from the wrong place. A tar link's target is read from the link's own directory, sousr/lib/libfoo.so -> ../lib64/libfoo.so.1is not flagged, and a zip symlink entry's target is checked at all, where only its name was (M26-DAT-007).sqlite_querybound a buffer as its hex text, so a lookup by a key or a hash never matched; it binds as a BLOB, and a value with no SQLite type is refused rather than bound as the text it prints as (M26-DAT-023, M26-DAT-040). A result with two columns of one name is refused, where a row kept only the second (M26-DAT-040). TEXT in a DATE, DATETIME or TIMESTAMP column keeps its fraction and its offset, where it was moved to UTC and cut to the second;CAST(col AS TEXT)returns it as stored (M26-DAT-024).time_parsegave an instant that depended on the examiner's machine: a zone abbreviation was read as UTC on a host that does not use it. The value and the layout went to Go'stime.Parse, which resolves an abbreviation -- theMSTlayout verb -- only when it belongs to the host's own zone, and otherwise keeps the name, applies offset 0 and returns no error. Sotime_parse("2024-07-01 10:00 PDT", "2006-01-02 15:04 MST")was 1719853200 on a Los Angeles host and 1719828000 on a UTC or Kolkata one: seven hours apart, both silent, and a timeline built from mail or log timestamps carried the difference without saying so. Parsing is now done against UTC, so the host cannot change the answer, and an abbreviation that cannot be resolved is refused with the two ways to say what was meant.UTCandGMTstill parse, being the only abbreviations Go accepts that name +0000; a numeric offset, anRFC3339value, a layout with no zone element, and an abbreviation written beside its offset (MST-0700) are all unaffected. The one case this turns from right into an error is an abbreviation that happens to match the host -- deliberately: a result that is correct only on one machine cannot be reproduced on another, and a refusal that names the fix is worth more than an answer that is right by accident. (M26-BLT-003)Integer arithmetic stays exact, and
nanor an out-of-range float is refused rather than turned into the most negative integer. Nine builtins took their operands through a helper that returned every INTEGER as afloat64, so they did integer work in a type that cannot hold anint64. Above 2^53 two different integers share one float, and each site got that wrong its own way:sumaccumulated in the float and narrowed the total back, so adding 1 to an NTFS file time returned the file time;minandmaxcompared the rounded copies and returned the wrong argument;clamprebuilt its answer from the float instead of returning the bound that won;floor,ceilandroundwidened an integer only to narrow it again;sortordered on the float; andto_intnarrowed with no range test, soto_int(1e19)andto_int(nan)both returned-9223372036854775808.abshad no guard for the one integer whose negation is itself, soabsof it was negative. None of it produced a diagnostic, and a forensic timestamp is anint64of nanoseconds. Each builtin still returns the kind its contract declares; what changed is that an INTEGER argument is no longer rounded on the way through, and that a total leaving theint64range is refused -- naming the element that did it -- rather than wrapped.avgis unchanged: its contract declares FLOAT, and accumulating inint64would refuse an average that is representable when its total is not. (M26-BLT-004)Two builtins no longer panic on an argument a caller is entitled to pass, and no longer record that panic as evidence of tampering.
str_substrclamped the start against the string and only then added the length, so asking for the rest of a string by passing a number larger than it wrapped the sum negative, the clamp could not fire on a negative, and the slice panicked -- with a start of 0 the sum does not overflow, which is why the obvious case worked.rand_intcomputedhi - loas anint64, so a range wider thanint64wrapped non-positive and the draw panicked on its own argument. Both now answer:str_substrreturns the rest of the string, andrand_intdraws uniformly from a range of any width, keeping its previous draw for every range that fits so no seeded run moves. This mattered beyond the crash. A builtin panic is contained as a runtime error and recorded as an integrity failure in the audit chain a case manifest seals, so a bad argument wrote a tamper record into the evidence.str_repeatandstr_pad_left/str_pad_rightalready refused what they could not build; they are now held to it by tests. (M26-BLT-006)Renaming a module member from its own declaration now edits every file that imports it. The rename handler looked at the workspace-wide uses only when the result for the current file came back empty -- and with the cursor on a declaration it never is, because the declaration itself is in it. So pressing F2 on a definition, the place a rename is usually started, was the one place that renamed nothing outside the file, while "find all references" from the same cursor listed the importers correctly. Both now ask one function, so the two can no longer disagree, and no span is edited twice. (M26-LSP-003)
The background workspace scan no longer replaces an unsaved file's declarations with the copy on disk. The scan checked whether the editor had a file open by building a URI for it and looking that string up. VS Code spells a Windows document
file:///c%3A/...and the server builtfile:///C:/..., so on Windows the check matched nothing and the scan treated every open document as closed. It then indexed the file a second time and pushed the saved text into the shared workspace -- which is keyed by path, not by URI -- so a function you had just written and not yet saved disappeared, and the file importing it was told the module had no such member. The scan now compares canonical paths, and a URI is kept exactly as the client spelled it. An import of a file no editor has open still resolves, which is what the scan is for and is covered by its own test. (M26-LSP-005)net_bannerthrew away the greeting of every service that waits for the client. SSH, SMTP and FTP each write their banner and then hold the connection open for a command, so the read ends atnet_banner's own deadline rather than at an EOF.io.ReadAllreports that as an error, and the error was taken for the whole answer:{ok: false, banner: "", error: "i/o timeout"}for a greeting that had already arrived in full. The three services most worth fingerprinting were the three it could not fingerprint. What decides the answer is now the bytes rather than the error -- a read deadline is how a banner ends, not a failure, andio.ReadAllhands back what it read alongside the error -- so the greeting is reported withok: true. A read that ends with no bytes at all is still a failure, and a peer that closes cleanly having said nothing still reportsok: truewith an empty banner, exactly as before. (M26-NET-001)A greeting past 4 KiB was clipped and reported as a whole banner. The read was bounded at exactly
maxBannerBytes, andio.LimitReaderreturns EOF at its bound whileio.ReadAllturns EOF into nil, so a peer that sent a megabyte came back as{ok: true, banner: <the first 4096 bytes>, error: ""}-- the prefix of a longer stream presented as a complete greeting, with nothing in the hash able to say otherwise. An examiner comparing or hashing banners could not tell a 4 KiB banner from the first 4 KiB of something larger. The read now takes one byte past the cap and refuses when it arrives, which is the same fix as the body cap below and is there for the same reason. The cap itself is unchanged, a greeting of exactly 4096 bytes still arrives whole, and no real SSH, SMTP or FTP greeting comes near it. (M26-NET-026)A pid past 2^31 was truncated and killed a different, live process. A script's integer is 64 bits and a pid is not: gopsutil takes an
int32and Windows takes aDWORD.process_kill(getpid() + 2^32)compared unequal to the current pid, walked past the guard that exists to refuse exactly that, and then killed the run anyway with the low 32 bits. The test that pins it ends the process running it and prints no failure message, because the binary is gone before it can; that signature is the evidence.process_hashand the rest answered about one process while labelling the answer with another. Every pid argument is now narrowed where it is read, in one place, and a value outside the range is refused by name. Zero is still accepted by the query builtins, and that is deliberate: Windows reports the System Idle Process as pid 0 andprocess_listreturns it first, andsyslog_parsewrites 0 for any line with no procid, so the ordinary loop over what Mutant handed back reachesprocess_threads(0), which answers today.process_killalone refuses 0 and negative values, because tokill(2)those name process groups and not processes. (M26-NET-005)The request line and header block of an intercepted message were read without a bound.
maxHTTPBodyBytescapped the body and nothing capped the head, so a peer -- which on an intercepted connection is whatever is on the wire -- could send one header field of any length and have it read in full: an 8 MiB field grew the heap by 12 MiB and a 32 MiB field by 69 MiB, and both were accepted. The head is now bounded at 1 MiB, the figure net/http uses for its own servers, with 4 KiB of slack for the singlebufiofill that can legitimately carry the start of a body past the end of the head -- which is also what net/http's server allows, and for the same reason. The limit sits on the reader beneath thebufio.Readerand is released once the head is parsed, sohttp_conn_read_request_headfollowed bynet_conn_readstill streams a body of any size; a 2 MiB body drawn through the 1 MiB head budget is a regression test. (M26-NET-008)A pcap record header sized its own allocation. gopacket takes the snapshot length out of the file header and then accepts any record no longer than it, so a forty-byte file declaring a snaplen of
0xffffffffdrew a 16 MiB allocation out of each ofnet_capture_raw,net_pcap_analyzeandnet_os_fingerprint. The reader's snaplen is now held to the file's own length, which no honest capture can exceed, and the forty-byte file is refused before anything is allocated for it. A constant would have been wrong: USBPcap declares 128 MiB and writes records in the megabytes, and EBHSCR records run to 32 MiB, and both read correctly today -- a record's index, timestamp and length come out of its sixteen-byte header and need no decoder, so a link type gopacket cannot decode costs the emptysrc,dstandprotocolfields and nothing else. A gzipped capture is the one case where the file's size bounds nothing, because gopacket decompresses it transparently, and there a 128 MiB ceiling applies instead. (M26-NET-009)A body past 32 MiB was cut and reported as a whole one. The body was read as
io.ReadAll(io.LimitReader(body, maxHTTPBodyBytes));io.LimitReaderreturns EOF at its bound andio.ReadAllturns EOF into nil, so a 40 MiB body came back as exactly 32 MiB with no error and no flag. An intercepted response was recorded as complete with 8 MiB missing and nothing in the result could tell a caller.http_conn_read_request,http_conn_read_response,http_parse_requestandhttp_parse_responsenow read one byte past the cap and refuse when it arrives. This is a behaviour change: a script that read a body larger than 32 MiB used to get the first 32 MiB and now gets an error. Take the head withhttp_conn_read_request_headand stream the body withnet_conn_readto read one of any size. (M26-NET-031)A response cut off mid-body was reported as a successful empty one.
http_get,http_postandhttp_requestread the body withio.ReadAlland then dropped the error: the body became"", the response hash'serrorfield was a hard-coded empty string, and the pair's error was nil. A server that declaredContent-Length: 100and sent 10 bytes before closing therefore came back as{status: 200, body: "", error: ""}with no error anywhere, so a dropped IOC feed or evidence download read as a document with nothing in it and a script that treats an empty feed as "no indicators" got a false negative. The bytes that did arrive are now returned, the reason travels with them in theerrorfield, and the pair's error is non-nil. The machinery for that was already in place -- the field is what decides whether an error is returned, and only the producer never filled it in. The status line and headers are reported as before, which is what tells a truncated response, with its real status, from a connection that never produced one, which still reports status 0. (M26-NET-002)process_treelooped forever and grew until the process died, starting withprocess_tree(0)on Windows. The walk was breadth-first over a parent map with no visited set, so a process that is its own parent put itself in its own child list: every pass appended it again, and the queue and the result both grew without bound. Windows reports pid 0, the System Idle Process, with parent pid 0, andprocess_listreturns it first -- so a triage script that calledprocess_treefor each pid it listed met this on the first one, at 2 MiB growing past 1 GiB in under five seconds. A parent cycle caused by pid reuse did the same from an ordinary pid. Nothing panicked, so therecover()around a builtin could not help. Each pid is now reported at most once, the root is never its own descendant, and the walk ends after at most one pass per distinct pid whatever the parent links say. Ordinary trees are unchanged, children still sorted by pid at each level. (M26-NET-003)The live registry reported a value that does not exist as a present, empty
REG_NONEvalue, with no error. Every error from the underlying read,ERROR_FILE_NOT_FOUNDincluded, was mapped to an entry of typeREG_NONEwith empty data and handed back as a success, soreg_get_valueon a live source answered "yes, an empty REG_NONE" for every name ever asked, while the JSON and hive-file sources answeredvalue not found. "Does this Run key hold value X?" came back yes for any X. Because realREG_NONEvalues exist, absence was not merely unreported but indistinguishable from a value that is there. All three sources now give the samevalue not founderror, from one function rather than from three copies of a string, and a value that is present and empty is still returned as itself. The type-specific reads no longer discard their errors either, so a value deleted or retyped between the call that reads its type and the call that reads its data is reported instead of coming back as a zero value; andreg_list_valuesfails rather than silently omitting a value that vanished while it was listing. (M26-NET-004)A small
.pcap.gzwas an unbounded read innet_pcap_analyzeandnet_os_fingerprint. Both looped over every record in a capture and accumulated a map entry per flow and per fingerprint with no cap at all, and pcapgo recognises the gzip magic and wraps the file in a decompressor, so the size of a compressed capture bounded nothing inside it. A 31 KiB file of two million sixteen-byte record headers read for 2.3 seconds, and 200,000 records each carrying a distinct flow left 224 MiB of live heap behind — 541 bytes per record, linear in a count the file does not disclose, so a larger file is an out-of-memory rather than a slow read. Evidence files are untrusted by definition.net_capture_raw, which already stopped at a record cap and marked the resulttruncated, was the contrast rather than a third case. Two bounds now compose, and both are reported the waynet_capture_rawalready reported its one: the record cap it had is now shared by all three readers, and a new named limit bounds how far a compressed capture may expand. The second bound is on the decompressed stream and not on the file, because the file's smallness is the attack — gopacket opens the decompressor itself, so Mutant now opens the gzip layer first and hands gopacket a stream that has already been counted. A capture gzipped more than once is refused, because a second decompressor inside gopacket would sit outside that count; such a file failed its header check before this change too. All three builtins returntruncated: truewhen they stopped at either bound, and then every count, every flow row and every fingerprint covers only the records that were read. An uncompressed capture is read exactly as before: there the file's own length is the bound, and a constant could only refuse something real. (M26-NET-027, and M26-NET-034 for the result size a million flow rows still reaches)Neither REPL could call a function defined on an earlier line.
let add = fn(a, b) { a + b };and thenadd(1, 2);failed in both the terminal and the browser REPL, and the error said the program had been built by a newer version of mutant. Each line is its own program, and the keystream its bytecode is sealed with is derived from that line's instruction length -- the number the VM recovers and derives its one decryption key from. The constant pool carries from line to line, so sealing it again on every line encrypted a function compiled earlier a second time, under the new line's key, and the VM then read the earlier line's ciphertext as opcodes. Both REPLs now seal through one shared helper that re-keys a carried-over function from the length it was sealed under to this line's, in place on the same function object so that a closure held in a global agrees with the pool; a line it cannot re-key is refused rather than half-written. Integers and strings bound on an earlier line were never affected, because each records the seed it was encrypted under (M26-TOOL-013). Fixing that exposed a second defect behind it: the terminal REPL wiped its own session after every line. The per-line cleanup's stack sweep emptied the shared encrypted constants and dropped closures' captured values, so a function that used a literal could not be called twice and a closure could not be called at all after the line that created it -- the same hazardnet_serveand the browser REPL already avoid, and they now all avoid it for the same stated reason (M26-TOOL-040). The unknown-opcode message no longer names one cause: it says the instruction did not decode, and that the bytecode may be damaged or decrypted with the wrong key, or may come from a newer mutant.process_killrefuses a thread id of the running script's own process, not just its pid. The refuse-self guard compared its argument with the process id and nothing else, and a thread id is not that number:process_threads()reports one per OS thread, each positive and well insideint32. On Linux a signal addressed to a non-leader thread id is delivered to the whole thread group, soprocess_kill(process_threads()["tids"][0])ended the run through a documented pair of builtins while the guard that exists to refuse exactly that reported nothing.process_killnow asks the operating system, at the moment of the call, whether the number names a thread of this process, and refuses it with a sentence saying which process the number belongs to. The thread set is read on every call and never cached, because a Go runtime starts and retires threads on its own schedule and a cached set is a guard with a hole that opens by itself. A pid belonging to any other process is unaffected. (M26-NET-029)
Security#
A string literal in an open document can no longer send the language server to a network share, at a device, or outside the workspace. The editor underlines string literals that name an existing file, so that an examiner can click through to an image or a report. Deciding whether a file exists means asking the filesystem, and the handler asked about any literal that looked like an absolute path — including a UNC path such as one naming a host that does not answer. That lookup ran on the single goroutine the server reads client messages on, so completion, hover, diagnostics and every other feature stopped until the network gave up: measured at 42 seconds for one literal in one document. A path reaching out of the workspace was offered as a live, clickable link, and nothing refused a Windows device name. A document is just text, and opening one is not consent to resolve what it says. Links are now resolved only inside directories the operator chose — the document's own folder and the workspace roots — and only through
os.Root, which refuses a..escape, a reserved device name such asNULorCON.txt, an alternate data stream, and a symbolic link whose target leaves the root. Anything else is refused before the filesystem is asked at all, so there is no lookup to wait for. Relative paths work exactly as before, including../evidence/usb.imgfrom a notes file inside a case folder, and a single file opened with no folder still links files beside it. One request is also now bounded: a literal is looked up once however many times it appears, and one request makes at most 1024 lookups, so no single document decides how long the rest of the editor waits. (M26-LSP-002)A field named twice in a sealed document was authenticated away.
encoding/jsonkeeps the last of two identically-named fields and drops the first without a word, and four readers in this tree authenticate a document over a canonical form they rebuild from what the decoder kept — so a second copy of any field, inserted in front of the real one, was a value a reader saw and that no hash and no signature covered. Measured rather than argued: a probe replicatingcase_manifest_verify's decoder settings and its canonical rebuild field for field signed an honest 98-byte manifest with Ed25519, and that one signature verified against a forged 119-byte file — both produced the identical canonical bytes and the identical SHA-256, so the digest matched, the signature held, and the verifier reported the file verified.DisallowUnknownFieldsdoes not catch it: the name is known, it is simply there twice. The four readers are the case manifest, the disclosure manifest, the case key file and the grant file, and each now refuses such a document as a verification failure, naming the field and the byte its second spelling ends at. The canonical form itself is deliberately unchanged, so every document this build has already sealed still verifies.jwt_decodehad the same fault without a seal under it: a header spelled{"alg":"none","alg":"HS256"}was read here as the last copy and by a library that keeps the first as the other, over the identical signed token, andalgis the field that decides whether a signature is checked at all. Both of its segments refuse a repeated name now, which RFC 7519 permits a parser to do. Nothing a JSON encoder emits is refused by any of this — no encoder writes a member name twice — and the one shape this build itself writes, a manifest whosefieldsarray names a field that is also a key, still verifies and is held to that by a test. One more line changed with them and nothing about it is visible today:json_parse's own end-of-input check compared a sentinel with==, which the newer of Go 1.26's twoencoding/jsonengines would read as a trailing document and refuse every well-formed file; it askserrors.Isnow, as the shared check beside it already did. (M26-CUS-032, M26-BLT-027)A file this build verifies holds one document, and every parser now reads all of it. Nine parsers decoded the first JSON document in a file and stopped. Bytes after it were never looked at, never hashed and never covered by the signature the verifier went on to report as holding — so a case manifest, a disclosure manifest, an audit log, a case key file, a record header, a record footer, a grant file, a registry hive and both segments of a JSON Web Token could each carry a second document that nothing had checked, and the file was reported verified. The seal is computed over the document the decoder returned, which is exactly why the tail changed no hash and raised no finding: an examiner was told a file verified when part of it had never been read. Six of the nine had no check at all. The other three had one, and it was not enough:
decoder.More()reports that there is nothing further to read whenever the next character is}or], so a document followed by a single closing brace passed — and every byte after that brace went unexamined, a whole second document included. All nine now ask one shared check that accepts only the end of the input, and refuse with the byte offset the document ended at, as a verification failure rather than a parse success. Trailing whitespace still parses, deliberately: a rewrite interrupted between the truncate and the write leaves a key file padded with newlines, and refusing to open it would turn a survivable crash into a lost case key.jwt_decodeis the one with a second lesson in it — a header segment holding two documents gave this build one answer foralg, the field that decides whether a signature is checked at all, and the next library along another. (M26-CUS-026, M26-CUS-031, M26-REC-028, M26-BLT-025, M26-BLT-026)The run time printed what
putlnhad just refused to print. A buffer read out of a classified record is refused byputln,fs_write,report_renderand thirteen other builtins. It was not refused by the three places the run time prints a value with no builtin involved: a traceback's arguments, the debugger's variables pane, and the value a finished program is echoed as bymutant prog.mu, by the REPL and by a debug session that steps to the end. A program that ended on the buffer printed all of it; a function that failed with one as a parameter printed the front of it; the variables pane, which has four times the room, printed more than either. The three now ask the same walk the sinks ask, before rendering anything, and print a notice naming the record and the classes instead. The walk itself gained two carriers it had no arm for: the values an error holds inRelated-- soputln(err)no longer prints a buffer the error was built around -- and a value still sealed, which is how a captured variable reaches a sink, since a cell is handed back by pointer without being opened. One renderer now serves the traceback and the pane, which were the same twenty lines in two files.An empty program crashed the runner. A file with nothing in it, or only a comment, compiled and then died with a Go nil-pointer panic and a goroutine dump, exit 2, because the value it ended on was read without checking for nil -- exiting quietly was the whole of what was wanted. The REPL and the debug adapter both checked already. A new file run from the editor was the common way to see it.
A traceback reported a captured parameter as its ciphertext. A parameter an inner function closes over lives in a cell, and decrypting a cell returns the cell, because the write-through a closure needs would be lost in a copy. The traceback rendered what was inside:
f(n=[153 72 150 254 248 119 238 27])wherenwas 2999, with different bytes on every run, so the traceback could not be compared between runs either. It takes the cell off first now, which is what the debugger already did.A crafted artifact could kill the process parsing it, and one kind could do it silently. A count read straight out of a registry hive, a jump list or a binary plist sized an allocation before the bytes it promised were there to read, so the memory a parse asked for grew with a header field rather than with the file: a 32-byte DestList stream claiming 2^22 entries asked for 224 MiB, and a 48-byte plist claiming 2^24 objects asked for 128 MiB. A Go allocation past what the host can commit is a fatal error no
recovercatches, and every one of these entry points wraps itself in one. Separately, a binary plist refers to its values by index, and expanding that graph into a tree was bounded only by nesting depth -- so twenty levels of arrays each holding two references to the next returned a 32 MiB value tree from 119 bytes with no error at all, and four times that per two further levels. Every pre-size is now bounded by what the bytes can hold, and a plist that would expand to more values than it has bytes is refused, naming the shared containers that did it; a shared string is decoded once, and a container holding itself is named as one. (M26-ART-005, M26-ART-008, MVF-2026-0012, MVF-2026-0013)Two of those were reported from outside the project as well. The allocation half -- a count read out of a file sizing an allocation before the bytes are there -- and the binary plist expansion were found independently and reproduced against 2.5.0 through a compiled artifact, which established that a crafted evidence file can kill a running script outright rather than return an error. Reported by Pranjal, a BTech student in his fifth semester at the National Forensic Sciences University (NFSU). He asked to be credited publicly, and the name and affiliation are printed at his request. (M26-ART-005, M26-ART-008)
The code-signature encoder destroyed the timestamp it wrote, and the decoder panicked on a malformed one.
CodeSignature.Encodewrote the timestamp asstring(rune(cs.Timestamp))-- a Unicode code point, not a number. Every Unix timestamp since 1970-01-13 is larger than the largest valid code point, so the conversion yielded U+FFFD and the field encoded asefbfbdwhatever the time was;DecodeSignaturethen read one byte of it and returned 239 for every real timestamp, and indexed an empty slice when the field was absent. The timestamp is now eight big-endian bytes, and a field of any other length is refused by name. Nothing called this pair, so it carries no MVF identifier and the custody seal that does carry a signature, writing its fields separately, was never affected -- docs/advisories/README.md records why. (M26-SEC-009)A key-derivation branch would have opened data under any password.
ReconstructKeypicked a derivation by the algorithm name stored beside the data, and itshkdf-sha256arm derived the key from the stored salt alone -- as both the secret and the salt -- without ever reading the password. Every passphrase produced the same key, so data naming that algorithm would have opened under an empty one. That arm is refused now, and the unknown-algorithm arm is held to returning no key material rather than a short one. Nothing reached it: the only caller writesargon2idinto the parameters itself and never takes the algorithm from the data, and the one function that would have read an algorithm name out of a stored blob has no callers and cannot parse what its own encoder writes. So this closes a trap for the next caller rather than a hole for the current one, and it carries no identifier for the same reason -- docs/advisories/README.md says which test it fails. (M26-SEC-009)The toolchain moves to Go 1.26.9,
golang.org/x/cryptoto v0.57.0 andgolang.org/x/netto v0.60.0. Under go1.26.2, govulncheck found 18 standard-library vulnerabilities the code reaches, several through parsers that read evidence: XML inside an E01 image and in plists, certificates, email addresses; go1.26.6 fixed those. The release gate then found 11 more in go1.26.6, innet/http(HTTP/2 among them, whichx/netv0.60.0 fixes on its side),crypto/tls,mime/multipartandos; go1.26.9 fixes all of them. A release binary compiles the standard library in, so the move matters to anyone who runs a releasedmutant, and anyone building from source needs go1.26.9 or newer.go test ./policy/refuses ago.modbelow go1.26.9,x/cryptov0.56.0 orx/netv0.60.0. These are upstream defects that already carry upstream identifiers, whichgovulnchecknames; this project does not rename other people's vulnerabilities, so there is no MVF for them. (M26-RUN-003)sqlite_queryandsqlite_query_bytescould modify the evidence they read. Both copy the database first and promised the original is never touched, but ran the script's SQL on a writable connection to that copy, soATTACHopened any other path read-write. A plainSELECTthrough an attached WAL-mode database checkpointed its write-ahead log into it and deleted the log -- uncheckpointed and deleted records with it -- and a bound parameter, classified plaintext included, could be written to a file the script named. The copy is now opened read-only and may attach nothing, soATTACHandVACUUM INTOare refused with a reason and no statement writes any file. Joining two databases takes two queries. Thebrowser_*parsers read through the same confined connection. (M26-DAT-001, MVF-2026-0007)audit_verifypassed two kinds of doctored log. A log with entries deleted from the front verified as intact, anchored and complete against the head sealed into the case manifest, because the head is the last entry's hash and the verifier never checked where the chain began; and an entry with no sequence number, or a sequence number that was not a positive integer, ended the walk as though nothing had broken, so forged entries appended after the last genuine one verified too. The last entry's sequence number is inside its hash and the memory cap drops entries by a fixed rule, so the verifier now knows which entry a log of that length must start with, and refuses any other; every entry needs a positive, consecutive sequence number;entries,droppedandchain_completeare recomputed from the entries rather than copied from the document; and a break carries its position in the newbroken_index. (M26-CUS-005 and M26-CUS-006, MVF-2026-0003 and MVF-2026-0004)A script could redact the disclosure ledger's own records. The
ledger_redact_*builtins checked the handle, the id and the reason but not what they were removing, and a redaction deletes a record's index entries with it -- which is how everydisclose_*builtin finds a withdrawal, a disclosure or a reclassification. Redacting a Withdrawal re-enabled grants to the recipient it had withdrawn; redacting a Disclosure took it out ofdisclose_history. The four redaction builtins now remove only what a script wrote: any node or edge of the disclosure schema, and any node whose cascade would take such an edge, is refused with the reason, andledger_redaction_impactreportsprotectedandprotected_reasonbefore anything is attempted. A disclosure record is corrected by appending a new one, never by removing an old one. (M26-CUS-001, MVF-2026-0002)disclose_verifyverified a genuine package that had been relabelled or given another disclosure's grant. Its ledger check proved the manifest's copy of the ledger record was in the snapshot, but never held that record to the package around it, and the manifest is signed by whatever key it carries. So a genuine package re-labelled for another recipient, or holding another disclosure's grant beside this one's ledger record, re-signed with any key, verified against the genuine root. A new check,ledger_matches_package, compares what the ledger recorded with the manifest's recipient, examiner and view and with the record and grant files themselves -- digest, uid, runs, descriptors root -- and names every disagreement; a package now takes eleven checks to verify. The result also givesmanifest_public_keyandrecord_public_key, because a signature means something only once its key is compared with one the recipient already trusts. (M26-REC-005, MVF-2026-0006)A refused disclosure was refused too late.
disclose_to_passphrasederived the grant's key material and asked the examiner to choose and confirm its passphrase before checking whether the recipient's earlier disclosure of the record had been withdrawn, or whether the record had been reclassified. The refusal still came and nothing was issued, but DISCLOSURE_POLICY promises the withdrawal is checked before any new key is issued. Both refusals now come first, and are asked again inside the ledger write for a withdrawal recorded in between. The refusal always came and nothing was ever issued, so what broke was the documented order rather than a security property: this is a conformance fix and carries no MVF. (M26-REC-001)Two different strings could be one hash key. A string or buffer used as a hash key was identified by its 64-bit FNV digest alone, and lookups never compared the key itself, so two values with one digest -- a pair takes under a minute to find -- were one entry in both engines: a count lost one of them, and a lookup of either returned the other's value. Hash keys chosen from evidence, such as file or account names, could be merged that way on purpose. A string key is now the whole string, and a buffer key its SHA-256. (M26-EVL-003, MVF-2026-0005)
A classified buffer put in a report table was written out whole.
report_tableandreport_listrender each cell to text as it is added -- a buffer as its hex -- so by the time the report reachedreport_renderorreport_write, both of which refuse classified plaintext, there was no marked buffer left for them to find. Both builders now refuse one where it goes in, and the editor warns at the call. (M26-DAT-002, MVF-2026-0008)HOMEorUSERPROFILEchose the signing key, and the key--signer-authtrusts. The local keystore -- the key pair that signs every.muartifact, case manifest, ledger commit and.mrecrecord, and whose public half--signer-authtrusts when no--trusted-keyis named -- sat under the home directoryos.UserHomeDirreturned, and that function reads those variables. So one command line could sign with, and trust, a different key depending on something the recorded invocation never shows. The home directory now comes from the account database: the process token's profile directory on Windows, the directory service on macOS, and/etc/passwdon Linux, read directly becauseos/userbuilt without cgo answers fromHOMEfor an account that file does not list. Such an account -- one only NSS or LDAP knows, or an unnamed container uid -- is refused rather than guessed at. Anyone who pointed either variable elsewhere to reach a keystore should move.mutant/keysunder the account's own home. The policy guard now also catchesos.UserHomeDirand the other standard-library calls that read the environment inside themselves, and CONFIGURATION_POLICY lists every variable the Go runtime and Mutant's libraries still read. (M26-DOC3-001, MVF-2026-0009)A ledger redacted by an older build could have lost a withdrawal without a trace. The redaction builtins refuse every record of the disclosure schema, but a ledger redacted by a build from before that refusal may hold a redaction that removed one -- a withdrawal, a disclosure or a reclassification -- and a redaction record names what it removed by id and hash, never by label. A withdrawal removed that way let its record be disclosed again to the recipient it was withdrawn from, and the history no longer counted it. Every redaction recorded with no role is now in question (every build that records a role also refuses the schema), unless it only stripped the properties of a node or an edge a script wrote that the ledger still holds;
disclose_to_passphraseissues no grant from a ledger holding one untilledger_redactions_reviewrecords that an examiner has answered for it. A withdrawal is now also found by the record and the recipient it names, so one whose disclosure was removed still stops a new grant. No released version is affected: the ledger family is new in 2.6.0. (M26-CUS-021, MVF-2026-0011)A macro body ran whatever it liked, while the compiler was running. A macro is expanded before any code is generated, and expansion evaluated its body against the whole builtin registry. So compiling somebody else's source wrote files, ran programs and read the host -- before the program was started, before a password was asked for, and before any of the run time's controls existed. Compiling is not running. Three separate paths reached the registry, not one: the plain name; the namespaced spelling, because
fs.writefolds to the same builtin through a second lookup that a fix aimed at the first would have left open; andwith_resource, which resolves what it calls from a string while it runs, where no rule that reads names can see it. Expansion now resolves only the builtins listed as macro-safe -- pure computation over values the body already holds -- and refuses every other one by name, saying which of reading a file, reaching the network, running a program, opening a handle, drawing entropy, reading the clock or writing state the program would later read back it would have done. Seven are refused for none of those:aes_encrypt,aes_decrypt,aes_decrypt_bytes,x509_parse,der_parse,jwt_decodeandpem_decodedecode cryptographic material, and a compile is the wrong place to run a cipher or a certificate parser over bytes the source carries; parsers of ordinary structure such asjson_parseandcsv_parsestay available, because writing code from a table the macro carries is one of the reasons to have macros. The reach was wider than the file anybody typed: expansion covers every module in the linked graph, so a macro in an imported library ran at the importer's compile;mutant testwith no arguments compiles every test program it can find, and filters by name only afterwards; and a debug launch compiled beforestopOnEntryapplied, so "load it and stop at the first line" had already expanded it. The language server never expanded macros, so opening or saving a file ran nothing. One thing changes for programs that worked before: a macro body that called a builtin outside the macro-safe list is now a compile error that names the builtin and says why. Reported by Pranjal, a BTech student in his fifth semester at the National Forensic Sciences University (NFSU). He asked to be credited publicly, and the name and affiliation are printed at his request. (M26-EVL-012, MVF-2026-0001)The editor extension's release task put the password in the argument list. The packaged task that builds a signed artifact passed it with
--password, so for as long as the build ran the password could be read from the process list by anything running as the same user. The task now sets itspasswordfield, which reaches the binary the way the example sweep does, and a bare--passwordis refused outright -- see Changed. mutant itself wrote the credential nowhere, but the route required it on disk: thepasswordproperty is part of a task definition, which lives in.vscode/tasks.jsonin the workspace and is commonly committed with the case scripts, so the exposure is that file as well as the argument list for the life of the build. (M26-LSP-017, MVF-2026-0010)A crafted ledger property blob could end the process. Reading a node's or an edge's properties back pre-sized its map from the entry count the blob declares, and a msgpack
map32header is five bytes that can claim 4,294,967,295 entries. So a stored blob with no body at all made Go allocate buckets for four billion properties -- about 96 bytes each, measured, so something near 400 GB -- and the process died on a fatal runtime out-of-memory, which no script can catch and which takes any unflushed work with it. The decode did reach an error about the missing body; it reached it after allocating. A declared count is now checked against the blob that declares it before anything is sized from it: a blob of n bytes cannot hold more than n/2 entries, which needs no new limit and rejects nothing the encoder can write, since every entry it writes takes at least three bytes. No released version is affected: the ledger family is new in 2.6.0. (M26-CUS-030)A signed record's footer no longer carries the key store's directory.
SignRecordFooterwrote the key-store path intosignature_reason, a field whose purpose is to say why a record has no signature, so every signed.mrec-- and every copydisclose_bundleplaced in a recipient's package -- published the absolute path of the examiner's private-key directory, and with it their operating-system account name. The field is now left empty on a signed record. Records sealed before this release keep the path in the files already issued (M26-SEC-002).disclose_verifynow comparesSHA256SUMSwith the manifest as two lists rather than only where they already agreed. The comparison was guarded on the file being listed in the manifest, so a checksum line naming a file the manifest did not list was skipped in silence, a manifest entry with no checksum line was never missed, and aSHA256SUMSlisting entirely different files agreed trivially -- while the summary still read "all N files hash to the digests the manifest names". The manifest's own line is now compared against the manifest on disk, which a manifest cannot do for itself, and the package directory is read: a file that neither list names is reported under a newpackage_contentscheck rather than passing unmentioned. Nothing signed was ever forged by this and every file the manifest listed was always hashed and compared, so a package that verified before still verifies now unless it holds something nothing vouches for (M26-REC-029).
Known issues#
These wrong answers were found during the 2.6.0 review and are not fixed in this release. Each is tracked and planned for the next one. Where a workaround exists it is given.
Language and runtime
- A program compiled by v2.2.x or earlier may call the wrong builtin and stop or give wrong results; recompile it from source.
- The evaluator reports some argument mistakes in higher-order builtins as runtime faults where the VM returns an error value.
- In the evaluator,
spawnrecords an error value as a task failure and a fault as a successful result. +,-and*wrap silently on 64-bit integer overflow.- On the VM, an error that holds a function is not equal to itself (
a == ais false), because storing the error loses the function's identity. - A program whose last statement is a
for-in loop prints<iterator>when it finishes. putlndrops the space before a value that starts with.,;:)]}and after one that ends in=([{.rangenear the 64-bit integer limits builds millions of elements and then fails, instead of returning the few values in range.- A timeout longer than about 292 years fires at once (
task_wait,chan_send,chan_recv,sleep_ms). - When padding is declined for a large stream, the compiler drops that stream's line, end and macro tables, so error positions in it are lost.
Files, artifacts and time
hive_list_valuesandreg_enum_valuesname the unnamed value(default), buthive_get_valueandreg_get_valuecannot find it by that name.syslog_parseassumes UTC and the current year, so aFeb 29line can become 1 March and the answer depends on the day it runs.timeline_sortandtimeline_mergecan misorder nanosecond timestamps.timestamp_normalizegives sub-second FILETIME and WebKit values before 1970 anisoone second early.fs_existsreturns true when a path cannot be checked for a reason other than "does not exist" (an invalid name, a denied permission).$LogFileparsing walks the log's tail-copy pages as ordinary pages, so records can be duplicated and an unwrapped log can read as wrapped.- A Rego policy's named time zones take their rules from the
ZONEINFOsetting, and SQLite'slocaltimefollowsTZon every system. A policy or query that must not depend on the machine should use UTC. lua_run_httpruns an HTTP error page as a script and reports success.- Lua results render whole numbers in exponent form.
Records, cases and custody
redaction_commitrecords a record whose signature no longer verifies as signed, without a word. Disclosure now refuses such a record; checkrecord_open'ssignature_validbefore a redaction.- A second reclassification of the same record makes later disclosures of it fail with a false "ledger tampered" error.
- The redaction-reason check can miss values stored only in a node's blob.
- A script can stop a withdrawal or disclosure from being recorded by declaring a schema key unique.
- A redaction reason that is not valid UTF-8 is recorded differently in the ledger and in the manifest.
case_evidenceignores an explicitly requested digest algorithm.- A witness time given in milliseconds becomes a plausible wrong date.
- The custody, review, retention and erasure readers accept shapes no writer
produces and check little across chains; the manifest's
ledger_stateomits open reviews, holds and erasures. - The record writer accepts header and footer lengths its own reader refuses.
- The signed-code parser accepts unsigned extra segments between the data and the signature.
Security detection
- The Windows sandbox detector reads only the immediate parent process, so a
program in between (
timeout,env,cmd /c) hides a Git Bash parent.
Networking
net_serveandnet_spawnhandlers cannot useimportor macros.net_udp_scanandnet_connect_scanaccept a zero or negative timeout and report every port closed.
Command line and release builds
- A standalone binary's case manifest names the
.exebut records the digest of its embedded payload. mutant releasehas no darwin/arm64 or windows/arm64 runtime.mutant gen assetsbuilds with whatevergois first on the PATH.- A password file written by Windows PowerShell (with a BOM, or UTF-16) silently changes the password. Write it as UTF-8 without a BOM.
--password-stdinreads all of standard input, so it cannot be combined withgets().genaccepts--password-stdin=trueand--dev=trueand ignores them.- The release target OS is lowercased for the asset lookup but not for the
.exesuffix. - On a filesystem that ignores letter case (macOS by default, WSL's
/mntdrives), one file imported under two spellings loads as two modules, and the editor andmutant lintcannot resolve a respelled import.
Editor and debugger
- Hover, go-to-definition and completion can show the wrong card or target where names are shared: an enum and a struct of the same name, a field named like a builtin, a declaration the parser drops, a module alias, a module the editor has not read yet.
- Signature help can show a reassigned function's old parameters, and some argument-type warnings fire on correct programs.
- Outgoing calls never list a callee in another module.
- Enum uses and string literals get the wrong semantic colouring or range.
- The editor binds a duplicated import alias to the later import, where the
build binds the first, and it resolves some import spellings the build
refuses (a trailing
/., a Windows name ending in a dot or space). - Document links come out in a changing order, a quick fix can act on a stale diagnostic, and the JSON-RPC bridge returns the wrong error codes for an uninitialised server and an unknown method.
- A file open under two spellings of its Windows path counts as two modules.
- A debug session launched with
noDebugstill stops at breakpoints, an==Nhit condition behaves as>=N, and Evaluate can answer a captured variable with a global of the same name.
Examples
binary_analysis_exampleanalyses a file that is not there, andregistry_persistence_huntqueries a Run key its fixture does not contain.
[2.5.0] — 2026-09-17#
The v2.5 line — trustworthy: structural correctness. The theme is that every claim the README makes becomes verifiable.
Added#
Sigma rules, evaluated here. Four builtins in the
detectioncategory:sigma_parseandsigma_parse_allcompile a rule or a multi-document ruleset,sigma_matchasks one rule about one event, andsigma_scanruns a ruleset over anevents_fromtimeline, compiling each rule once.Supported: search identifiers as mappings, lists of mappings or keyword lists;
and/or/not, parentheses and theall of/N of/any ofquantifiers overthemand over identifier patterns; and the value modifierscontains,startswith,endswith,all,cased,re(withi/m/s),base64,base64offset,utf16/utf16le/utf16be/wide,windash,cidr,lt/lte/gt/gte,existsandfieldref, with Sigma's*and?wildcards and its case-insensitive default.This is possible where a YARA engine is not, and the difference is worth stating: YARA needs libyara, libyara needs cgo, and everything here builds under
CGO_ENABLED=0. A Sigma rule is YAML and a matching model, and the two things it needed already existed --yaml_parse_all, because a ruleset is a multi-document YAML file, andevents_from, because a rule needs something uniform to match against.Field names are looked up on the event and then inside
extra, whereevents_fromkeeps the parser's entry verbatim, so a rule written in a source's own taxonomy runs against a normalized Mutant event with no field-mapping configuration in between.A rule this engine cannot evaluate is a compile error, never a silent non-match. Aggregations,
near,timeframe, rule collections,|expandand unknown modifiers are all refused by name, as are a condition naming an undefined search identifier and anall of filter*that matches none. A rule that quietly never fires counts toward coverage and finds nothing, and nobody goes looking for it. For the same reasonsigma_parse_allfails the whole ruleset when one rule in it does not compile.sigma_matchandsigma_scanalso report the fields a rule read that the evidence never carried --fields_missingandunmatched_fields. A ruleset pointed at an artifact without its fields returns zero hits and is correct about it, and that zero is a rule that never ran rather than a clean host.See DETECTION_RULES.md,
examples/detection/sigma_rules.mutand cookbook recipe 15.docs/COMPARISON.mdsaid "there is no Sigma support at all"; it no longer does, and it still says what a rule engine is better at.A probe that checks the field names in a return contract. A contract's shape (bare or
(value, err)) and the kind of its success value were both verified against a real call; its field names were not, and those are what the editor renders on hover and what a program types.{path, bytes, format, sha256}is declared inbuiltin/metadata.goand spelled again wherever the builtin builds its result, with nothing linking the two.TestEveryFieldAnImplementationReturnsIsDeclaredcalls a builtin for real and requires every key it returns to be declared -- which catches a typo in either copy, since a renamed key is undeclared and a renamed declaration leaves the real key undeclared. It reuses the existing purity gate, so it reaches only the builtins that compute rather than touch the world: 4 of the 162 that declare fields. It logs that number rather than implying coverage it does not have, and widens for free as the pure-probe categories do.Every build script writes a
SHA256SUMSbeside what it produced.scripts/build.shandscripts/build.ps1write one next to the six binaries and another next to the wasm REPL artifacts;lsp/build.shandlsp/build.ps1write one next to the six language servers; both VS Code packaging wrappers write one next to the vsix, andnpm run packagewrites one covering all six per-platform vsix files.It is the format
sha256sum -creads -- lowercase hex, two spaces, the file's bare name -- so someone who downloads a binary verifies it with a tool they already have and nothing from this project. The same formatcase_bundlehas always written, for the same reason. Names are bare and each file sits beside what it covers, so checking iscd <dir> && sha256sum -c SHA256SUMS. Lines are sorted underLC_ALL=Cand the PowerShell scripts sort to match, so the shell and PowerShell halves emit byte-identical files; a script refuses rather than recording a digest for a file the build did not produce, and the bootstrap binary is deleted before the sums are taken rather than sitting unlisted in the output directory.This is not a substitute for signed binaries, which is still open. A checksum published beside the file it covers proves the download arrived intact, not that it came from us.
One event vocabulary across every parser, and three ways out of it. Five of the seven builtins in a new
schema interchangecategory.events_from(artifact, kind_or_mapping)normalizes any parsed artifact into a fixed set of event fields andevent_kinds()reports what each supported artifact maps;ecs_event,ocsf_eventandtimesketch_eventwrite that vocabulary out as Elastic Common Schema documents, OCSF events, or the plaso records Timesketch ingests.The parsers each hand back their own shape, because each artifact is its own shape -- an
$MFTrecord carries eight timestamps, a Prefetch file carries a list of run times, a syslog line carries one. That is right for a parser and wrong for a timeline, and wrong for anything downstream that wants one row format. The alternative to normalizing was a table per (artifact, schema) pair: thirteen artifacts and three interchange schemas is thirty-nine tables, each of which drifts the moment either side changes. With one hop in between it is thirteen plus three, and a new parser reaches every schema by writing one mapping.Thirteen source kinds are mapped:
mft,prefetch,evtx,lnk,amcache,shimcache,jumplist,syslog,browser_history,browser_cookies,browser_downloads,bodyfileandmactime. Every kind produces the same shape, so a supertimeline across all of them is atimeline_mergeof the results.Three rules keep the output honest. An unknown field is absent, not empty -- an empty string in a forensic record reads as "the artifact recorded nothing here", which is usually a claim the parser never made; a
sizeof zero is kept because an empty file has a real size of zero. A zero timestamp produces no event, because these artifacts use 0 for "not recorded" and an$MFThas plenty; emitting them would bury the real rows under thousands of 1970 ones.extracarries the source entry verbatim, so normalizing is additive and nothing the parser found is lost -- a normalized view of evidence that quietly drops fields is a view an examiner cannot testify from.severityis a word (informationalthroughfatal) rather than a number, because the numeric scales disagree about direction: syslog counts down from 0 Emergency, Windows event Levels count up from 1 Critical. Converting once in the source mapping means each downstream schema converts from one known thing. Windows Level 0 (LogAlways) asserts nothing about severity, so it produces none rather than a guessedinformational.$FILE_NAMEtimestamps are described apart from$STANDARD_INFORMATIONones, because the disagreement between the two sets is the timestomping tell and a timeline that merged them would hide it. Amcache and Shimcache are categorised asexecutionwith the actionpresent, notrun: both record that a program was on the host, which is evidence of execution rather than proof of it.The three emitters each take one event or a whole timeline and hand back the same shape, so an
$MFTnormalized and handed to Timesketch is two calls and anndjson_stringifyrather than a map over three hundred thousand rows with a(value, err)pair on each. All three take the same options:hostanduserfill in what an artifact structurally cannot record -- an$MFTknows every path on the volume and nothing about which host the volume came out of -- without ever overwriting what the artifact did record;tagslands in each schema's own tag field;extra: falseleaves the verbatim source entry out. An unknown option key is an error, and so is a hash that did not come out ofevents_from: emitting a raw parser entry would produce a document with no timestamp and every mapped field missing, which looks like a real document and indexes like one.The emitters carry the envelope's honesty into each schema rather than filling required fields with guesses. ECS
event.typeis read off what the timestamp records rather than off the artifact's kind, andevent.categoryis omitted where ECS has no honest value instead of filing a log line under something it is not; severity is emitted as both the word (log.level) and a syslog-direction number (event.severity), so nothing downstream has to know which way the scale runs. OCSF classes say where a record belongs, not that Mutant observed it happen:executionstays on the Base Event rather than taking 1007 Process Activity, because Amcache records presence and a detection written against 1007 would fire on it, andregistrystays there rather than borrowing thewinextension's uid, which means nothing to a consumer that has not loaded it;severity_id0 andfile.type_id0 are OCSF's own way of saying the source did not report one. Timesketch's three required fields --message,datetimeandtimestamp_desc-- are never left empty, because Timesketch drops such a record on ingest rather than flagging it, and a kind with no plaso equivalent getsmutant:<kind>:eventrather than a borroweddata_typethat an analyzer would then run over and report on.An artifact Mutant does not parse is described with a mapping hash -- timestamps, their meanings and formats, and which source field fills which event field -- rather than waiting for a source kind to be added. It is the same mechanism the built-in kinds use; there is no privileged path. See docs/INTERCHANGE_SCHEMAS.md.
Investigations end in a report. Seven builtins in a new
reportingcategory.report_newstarts one andreport_section,report_text,report_listandreport_tablefill it in;report_renderrenders it as HTML, Markdown or CSV, andreport_writerenders and writes it in one step. The report is a plain hash, so it can be JSON-encoded, stored, diffed against the last one or written by hand, and every builder returns a new document rather than changing the one it was given.generateddefaults to now and is pinnable, so two renders of one investigation are the same bytes.The escaping is the substance of it. Nearly every string in a report came from the evidence, which is to say it was written by the subject of the investigation, so the model holds values and each format is escaped for the thing that actually goes wrong in it. HTML is the boundary: every string is escaped, the stylesheet is inlined, and the document carries no script, font, image or link -- a report that fetches something tells whoever serves it that the examiner opened it, and evidence text is never made clickable, because a report that links to the attacker's URL is one that can be clicked. Markdown is escaped for structure, since a pipe in a filename ends a table cell and shifts every column after it, and a line-leading
#turns a finding into a heading. CSV is guarded against formula injection: a cell beginning=,+,-or@is executed by a spreadsheet when the file is opened, so it is written with the leading apostrophe spreadsheets strip on display -- unless it is simply a signed number, because a report whose numbers all gained an apostrophe is one nobody can sort.A block nothing renders is an error rather than a gap.
report_rendervalidates the whole document before writing any of it and refuses by section and block index; a renderer that stepped over what it did not understand would hand back a report that looks complete and is missing a finding. A CSV of a report with several tables is refused for the same reason until one is named: two different headers stacked into one file is not something a spreadsheet reads the way it was meant.The handover:
case_reportandcase_bundle.case_report()renders the open case as a report value -- the header, the evidence with its digests, what each builtin touched and how often, the timeline, the integrity statement and the security counters. It is built from the manifest rather than from the session, so the report a person reads and the document a machine verifies cannot disagree about what was examined; and because it is a value, an examiner's conclusions go in withreport_sectionandreport_textbefore anything is rendered.case_bundle(dir)writes what actually gets handed over:manifest.json,report.html,report.md, and aSHA256SUMSanysha256sum -ccan check.Which document vouches for which, and why it only works one way. The reports are written first and the manifest records what they hashed to, so the seal over the manifest -- the SHA-256 over every other field, plus the Ed25519 signature over the same bytes -- covers the reports as well. Edit one byte of
report.htmland it no longer matches a digest inside a document whose own integrity still verifies, which is a thing the recipient can establish with the file alone. The reverse ordering cannot be made honest: a report quoting the manifest's hash would have to quote it before the manifest was written, and would be quoting a document that was about to change.SHA256SUMSis the convenience on top of that, not the guarantee underneath it.report_write(report, path)renders and writes in one step, taking the format from the path's extension --.html,.htm,.md,.markdown,.csv-- or from{"format": ...}for a name that says nothing. It returns{path, bytes, format, sha256}.The digest is the reason it exists. A report is written to be given to somebody, and the only useful thing to say about a file that has left your hands is what it hashed to when it left them. So the digest is read back off the disk and checked against the document that was meant to be there: a short write, a full disk or a filter driver that rewrote the bytes on the way past is a refusal rather than a hash of something nobody can reproduce. When a case is open, the write becomes a timeline entry carrying the path and that digest -- the whole of the link between an investigation and the documents it produced.
Indicators out as STIX 2.1.
stix_bundlerenders indicators as a bundle of Cyber-observable Objects, takingextract_iocsoutput directly, andstix_patternrenders the pattern that matches one of them for a query. Addresses, domains, URLs and email addresses each become their own observable; each MD5, SHA-1 and SHA-256 digest becomes a file observable carrying that one algorithm, because nothing in a list of digests says they describe the same file, and merging them on the guess that they do would invent a file nobody observed.Every id is derived from the object rather than generated. An observable's id is the UUIDv5 over the canonical form of its identifying properties that STIX specifies, so the same indicator is the same object wherever it is seen -- twice in one case, or here and in someone else's platform. The bundle and the indicators get derived ids too, which the specification would let be random: a random id is a new id on every run, and two bundles built from one body of evidence would then differ in every identifier while describing exactly the same findings, which is impossible to diff and impossible to testify from. Values are normalized before they are hashed for the same reason -- a digest in upper case and the same digest in lower case are one object, not two.
indicators: trueadds an Indicator beside each observable, typedunknownrather thanmalicious-activity: the extraction found a value written in a document and concluded nothing about it, and a bundle that says otherwise carries that conclusion into every platform it is shared with. The three timestamps an Indicator requires come from acreatedoption rather than a clock, so pinning it to when the case was collected makes the bundle byte-identical between runs. A key no indicator type claims is an error, and so is a value that is not what its key says: a forty-character digest filed undersha256would become an observable whose id nothing else computes, and a STIX object nothing matches produces no report at all.Chain of custody. Eight builtins in a new
chain of custodycapability category --case_open,case_note,case_evidence,case_verify,case_manifest,case_write,case_manifest_verify,case_close-- and the discipline that ties the pieces this language already had (digests, timestamps, a deterministic compiler, Ed25519 signing) to one investigation.case_open(id, examiner)starts a session. From there every evidence opener (raw_open,ewf_open,vhdi_open, the six filesystem parsers,table_open,hive_open,reg_open,zip_open,tar_open) records its source into the manifest, and every builtin that reads or closes an evidence handle is counted against that source.case_closereturns a document naming the examiner, the tool build, every source with its size and digest, every builtin that touched each one, the timeline, and the run's security telemetry.Four decisions shape it. Nothing is recorded until a case is opened -- the hooks sit on the hot path of every forensic program ever written in this language, so not using the feature costs one atomic load and changes nothing. Hashing is opt-in and the manifest says which it was:
{"hash": "sha256"}digests each source as it is opened, and the default is no digest, because the alternative israw_openon a 500 GB image silently reading the whole thing before it returns a handle. A case without digests records size and modification time and states"hash_policy": "none";case_verifythen reports"basis": "size and mod time"per source, so a weaker check is never mistaken for a stronger one. The touch record is aggregated, not logged -- first touch, last touch and a count per builtin per source -- because a program that reads a hundred thousand files must not produce a hundred-thousand-line manifest. The seal is checkable by someone else:case_writeputs a SHA-256 over every field but the seal, an Ed25519 signature over the same bytes, and the public key that verifies it into the document, andcase_manifest_verify(path)is a function of the file alone. Reformatting the JSON does not break it; altering a character does.The manifest also carries the reproducibility record -- the path and digest of the exact
.muthat produced it, taken by the runner from the signed bytecode before any of it executed. Not "a tool wrote this" but "this bytecode wrote this".Evidence is read-only, and now provably. A second machine-checked policy in
policy/, alongside the environment-variable guard.policy/evidence_guard_test.goparses every file that reads evidence and fails the build on any call that asks the operating system to create, truncate, rename, delete or chmod anything, or to open a file with a writable flag. Two reviewed exceptions, both writing somewhere other than the evidence: the temp file libxfat demands, and the temp copywithSQLiteCopytakes precisely so an evidence database is never opened by something that wants to write a journal beside it. Each is pinned to an exact line count, so a new write inside an already-permitted function still fails.The invariant already held. Writing it down is what lets a manifest assert
integrity.evidence_read_only: trueand have that mean something -- a manifest that claims what nobody checks is worse than one that stays quiet. The rule, its reasoning and what the guard deliberately does not look for are indocs/EVIDENCE_HANDLING_POLICY.md.Seven security lint rules.
commandInjection,pathTraversal,weakCrypto,tlsVerificationDisabled,hardcodedSecret,evidenceMutationandunboundedResource, each settable throughmutant.lint.rules.<name>.severitylike every rule before them. They take the editor from 16 rules to 23, and they are the rules only a language that knows what a disk image is can write.The rules that shipped before these are right by construction: each compares a call site against a fact the builtin registry states. These read intent, which cannot be stated, so each carries its own answer to what makes a finding certain enough to interrupt someone. The shared principle is that what the program does with a value decides the finding, not what the value looks like -- and the corpus is what forced it.
examples/already held three literals a textbook secret scanner flags first, all three legitimate teaching material, and the one that separates them from a real leak is whether the program uses the value or takes it apart. A JWT handed tojwt_decodeis a sample; the same string put in anAuthorizationheader is a credential.weakCryptodraws the same line for MD5, which in a forensic tool is daily work: matching an artifact against a known-file set wants MD5 specifically, so the rule reports only a digest compared against one written into the program, and neverimphash,nt_hashorlm_hash.evidenceMutationis the one no general-purpose linter has: elsewhere a path is a path, butraw_openandewf_opensay out loud that the file is an exhibit, andfs_writeto that same path is a hash that no longer matches the one in the notes.unboundedResourceturned out not to be about unboundedness at all --cidr_hostsrefuses more than 20 host bits andrangemore than 10,000,000 elements, socidr_hosts("10.0.0.0/8")is not a memory risk but a call that reads as a reasonable network sweep and simply fails. The rule evaluates exactly the predicate the builtin evaluates.Running the rules over the corpus found three real defects in shipped examples:
phishing_url_analyzer.mutandioc_fetcher.mutspliced values -- one of them an attacker-authored URL -- straight into Lua single-quoted strings, where a'ends the string and the rest is read as Lua. All three are fixed.A real test framework:
mutant test. Named tests and subtests, assertions that report the file and line they failed on, fixtures, name filtering, a--jsonreporter for CI, and line coverage with an LCOV profile. The command existed before this and did one thing: run each*_test.mutand call it passed unless it errored or its last expression wasfalse. There were no*_test.mutfiles in the repository, which is the usual sign.The fix underneath all of it: a test file is now compiled through the same build every other program goes through. It used to have its own pipeline, which never walked the module graph -- so an
importin a test file resolved to nothing and the namespace it bound was an undefined variable. A test framework that could not cross a file boundary could not test the feature files exist for.test(name, fn)runs the test where it is written, so a file reads top to bottom and a test declared inside another is a subtest. A failed assertion is recorded and the test carries on -- the language has no exceptions and this does not invent one -- and every assertion also returns its verdict, so a failure is an ordinary value. A test that dies of a runtime error is caught at its own boundary, so one broken test costs the file no other test.Ten builtins:
test,before_each,after_each,assert,assert_eq,assert_ne,assert_contains,assert_err,assert_ok,fail. Deferred and said so: parallel test files (the task registry behindspawnis process-wide), benchmarks, mocking, and asserting on a crash. See docs/TESTING.md.A debugger:
mutant debug. Breakpoints, stepping, the call stack and variable inspection, delivered as a Debug Adapter Protocol server, so VS Code (press F5 on a.mut), Neovim'snvim-dapand anything else that speaks DAP get it from one implementation. For a language whose programs run for minutes over multi-terabyte images,putlndebugging was not viable.It launches rather than attaches:
mutant debugis the program's own process. A Mutant program carries anti-debugging probes that end a secure-mode run when an OS debugger is present, so attaching one would have meant the language's own tooling tripping its own tamper response.Debugging runs from source at mutation level 0. A release artifact has had its positions stripped -- there is nothing to step through, and the values held by a program that left this machine are not this machine's to render.
Three things are deliberately absent and say so rather than half-working: watch expressions beyond a plain variable name, conditional breakpoints, and stepping into
spawnorpmap. Evaluating an expression means compiling it in the frame's scope, and a compiled program does not carry its scopes; a breakpoint carrying a condition stays armed with a message rather than silently never firing. Hit counts do work. See docs/DEBUGGING.md.while,for (item in collection)andmatch. The countingforwas the only loop the language had, and the only way to branch on a value was a chain ofifs.while (c) { ... }is its own construct rather than sugar forfor (; c; ), so the formatter prints back the loop that was written;breakandcontinuework in it unchanged.for (v in xs)walks arrays, hashes, strings and bytes. One binding yields the thing the collection is made of -- an array's element, but a hash's key -- and two bindings yield the index or key alongside the value. A hash is visited in the order printing it shows, because both the printer and the loop sort with the same comparison: a program that prints a hash and a program that loops over one must agree about what order it is in. Iteration is two opcodes rather than a desugar tolen()plus indexing -- a program that declared its ownlenwould otherwise change what every loop in the file means, and a hash has no ordered index to desugar to. There is no range syntax:range(0, 10)is an ordinary builtin returning an array, and looping over it is afor…inlike any other.matchis an expression, so it binds, returns and nests like any other value:let label = match (code) { 0 => "clean", 1 | 2 | 3 => "a few", _ => "many" };. A pattern is a literal, a negated number, an enum variant such asStatus.Ok, or_; alternatives are joined with|. Patterns have their own grammar rather than going through the expression parser, which is what makes1 | 2 | 3those three values instead of the number3--|is the bitwise-or operator everywhere else. An arm body is one expression or a block whose value is its last expression, and the parentheses around the subject are required, sincematch x { ... }without them is a struct literal.Because a match must produce a value, a subject no arm matches is a run-time error naming the value rather than a
nullflowing onward. The case that is about is adding a variant to an enum, which leaves every existing match over it one arm short and nothing else notices; the language server now reports that gap before the program runs (matchExhaustiveness, settable like every other rule), and reports an arm written after_as unreachable. All three constructs run identically on the tree-walking evaluator and the VM, the formatter round-trips them, and editor grammar, snippets, hover help and completion cover them. See examples/basics/control_flow.mut.String interpolation, raw strings and triple-quoted strings.
"${...}"puts an expression in a string:"host=${h}:${p}"wherehis a string andpan integer. A piece that is already a string contributes its own text, anything else contributes what it would print, so there is no conversion to write and no value a hole cannot take. A hole holds one expression -- a call, an index, arithmetic, another interpolated string -- and it is compiled where it stands, so nothing is re-scanned at runtime and a%in the text is a percent sign.Only the two characters
${open a hole. A lone$is unchanged, so"cost: $5"and"$PATH"mean what they always meant;\${writes the two characters literally. A hole is parsed in place: a broken one is reported at its own line and column inside the string, a traceback through a call in a hole underlines that call, and go-to-definition and every builtin diagnostic reach into holes exactly as they reach into any other expression.r"..."decodes nothing -- no escapes, no interpolation -- sor"C:\Users\Public"andr"\d{4}-\d{2}"say what they look like. It ends at the first quote, so it cannot contain one."""..."""spans lines, with escapes and interpolation still live;r"""..."""is its raw form, and either may hold a lone"or"". A newline right after the opening delimiter is dropped and the smallest indentation of any non-blank line -- counting the closing delimiter's own line -- is removed, so a block reads as the text it is rather than as the text plus the surrounding code's indentation. CRLF becomes LF inside one: the line endings in a multi-line literal come from how the file was checked out, and the same source must not mean two things in two clones.A literal with no hole is still an ordinary string token, so nothing in a program that does not interpolate changes -- not the AST, not the bytecode. Interpolation compiles to its own opcode rather than to a call, so a module that declares a name like
str_formatcannot change what every interpolated string in the program means. The formatter reprints a raw or triple-quoted literal as written instead of re-quoting its value, which would have doubled the backslashes it exists to avoid.Modules and imports. A program can be more than one file.
import "lib/stats.mut";binds the namespacestats, andimport s "lib/stats.mut";bindssinstead; the namespace is otherwise the file's base name with the extension removed. An import binds that one name and nothing else -- the imported file's own names are not visible unqualified, so two modules may each declarehelperwithout one silently overwriting the other. A top-level name beginning with_is private to the file that declares it, and reaching for it through a namespace is a compile error naming both the module and the name: there is no export list to keep in step with the code.Paths resolve relative to the importing file's own directory first, then against each
--module-path <dir>directory in the order the flags were given. The flag repeats rather than taking a separator-joined list, and it is the only thing that widens the search: no manifest, no lockfile, no config file, no environment variable (see docs/CONFIGURATION_POLICY.md). The extension is written out rather than guessed, a cycle is reported as the chain that closed it (main.mut -> a.mut -> b.mut -> a.mut), a module reached two ways is compiled and run once, and a path that names no file lists every directory that was tried.structandenumnames stay program-wide, because that is how they travel in the bytecode: one declaration is visible from every module with no qualification, and two modules declaring the same type name is an error naming both files. Values are per-module, types are per-program.Modules link into one instruction stream, one constant pool and one global slot space -- the bytecode format leaves no choice, since the polymorphic engine shuffles the whole constant pool, the opcode permutation ships as one table for the entire program, and jumps carry absolute offsets. None of that is visible in a failure: each traceback frame names the file it came from and quotes that file's line, from a new
ModuleSpanstable thatStripDebugInforemoves with the rest of the debug information, so a released artifact does not carry the layout of your source tree. See docs/MODULES.md and examples/modules/.Builtin namespaces. Every builtin family is now addressable with a dot:
fs.readisfs_read,str.upperisstr_upper,base64url.encodeisbase64url_encode. Nothing is imported and nothing is declared -- the flat name is derived by joining the halves with_, so all 44 families worked the day this landed and none has a list to maintain. Both spellings are one function with one contract: one arity check, one argument-type check, one deprecation notice, one entry in the traceback. A variable, parameter, field or import that already binds the name wins, so no program written before this changes meaning. Flat names keep working unchanged.Bitwise operators.
&,|,^,<<,>>and the prefix complement~, with the compound forms&= |= ^= <<= >>=. A language whose brochure leads with disk-image parsing could not express a mask, a flag test or a shift; the workaround was arithmetic or a builtin call. Semantics and precedence are Go's:<< >> &bind as tightly as* / %and| ^as+ -, soflags & MASK == 0reads as(flags & MASK) == 0rather than C'sflags & (MASK == 0).>>is an arithmetic shift over the signed 64-bit integers the VM has. Non-integer operands are refused rather than truncated, and a negative shift count is a runtime error instead of the panic Go would raise. Six new opcodes, appended so existing opcode values are unchanged.A real
bytestype.BYTESis a first-class object: it indexes to an integer 0–255, concatenates with+, compares by content, measures withlen, and hashes in a keyspace disjoint from strings. Every disk sector, PE section, memory page and socket read previously travelled in a Go string, and nothing in the language could tell a binary value from text — sostr_reverseturned every non-UTF-8 byte into U+FFFD andstr_substrindexed by rune whilebytes_getindexed by byte, both without failing.string_to_bytes(s, encoding)andbytes_to_string(b, encoding), with"raw","utf8"(validated, not substituted),"latin1","hex"and"base64". Because every pre-existing producer already returned byte-exact data in a string,string_to_bytes(x, "raw")bridges all of them losslessly.16 native
*_bytesproducers —fs_read_bytes,hex_decode_bytes,base64_decode_bytes,gunzip_bytes,zlib_decompress_bytes,aes_decrypt_bytes,net_conn_read_bytes, six*_read_file_bytesfilesystem readers and three*_read_at_bytesimage readers. Builtin count 409 → 427 across 33 categories.Runtime tracebacks with source positions. A fault used to print one sentence with no way back to a line. The VM now prints every frame with the arguments it received, the line it is on, and an underline beneath the failing span; deep recursion collapses to "N frames repeated M more times". Positions ride in a delta-encoded line table costing single-digit percent of the instruction stream it annotates, and the program's source text travels with the artifact so a failing
.mucan quote the line it died on.Builtin stability tiers. Metadata declares
stable(the unwritten default),experimentalordeprecated; a deprecated builtin names its replacement. Surfaced as an LSP hover footer and a newbuiltinDeprecateddiagnostic.--timingfor stage timing,--trusted-key <path>for the verification key, and--targetfor release cross-builds — replacing the environment variables that used to carry them.Credential handling that never records the secret: interactive prompt (the default),
--password-file <path>, and--password-stdin.A documentation path from nothing to a shipped binary.
docs/TUTORIAL_30_MIN.md("Mutant in 30 minutes") goes from install to a signed standalone executable;docs/COOKBOOK.mdis fourteen complete programs organised by investigation rather than by builtin category, ending with the traps that actually bit while writing them;docs/COMPARISON.mdplaces Mutant against Python+plaso, Velociraptor, osquery and YARA+Sigma, including a section naming eight cases where it is the wrong tool. Every command and output in the tutorial and the cookbook was run against a binary built from this tree.docs/CONFIGURATION_POLICY.md,docs/WHAT_IS_MUTANT.md,docs/WASM_REPL_REFERENCE.md, and this file, plus SECURITY.md, CONTRIBUTING.md and CODE_OF_CONDUCT.md.examples/data/autoruns_hive.jsonandexamples/data/phish.eml— synthetic fixtures so two of the cookbook recipes run unchanged.docs/EXECUTION_MODES.md— the definitive statement of what each mode changes, every row traced to the code that implements it. The distinction it exists to write down is that--compatweakens the response and--devweakens the key: compat still requires your password and still verifies the artifact, while an artifact built or run under--devhas no confidentiality at all, because the development key is a compile-time constant shared by every Mutant binary. That sentence was in no help text and no document.mlsp --version— the language server now says which release it was built from and how many builtins it linked (mlsp 2.5.0 (493 builtins)). It ships as a binary inside the editor extension, built from a checkout that can be older than the language it is asked to teach, and until now nothing it reported distinguished a current server from one cut months earlier. Both numbers are read from the same source as everything else:global.Versionandbuiltin.Builtins.
Changed#
A builtin no longer spells its own name as a literal. The name was declared once in
builtin/names.goand said twice more as a constant -- by the dispatch table and by the return contract -- but the error messages a user actually reads spelled it a fourth way, as a string literal inside the implementation:requireArrayArg("sort", args[0], 1). Renaming a builtin was already caught three ways, and none of them looked at what its errors say, so every message it raised would have gone on quoting a name that no longer existed. The same literal reached the chain of custody, wherecustodyRecordOpen("zip_open", ...)is the name that ends up in a case manifest.456 literals across 56 files now say the constant. The rewrite was scoped to the function each name is registered to rather than matched across the tree, because short builtin names collide with ordinary hash keys --
"sort","slice","error"and"count"are all both -- and a literal is only suspicious inside the function that owns it.TestNoBuiltinSpellsItsOwnNameAsALiteralwalks the AST the waypolicy/does and fails if one comes back.case_reportandcase_bundlepassed the builtin's name to the same function twice, once inside the options value and once as a parameter the function ignored -- so the options and the document it built could have been labelled differently. The dead parameter is gone.The GitHub Actions workflow is gone, and the docs no longer claim it runs.
.github/workflows/ci.ymlbuilt, vetted and tested on Linux, Windows and macOS, cross-compiled six targets and rango mod verify. Those are still the gates -- they are now commands a contributor runs, listed in CONTRIBUTING.md, and nothing runs them automatically.Four documents asserted otherwise and have been corrected:
CONTRIBUTING.md,docs/CONFIGURATION_POLICY.md,docs/SECURITY_LLD.mdanddocs/SECURITY_LLD_TRACEABILITY.md. Two claims in them were already false before this change:SECURITY_LLD.md§16.2 described a.github/workflows/security-profile.ymlthat was never written, and the traceability matrix'sCevidence level meant "CI wired" -- it now means wired into the repository-widego test ./...gate, which is what those rows were actually describing. SEC-015 ("the security suites must run on every supported OS") drops fromI/CtoI/P, because nothing automates the "every OS" half any more.Enum equality is typed rather than textual. Comparing an enum value with anything that was not an enum fell through to comparing what the two would print, and an enum prints as
Status.Ok(0)-- soStatus.Ok == "Status.Ok(0)"answered true, in both engines. Bytes and errors each already had a typed comparison arm for exactly this class of accident; enums never did, andmatchmade it reachable in a way plain==rarely was. Two enum values are now equal when they share a type and a variant, and an enum is equal to nothing else. A program that relied on the old answer changes behaviour.Contradictory mode flags are an error instead of a silent resolution.
--secure --compat,--secure --devand--signer-auth --no-signer-autheach exit non-zero naming both flags and why they conflict. Every flag scanner was last-flag-wins, and--devdid not even need to come last — it forced compatibility mode wherever it appeared — somutant prog.mu --dev --secureran unsecured having been asked in the same breath to run secured, and said nothing about it.--dev --compatis still accepted: dev mode implies compat mode, so naming both is redundant rather than contradictory, and repeating a flag stays harmless. Validation runs before any other work, so the embedded standalone path and the CLI reject the same command line identically.A terminating security event says what fired and what to do about it. The run used to stop on one sentence —
sandbox detected, execution halted for security— that named no probe, no reason and no way forward, on a check that fires on ordinary containers, VMs and CI runners, which is where forensic tooling normally runs. It now prints the detector, its classification and confidence, the signals behind it, and the remedy.--compatis named for the host-probe events and deliberately not for a failed signature or a failed integrity check: those are statements about the artifact, and offering--compatthere would be advice to run a modified file anyway. The VM's security opcodes return their error directly rather than through the response policy, so they call the same explainer.Help text for the modes was rewritten:
--devnow says the fallback key is a compile-time constant shared by every Mutant binary,--signer-authsays it upgrades verification rather than enabling it, and the compat-weakens-response versus dev-weakens-key distinction is stated in the help itself.Builtins resolve by name, not by registry ordinal.
OpGetBuiltincarried an index into the global registry, which made that registry append-only forever — nothing could be renamed, retired or reordered without silently repointing every artifact ever compiled. The compiler now interns the builtins a program references and ships that table in the artifact; the VM binds every name before an instruction runs, so a missing builtin fails by name at load.ByteCode.Versionis 2.Binary-accepting builtins widen rather than fork: the whole
bytes_*family, the hash and encoding families,fs_write,fs_appendandnet_conn_writetakeBYTESorSTRINGand hand back the representation they were given. No existing builtin changed its return type — thebyteswork is additive, and every.mutprogram written before it behaves identically.Debug information is unconditional in development artifacts and stripped only by
mutant release, rather than being gated on a mode flag. Gating would have coupled debuggability to a security downgrade.The polymorphic engine carries line tables through the offset remap it already builds for jump targets, so mutated builds keep their positions.
The editor's builtin highlighting is generated from the registry. The TextMate grammar carried a hand-copied list of builtin names, and a hand-copied list of 490 names is a list that falls behind: it had 76 of them, missing every
report_*,case_*,stix_*,ocsf_*andecs_*builtin along with 25 of the 26net_*.cmd/gendocsnow writes that one rule frombuiltin.Builtins, andTestGrammarHighlightsEveryBuiltinfails when the grammar and the registry disagree — because a generator nobody runs is the same drift one step further away. Only the builtin rule is generated; the rest of the grammar stays hand-written.Hover, completion, signature help and every diagnostic already covered all 490, since those come from the language server and it derives them from
builtin/metadata.go. What was actually wrong was narrower and still worth fixing: areport_newcall read as an unknown identifier next to a highlightedputln, which tells the reader the wrong thing about what the language knows.cmd/gendocswrites each document with the line endings the file already had, instead of always writing LF into a tree checked out with CRLF.The staleness check on the shipped language server was looking in the wrong place, and nothing ran it.
check:lsp-binscompared the stagedmlspbinaries against the Go files underlsp/— but the analyzer derives arity, parameter kinds, result types and deprecation frombuiltin/metadata.goin the parent module, so registering a builtin changes what the server teaches and leaves every file underlsp/untouched. A server could fall a release behind the language and the check would call it current. It now compares against the whole module, requires all six binaries rather than passing on whichever ones happen to be present, and — for the one target the machine running it can execute — asks the binary for--versionand compares that with what the sources build, because mtimes lie after a fresh clone. The check had also never been wired into anything;scripts/package-targets.mjs, which stages fromlsp/distwithout building, now runs it before it publishes.
Removed#
- All environment-variable configuration. Mutant takes no configuration from
the environment, in code or in documentation: the environment is part of the
untrusted thing a forensic tool examines, and an environment switch is an
unlogged control channel absent from the command line an analyst records.
Ten dead exported
*Envconstants across five files were deleted and 16 documents purged of the variables they described — several of which named knobs the code had already collapsed to constants. - The environment-driven builtin capability allowlist. Capability configuration is under design; no interface is specified.
Fixed#
A test compared an evidence path against the spelling it typed, not the one custody records.
case_evidencestores the resolved path -- that is how two handles on one file become one exhibit -- and Windows hands back an 8.3 short path for aTEMPunder a username longer than eight characters, soTestCaseReportSaysOnlyWhatTheManifestSayslooked forC:\Users\RUNNER~1\...in a report that carriedC:\Users\runneradmin\.... It failed on that one kind of host and passed on every other, including every machine this was developed on.The helper now returns the path as custody will record it. The product behaviour was correct and is unchanged; what was missing was a test saying so, and
TestTwoSpellingsOfOneFileAreOneEvidenceEntrynow registers one file under two names and fails if the manifest grows two entries.A write through more than one container was silently lost.
grid[0][1] = 9,h["a"]["b"] = 2,rows[0]["n"] = 99,o.inner.v = 42-- every shape with more than one hop in its target -- compiled, ran, changed nothing and reported nothing. The compiler emitted the store back into the variable only when the container expression was a plain identifier; anything deeper mutated a value nothing stored back. Globals and locals are held encrypted, so a load hands back a copy, and the write went into the copy.For a tool that reports on evidence this is the worst failure available: the report is wrong and looks right. The idiom it breaks is the ordinary one --
counts[host]["n"] = counts[host]["n"] + 1over a timeline.The compiler now flattens an assignment target to the variable under it plus its hops, and stores every container it passed through back where it came from. What it still cannot emit correctly it refuses by name: a target with no variable under it (
[1, 2][0] = 9) has nowhere to store its result, and an index before the last one is loaded again on the way out, so it has to be a name or a literal rather than something with a side effect. The last index is compiled once and is free to be a call.The two engines now agree on ten shapes that used to diverge (
TestNestedIndexAssignmentParity, previously skipped as a known divergence), and the editor reports both refusals where they are written, under a newassignmentTargetlint rule whose answers are pinned against the compiler's so the two cannot drift.Index assignment evaluated to the container instead of the value assigned.
arr[0] = 42answered with[42, 2, 3], andh["k"] = 7with the whole hash, whilex = 5,p.x = 9andx += 1all answered with the value -- and so did the evaluator, in every case.let bound = arr[0] = 42bound the array.Every language where assignment is an expression yields the assigned value and none yields the container, so this was an unfinished implementation rather than a choice: index assignment simply left whatever
OpSetIndexhad put on the stack. The compiler now spills the value to storage of its own, where the value expression was already being compiled, and reads it back after the stores. Nothing is re-evaluated, no opcode was added, and the last index stays free to be a call --counts[etld1(url)] = 1still compiles.TestIndexAssignmentValueParitywas skipped as a known divergence and is nowTestAssignmentYieldsTheValueAssigned, covering all five forms at three depths.The two engines evaluated an assignment's parts in different orders. The compiler emits the container, then the index, then the value; the evaluator read the value first, and folded a compound assignment's right-hand side before its target. Invisible until both halves have side effects --
a[note("index")] = note("value")recorded them in opposite orders -- and it reached real programs through macros, since the evaluator is what computesunquote(...)at expansion time. The evaluator now follows source order. Found by the test written for the change above.Twenty-one example programs were in no README. Among them workshop step 6, a whole numbered lesson in a sequence the reader is told to follow in order. The ten filesystem format demos, the two
net_serveserver pairs and every macro example were likewise unlisted. All of them are now described where they live, andTestEveryExampleIsListedInItsReadmefails when a directory that has a README gains a program it does not mention.Workshop step 6 warned about a defect that had been fixed. Its style note said a closure writing an outer variable does not write back. It does, and has since captured locals became cells; the one case that still does not is a callback running on its own VM, which the editor already reports. A false caveat is worse than none: it teaches a reader to avoid something that works.
An example pointed at someone's dev machine.
ext_example.mutopenedN:\dev\dataset\img6_ext4.ddwhere every one of its nine siblings uses a relative placeholder. It is./disk.ext4now, like the rest.Four documents described a standard library three releases old.
WHAT_IS_MUTANT.md,WASM_REPL_REFERENCE.mdandCOMPARISON.mdeach said 459 builtins,TUTORIAL_30_MIN.mdsaid 469, against 497; the category counts (33, 34, 35) were all wrong too, and two documents undercountedexamples/by twelve programs. Corrected, and gated:TestProseCountsMatchTheRegistryandTestProseExampleCountsMatchTheTreeincmd/gendocsnow fail when a document claims a number the registry or the tree does not support.This is ED-1's defect in a different file type -- a hand-written claim about the registry with nothing that fails when the registry moves -- so it gets ED-1's answer. Counts in
CHANGELOG.md,CONTRIBUTING.mdand the roadmap are deliberately outside the gate: those record what was true at a past release, and correcting history would be the untruth.The debugger refused to run on a virtual machine. A debug session built its VM in secure mode, so the injected sandbox probe fired on launch and the security policy ended the session before the first line could be stepped. Every CI runner is a virtual machine and so is every malware-analysis workstation, which is to say the debugger stopped exactly where a forensic tool is most likely to be pointed at something.
A session now runs in the posture
--compatgives a run, the same onemutant testalready used: the probes are still compiled into the bytecode being stepped and still execute, but a hit warns instead of terminating.VM.SecureMode()reports which posture a machine was built with, so a caller that chose the advisory one can say so rather than imply it.A frame's unassigned local slots held the previous call's values. The calling convention never cleared the region between a new frame's arguments and the end of its locals, so those slots held whatever the last call left on the stack. No program could see it -- every local is assigned before it is read -- but a debugger reads them all, and would have shown a dead value from an unrelated frame under the name of a variable whose declaration had not run yet. The slots are cleared when a debugger is attached, so no ordinary run pays for it.
An
ifused as a value left the stack wrong in two cases. The compiler decided whether a branch had produced a value by looking at the last instruction it had emitted. A branch ending in aletemits no pop, so that branch pushed nothing where its sibling pushed one and the VM underflowed; a branch ending in afor (v in xs)ends in the pop that drops the loop cursor, which looks identical to a value being discarded, so that pop was removed and the cursor became the branch's value --if (true) { for (v in []) {} } else { 4 }evaluated to<iterator>. Both branches now decide from the syntax, the same rulematcharms use.docs/SECURITY_LLD.mdclaimed in two places that the VM forcedsecureMode=truefor integrity failures, making integrity the one check no mode could downgrade. It does not:vm.secureModecarries the launch mode into both integrity checks, so under--compatand--deva mismatch warns and the run continues. The documents now state what the code does. Whether integrity should be the exception to the mode rule -- "the host looks suspicious" is a guess about the environment, "this bytecode is not what was signed" is not -- is recorded as an open question rather than settled here.The test helpers that capture stdout and stderr wrote everything before reading anything, so they deadlocked once the captured output outgrew the pipe buffer (4 KiB on Windows).
mutant --helpcrossed that line when the mode section was rewritten. They now drain concurrently.Assignment to a captured variable corrupted the frame. The compiler branched on two of
SymbolScope's five values at its three assignment sites, so a write to a free variable was emitted asOpSetLocalagainst the free index -- a position in the closure's capture list, not a frame slot. Writing free 0 landed on local 0, which is usually the first parameter:fn(x) { let acc = 0; let inner = fn(p) { acc = 7; return p; }; return inner(x); }called with 42 answered 7, and two captures over two parameters answered[777, 888]for(10, 20). Silent argument corruption, with no diagnostic from any stage. The tree-walking evaluator was correct throughout, so this was a defect against a reference implementation rather than a semantics question.A captured local now lives in a cell that the frame slot and every closure over it point at, so a write through any of them is a write all of them see. An accumulator over a callback works, a counter outlives the frame that made it, and two closures over one variable agree about its value. Adding
OpSetFreeover the existing by-value capture list was rejected rather than deferred: it would have stopped the corruption and left the accumulator answering 0, trading a findable bug for a quiet one.Five new opcodes, appended so existing opcode values are unchanged. Bytecode compiled before this runs unchanged, including its by-value capture.
pmap,peachandspawnhand each worker its own copy of the captured cells, so a callback's writes stay local -- the rule those builtins already documented for globals, and without which the sharing would be a data race. Assigning to a builtin's name or to the name a function literal was bound to is still refused at compile time; neither is storage.The VM skipped opcodes it did not recognise. The dispatch switch had no default arm, so bytecode built by a newer toolchain ran to completion and answered with nonsense: the unknown instruction matched nothing, the instruction pointer advanced by one, and its operand bytes were executed as opcodes. It now stops and names the opcode. Opcodes are append-only, so this is the only direction that can fail -- old bytecode has always run on a new runtime, and still does.
mutil.EncryptObject's default arm returns an error both call sites discard, so an object type without an explicit arm travelled unencrypted with nothing said.BYTEShas arms in both directions, with a test asserting the sealed type rather than trusting the suite.The parameter-kind conformance probe drew from a fixed candidate list that never exercised the new kind. Making it reachable immediately caught 15 under-declared builtins, including
lenandjson_stringify, that a hand audit had missed.The evaluator has never indexed strings while the VM has. Bytes indexing was implemented in both engines so the new type does not inherit the divergence.
A missing or malformed trusted-key file now fails hard instead of silently falling back to the local keystore.
LSP: the type lattice, the function-parameter solver, inlay hints and signature help all know
bytes;builtinArgTypereports binary passed into a text builtin and names the conversion.email_urlsdeclared[]STRINGelements over an implementation that returns a{host, scheme, url}hash per link, so the published signature, the hover and the argument-type diagnostic were all confidently wrong aboutu["host"]. The contract now matches the code. The conformance probe could not have caught it: it checked only the top-level returned kind, never the declared element kind. It now checks both, verified by mis-declaringtext_splitand watching it fail.The generated
docs/CAPABILITY_REFERENCE.mdstill told every reader that Mutant "has no separate bytes type" — untrue sinceBYTESshipped, and contradicted by a signature on the same page. Fixed incmd/gendocs, which is where generated prose lives.
Security#
go test ./policy/...— an AST guard, part of the ordinarygo test ./...— fails the build on aMUTANT_-prefixed string literal anywhere in the module, on an environment read outside ten allowlisted functions with pinned line budgets, and on an environment write outside a build-time childgo build. Sandbox, VM and debugger detection is unchanged: those probes read variables set by Sandboxie, WSL, a debugger or an injector — never by a Mutant user — and the only outcome they can produce is a refusal to run.SecureStack.clearObject's*Stringarm zeroes[]byte(v.Value), a copy Go makes at the conversion, so it has never wiped anything and cannot: Go strings are immutable. It is documented in place. Key material belongs in abytes, whose backing arrayZero()genuinely clears.
[2.4.0] — 2026-08-25 — "Legion"#
One compiled program, many minds running it.
Added#
- Concurrency. The unit is a whole VM, not a goroutine sharing one: a worker
gets its own stack and frames and a snapshot of the globals over the same
bytecode, so nothing is shared and nothing needs a lock.
spawn, channels, andpmap/peach, up to 1024 workers. 10 new builtins; 399 → 409. - Compound assignment operators.
- Struct and enum hover in the language server; SSA-based analysis and ghost-code resolution in the extension.
Changed#
- The polymorphic engine went live. Previously 1 of 5 transforms, active only at level ≥ 6; now 4 of 4 from level 1.
- Execution engines reduced from three to two — the separate WASM REPL interpreter was deleted rather than kept in sync.
- LSP diagnostics 7 → 12. The VS Code extension moved to 0.1.0 with per-platform packaging.
- A failing run now exits non-zero. It previously exited
0.
Fixed#
- Hash literal and LSP formatter fixes; operator parity between the two engines; nopsled and polymorphic correctness fixes.
[2.3.0] — 2026-08-11#
Added#
- The forensic suite in depth: MFT, prefetch, registry,
fs_deleted, syslog, SQLite and regex builtins, link builtins, process listing, and process injection detection. - Higher-order collection functions;
&&and||operators. - Workshop functions and example programs.
Changed#
- Parser, formatter and extension updates; SQLite excluded from the WASM build.
[2.2.0] — 2026-07-14#
Added#
- Interactive REPL tab completion with context-aware ranking; shared help docs across the REPL and web surfaces.
- The WASM REPL and automatic WASM builds; LSP build scripts and extension packaging.
Changed#
- Compiled bytecode is compressed. Multi-value returns were reworked, and
lenreturns a single object. - Sandbox detection updated; macro fixes.
- Moved to the
aoifluxdomain and the AGPL-3.0 licence.
Removed#
- An earlier round of environment variables, and large files from the tree.
[2.1.0] — 2026-06-28#
Added#
- The polymorphic bytecode engine; sandbox, VM and debugger detection; deep binary protection and randomised in-code security checks.
- Sandboxed Lua: a bytecode integration, an executor with timeouts and scrubbed errors, and pre-VM Lua patch execution under tamper policy.
- Structs, enums, loops, float types and the modulo operator; encrypted structs and enums; command execution; many builtins.
- Dynamic stack size, global size and frame limits.
Changed#
- Moved to a pure-Go implementation; improved runtime encryption and security logging.
[2.0.1] — 2022-06-26#
Added#
- Graceful exit handling.
Changed#
- Go version upgrade.
[2.0.0] — 2021-06-02#
Added#
mutant release— standalone compiled binaries for multiple platforms, with per-platform binary formats and extensions.- A basic CLI with help and argument validation.
[1.0.1] — 2021-03-29#
Documentation only.
[1.0.0] — 2021-03-22#
Initial release: the language, the compiler, the VM, and encrypted bytecode.