Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,22 @@ is confined to the skill's own directory — real paths are resolved, so a symli
refused, not followed — and a malformed `SKILL.md` is warned about and skipped rather
than failing the run.

**Skill trust** (`src/skill-trust.ts`) decides whether a skill is offered at all, which
is the control that matters for the real threat: a `SKILL.md` is instructions the model
obeys. An untrusted skill is **neither listed in the prompt nor readable** — the listing
is itself a foothold, since names and descriptions are injected for every skill — so
unvetted text never enters the context window. Trust is _declared by the operator, never
by the skill_ (a malicious `SKILL.md` would simply claim to be trusted), so the record
lives outside it, in `$XDG_CONFIG_HOME/agent/skills.trust.json`, keyed by the skill's
**content hash**: "I reviewed this" is worth nothing if the bytes can change underneath
the review. The operator's own trees are trusted by default — `--skills`, the project's
`./skills`, the user-level shelf; nothing arrives without them putting it there — a
directory they got elsewhere is named with `--skills-untrusted`, and a **blocked**
verdict refuses a skill whatever else says. `--skills-trust <name>` vouches for one by
recording its hash, and `--skills-list` prints the discovered skills with their level,
hash and status. A scan (a separate, out-of-band step) is what turns "unvetted" into a
recorded verdict — the runtime never fetches and never scans.

**Delegation** (`src/delegation.ts`) is a `task` tool, off unless `--task`: a
sub-agent that runs in its own session and returns only its result. Two rules
bound it — the child never auto-approves (delegation must not amplify authority)
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ do nothing at all in the REPL.)
large skill library costs almost nothing until it is used. A malformed `SKILL.md`
is reported and skipped rather than failing the run, and the read tool cannot leave
a skill's own directory — real paths are resolved, so a symlink out is refused.
A skill you got from somewhere else is kept out of the model's reach entirely:
name its directory with `--skills-untrusted` and it is neither listed in the
prompt nor readable, until you vouch for it (`--skills-trust <name>` records its
content hash in `$XDG_CONFIG_HOME/agent/skills.trust.json`) or a scan clears it.
`--skills-list` prints what was found, with each skill's level, hash and trust
status, and needs no MCP server.
- **Delegation.** `--task` exposes a `task` tool: the model can hand a
self-contained job to a **sub-agent** that runs in its own session and returns
only its result — so a broad search or a long review cannot flood the caller's
Expand Down
17 changes: 17 additions & 0 deletions docs/archetypes/skills-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,30 @@ skill; "read a file" is a tool.
own permissions — or the reverse, a procedure that has to run in the caller's
context when it needed isolation.

### Trust and containment

- **Purpose.** Decide whether a skill's instructions may enter the model's context at
all, and bound what reading one can reach.
- **Contract (sketch).** Containment: resolve real paths and refuse anything that is
not a regular file inside the skill's own directory. Trust: declared by the operator
(never by the skill — a hostile `SKILL.md` would simply claim to be trusted), keyed
by the skill's content hash, with a scan verdict able to refuse a skill regardless.
- **Reference.** `opencode`'s discovery service trusts a publisher-declared `version`
string and downloads over HTTPS — the weakest useful model; `llama.cpp` ships skills
as local files and never fetches, so it has no trust model to get wrong. In this host
the untrusted case is a skill _copied_ in — a vendored or submoduled pack — which is
where both a hostile instruction and a symlink arrive.
- **Failure mode prevented.** A hostile procedure entering the context window, and a
read escaping the skill's directory through a link.

## Our coverage

| Component | Status | Evidence |
| --------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
| `SKILL.md` definition + trigger description | ✅ | `src/skills.ts`: frontmatter `name`/`description`, the `description` used as the trigger |
| Progressive disclosure | ✅ | the prompt carries names + descriptions only; the body loads through `skills_read_skill`, bundled files by name |
| Discovery (user / project / remote) | 🟡 | `--skills <dir>` (repeatable), the `./skills` convention, and a user-level default; nothing remote |
| Trust and containment | ✅ | operator-declared and hash-keyed (`src/skill-trust.ts`); an untrusted skill is neither listed nor readable, and the read tool resolves real paths, refusing anything that is not a regular file inside the skill |
| Supply chain (fetch / cache / version) | ❌ | local directories only — nothing is fetched, so nothing needs signing |
| Invocation (context injection vs sub-session) | ✅ | both, explicitly: the body enters the current context via `skills_read_skill`, or the skill runs as an isolated sub-session when a `task` names it (`agent`), where its `tools`/`deny` narrows what that child may use |

Expand Down
17 changes: 9 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,15 @@ ordinary **toolsets**, so using them takes the same path as any MCP tool — app
gate, middleware, execution graph and telemetry all apply unchanged, and none adds
a protocol concept.

