Skip to content

Commit 7944070

Browse files
committed
fix: address PR #139 review feedback (18 issues)
Security & correctness - skill-writer: cap on UTF-8 byte length (not String.length) so a multi-byte payload can't bypass the 64 KB limit - skill-writer: reject reserved /skills subcommand names (info, edit, delete, list) to prevent shadowing the CLI surface - skill-writer: reject description/triggers containing newlines or a bare '---' line so they can't break out of YAML frontmatter - slash-commands /skills info|edit|delete: validate <name> via validateSkillName before any filesystem join — closes the path traversal vector pointed out by the reviewer UX correctness - index.ts generate_skill: surface an 'Overwrite existing user skill?' confirmation when overwrite=true and the file already exists - slash-commands /save-skill: pass skipAutoSuggest=true so the synthetic prompt's scaffolding terms don't trigger unrelated skills via runSuggestApproach - slash-commands /new: also reset currentUserPrompt + lastGuidance - slash-commands /resume: reset toolCallHistory, mcpServersUsed, modulesRegistered, currentUserPrompt, lastGuidance — local session-learning state can't be reconstructed from a resumed remote session - slash-commands /save-skill: fix 'distinct tools' status line to count the full tool history, not the bounded topTools view - session-context: truncate currentUserPrompt to 2000 chars with an ellipsis so a giant paste can't dominate the prompt MCP session-learning correctness - mcp/plugin-adapter: add optional onCall observer; agent wires it to state.mcpServersUsed so calls made from inside execute_javascript via host:mcp-<name> imports are now tracked - state.ts: add skipNextAutoSuggest flag (consumed in onUserPromptSubmitted) Documentation - docs/TESTING-USER-SKILLS.md: drop branch-name reference, switch override example from non-existent 'code-review' to bundled 'kql-expert', clarify '/skills edit' prints a path (no $EDITOR), describe the now-correct overwrite confirmation flow, note that the override badge surfaces in '/skills' list view, fix approval prompt wording (summary, not full content) Tests - Reserved-name rejection - YAML-unsafe newline rejection (description + trigger) - UTF-8 byte-length cap (32 KB of 4-byte chars) - User-prompt truncation contract Quality gate: 2448 TS tests pass (+5), 124 Rust tests pass. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent 9ed18e1 commit 7944070

9 files changed

Lines changed: 292 additions & 37 deletions

File tree

docs/TESTING-USER-SKILLS.md

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,11 @@ feature lets a user persist what HyperAgent learned in a session as a
55
reusable skill at `~/.hyperagent/skills/<name>/SKILL.md`, surviving
66
upgrades and overriding system skills with the same name.
77

8-
Implemented on branch `feat-user-skills` (atop fix
9-
`fix-marked-v15-renderer` for the markdown renderer crash).
10-
118
---
129

1310
## Prerequisites
1411

15-
- A working HyperAgent checkout on the `feat-user-skills` branch
12+
- A working HyperAgent checkout
1613
- `just setup` already run (Rust addons built, deps installed) — see the
1714
project [README](../README.md) and [DEVELOPMENT.md](DEVELOPMENT.md)
1815
- A terminal where `just start` launches the agent successfully
@@ -59,8 +56,10 @@ Let it run to completion. Then ask the agent to save what it learned:
5956
1. The agent receives a synthetic prompt summarising the session
6057
context (tools used, MCP servers, modules registered, recent errors)
6158
2. The LLM calls the `generate_skill(...)` tool
62-
3. You see an interactive approval prompt with a preview of the
63-
`SKILL.md` content
59+
3. You see an interactive approval prompt showing a **summary** — the
60+
skill name, the one-line description, a preview of the first few
61+
triggers, the allowed-tools list, and a byte count for the guidance
62+
body. (The full content is *not* echoed to stdout.)
6463
4. Hit `y` to approve
6564

6665
Verify the file landed on disk:
@@ -82,14 +81,19 @@ Exercise every command path. From a fresh `just start`:
8281

8382
```text
8483
> /skills # list both system + user skills
85-
> /skills info code-review # show full detail for a system skill
84+
> /skills info kql-expert # show full detail for a bundled system skill
8685
> /save-skill # no name → LLM picks one
8786
> /skills # user skill now shows with 👤
8887
> /skills info fetch-page-title # user skill detail
89-
> /skills edit fetch-page-title # opens $EDITOR for hand-tuning
88+
> /skills edit fetch-page-title # prints the user-skill path; open it in your editor
9089
> exit
9190
```
9291

