@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.
@@ -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] };