| Capability | Module | What it is |
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Skills** | `src/skills.ts` | `<name>/SKILL.md` instructions, found at three levels: `--skills <dir>`, the project's `./skills`, and the user-level `$XDG_CONFIG_HOME/agent/skills` — a project skill wins over a user one. Only names and descriptions enter the system prompt; `skills_read_skill` loads the body on demand and cannot leave the skill's directory. Frontmatter may also declare `tools`/`deny`, making the skill an **agent definition**. |
| **Pause/resume** | `src/runtime.ts` | With `--pause`, a `require_confirmation` call is recorded as a suspension and the run stops with exit `5` instead of denying it; `--resume <id>` continues the saved conversation so the blocked call simply runs. The record is `Suspension` (`@adaptivemcp/spec`), persisted by `memory`. |
| **Delegation** | `src/delegation.ts` | `--task` exposes `task`: a sub-agent in its own session that returns a **result, never its transcript**. It shares the parent's store (same graph and learned state, its own session) and inherits no authority — it never auto-approves and never receives `task` itself. `agent: "<name>"` names a definition whose policy is intersected with the parent's (`narrowTools`), so it can only narrow. |
| **Task list** | `src/tasks.ts` | `--tasks` exposes the shared list: `tasks_create` writes down intended work and the ids it `dependsOn`, `tasks_list` reads it back (or only what is _ready_), and `tasks_update` moves a task on — reporting what it left unmet or unblocked. Transitions are not policed: the record says what was decided, and the store's `readyTasks` is what orders it, treating an unknown dependency id as unfinished so a typo blocks rather than unblocks. |
| **Modes** | `src/modes.ts` | `--mode <name>` names the coordination pattern (`coordinate`/`route`/`broadcast`/`tasks`), implies the toolsets it rests on, and adds its paragraph to the system prompt — so the shape of the work is a choice rather than an emergent accident. `tasks` goes furthest and passes a **stop guard** to the loop. |
| **Typed steps** | `src/steps.ts` | One typed history per session: an `ActionStep` for every attempted call (a gate refusal or a pause is an attempt, not a failure), and — with `--plan-interval <n>`, off by default — a **planning pass** that records a `PlanStep` asked for from the _recorded_ state and injects it for the step that follows. `/steps` renders tasks, actions and plans in order. |
| Capability | Module | What it is |
| ---------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Skills** | `src/skills.ts` | `<name>/SKILL.md` instructions, found at three levels: `--skills <dir>`, the project's `./skills`, and the user-level `$XDG_CONFIG_HOME/agent/skills` — a project skill wins over a user one. Only names and descriptions enter the system prompt; `skills_read_skill` loads the body on demand and cannot leave the skill's directory. Frontmatter may also declare `tools`/`deny`, making the skill an **agent definition**. |
| **Skill trust** | `src/skill-trust.ts` | Trust is declared by the operator, never by the skill, and keyed by the skill's content hash (`$XDG_CONFIG_HOME/agent/skills.trust.json`): an untrusted skill is neither listed in the prompt nor readable, so unvetted instructions never enter the context window. `--skills-untrusted <dir>` marks a directory from elsewhere; `--skills-trust <name>` vouches for one; a blocked scan verdict refuses it whatever else says. |
| **Pause/resume** | `src/runtime.ts` | With `--pause`, a `require_confirmation` call is recorded as a suspension and the run stops with exit `5` instead of denying it; `--resume <id>` continues the saved conversation so the blocked call simply runs. The record is `Suspension` (`@adaptivemcp/spec`), persisted by `memory`. |
| **Delegation** | `src/delegation.ts` | `--task` exposes `task`: a sub-agent in its own session that returns a **result, never its transcript**. It shares the parent's store (same graph and learned state, its own session) and inherits no authority — it never auto-approves and never receives `task` itself. `agent: "<name>"` names a definition whose policy is intersected with the parent's (`narrowTools`), so it can only narrow. |
| **Task list** | `src/tasks.ts` | `--tasks` exposes the shared list: `tasks_create` writes down intended work and the ids it `dependsOn`, `tasks_list` reads it back (or only what is _ready_), and `tasks_update` moves a task on — reporting what it left unmet or unblocked. Transitions are not policed: the record says what was decided, and the store's `readyTasks` is what orders it, treating an unknown dependency id as unfinished so a typo blocks rather than unblocks. |
| **Modes** | `src/modes.ts` | `--mode <name>` names the coordination pattern (`coordinate`/`route`/`broadcast`/`tasks`), implies the toolsets it rests on, and adds its paragraph to the system prompt — so the shape of the work is a choice rather than an emergent accident. `tasks` goes furthest and passes a **stop guard** to the loop. |
| **Typed steps** | `src/steps.ts` | One typed history per session: an `ActionStep` for every attempted call (a gate refusal or a pause is an attempt, not a failure), and — with `--plan-interval <n>`, off by default — a **planning pass** that records a `PlanStep` asked for from the _recorded_ state and injects it for the step that follows. `/steps` renders tasks, actions and plans in order. |