92+
> `/skills edit <name>` does **not** spawn `$EDITOR`. It just prints
93+
> the absolute path to the user-skill `SKILL.md` so you can open it
94+
> in your own editor of choice. Save the file, then restart (or run
95+
> `/suggest_approach`) and the change takes effect.
96+
9397
Then restart the agent and repeat the original task — the matching
9498
`/suggest_approach` should surface the saved skill via its triggers.
9599

@@ -98,15 +102,17 @@ Then restart the agent and repeat the original task — the matching
98102
## 3. Override Test
99103

100104
User skills must override system skills with the same name. Drop a user
101-
skill that shadows an existing system one:
105+
skill that shadows an existing system one (pick any skill that `ls
106+
skills/` shows — here we use `kql-expert`):
102107

103108
```bash
104-
mkdir -p "$HYPERAGENT_USER_SKILLS_DIR/code-review"
105-
cat > "$HYPERAGENT_USER_SKILLS_DIR/code-review/SKILL.md" << 'EOF'
109+
mkdir -p "$HYPERAGENT_USER_SKILLS_DIR/kql-expert"
110+
cat > "$HYPERAGENT_USER_SKILLS_DIR/kql-expert/SKILL.md" << 'EOF'
106111
---
107-
name: code-review
108-
description: My customised code review skill
109-
triggers: [review, audit]
112+
name: kql-expert
113+
description: My customised KQL skill
114+
triggers: [kql, kusto, query]
115+
allowed-tools: [execute_javascript]
110116
---
111117
This overrides the system version.
112118
EOF
@@ -117,11 +123,12 @@ just start
117123
In the REPL:
118124

119125
```text
120-
> /skills info code-review
126+
> /skills
121127
```
122128

123-
**Expected:** the **user** description ("My customised code review
124-
skill") appears, and an **override** flag/note is present.
129+
**Expected:** the `kql-expert` row appears with the **`👤 (overrides
130+
built-in)`** badge in the list view. Running `/skills info kql-expert`
131+
then shows the **user** description ("My customised KQL skill").
125132

126133
---
127134

@@ -134,7 +141,8 @@ Validation should reject bad input cleanly without crashing the agent:
134141
| `/save-skill BadName` | Rejected — not kebab-case |
135142
| `/save-skill ../escape` | Rejected — path traversal |
136143
| `/save-skill thisnameisreallylongandshouldfailitsbeyondsixtyfourcharactersnowforsure` | Rejected — exceeds 64 chars |
137-
| `/save-skill fetch-page-title` (second time) | Overwrite confirmation prompt |
144+
| `/save-skill info` | Rejected — reserved subcommand name |
145+
| `/save-skill fetch-page-title` (second time, fresh session) | `generate_skill` first errors with "already exists — set overwrite=true"; the LLM retries with `overwrite=true`, and you get an **"Overwrite existing user skill?"** confirmation before the file is replaced |
138146

139147
---
140148

@@ -155,7 +163,7 @@ unset HYPERAGENT_USER_SKILLS_DIR
155163
| Approval prompt shows a skill preview | Tool handler validation working ✅ |
156164
| `.md` file lands on disk under `$HYPERAGENT_USER_SKILLS_DIR` | `writeUserSkill()` working ✅ |
157165
| `/skills` shows the 👤 badge for the new skill | Multi-dir loader + `source` field working ✅ |
158-
| `/skills info <name>` shows the override flag for shadowed system skills | Name-collision detection working ✅ |
166+
| `/skills` shows `👤 (overrides built-in)` for shadowed system skills | Name-collision detection working ✅ |
159167
| Restarting the agent matches the skill on similar prompts | `loadSkillsFromDirs` + boot wiring working ✅ |
160168

161169
---

src/agent/index.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,14 @@ async function syncPluginsToSandbox(): Promise<void> {
10161016
conn,
10171017
mcpManager,
10181018
mcpWriteSafetyGate,
1019+
// Session-learning: record any MCP server the LLM
1020+
// actually exercised — including calls made from inside
1021+
// `execute_javascript` via `host:mcp-<name>` imports
1022+
// (which never surface as a top-level `mcp__*` tool name
1023+
// and so are invisible to onPostToolUse).
1024+
(serverName: string) => {
1025+
state.mcpServersUsed.add(serverName);
1026+
},
10191027
);
10201028
registrations.push(adapter);
10211029
}
@@ -1131,8 +1139,11 @@ async function handleSlashCommand(
11311139
drainAndWarn,
11321140
mcpManager, // Real MCP manager (or null if no config)
11331141
syncPlugins: syncPluginsToSandbox,
1134-
submitToLLM: (prompt: string) => {
1142+
submitToLLM: (prompt: string, options?: { skipAutoSuggest?: boolean }) => {
11351143
state.pendingPrompt = prompt;
1144+
if (options?.skipAutoSuggest) {
1145+
state.skipNextAutoSuggest = true;
1146+
}
11361147
},
11371148
};
11381149
return handleSlashCommandImpl(rawInput, rl, slashDeps);
@@ -5574,7 +5585,18 @@ const generateSkillTool = defineTool("generate_skill", {
55745585
params.triggers.length > 5
55755586
? ` (+${params.triggers.length - 5} more)`
55765587
: "";
5577-
console.log(`\n ${C.warn("📚 Save skill:")} ${C.tool(params.name)}`);
5588+
// Surface the overwrite path explicitly — the LLM passing
5589+
// `overwrite=true` is necessary but not sufficient. The user
5590+
// gets a chance to refuse before we replace existing content.
5591+
const isOverwrite =
5592+
params.overwrite === true && userSkillExists(params.name);
5593+
if (isOverwrite) {
5594+
console.log(
5595+
`\n ${C.warn("⚠️ Overwrite existing user skill:")} ${C.tool(params.name)}`,
5596+
);
5597+
} else {
5598+
console.log(`\n ${C.warn("📚 Save skill:")} ${C.tool(params.name)}`);
5599+
}
55785600
console.log(` ${params.description}`);
55795601
console.log(` Triggers: ${triggerPreview}${triggerSuffix}`);
55805602
console.log(
@@ -5589,9 +5611,12 @@ const generateSkillTool = defineTool("generate_skill", {
55895611
}
55905612

55915613
await drainAndWarn(rl);
5614+
const promptLabel = isOverwrite
5615+
? ` ${C.dim("Overwrite skill? [y/n] ")}`
5616+
: ` ${C.dim("Save skill? [y/n] ")}`;
55925617
const approval = state.autoApprove
55935618
? "y"
5594-
: await promptUser(rl, ` ${C.dim("Save skill? [y/n] ")}`);
5619+
: await promptUser(rl, promptLabel);
55955620
if (approval.trim().toLowerCase() !== "y") {
55965621
console.log(` ${C.dim("Denied by user.")}`);
55975622
return { success: false, error: "Skill save denied by user" };
@@ -6013,9 +6038,16 @@ function buildSessionConfig() {
60136038
state.currentUserPrompt = input.prompt;
60146039
state.hasCalledListModules = false;
60156040

6041+
// Slash commands that queue a synthetic prompt (e.g. /save-skill)
6042+
// set state.skipNextAutoSuggest so the auto-suggest pass doesn't
6043+
// match unrelated skills on scaffolding terms like "MCP" or
6044+
// "SKILL.md". Consume the flag before the suggest pass below.
6045+
const skipAutoSuggest = state.skipNextAutoSuggest;
6046+
state.skipNextAutoSuggest = false;
6047+
60166048
// Auto-invoke suggest_approach for non-trivial prompts
60176049
const isNonTrivial = input.prompt.length > 25;
6018-
if (isNonTrivial) {
6050+
if (isNonTrivial && !skipAutoSuggest) {
60196051
const result = runSuggestApproach(
60206052
input.prompt,
60216053
state.preLoadedSkills,
@@ -6144,9 +6176,11 @@ function buildSessionConfig() {
61446176
state.toolCallHistory.length - MAX_TOOL_HISTORY,
61456177
);
61466178
}
6147-
// Tools whose name looks like `mcp__<server>__<tool>` count
6148-
// their server as "used" — that's how the SDK exposes MCP
6149-
// tools to the LLM. See manage_mcp + listMCPServersTool.
6179+
// Top-level MCP tool fallback: when an SDK ever surfaces a
6180+
// tool as `mcp__<server>__<tool>` we still count the server.
6181+
// The primary tracking path is the MCP plugin-adapter's onCall
6182+
// observer (wired in syncPluginsToSandbox above) which catches
6183+
// calls made via `host:mcp-<name>` imports too.
61506184
if (success && toolName.startsWith("mcp__")) {
61516185
const server = toolName.split("__")[1];
61526186
if (server) state.mcpServersUsed.add(server);

src/agent/mcp/plugin-adapter.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ export type WriteSafetyGate = (
3333
annotations: MCPToolAnnotations | undefined,
3434
) => Promise<boolean>;
3535

36+
/**
37+
* Callback invoked at the start of every MCP tool call routed through
38+
* the adapter. Used by the agent to track which MCP servers were
39+
* actually exercised in this session (independent of whether the LLM
40+
* called the tool via a top-level `mcp__*` name or imported it as a
41+
* `host:mcp-<name>` module from inside `execute_javascript`).
42+
*
43+
* Fires after the write-safety gate (if any) has approved, but before
44+
* the call is dispatched to the manager. Synchronous on purpose so
45+
* tracking cannot delay the call.
46+
*
47+
* @param serverName - MCP server name (e.g. "work-iq-mail")
48+
* @param toolName - Tool name being invoked
49+
*/
50+
export type MCPCallObserver = (serverName: string, toolName: string) => void;
51+
3652
/**
3753
* PluginRegistration-compatible interface.
3854
* Matches the shape expected by src/sandbox/tool.js setPlugins().
@@ -55,12 +71,16 @@ export interface MCPPluginRegistration {
5571
* @param manager - The client manager for making tool calls.
5672
* @param gate - Optional write-safety gate. When provided, non-read-only
5773
* tools are checked before execution.
74+
* @param onCall - Optional observer fired on every successful gate-pass
75+
* immediately before the tool is dispatched. Used by the agent for
76+
* session-learning tracking (see state.mcpServersUsed).
5877
* @returns A PluginRegistration that can be passed to setPlugins().
5978
*/
6079
export function createMCPPluginAdapter(
6180
conn: MCPConnection,
6281
manager: MCPClientManager,
6382
gate?: WriteSafetyGate,
83+
onCall?: MCPCallObserver,
6484
): MCPPluginRegistration {
6585
const moduleName = `mcp-${conn.name}`;
6686

@@ -93,6 +113,11 @@ export function createMCPPluginAdapter(
93113
}
94114
}
95115

116+
// Notify any observer that we are about to dispatch. The
117+
// observer is intentionally invoked AFTER the gate so denied
118+
// calls do not pollute session-learning state.
119+
onCall?.(conn.name, tool.name);
120+
96121
return manager.callTool(conn.name, tool.name, toolArgs);
97122
};
98123
}

src/agent/session-context.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ const MAX_ERRORS_REPORTED = 8;
3030
*/
3131
const MAX_TOP_TOOLS = 10;
3232

33+
/**
34+
* Maximum characters of the user's most-recent prompt kept in the
35+
* session-context summary. Anything longer is truncated with an
36+
* ellipsis — the LLM only needs the gist of the original task to
37+
* anchor the SKILL.md it writes, and a 50-KB paste here would
38+
* dominate the prompt and crowd out the actual session history.
39+
*/
40+
const MAX_USER_PROMPT_CHARS = 2000;
41+
3342
// ── Types ────────────────────────────────────────────────────────────
3443

3544
/**
@@ -95,8 +104,16 @@ export function extractSessionContext(state: AgentState): SessionContext {
95104
.sort((a, b) => b.count - a.count)
96105
.slice(0, MAX_TOP_TOOLS);
97106

107+
// Truncate the user prompt so a giant paste doesn't dominate the
108+
// session-context summary. We keep the head — the leading phrase
109+
// is the strongest signal of intent.
110+
const userPrompt =
111+
state.currentUserPrompt.length > MAX_USER_PROMPT_CHARS
112+
? state.currentUserPrompt.slice(0, MAX_USER_PROMPT_CHARS) + "…"
113+
: state.currentUserPrompt;
114+
98115
return {
99-
userPrompt: state.currentUserPrompt,
116+
userPrompt,
100117
topTools,
101118
mcpServersUsed: Array.from(state.mcpServersUsed).sort(),
102119
modulesRegistered: [...state.modulesRegistered].sort(),

src/agent/skill-writer.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ import { ALLOWED_TOOLS } from "./tool-gating.js";
2929
/**
3030
* Root directory for user-created skills.
3131
*
32-
* Defaults to `~/.hyperagent/skills/`. The `HYPERAGENT_USER_SKILLS_DIR`
33-
* environment variable overrides the default — tests use this to point
34-
* at a temporary directory without polluting the real user library.
32+
* Resolved at MODULE LOAD TIME. Tests that need to redirect the path
33+
* to a tmpdir must set `HYPERAGENT_USER_SKILLS_DIR` and then re-import
34+
* this module via `vi.resetModules()` + dynamic `import()` — see
35+
* `tests/skill-writer.test.ts` for the pattern. Setting the env var
36+
* AFTER the first import has no effect.
3537
*/
3638
const DEFAULT_USER_SKILLS_DIR =
3739
process.env.HYPERAGENT_USER_SKILLS_DIR ??
@@ -49,6 +51,27 @@ const MAX_TRIGGERS = 50;
4951
/** Kebab-case name pattern (lowercase letters, digits, hyphens). */
5052
const VALID_NAME_RE = /^[a-z][a-z0-9-]*$/;
5153

54+
/**
55+
* Names that double as `/skills` subcommands — accepting them as skill
56+
* names would let `/skills <name>` shadow `/skills info|edit|delete|list`
57+
* and create confusing CLI behaviour.
58+
*/
59+
const RESERVED_SKILL_NAMES: ReadonlySet<string> = new Set([
60+
"info",
61+
"edit",
62+
"delete",
63+
"list",
64+
]);
65+
66+
/**
67+
* Pattern matching YAML-frontmatter characters that would break a
68+
* single-line `key: value` representation — newlines split fields and
69+
* a literal `---` terminates the frontmatter block. Used by
70+
* `validateSkillData` to reject payloads that would otherwise need
71+
* heavy YAML escaping in `renderSkillMarkdown`.
72+
*/
73+
const YAML_UNSAFE_RE = /[\r\n]|^---$/;
74+
5275
// ── Types ────────────────────────────────────────────────────────────
5376

5477
/** Input data for a new skill, mirroring SKILL.md frontmatter fields. */
@@ -101,7 +124,8 @@ export function getUserSkillsDir(): string {
101124
* Validate a skill name. Returns an error message string, or null if valid.
102125
*
103126
* Rules: kebab-case (lowercase letters, digits, hyphens; must start with a
104-
* letter), ≤64 characters, no path traversal characters.
127+
* letter), ≤64 characters, no path traversal characters, and not one of
128+
* the reserved `/skills` subcommand names.
105129
*/
106130
export function validateSkillName(name: string): string | null {
107131
if (!name) return "Skill name must not be empty";
@@ -112,6 +136,9 @@ export function validateSkillName(name: string): string | null {
112136
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
113137
return "Skill name must not contain path traversal characters";
114138
}
139+
if (RESERVED_SKILL_NAMES.has(name)) {
140+
return `Skill name '${name}' is reserved (collides with a /skills subcommand)`;
141+
}
115142
return null;
116143
}
117144

@@ -141,6 +168,10 @@ export function validateSkillData(
141168
errors.push(
142169
`Skill description must be ≤${MAX_DESCRIPTION_LENGTH} characters`,
143170
);
171+
} else if (YAML_UNSAFE_RE.test(data.description)) {
172+
errors.push(
173+
"Skill description must be a single line (no newlines or '---')",
174+
);
144175
}
145176

146177
if (!Array.isArray(data.triggers) || data.triggers.length === 0) {
@@ -153,6 +184,12 @@ export function validateSkillData(
153184
errors.push("Triggers must be non-empty strings");
154185
break;
155186
}
187+
if (YAML_UNSAFE_RE.test(t)) {
188+
errors.push(
189+
"Triggers must be single-line strings (no newlines or '---')",
190+
);
191+
break;
192+
}
156193
}
157194
}
158195

@@ -252,9 +289,12 @@ export function writeUserSkill(data: SkillData, patternsDir: string): string {
252289
}
253290

254291
const rendered = renderSkillMarkdown(data);
255-
if (rendered.length > MAX_SKILL_SIZE_BYTES) {
292+
// Size cap is on disk-bytes (UTF-8) — `.length` would under-count for
293+
// multi-byte characters and let a payload sneak past the limit.
294+
const renderedBytes = Buffer.byteLength(rendered, "utf-8");
295+
if (renderedBytes > MAX_SKILL_SIZE_BYTES) {
256296
throw new Error(
257-
`SKILL.md exceeds maximum size (${rendered.length} bytes > ${MAX_SKILL_SIZE_BYTES} bytes)`,
297+
`SKILL.md exceeds maximum size (${renderedBytes} bytes > ${MAX_SKILL_SIZE_BYTES} bytes)`,
258298
);
259299
}
260300

0 commit comments

Comments
 (0)