The loop

The security vendors' moat is "connect to anything, learn to detect anything." Mallcop's answer is a loop the product drives at runtime instead of a batch job of hand-written rules. Every scan that force-escalates an action it couldn't map, or misses a finding it should have caught, becomes feedstock. Mallcop mines that feedstock, proposes a narrowly-scoped fix, and puts the fix through the same gate every time.

Scan

mallcop scan
  • Pull events
  • Detect + investigate
  • Force-escalate the unmapped

Collect

mallcop collect
  • Mine the git store
  • Surface mapping gaps
  • Rank coverage candidates

Propose

selfext --propose
  • Author an add-only widen
  • Existing vocabulary only
  • — or opencode writes a detector

Gate

validate-proposal
  • Protected paths frozen
  • Builds + import allow-list
  • No regression, gap closed

Route

(within selfext)
  • Data → your overlay
  • Code → review artifact
  • Uncertain → human gate

The gate is the whole point. It is a $0, no-inference check on a real git diff: it freezes the parts of mallcop the loop must never touch (the committee, the pipeline, the tools, the gate itself), confirms the change still builds, and re-runs the labeled detection exam to prove the proposal closes a real gap without breaking anything that already worked. Only inference — authoring the proposal — costs anything; the gate is free.

Two lanes: data & code

There are exactly two things a proposal can be, and they carry very different risk. Mallcop keeps them separate on purpose.

Data lane safe by default Code lane gated escape hatch
What it changes A YAML overlay — a widen-only tuning knob or an action mapping A brand-new Go detector, authored in a worktree jail
What it can express Only widen what the committee sees. Narrowing is inexpressible. New detection logic keyed on an event type
Needs inference? The gate does not. Only the optional proposer does. Yes — it writes code
How it lands Applied as your local overlay after the gate passes Never auto-merged. Emits a human-review artifact.
Blast radius Config only — more alerts, never fewer Fenced: capped subkey, sandbox, four-layer gate, then a human

The data lane can only ever make mallcop more watchful — it widens what the committee reviews, so the worst case is a false positive you rule away. It never adds a rule that suppresses or force-resolves a finding. The code lane can do more, so it is fenced in more: it runs on a capped key in a sandbox, clears a four-layer gate, and still only produces a patch for a human to read and merge. The engine never pushes.

Three ways to run it

The gate and the offline demos on this page are pure OSS — free, deterministic, and reproducible with the mallcop binary. Only the proposer and the code-authoring lane spend anything, and you choose how they are billed.

Rail What it costs Spend cap Who it's for
Free OSS data lane $0 — the gate needs no inference n/a Anyone. No account, no key.
Donut rail Metered to your mallcop tenant balance Yes — a dedicated, spend-capped self-ext account Pro tenants who want managed inference
BYOI rail Metered to your endpoint + key None — your key, your blast radius Anyone with their own inference account

The free data-lane gate and every demo below run today with the OSS mallcop binary (exam-detect, collect, validate-proposal). The metered proposer and code-authoring lanes are operator-run through a separate binary, mallcop-ops selfext (--propose for the data lane, --run for the code lane): they need donut balance (managed inference) or your own inference key (BYOI), and they refuse loudly rather than spend without credentials.

Demo: it gets smarter over time (free, offline)

