@poncho-ai/harness 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +16 -0
- package/dist/index.d.ts +58 -4
- package/dist/index.js +752 -118
- package/package.json +1 -3
- package/src/agent-parser.ts +25 -0
- package/src/config.ts +2 -0
- package/src/harness.ts +245 -9
- package/src/mcp.ts +398 -123
- package/src/skill-context.ts +37 -9
- package/src/skill-tools.ts +72 -2
- package/src/tool-dispatcher.ts +10 -0
- package/src/tool-policy.ts +104 -0
- package/test/harness.test.ts +437 -10
- package/test/mcp.test.ts +350 -55
package/src/skill-context.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, readdir } from "node:fs/promises";
|
|
2
2
|
import { dirname, resolve, normalize } from "node:path";
|
|
3
3
|
import YAML from "yaml";
|
|
4
|
+
import { validateMcpPattern, validateScriptPattern } from "./tool-policy.js";
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Skill directory scanning — default directories and ecosystem compatibility
|
|
@@ -36,8 +37,11 @@ export interface SkillMetadata {
|
|
|
36
37
|
name: string;
|
|
37
38
|
/** What the skill does and when to use it. */
|
|
38
39
|
description: string;
|
|
39
|
-
/** Tool
|
|
40
|
-
tools:
|
|
40
|
+
/** Tool intent declared in frontmatter. */
|
|
41
|
+
tools: {
|
|
42
|
+
mcp: string[];
|
|
43
|
+
scripts: string[];
|
|
44
|
+
};
|
|
41
45
|
/** Absolute path to the skill directory. */
|
|
42
46
|
skillDir: string;
|
|
43
47
|
/** Absolute path to the SKILL.md file. */
|
|
@@ -64,7 +68,7 @@ const asRecord = (value: unknown): Record<string, unknown> =>
|
|
|
64
68
|
|
|
65
69
|
const parseSkillFrontmatter = (
|
|
66
70
|
content: string,
|
|
67
|
-
): { name: string; description: string; tools: string[] } | undefined => {
|
|
71
|
+
): { name: string; description: string; tools: { mcp: string[]; scripts: string[] } } | undefined => {
|
|
68
72
|
const match = content.match(FRONTMATTER_PATTERN);
|
|
69
73
|
if (!match) {
|
|
70
74
|
return undefined;
|
|
@@ -80,6 +84,13 @@ const parseSkillFrontmatter = (
|
|
|
80
84
|
const description =
|
|
81
85
|
typeof parsed.description === "string" ? parsed.description.trim() : "";
|
|
82
86
|
|
|
87
|
+
const toolsValue = asRecord(parsed.tools);
|
|
88
|
+
const modernMcp = Array.isArray(toolsValue.mcp)
|
|
89
|
+
? toolsValue.mcp.filter((tool): tool is string => typeof tool === "string")
|
|
90
|
+
: [];
|
|
91
|
+
const modernScripts = Array.isArray(toolsValue.scripts)
|
|
92
|
+
? toolsValue.scripts.filter((tool): tool is string => typeof tool === "string")
|
|
93
|
+
: [];
|
|
83
94
|
const allowedToolsValue = parsed["allowed-tools"];
|
|
84
95
|
const allowedTools =
|
|
85
96
|
typeof allowedToolsValue === "string"
|
|
@@ -88,15 +99,25 @@ const parseSkillFrontmatter = (
|
|
|
88
99
|
.map((tool) => tool.trim())
|
|
89
100
|
.filter((tool) => tool.length > 0)
|
|
90
101
|
: [];
|
|
91
|
-
|
|
92
102
|
const legacyToolsValue = parsed.tools;
|
|
93
103
|
const legacyTools = Array.isArray(legacyToolsValue)
|
|
94
104
|
? legacyToolsValue.filter((tool): tool is string => typeof tool === "string")
|
|
95
105
|
: [];
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
const mcpTools = modernMcp.length > 0 ? modernMcp : [...allowedTools, ...legacyTools];
|
|
107
|
+
for (const [index, pattern] of mcpTools.entries()) {
|
|
108
|
+
validateMcpPattern(pattern, `SKILL.md tools.mcp[${index}]`);
|
|
109
|
+
}
|
|
110
|
+
for (const [index, pattern] of modernScripts.entries()) {
|
|
111
|
+
validateScriptPattern(pattern, `SKILL.md tools.scripts[${index}]`);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
name,
|
|
115
|
+
description,
|
|
116
|
+
tools: {
|
|
117
|
+
mcp: mcpTools,
|
|
118
|
+
scripts: modernScripts,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
100
121
|
};
|
|
101
122
|
|
|
102
123
|
// ---------------------------------------------------------------------------
|
|
@@ -155,7 +176,14 @@ export const loadSkillMetadata = async (
|
|
|
155
176
|
skillPath: manifest,
|
|
156
177
|
});
|
|
157
178
|
}
|
|
158
|
-
} catch {
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
if (
|
|
182
|
+
message.startsWith("Invalid MCP tool pattern") ||
|
|
183
|
+
message.startsWith("Invalid script pattern")
|
|
184
|
+
) {
|
|
185
|
+
throw new Error(`Invalid SKILL.md frontmatter at ${manifest}: ${message}`);
|
|
186
|
+
}
|
|
159
187
|
// Ignore unreadable skill manifests.
|
|
160
188
|
}
|
|
161
189
|
}
|
package/src/skill-tools.ts
CHANGED
|
@@ -17,6 +17,12 @@ import { loadSkillInstructions, readSkillResource } from "./skill-context.js";
|
|
|
17
17
|
*/
|
|
18
18
|
export const createSkillTools = (
|
|
19
19
|
skills: SkillMetadata[],
|
|
20
|
+
options?: {
|
|
21
|
+
onActivateSkill?: (name: string) => Promise<string[]> | string[];
|
|
22
|
+
onDeactivateSkill?: (name: string) => Promise<string[]> | string[];
|
|
23
|
+
onListActiveSkills?: () => string[];
|
|
24
|
+
isScriptAllowed?: (skill: string, scriptPath: string) => boolean;
|
|
25
|
+
},
|
|
20
26
|
): ToolDefinition[] => {
|
|
21
27
|
if (skills.length === 0) {
|
|
22
28
|
return [];
|
|
@@ -53,8 +59,12 @@ export const createSkillTools = (
|
|
|
53
59
|
}
|
|
54
60
|
try {
|
|
55
61
|
const instructions = await loadSkillInstructions(skill);
|
|
62
|
+
const activeSkills = options?.onActivateSkill
|
|
63
|
+
? await options.onActivateSkill(name)
|
|
64
|
+
: [];
|
|
56
65
|
return {
|
|
57
66
|
skill: name,
|
|
67
|
+
activeSkills,
|
|
58
68
|
instructions: instructions || "(no instructions provided)",
|
|
59
69
|
};
|
|
60
70
|
} catch (err) {
|
|
@@ -64,6 +74,50 @@ export const createSkillTools = (
|
|
|
64
74
|
}
|
|
65
75
|
},
|
|
66
76
|
}),
|
|
77
|
+
defineTool({
|
|
78
|
+
name: "deactivate_skill",
|
|
79
|
+
description:
|
|
80
|
+
"Deactivate a previously activated skill and update scoped tool availability.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: "object",
|
|
83
|
+
properties: {
|
|
84
|
+
name: {
|
|
85
|
+
type: "string",
|
|
86
|
+
description: "Name of the skill to deactivate",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
required: ["name"],
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
},
|
|
92
|
+
handler: async (input) => {
|
|
93
|
+
const name = typeof input.name === "string" ? input.name.trim() : "";
|
|
94
|
+
if (!name) {
|
|
95
|
+
return { error: "Skill name is required" };
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const activeSkills = options?.onDeactivateSkill
|
|
99
|
+
? await options.onDeactivateSkill(name)
|
|
100
|
+
: [];
|
|
101
|
+
return { skill: name, activeSkills };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
return {
|
|
104
|
+
error: `Failed to deactivate skill "${name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
defineTool({
|
|
110
|
+
name: "list_active_skills",
|
|
111
|
+
description: "List currently active skills with scoped MCP tools.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {},
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
},
|
|
117
|
+
handler: async () => ({
|
|
118
|
+
activeSkills: options?.onListActiveSkills ? options.onListActiveSkills() : [],
|
|
119
|
+
}),
|
|
120
|
+
}),
|
|
67
121
|
defineTool({
|
|
68
122
|
name: "read_skill_resource",
|
|
69
123
|
description:
|
|
@@ -133,7 +187,7 @@ export const createSkillTools = (
|
|
|
133
187
|
};
|
|
134
188
|
}
|
|
135
189
|
try {
|
|
136
|
-
const scripts = await listSkillScripts(skill);
|
|
190
|
+
const scripts = await listSkillScripts(skill, options?.isScriptAllowed);
|
|
137
191
|
return {
|
|
138
192
|
skill: name,
|
|
139
193
|
scripts,
|
|
@@ -191,6 +245,18 @@ export const createSkillTools = (
|
|
|
191
245
|
|
|
192
246
|
try {
|
|
193
247
|
const scriptPath = resolveSkillScriptPath(skill, script);
|
|
248
|
+
const relativeScript = `scripts/${scriptPath
|
|
249
|
+
.slice(resolve(skill.skillDir, "scripts").length + 1)
|
|
250
|
+
.split(sep)
|
|
251
|
+
.join("/")}`;
|
|
252
|
+
if (
|
|
253
|
+
options?.isScriptAllowed &&
|
|
254
|
+
!options.isScriptAllowed(name, relativeScript)
|
|
255
|
+
) {
|
|
256
|
+
return {
|
|
257
|
+
error: `Script "${relativeScript}" for skill "${name}" is not allowed by policy.`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
194
260
|
await access(scriptPath);
|
|
195
261
|
const fn = await loadRunnableScriptFunction(scriptPath);
|
|
196
262
|
const output = await fn(payload, {
|
|
@@ -215,7 +281,10 @@ export const createSkillTools = (
|
|
|
215
281
|
|
|
216
282
|
const SCRIPT_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts"]);
|
|
217
283
|
|
|
218
|
-
const listSkillScripts = async (
|
|
284
|
+
const listSkillScripts = async (
|
|
285
|
+
skill: SkillMetadata,
|
|
286
|
+
isScriptAllowed?: (skill: string, scriptPath: string) => boolean,
|
|
287
|
+
): Promise<string[]> => {
|
|
219
288
|
const scriptsRoot = resolve(skill.skillDir, "scripts");
|
|
220
289
|
try {
|
|
221
290
|
await access(scriptsRoot);
|
|
@@ -226,6 +295,7 @@ const listSkillScripts = async (skill: SkillMetadata): Promise<string[]> => {
|
|
|
226
295
|
const scripts = await collectScriptFiles(scriptsRoot);
|
|
227
296
|
return scripts
|
|
228
297
|
.map((fullPath) => `scripts/${fullPath.slice(scriptsRoot.length + 1).split(sep).join("/")}`)
|
|
298
|
+
.filter((path) => (isScriptAllowed ? isScriptAllowed(skill.name, path) : true))
|
|
229
299
|
.sort();
|
|
230
300
|
};
|
|
231
301
|
|
package/src/tool-dispatcher.ts
CHANGED
|
@@ -26,6 +26,16 @@ export class ToolDispatcher {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
unregister(name: string): void {
|
|
30
|
+
this.tools.delete(name);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
unregisterMany(names: Iterable<string>): void {
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
this.unregister(name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
list(): ToolDefinition[] {
|
|
30
40
|
return [...this.tools.values()];
|
|
31
41
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type RuntimeEnvironment = "development" | "staging" | "production";
|
|
2
|
+
|
|
3
|
+
export type ToolPolicyMode = "all" | "allowlist" | "denylist";
|
|
4
|
+
|
|
5
|
+
export interface ToolPatternPolicy {
|
|
6
|
+
mode?: ToolPolicyMode;
|
|
7
|
+
include?: string[];
|
|
8
|
+
exclude?: string[];
|
|
9
|
+
byEnvironment?: {
|
|
10
|
+
development?: Omit<ToolPatternPolicy, "byEnvironment">;
|
|
11
|
+
staging?: Omit<ToolPatternPolicy, "byEnvironment">;
|
|
12
|
+
production?: Omit<ToolPatternPolicy, "byEnvironment">;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MCP_PATTERN = /^[^/*\s]+\/(\*|[^/*\s]+)$/;
|
|
17
|
+
const SCRIPT_PATTERN = /^[^/*\s]+\/(\*|[^*\s]+)$/;
|
|
18
|
+
|
|
19
|
+
export const validateMcpPattern = (pattern: string, path: string): void => {
|
|
20
|
+
if (!MCP_PATTERN.test(pattern)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Invalid MCP tool pattern at ${path}: "${pattern}". Expected "server/tool" or "server/*".`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const validateScriptPattern = (pattern: string, path: string): void => {
|
|
28
|
+
if (!SCRIPT_PATTERN.test(pattern)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Invalid script pattern at ${path}: "${pattern}". Expected "skill/script-path" or "skill/*".`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const splitPattern = (pattern: string): [string, string] => {
|
|
36
|
+
const slash = pattern.indexOf("/");
|
|
37
|
+
if (slash < 0) {
|
|
38
|
+
return [pattern, ""];
|
|
39
|
+
}
|
|
40
|
+
return [pattern.slice(0, slash), pattern.slice(slash + 1)];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const matchesSlashPattern = (value: string, pattern: string): boolean => {
|
|
44
|
+
const [targetScope, targetName] = splitPattern(value);
|
|
45
|
+
const [patternScope, patternName] = splitPattern(pattern);
|
|
46
|
+
if (targetScope !== patternScope) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (patternName === "*") {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return targetName === patternName;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const mergePolicyForEnvironment = (
|
|
56
|
+
policy: ToolPatternPolicy | undefined,
|
|
57
|
+
environment: RuntimeEnvironment,
|
|
58
|
+
): Omit<ToolPatternPolicy, "byEnvironment"> => {
|
|
59
|
+
const base: Omit<ToolPatternPolicy, "byEnvironment"> = {
|
|
60
|
+
mode: policy?.mode,
|
|
61
|
+
include: [...(policy?.include ?? [])],
|
|
62
|
+
exclude: [...(policy?.exclude ?? [])],
|
|
63
|
+
};
|
|
64
|
+
const env = policy?.byEnvironment?.[environment];
|
|
65
|
+
if (!env) {
|
|
66
|
+
return base;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
mode: env.mode ?? base.mode,
|
|
70
|
+
include: env.include ? [...env.include] : base.include,
|
|
71
|
+
exclude: env.exclude ? [...env.exclude] : base.exclude,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const applyToolPolicy = (
|
|
76
|
+
values: string[],
|
|
77
|
+
policy: Omit<ToolPatternPolicy, "byEnvironment"> | undefined,
|
|
78
|
+
): { allowed: string[]; filteredOut: string[] } => {
|
|
79
|
+
const mode = policy?.mode ?? "all";
|
|
80
|
+
const include = policy?.include ?? [];
|
|
81
|
+
const exclude = policy?.exclude ?? [];
|
|
82
|
+
const allowed: string[] = [];
|
|
83
|
+
const filteredOut: string[] = [];
|
|
84
|
+
|
|
85
|
+
for (const value of values) {
|
|
86
|
+
const inInclude = include.some((pattern) => matchesSlashPattern(value, pattern));
|
|
87
|
+
const inExclude = exclude.some((pattern) => matchesSlashPattern(value, pattern));
|
|
88
|
+
let keep = true;
|
|
89
|
+
if (mode === "allowlist") {
|
|
90
|
+
keep = inInclude;
|
|
91
|
+
} else if (mode === "denylist") {
|
|
92
|
+
keep = !inExclude;
|
|
93
|
+
}
|
|
94
|
+
if (mode === "all" && exclude.length > 0) {
|
|
95
|
+
keep = !inExclude;
|
|
96
|
+
}
|
|
97
|
+
if (keep) {
|
|
98
|
+
allowed.push(value);
|
|
99
|
+
} else {
|
|
100
|
+
filteredOut.push(value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { allowed, filteredOut };
|
|
104
|
+
};
|