All of these are opt-in, so a host that enables none behaves exactly as it did
before. A host must therefore **forward the loop's options rather than re-list the
Expand Down
10 changes: 10 additions & 0 deletions src/agent-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ describe("loadAgentConfig", () => {
expect(() => loadAgentConfig(write("s3.yaml", "skills: 5"))).toThrow(/skills/);
});

it("accepts skillsUntrusted as a string or a list", () => {
expect(loadAgentConfig(write("u.yaml", 'skillsUntrusted: "vendor"')).skillsUntrusted).toEqual([
"vendor",
]);
expect(
loadAgentConfig(write("u2.yaml", "skillsUntrusted:\n - a\n - b")).skillsUntrusted,
).toEqual(["a", "b"]);
expect(() => loadAgentConfig(write("u3.yaml", "skillsUntrusted: 5"))).toThrow(/skillsUntrusted/);
});

it("treats an empty file as no options", () => {
expect(loadAgentConfig(write("empty.yaml", ""))).toEqual({});
});
Expand Down
11 changes: 11 additions & 0 deletions src/agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ export function loadAgentConfig(path: string): Partial<CliArgs> {
out.skills = value as string[];
else throw new Error(`config "${path}": "skills" must be a string or a list of strings`);
}
if (doc.skillsUntrusted !== undefined) {
const value = doc.skillsUntrusted;
if (typeof value === "string") out.skillsUntrusted = [value];
else if (Array.isArray(value) && value.every((s) => typeof s === "string"))
out.skillsUntrusted = value as string[];
else
throw new Error(
`config "${path}": "skillsUntrusted" must be a string or a list of strings`,
);
}
for (const key of NUMBER_KEYS) {
const value = doc[key];
if (value === undefined) continue;
Expand Down Expand Up @@ -158,6 +168,7 @@ export function loadAgentConfig(path: string): Partial<CliArgs> {
const known = new Set<string>([
"servers",
"skills",
"skillsUntrusted",
"defaultRisk",
"system1Backend",
"mode",
Expand Down
8 changes: 8 additions & 0 deletions src/cli-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ describe("parseArgs coverage of the full flag surface", () => {
"skills",
"--skills",
"/opt/skills",
"--skills-untrusted",
"/vendor/skills",
"--skills-trust",
"deploy",
"--skills-list",
"--compress",
"--report",
"--no-server-policy",
Expand Down Expand Up @@ -148,6 +153,9 @@ describe("parseArgs coverage of the full flag surface", () => {
history: "hist.json",
workflow: "wf1",
skills: ["skills", "/opt/skills"],
skillsUntrusted: ["/vendor/skills"],
skillsTrust: ["deploy"],
skillsList: true,
compress: true,
report: true,
serverPolicy: false,
Expand Down
27 changes: 27 additions & 0 deletions src/cli-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ export interface CliArgs {
* `./skills` is added automatically when it exists.
*/
skills: string[];
/**
* Skill directories whose skills are untrusted: not listed in the prompt and
* not readable until vouched for by name or by a scan verdict.
*/
skillsUntrusted: string[];
/** Skill names to vouch for (records their hash in the trust file), then exit. */
skillsTrust: string[];
/** Print the discovered skills with their trust status, then exit. */
skillsList: boolean;
/** Local fallback risk for tools whose server ships no MCP annotations. */
defaultRisk: "low" | "medium" | "high" | "none";
/** Poll long-running tool jobs (payloads carrying a job handle) to completion. */
Expand Down Expand Up @@ -138,6 +147,9 @@ export function parseTierList(raw: string, source: string): System1Tier[] {
const DEFAULT_ARGS: CliArgs = {
servers: [],
skills: [],
skillsUntrusted: [],
skillsTrust: [],
skillsList: false,
listTools: false,
interactive: false,
defaultRisk: "medium",
Expand Down Expand Up @@ -218,6 +230,15 @@ export function parseFlags(argv: string[]): Partial<CliArgs> {
case "--skills":
(args.skills ??= []).push(next());
break;
case "--skills-untrusted":
(args.skillsUntrusted ??= []).push(next());
break;
case "--skills-trust":
(args.skillsTrust ??= []).push(next());
break;
case "--skills-list":
args.skillsList = true;
break;
case "--default-risk": {
const value = next();
if (value !== "low" && value !== "medium" && value !== "high" && value !== "none") {
Expand Down Expand Up @@ -482,6 +503,12 @@ Options:
./skills and $XDG_CONFIG_HOME/agent/skills are searched
too, a project skill winning over a user one. Only names
and descriptions enter the prompt; bodies load on demand
--skills-untrusted <dir> Treat the skills under <dir> as untrusted: not listed in
the prompt and not readable, until vouched for (repeatable)
--skills-trust <name> Vouch for a skill by recording its content hash in
$XDG_CONFIG_HOME/agent/skills.trust.json, then exit
--skills-list Print the discovered skills, their level, hash and trust
status, then exit (needs no MCP server)
--prompt <text> One-shot prompt instead of the interactive REPL
-i, --interactive Force the interactive REPL
--model <id> Pin one catalog model (disables adaptive selection)
Expand Down
Loading
Loading