Documentation menu

Skills and roles

Two mechanisms shape a run without touching the harness. Skills are playbooks the model can choose to open. Roles are typed subagents with their own prompt, tools, and report shape.

Both are read only from LOCAC_HOME (~/.locac by default) or from the binary itself. Neither is ever read from the repository under audit. See the Security model.

Skills

A skill is a directory containing a SKILL.md:

~/.locac/skills/
  archive-extraction/
    SKILL.md
  jwt-auth/
    SKILL.md

The front matter needs two fields. Without both, the file is not a skill and is skipped silently:

---
name: archive-extraction
description: Traversal and zip-slip in archive handlers: where entry names reach a path join, and what a correct destination check looks like. Use when auditing tar/zip extraction.
---

# archive-extraction

The body. Written for the agent, not for a human reader.

What the model sees

Only the name, the description and the path go into the system prompt:

Load a skill when its description matches what you are about to do: read its `path`, then follow it.
<available_skills>
  <skill name="archive-extraction" path="/home/you/.locac/skills/archive-extraction/SKILL.md">Traversal and zip-slip in archive handlers…</skill>
</available_skills>

The body is not in the prompt. The agent reads the file when the description matches what it is about to do, which is what keeps a large skill library affordable: the menu costs a line per skill, the playbook costs nothing until it is used.

Write the description as a trigger, not as a title. "Traversal and zip-slip in archive handlers … Use when auditing tar/zip extraction" is a description the model can route on; "archive stuff" is not.

Optional front matter

FieldEffect
disable-model-invocationtrue removes the skill from <available_skills>. It stays invokable as /skill:<name> in the TUI
allowed-toolsParsed and stored. Not yet enforced, experimental
versionFree-form string, carried through
licenseFree-form string, carried through
metadataAn inline key=value, key2=value2 map

disable-model-invocation is the lever for a skill you want available but not advertised: a long checklist you invoke by hand, whose description would otherwise cost prompt tokens on every request of every run.

metadata is an inline map rather than nested YAML because the front-matter parser is hand-rolled: locac ships with no YAML dependency. It handles scalars and key: followed by indented - item lists, and it splits on both LF and CRLF. That last detail was a bug: \r is a line terminator to a JavaScript regex, so a CRLF-authored SKILL.md parsed as having no front matter at all and the skill vanished without a message.

Discovery order

Skills are discovered from the user directory first, then the bundled set. A user skill overrides a bundled one of the same name, and the merged list is sorted by name in byte order, not by locale collation, because the rendered block is a model input and must be identical on every host.

The glob is exactly */SKILL.md, one level deep. It is not recursive, and that is deliberate: a skill's description lands in the system prompt, so anything the glob matches is prompt text. At unbounded depth, a stray SKILL.md inside something you merely cloned or vendored into ~/.locac/skills, such as a node_modules tree or a fixtures directory, became a system-prompt entry nobody chose to install.

The bundled skills

Three ship inside the binary:

SkillWhat it is for
srcwalkSource-to-sink tracing: sink-first, source-first, and meet-in-the-middle workflows over outline / grok_sink / search / read
verifyTurning a candidate flow into a proven finding: reachability, controllability, sanitisation, default-deployment, PoC reproduction
variantAfter a bug clears verify, finding its siblings: the same root cause elsewhere, generalised one element at a time

They are the intended sequence: srcwalk produces candidates, verify kills the false positives, variant turns one confirmed bug into a family.

In a development checkout they are read from the source tree. In a compiled binary each SKILL.md is embedded and materialised on first use under ~/.locac/bundled-skills/, refreshed only when the content differs.

That location is not incidental. It is under LOCAC_HOME rather than the system temp directory because sandboxed target code can write to tmpdir() on POSIX, and a bundled skill's description goes into the system prompt, so a temp-directory extraction would be a persistent prompt-injection channel. It is a sibling of the user skills directory rather than a child, because the dashboard decides whether a skill is editable by testing the user-directory prefix, and a bundled skill must not be editable through the browser.

Managing them

locac skills list
locac skills add ./my-skill            # a SKILL.md file, or a directory containing one
locac skills remove archive-extraction
locac skills edit archive-extraction

The install name comes from the front matter and must be a safe slug: letters, digits, _ and -, starting with a letter or digit. No separators, no dots, no traversal:

error: Unsafe skill name "../evil" — use letters, digits, _ or -.
error: SKILL.md must have `name` and `description` frontmatter.

Installing over an existing skill of the same name replaces it. Full command behaviour is in Other commands.

Installing from a URL

locac skills add https://github.com/anthropics/skills --all        # prints the index, installs nothing
locac skills add https://github.com/anthropics/skills --all --yes  # installs all 18
locac skills add https://github.com/anthropics/skills pdf --yes    # installs one

A github repo, tree or blob URL, a raw.githubusercontent URL, or a bare https .md. The repo is shallow-cloned into a temp directory, .git is dropped, and the skill directories found in it become the menu.

Everything about the path from URL to prompt is adversarial, and it is gated three times.

The URL is parsed offline before anything is fetched. A ref or a subpath is validated component by component against a pattern whose leading class excludes -, so a crafted URL cannot smuggle a git flag into the clone: --upload-pack=… is remote code execution with no shell involved. . and .. are rejected, so a percent-encoded traversal cannot escape the clone.

The host is resolved and re-checked before the connection, by the same guard DAST egress uses - so a skill URL is not a path to the cloud metadata endpoint.

The search for skills inside the clone is depth-bounded to two levels. That is enough for both a flat layout and Anthropic's document-skills/pdf/SKILL.md, and short of sweeping a vendored node_modules into a menu you are about to approve.