Here is "gets smarter over time" made concrete — no inference, no mocks, $0. Mallcop ships with a labeled detection exam. mallcop exam-detect grades the offline detect layer against it. Two cases start RED (excerpt below: 7 of the corpus's 17 labeled scenarios, the ones relevant to this walkthrough):

$ MALLCOP_REPO_ROOT=~/projects/mallcop mallcop exam-detect

PASS AC-01-external-access-stolen-cred
       must_fire:     new-external-access
       emitted:       new-external-access
PASS VA-01-deploy-burst
       must_not_fire: volume-anomaly
       emitted:       (none)
PASS VA-02-month-end-batch
       must_not_fire: volume-anomaly
       emitted:       (none)
FAIL VA-03-data-exfil
       must_fire:     volume-anomaly
       emitted:       (none)
PASS VA-05-quarterly-report-burst
       must_not_fire: volume-anomaly
       emitted:       (none)
FAIL PE-08-aws-poweruser-grant
       must_fire:     priv-escalation
       emitted:       (none)
PASS PE-09-aws-readonly-grant-benign
       must_not_fire: priv-escalation
       emitted:       new-actor
exam-detect: 17 labeled (13 passed, 4 failed), 41 unlabeled (skipped)
# exit 1

PE-08 is a false negative you can close with pure data. AWS's managed PowerUserAccess policy grants nearly everything, but its name carries no built-in elevation keyword (admin / owner / write), so priv-escalation missed the grant. The fix is a hand-authored, widen-only overlay — the entire file:

# detectors/tuning.yaml — widen-only. Narrowing is inexpressible here.
priv_escalation:
  extra_elevated_keywords:
    - poweruser

Re-grade with the overlay applied. PE-08 flips to GREENpriv-escalation now fires on the grant — and the benign twin PE-09 (the read-only grant that must not fire) stays GREEN. Widen-only, no over-fire:

$ MALLCOP_REPO_ROOT=~/projects/mallcop mallcop exam-detect \
    --tuning ~/projects/mallcop/detectors/tuning.yaml

...
PASS PE-08-aws-poweruser-grant
       must_fire:     priv-escalation
       emitted:       priv-escalation
PASS PE-09-aws-readonly-grant-benign
       must_not_fire: priv-escalation
       emitted:       new-actor
FAIL VA-03-data-exfil
       must_fire:     volume-anomaly
       emitted:       (none)
exam-detect: 17 labeled (14 passed, 3 failed), 41 unlabeled (skipped)
# exit 1 — VA-03 is still open

13 passed → 14 passed, as pure data. No code changed, no inference ran. VA-03 stays RED on purpose: a data-exfil case needs detection logic that doesn't exist yet — a tuning knob can't invent a detector. That gap is exactly what the code lane is for, and why the code lane exists behind a gate and a human review rather than a knob.

Demo: finding the gap (free, offline)

Where do proposals come from? From the scan store. When a scan meets an action it can't map, the offline fail-safe force-escalates it rather than dropping it, and records it. Run a scan over three events whose actions land in the connector's github_other bucket (the fixture is test/docdemo/fixtures/gap-demo-events.jsonl in this repo — this exact command and its output are doc-tested against it). The flags below point at that fixed fixture so the numbers are exactly reproducible; day to day, the same scan reads mallcop.yaml with zero flags — see Configuration:

$ mallcop scan --connector file --events events.jsonl --store ./store --json
{
  "events_scanned": 3,
  "findings_detected": 2,
  "escalated": 2,
  "resolved": 0
}
# exit 1 — scan exits non-zero when it has findings (alert semantics)

Then mallcop collect mines the store and surfaces the mapping gap the proposer would turn into an add-only widen — two repository.transfer events with no vocabulary mapping, plus the known event types it could map them onto:

$ mallcop collect --store ./store --json
{
  "schema_version": 1,
  "mapping_gaps": [
    {
      "source": "github",
      "raw_action": "repository.transfer",
      "count": 2,
      "sample_event_ids": ["evt-gap-001", "evt-gap-002"],
      "suggested_vocabulary": [ /* 64 known event types the proposer maps onto */ ]
    },
    {
      "source": "github",
      "raw_action": "environment.protection_rule.override",
      "count": 1,
      "sample_event_ids": ["evt-gap-003"],
      "suggested_vocabulary": [ /* ... */ ]
    }
  ],
  "gap_candidates": []
}
# exit 0

That envelope is the proposer's input. On the donut or BYOI rail, one metered inference call turns it into a strict, add-only mapping proposal — which then goes through the same gate as everything else. The feedstock is produced entirely offline and for free.

Demo: the safety gate (free, offline)

The gate is a static guard over a real git diff — no inference, no corpus, $0. Its most important job is refusing to let the loop edit the parts of mallcop that make it trustworthy. Point it at a diff that touches core/agent/ — the committee and its knobs — and it rejects, flat:

$ mallcop validate-proposal --base <base> --head <head> --guard-only --json
{
  "schema_version": 1,
  "tier": "free",
  "passed": false,
  "stages": [
    {
      "name": "guard",
      "passed": false,
      "evidence": "static invariant guard over ee09ee11..cc4cdeba: 1 finding(s)",
      "findings": [
        {
          "path": "core/agent/consensus.go",
          "rule": "protected-path",
          "detail": "modification on a protected path: the self-extension loop
                     may not touch the committee, pipeline, lint, tools, grader,
                     inference, contracts, cmd, CI, module files, or the guard itself"
        }
      ]
    }
  ]
}
# exit 1 — the loop can never edit the committee

A clean, additive change the guard has no opinion on — here a docs edit — passes:

$ mallcop validate-proposal --base <base> --head <head> --guard-only --json
{
  "schema_version": 1,
  "tier": "free",
  "passed": true,
  "stages": [ { "name": "guard", "passed": true, "findings": [] } ]
}
# exit 0

This is the load-bearing invariant: the loop can widen what the committee sees, but it can never edit the committee. The gate enforces it as diff analysis, not as a prompt the model could talk its way around.

Running the code lane (GitHub Actions)

The data lane runs inline in the CLI — a metered proposer call, the offline gate, and a local overlay, no infrastructure. The code lane is different: authoring a brand-new Go detector needs a real sandbox and a real human-review boundary, so it ships as a GitHub Actions workflow that runs in your own fork of mallcop-app/mallcop. The ephemeral runner is the sandbox; the required merge check is the gate; the pull request is the human review. The engine never pushes and never merges.

One operator command scaffolds the workflow into your fork checkout. It writes three files — a thin caller workflow, a pinned reusable workflow (all the orchestration), and a CODEOWNERS belt that freezes the committee, grader, and gate paths — then prints a checklist for the steps GitHub won't let a scaffold do for you (secrets, branch protection):

$ mallcop-ops selfext --scaffold-gha --out ./mallcop-fork
wrote 5 CODE-lane file(s) into ./mallcop-fork:
  .github/workflows/mallcop-selfext-code.yml
  .github/workflows/selfext-code-reusable.yml
  .github/workflows/mallcop-version-bump.yml
  .github/CODEOWNERS
  .github/MALLCOP_SELFEXT_SETUP.md

Next steps (scaffolding cannot set secrets or branch protection for you):
  1. Set the inference secret — your OWN donut key, NOT a Forge admin key:
       gh secret set MALLCOP_SK        # paste your mallcop-sk-... key
  2. Edit .github/CODEOWNERS: replace @operator with your GitHub handle.
  3. Pin the reusable-workflow ref to a mallcop-app/selfext release commit SHA.
  4. Require the exam gate + code-owner review before merge to main.
  5. Trigger an authoring run:
       gh workflow run mallcop-selfext-code.yml \
         -f detector_id=authored-deploy-burst -f event_type=github.deployment

Once configured, you dispatch a run by naming the gap — the detector id and the connector event type it keys on. Inside the pinned runner image (ghcr.io/3dl-dev/selfext-runner), opencode authors the detector on your secret's inference (your mallcop-sk-* donut key, or a BYOI provider key), the in-runner four-layer gate runs, and on GREEN a review PR opens in your fork under your own identity. The runner has contents: write + pull-requests: write scoped to the fork and nothing else — it cannot write main (branch protection), cannot reach the upstream repo, and never holds an admin key. You review and merge the PR; it takes effect only once merged and released.

Contributing a detector upstream (opt-in)

A merged detector is yours — a private overlay in your own repo. Sharing one back into the open-source corpus is a separate, opt-in step that needs two things you set once. First, consent: mallcop config set contribute_back on, which writes learning.contribute_back: true — the live consent knob. (There is a similarly-named sovereignty.contribute_back field that gates nothing; the config set command and the config summary both point you at the live one.) Second, a credential of your own: because the upstream PR is opened under your identity and never mallcop's, you set an OSS_CONTRIB_TOKEN secret — a least-privilege token you control. Miss it and the run tells you exactly how to add it, in the run summary, instead of failing silently.

# 1. Opt in (writes learning.contribute_back: true — the live knob):
$ mallcop config set contribute_back on

# 2. Give it a token YOU control (mallcop holds none). Fork the OSS repo, then mint a
#    fine-grained PAT scoped to ONLY that fork — Contents: read/write + Pull requests:
#    read/write (or a classic token with just public_repo). Least privilege, nothing else:
$ gh secret set OSS_CONTRIB_TOKEN        # paste your least-privilege token

# 3. Propose an ALREADY-MERGED detector upstream (opens a REVIEW PR — never merges):
$ gh workflow run mallcop-selfext-code.yml -f promote_detector=authored-deploy-burst

The upstream PR is a review PR: the OSS repo's own exam gate and code-owner review decide whether it merges, at every autonomy setting. mallcop never merges it for you, and never holds a credential that could.

BoundaryWhat enforces it
The runner can't merge its own work Branch protection + required exam check + code-owner review — all outside the job's reach
The runner can't touch the committee or gate In-runner guard stage rejects any edit to core/agent, eval, selfgate, or .github/; a CODEOWNERS belt backs it up
No standing credentials leak Ephemeral VM destroyed after the job; the only secret is your own inference key, redacted from logs
Supply chain is pinned Every uses: is a 40-hex commit SHA; the runner image is pinned by digest; opencode is baked at a checksum-verified version

Availability. The --scaffold-gha command and the templates ship today, and the supply chain is now SHA-locked end to end: the scaffold emits a caller pinned to the central reusable workflow (mallcop-app/selfext), which in turn pins the runner image (ghcr.io/3dl-dev/selfext-runner) by @sha256 digest — the image is built in CI from the same mallcop-ops release that authored the detector above. The single remaining step to enable external forks is flipping that runner image to public in the registry; until then, run the code lane operator-side (proven above) on your own inference or the donut rail. Donut billing for the rails above (see Pricing) is already live. The full as-shipped record, including the honest egress story on hosted runners, is in the GHA self-extension runtime design note.

Proven end to end

The hosted GitHub-Actions flow above is still being turned on — but the loop it runs is not theoretical. Driven operator-side today (your own inference, or the donut rail), the code lane has already authored a brand-new detector end to end: opencode on GLM-5.2 wrote it in a sandbox jail, and it cleared the full gate.

opencode invocation complete   authored_files=8
PROPOSED GREEN gate — reviewable artifact (proposal.patch + gate.json + provenance)
  guard        PASS   static invariant guard: 0 findings
  structural   PASS   go build OK · go vet OK · import allow-list clean · shape gate clean
  exam-detect  PASS   coverage +1 — must-fire fires, benign-twin stays silent
# applied=false — the engine never auto-merges

What it wrote is real Go — its own package, a literal Name(), and a pure Detect keyed on the event type, plus a must-fire and a benign-twin exam scenario proving it fires when it should and stays silent when it shouldn't. A human reviewed the artifact and opened it as a pull request against the mallcop repo — #157: authored by the product, gate-passed with coverage +1, opened for human review, never auto-merged. That is the whole loop, end to end, on a real detector.

The safety story

Self-modifying security tooling is only worth having if you can trust the modifications. Every claim below is enforced in code and provable offline, not a promise.

Consensus is never suppressed
The loop only ever widens what the committee reviews. There is no syntax to add a suppress or force-resolve rule, and the gate rejects any edit to the committee itself. Accuracy comes from consensus, not from patched-in exceptions.
Every change is gated
Data widen or authored detector, both clear the same validate-proposal gate: protected paths frozen, still builds, no regression on the labeled exam, at least one real gap closed. The gate is free and runs with no inference.
Authored code is always human-reviewed
The code lane never auto-merges. A GREEN gate produces a review artifact — the patch, the gate report, and the provenance — for a person to read and merge. The engine never pushes.
Add-only, existing vocabulary
The proposer strict-parses its own output: out-of-vocabulary, prose, or narrowing proposals are rejected at parse with no retry and no spend. It can only add, using words mallcop already knows.
Contribute-back is opt-in
A tenant's improvement stays that tenant's private overlay. It only becomes an OSS pull request if you explicitly consent. Your coverage is yours by default.
BYOI is your blast radius
Bring your own inference and there is no spend cap — because it is your key and your endpoint. The donut rail, which bills your tenant, runs on a dedicated spend-capped account instead.

Want to see the whole story end to end? The consensus cascade is how a single scan reasons and votes; the security model covers the airlock that sanitizes every byte the AI reads; and the detector catalog lists what mallcop already watches for out of the box.