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
| Field | Effect |
|---|---|
disable-model-invocation | true removes the skill from <available_skills>. It stays invokable as /skill:<name> in the TUI |
allowed-tools | Parsed and stored. Not yet enforced, experimental |
version | Free-form string, carried through |
license | Free-form string, carried through |
metadata | An 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:
| Skill | What it is for |
|---|---|
srcwalk | Source-to-sink tracing: sink-first, source-first, and meet-in-the-middle workflows over outline / grok_sink / search / read |
verify | Turning a candidate flow into a proven finding: reachability, controllability, sanitisation, default-deployment, PoC reproduction |
variant | After 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:
| Part | Meaning |
|---|---|
systemPrompt | Replaces the default prompt for the child |
tools | The allow-list of tool names the child may call |
taskSchema | Validates the spawn input before the child is created |
reportSchema | Validates the child's submit_report payload |
model | Optional 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:
| Lens | Instruction |
|---|---|
reproduce | Build 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 |
refute | Try your hardest to break the claim: find the sanitiser or guard that makes it a false positive |
boundary | Confirm 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.