Then the operator gate. A description lands verbatim in the system prompt, beside the harness invariants and with no fence around it, so nothing remote installs until you have seen the exact text that will go there. Control characters are stripped rather than escaped on the way to your terminal: an ANSI sequence from an untrusted repo can repaint the very line asking for approval.

Provenance and updates

Each install from a URL writes a .source.json beside the SKILL.md:

{
    "commit": "b29e7cf65e5cb78a5ac33d582270551bc74a14eb",
    "dir": "skills/pdf",
    "kind": "git",
    "ref": null,
    "source": "https://github.com/anthropics/skills"
}

It lives in the skill directory rather than in config.json because the directory is the install: delete a skill by hand and its record goes with it, instead of leaving a stale entry behind. There is no timestamp. The commit is the freshness key, and a clock in a file that gets compared is a determinism bug waiting to happen.

locac skills update pdf
locac skills update --all

update replays the record rather than re-deriving anything from the URL. Same commit means up to date and nothing is written; a changed name or description stops for --yes; a rename removes the old directory, which otherwise leaves two installs with the stale one still loading.

The sidecar is read as remote input, not trusted config. The installer copies a source tree verbatim, so a repo shipping its own .source.json would otherwise choose what a later update cloned from and with which flags. The copier drops any it finds, and the reader independently re-validates every field. Either defence alone would do; neither has to be perfect.

A skill installed from a local path has no record and is skipped by update - it is yours, and locac has nowhere to re-fetch it from.

Roles

A subagent is not a copy of the main agent with a different prompt. It is spawned as a role, and the role is a typed contract:

PartMeaning
systemPromptReplaces the default prompt for the child
toolsThe allow-list of tool names the child may call
taskSchemaValidates the spawn input before the child is created
reportSchemaValidates the child's submit_report payload
modelOptional per-role model. Unset means the child inherits the parent's

Two roles ship built in.

surface-mapper

Read-only reconnaissance. Its tool list is the eleven read-only tools plus scratchpad and memory. No bash, no execute, nothing that writes. It enumerates entry points, sinks and trust boundaries and reports:

{
  "surfaces": [{ "kind": "http-route", "location": "src/api/upload.ts:24", "notes": "multipart" }],
  "summary": ""
}

exploit-verifier

The armed role: the read-only tools plus bash, execute, reproduce, coverage, the DAST tools, record_finding, score_finding, scratchpad and memory. Its instruction is to reproduce a candidate from scratch and try hard to refute it.

Its task accepts a finding_id, a candidate (file / line / vulnClass), or both, not either one exclusively, because cross_verify sends both: the id to vote on, plus the resolved location, which the child has no tool to look up for itself.

It also accepts a lens, which is what makes the quorum three different verifiers rather than three identical ones:

LensInstruction
reproduceBuild the PoC from scratch, run it through reproduce with a claim whose location and finding_id match the candidate, and promote only if the oracle triggered
refuteTry your hardest to break the claim: find the sanitiser or guard that makes it a false positive
boundaryConfirm it fires in the default deployment - no debug flag, no example credentials, no administrator mistake - and independently mint the same bound proof

Its report is a verdict:

{ "verdict": "promoted", "finding_id": "f_…", "artifact_id": "ev_…", "rationale": "" }

The role's own prompt carries the quorum's rules, so a verifier is told them rather than discovering them at the gate. It must cite proof it minted in this round, bound to this finding_id and location; re-citing the candidate's existing artifact, or an artifact about some other path, is not independent evidence. Generic execute output and a plain replay are diagnostics, so a replay counts only with a baseline-differential assertion, and re-verifying a DAST finding means calling run_module with its finding_id so only an exact URL-and-class match binds. And when a PoC runs without crashing, it emits an lcov report and calls coverage on the sink line to tell "not reached" (fix the PoC) apart from "reached, no crash" (refine the payload) - while remembering that coverage never satisfies the evidence gate either.

Defining and overriding roles

The config's roles map can override a built-in's tunable fields, or define a new role entirely:

{
  "roles": {
    "exploit-verifier": {
      "model": "claude-opus-4-8"
    },
    "docs-reader": {
      "description": "Read the vendor's changelog and advisories for the version under test.",
      "systemPrompt": "You read documentation only. Never modify files.",
      "tools": ["read", "search", "find", "ls", "submit_report"]
    }
  }
}

Only description, systemPrompt, tools and model are overridable. Schemas are code: a built-in's task and report shapes cannot be changed from JSON, because they are what the rest of the pipeline relies on.

A role defined here under a new name gets generic schemas instead:

// task
{ "task": "", "context": { } }
// report
{ "status": "done", "summary": "", "findingIds": [""] }

Every role's tool list is validated against the tool universe at load, so a typo fails at startup rather than at spawn time, halfway through a run:

Role "docs-reader" names unknown tool "raed". Known: bash, code_search, coverage, …

Spawning

spawn_subagent(role: "exploit-verifier", input: { finding_id: "f_…", lens: "refute" })
→ Spawned exploit-verifier subagent sub_3. Do other work, then wait_subagent id="sub_3".

The input is validated against the role's task schema before the child exists, so a malformed spawn costs nothing:

Unknown role "verifier". Available: exploit-verifier, surface-mapper.
Invalid input for role "exploit-verifier": provide finding_id, candidate, or both

A child cannot spawn children, because the spawn/wait/steer tools are never in a child's tool set, and submit_report is always added to it, so it can always answer its parent. --tools and --exclude-tools apply to children too; submit_report is the one name neither flag can remove.

The orchestration tools themselves are in the Tool reference.

Next

Dashboard and evaluation covers the browser UI over a session database, and the two eval verbs that measure whether a change to any of the above actually helped.