@graphorin/skills 0.6.1 → 0.7.0
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/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +14 -13
- package/src/activation/index.ts +76 -0
- package/src/errors/index.ts +162 -0
- package/src/frontmatter/index.ts +646 -0
- package/src/index.ts +49 -0
- package/src/loader/index.ts +813 -0
- package/src/migration/index.ts +192 -0
- package/src/registry/bridge.ts +169 -0
- package/src/registry/index.ts +414 -0
- package/src/spec/index.ts +158 -0
- package/src/types/index.ts +338 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `migrate-frontmatter` - idempotent rewrite helper that migrates
|
|
3
|
+
* legacy `graphorin-*` frontmatter fields onto their upstream
|
|
4
|
+
* equivalents per the `deprecate-graphorin-prefix` mappings recorded
|
|
5
|
+
* in the bundled spec snapshot.
|
|
6
|
+
*
|
|
7
|
+
* The function is dry-run by default - callers must opt in to
|
|
8
|
+
* persisting the rewritten bytes. The CLI binary in Phase 15 wraps
|
|
9
|
+
* this surface; the library is exposed here so other tooling can
|
|
10
|
+
* reuse it.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
16
|
+
|
|
17
|
+
import { SkillManifestParseError } from '../errors/index.js';
|
|
18
|
+
import { splitSkillMd } from '../frontmatter/index.js';
|
|
19
|
+
import { getSpecSnapshot } from '../spec/index.js';
|
|
20
|
+
|
|
21
|
+
/** A single rewrite the migrator applied (or would apply in a dry-run). */
|
|
22
|
+
export interface MigrationRewrite {
|
|
23
|
+
readonly fromField: string;
|
|
24
|
+
readonly toField: string;
|
|
25
|
+
readonly reason: 'deprecate-graphorin-prefix' | 'co-exist-noop' | 'graphorin-only-noop';
|
|
26
|
+
readonly applied: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Result of a single SKILL.md migration. */
|
|
30
|
+
export interface MigrationResult {
|
|
31
|
+
readonly skillId: string;
|
|
32
|
+
readonly rewrites: ReadonlyArray<MigrationRewrite>;
|
|
33
|
+
readonly originalSkillMd: string;
|
|
34
|
+
readonly migratedSkillMd: string;
|
|
35
|
+
readonly changed: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Options accepted by {@link migrateFrontmatter}. */
|
|
39
|
+
export interface MigrateFrontmatterOptions {
|
|
40
|
+
/** Identifier used in audit / error messages. Defaults to `'<inline>'`. */
|
|
41
|
+
readonly skillId?: string;
|
|
42
|
+
/**
|
|
43
|
+
* When `true`, rewrites are applied to the returned `migratedSkillMd`.
|
|
44
|
+
* When `false` (default), `migratedSkillMd === originalSkillMd` and
|
|
45
|
+
* the function operates as a dry-run report.
|
|
46
|
+
*/
|
|
47
|
+
readonly apply?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Migrate the bundled `deprecate-graphorin-prefix` mappings on a
|
|
52
|
+
* single SKILL.md. The function is idempotent: re-running it on an
|
|
53
|
+
* already-migrated SKILL.md returns `changed: false` and an empty
|
|
54
|
+
* `rewrites` array.
|
|
55
|
+
*
|
|
56
|
+
* @stable
|
|
57
|
+
*/
|
|
58
|
+
export function migrateFrontmatter(
|
|
59
|
+
skillMd: string,
|
|
60
|
+
options: MigrateFrontmatterOptions = {},
|
|
61
|
+
): MigrationResult {
|
|
62
|
+
const skillId = options.skillId ?? '<inline>';
|
|
63
|
+
const apply = options.apply ?? false;
|
|
64
|
+
const split = splitSkillMd(skillMd);
|
|
65
|
+
let frontmatter: unknown;
|
|
66
|
+
try {
|
|
67
|
+
frontmatter = parseYaml(split.frontmatter);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new SkillManifestParseError(`Skill '${skillId}' frontmatter is not valid YAML.`, {
|
|
70
|
+
cause: err,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
74
|
+
throw new SkillManifestParseError(`Skill '${skillId}' frontmatter must be a top-level object.`);
|
|
75
|
+
}
|
|
76
|
+
const fm = { ...(frontmatter as Record<string, unknown>) };
|
|
77
|
+
|
|
78
|
+
const snapshot = getSpecSnapshot();
|
|
79
|
+
const rewrites: MigrationRewrite[] = [];
|
|
80
|
+
let mutated = false;
|
|
81
|
+
|
|
82
|
+
for (const [graphorinField, mapping] of Object.entries(snapshot.graphorinMapping)) {
|
|
83
|
+
if (!(graphorinField in fm)) continue;
|
|
84
|
+
if (mapping.policy !== 'deprecate-graphorin-prefix') {
|
|
85
|
+
rewrites.push(
|
|
86
|
+
Object.freeze({
|
|
87
|
+
fromField: graphorinField,
|
|
88
|
+
toField: graphorinField,
|
|
89
|
+
reason: mapping.policy === 'co-exist' ? 'co-exist-noop' : 'graphorin-only-noop',
|
|
90
|
+
applied: false,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (mapping.anthropicEquivalent === null) {
|
|
96
|
+
rewrites.push(
|
|
97
|
+
Object.freeze({
|
|
98
|
+
fromField: graphorinField,
|
|
99
|
+
toField: graphorinField,
|
|
100
|
+
reason: 'graphorin-only-noop',
|
|
101
|
+
applied: false,
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (mapping.anthropicEquivalent in fm) {
|
|
107
|
+
// Both fields are already set - the validator will surface the
|
|
108
|
+
// diagnostic at load time. The migrator does not silently drop
|
|
109
|
+
// either side; the operator is expected to remove the redundant
|
|
110
|
+
// `graphorin-*` field manually after reviewing the diagnostic.
|
|
111
|
+
rewrites.push(
|
|
112
|
+
Object.freeze({
|
|
113
|
+
fromField: graphorinField,
|
|
114
|
+
toField: mapping.anthropicEquivalent,
|
|
115
|
+
reason: 'deprecate-graphorin-prefix',
|
|
116
|
+
applied: false,
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (apply) {
|
|
122
|
+
fm[mapping.anthropicEquivalent] = fm[graphorinField];
|
|
123
|
+
delete fm[graphorinField];
|
|
124
|
+
mutated = true;
|
|
125
|
+
}
|
|
126
|
+
rewrites.push(
|
|
127
|
+
Object.freeze({
|
|
128
|
+
fromField: graphorinField,
|
|
129
|
+
toField: mapping.anthropicEquivalent,
|
|
130
|
+
reason: 'deprecate-graphorin-prefix',
|
|
131
|
+
applied: apply,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!apply || !mutated) {
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
skillId,
|
|
139
|
+
rewrites: Object.freeze(rewrites),
|
|
140
|
+
originalSkillMd: skillMd,
|
|
141
|
+
migratedSkillMd: skillMd,
|
|
142
|
+
changed: false,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sortedFm = sortKeysAnthropicFirst(fm);
|
|
147
|
+
const migratedFrontmatter = stringifyYaml(sortedFm, {
|
|
148
|
+
sortMapEntries: false,
|
|
149
|
+
lineWidth: 0,
|
|
150
|
+
}).trimEnd();
|
|
151
|
+
const migratedSkillMd = `---\n${migratedFrontmatter}\n---\n${split.body}`;
|
|
152
|
+
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
skillId,
|
|
155
|
+
rewrites: Object.freeze(rewrites),
|
|
156
|
+
originalSkillMd: skillMd,
|
|
157
|
+
migratedSkillMd,
|
|
158
|
+
changed: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Stable key ordering: Anthropic-base fields first (in their snapshot
|
|
164
|
+
* insertion order), then the `metadata` bucket, then the
|
|
165
|
+
* `graphorin-*` fields, then anything else. The migrator emits in
|
|
166
|
+
* this order so re-running the migrator on the same input yields
|
|
167
|
+
* identical bytes (idempotence).
|
|
168
|
+
*
|
|
169
|
+
* @stable
|
|
170
|
+
*/
|
|
171
|
+
export function sortKeysAnthropicFirst(
|
|
172
|
+
frontmatter: Record<string, unknown>,
|
|
173
|
+
): Record<string, unknown> {
|
|
174
|
+
const snapshot = getSpecSnapshot();
|
|
175
|
+
const baseKeys = Object.keys(snapshot.knownFields);
|
|
176
|
+
const out: Record<string, unknown> = {};
|
|
177
|
+
for (const key of baseKeys) {
|
|
178
|
+
if (key in frontmatter) out[key] = frontmatter[key];
|
|
179
|
+
}
|
|
180
|
+
if ('metadata' in frontmatter) out.metadata = frontmatter.metadata;
|
|
181
|
+
const remaining = Object.keys(frontmatter)
|
|
182
|
+
.filter((k) => !(k in out))
|
|
183
|
+
.sort((a, b) => {
|
|
184
|
+
const aGraphorin = a.startsWith('graphorin-');
|
|
185
|
+
const bGraphorin = b.startsWith('graphorin-');
|
|
186
|
+
if (aGraphorin && !bGraphorin) return 1;
|
|
187
|
+
if (!aGraphorin && bGraphorin) return -1;
|
|
188
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
189
|
+
});
|
|
190
|
+
for (const key of remaining) out[key] = frontmatter[key];
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper functions that bridge skill-bundled `Tool` records into the
|
|
3
|
+
* `@graphorin/tools` registry.
|
|
4
|
+
*
|
|
5
|
+
* The skills loader does not import the runtime registry directly -
|
|
6
|
+
* downstream callers (the agent runtime in Phase 12, the test suite,
|
|
7
|
+
* and any user-supplied bootstrap script) materialise the actual
|
|
8
|
+
* `Tool[]` from the skill module surface and feed each one through
|
|
9
|
+
* {@link stampSkillTool} to get a {@link Tool} + a {@link ToolSource}
|
|
10
|
+
* pair the registry can register with the correct trust-class
|
|
11
|
+
* derivation, sandbox tier propagation, and inbound-sanitization
|
|
12
|
+
* defaults.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { InboundSanitizationPolicy, SandboxPolicy, Tool, ToolSource } from '@graphorin/core';
|
|
18
|
+
import { resolveSandbox, type SandboxTrustLevel } from '@graphorin/security/sandbox';
|
|
19
|
+
|
|
20
|
+
import type { Skill, SkillMetadata } from '../types/index.js';
|
|
21
|
+
|
|
22
|
+
/** Result of {@link stampSkillTool}. */
|
|
23
|
+
export interface StampedSkillTool<TInput = unknown, TOutput = unknown, TDeps = unknown> {
|
|
24
|
+
readonly tool: Tool<TInput, TOutput, TDeps>;
|
|
25
|
+
readonly source: ToolSource;
|
|
26
|
+
/** Resolved sandbox policy after the tier resolver ran. */
|
|
27
|
+
readonly resolvedSandbox: ReturnType<typeof resolveSandbox>;
|
|
28
|
+
/** `true` when the resolver overrode the operator's choice. */
|
|
29
|
+
readonly sandboxForced: boolean;
|
|
30
|
+
/** `true` when the inbound sanitization policy was upgraded to the untrusted default. */
|
|
31
|
+
readonly inboundSanitizationForced: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stamp a skill-bundled tool with the metadata the
|
|
36
|
+
* `@graphorin/tools` registry expects:
|
|
37
|
+
*
|
|
38
|
+
* 1. Derive a `ToolSource` of kind `'skill'` carrying the skill's
|
|
39
|
+
* name and trust level.
|
|
40
|
+
* 2. Run `resolveSandbox(...)` so the resulting `Tool.sandboxPolicy`
|
|
41
|
+
* matches the mandatory tier for untrusted skills (DEC-148).
|
|
42
|
+
* 3. Default the `inboundSanitization` policy to
|
|
43
|
+
* `'detect-and-strip-and-wrap'` for untrusted skills when the tool
|
|
44
|
+
* author left it unset; trusted skills inherit the operator's
|
|
45
|
+
* choice.
|
|
46
|
+
*
|
|
47
|
+
* The function returns a freshly-frozen `Tool` with the resolved
|
|
48
|
+
* `sandboxPolicy` and `inboundSanitization` baked in so downstream
|
|
49
|
+
* registries cannot accidentally re-inherit a relaxed policy.
|
|
50
|
+
*
|
|
51
|
+
* @stable
|
|
52
|
+
*/
|
|
53
|
+
export function stampSkillTool<TInput = unknown, TOutput = unknown, TDeps = unknown>(
|
|
54
|
+
tool: Tool<TInput, TOutput, TDeps>,
|
|
55
|
+
skill: Pick<Skill, 'metadata'>,
|
|
56
|
+
): StampedSkillTool<TInput, TOutput, TDeps> {
|
|
57
|
+
return stampSkillToolFromMetadata(tool, skill.metadata);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Lower-level variant accepting a raw {@link SkillMetadata} so
|
|
62
|
+
* fixtures and tests do not have to materialise a full {@link Skill}.
|
|
63
|
+
*
|
|
64
|
+
* @stable
|
|
65
|
+
*/
|
|
66
|
+
export function stampSkillToolFromMetadata<TInput = unknown, TOutput = unknown, TDeps = unknown>(
|
|
67
|
+
tool: Tool<TInput, TOutput, TDeps>,
|
|
68
|
+
metadata: SkillMetadata,
|
|
69
|
+
): StampedSkillTool<TInput, TOutput, TDeps> {
|
|
70
|
+
const trustLevel = mapTrustLevel(metadata.graphorinTrustLevel);
|
|
71
|
+
const sandboxResolverArgs: Parameters<typeof resolveSandbox>[0] = {
|
|
72
|
+
trustLevel,
|
|
73
|
+
};
|
|
74
|
+
if (tool.name !== undefined)
|
|
75
|
+
(sandboxResolverArgs as Mutable<typeof sandboxResolverArgs>).toolName = tool.name;
|
|
76
|
+
if (metadata.name !== undefined)
|
|
77
|
+
(sandboxResolverArgs as Mutable<typeof sandboxResolverArgs>).skillName = metadata.name;
|
|
78
|
+
const operatorOverride = sandboxOverrideFromTool(tool);
|
|
79
|
+
if (operatorOverride !== undefined)
|
|
80
|
+
(sandboxResolverArgs as Mutable<typeof sandboxResolverArgs>).override = operatorOverride;
|
|
81
|
+
const resolvedSandbox = resolveSandbox(sandboxResolverArgs);
|
|
82
|
+
const inboundSanitization = inboundSanitizationFor(tool, metadata.graphorinTrustLevel);
|
|
83
|
+
const stamped: Mutable<Tool<TInput, TOutput, TDeps>> = {
|
|
84
|
+
...tool,
|
|
85
|
+
sandboxPolicy: mapSandboxKindToPolicy(resolvedSandbox.kind),
|
|
86
|
+
...(inboundSanitization === undefined ? {} : { inboundSanitization }),
|
|
87
|
+
};
|
|
88
|
+
const source: ToolSource = Object.freeze({
|
|
89
|
+
kind: 'skill' as const,
|
|
90
|
+
skillName: metadata.name,
|
|
91
|
+
// 'unknown' inherits the strict sandbox policy of 'untrusted', so
|
|
92
|
+
// the agent runtime registers the source as untrusted too - that
|
|
93
|
+
// forces the inbound-sanitization default
|
|
94
|
+
// ('detect-and-strip-and-wrap') and the precedence ladder used by
|
|
95
|
+
// collision resolution to demote it relative to first-party
|
|
96
|
+
// / trusted-skill registrations.
|
|
97
|
+
trustLevel:
|
|
98
|
+
metadata.graphorinTrustLevel === 'untrusted' || metadata.graphorinTrustLevel === 'unknown'
|
|
99
|
+
? 'untrusted'
|
|
100
|
+
: 'trusted',
|
|
101
|
+
});
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
tool: Object.freeze(stamped) as Tool<TInput, TOutput, TDeps>,
|
|
104
|
+
source,
|
|
105
|
+
resolvedSandbox,
|
|
106
|
+
sandboxForced: resolvedSandbox.forced,
|
|
107
|
+
inboundSanitizationForced:
|
|
108
|
+
inboundSanitization !== undefined && tool.inboundSanitization === undefined,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function mapTrustLevel(level: SkillMetadata['graphorinTrustLevel']): SandboxTrustLevel {
|
|
113
|
+
switch (level) {
|
|
114
|
+
case 'untrusted':
|
|
115
|
+
case 'unknown':
|
|
116
|
+
// Phase 08 § Risks & mitigations: 'unknown' enforces sandbox
|
|
117
|
+
// without requiring signature, so the sandbox tier resolver
|
|
118
|
+
// applies the same strict policy as 'untrusted'.
|
|
119
|
+
return 'untrusted';
|
|
120
|
+
case 'trusted':
|
|
121
|
+
case 'trusted-with-scripts':
|
|
122
|
+
return 'trusted';
|
|
123
|
+
default: {
|
|
124
|
+
const exhaustive: never = level;
|
|
125
|
+
void exhaustive;
|
|
126
|
+
return 'user-defined';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function mapSandboxKindToPolicy(kind: ReturnType<typeof resolveSandbox>['kind']): SandboxPolicy {
|
|
132
|
+
if (kind === 'none') return 'none';
|
|
133
|
+
if (kind === 'worker-threads') return 'sandboxed';
|
|
134
|
+
if (kind === 'isolated-vm') return 'isolated';
|
|
135
|
+
if (kind === 'docker') return 'docker';
|
|
136
|
+
return 'sandboxed';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function inboundSanitizationFor(
|
|
140
|
+
tool: Pick<Tool, 'inboundSanitization'>,
|
|
141
|
+
trustLevel: SkillMetadata['graphorinTrustLevel'],
|
|
142
|
+
): InboundSanitizationPolicy | undefined {
|
|
143
|
+
if (tool.inboundSanitization !== undefined) return tool.inboundSanitization;
|
|
144
|
+
if (trustLevel === 'untrusted' || trustLevel === 'unknown') {
|
|
145
|
+
return 'detect-and-strip-and-wrap';
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function sandboxOverrideFromTool(
|
|
151
|
+
tool: Pick<Tool, 'sandboxPolicy'>,
|
|
152
|
+
): { readonly kind?: ReturnType<typeof resolveSandbox>['kind'] } | undefined {
|
|
153
|
+
switch (tool.sandboxPolicy) {
|
|
154
|
+
case undefined:
|
|
155
|
+
return undefined;
|
|
156
|
+
case 'none':
|
|
157
|
+
return { kind: 'none' };
|
|
158
|
+
case 'sandboxed':
|
|
159
|
+
return { kind: 'worker-threads' };
|
|
160
|
+
case 'isolated':
|
|
161
|
+
return { kind: 'isolated-vm' };
|
|
162
|
+
case 'docker':
|
|
163
|
+
return { kind: 'docker' };
|
|
164
|
+
default:
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
|