@aragon-agent/core 0.1.3

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/LICENSE +21 -0
  3. package/README.md +147 -0
  4. package/dist/engine/agent-loop.d.ts +52 -0
  5. package/dist/engine/agent-loop.js +260 -0
  6. package/dist/engine/agent.d.ts +129 -0
  7. package/dist/engine/agent.js +283 -0
  8. package/dist/engine/message-manager.d.ts +26 -0
  9. package/dist/engine/message-manager.js +39 -0
  10. package/dist/engine/steering.d.ts +27 -0
  11. package/dist/engine/steering.js +51 -0
  12. package/dist/engine/watchdog.d.ts +24 -0
  13. package/dist/engine/watchdog.js +50 -0
  14. package/dist/index.d.ts +46 -0
  15. package/dist/index.js +63 -0
  16. package/dist/llm/model-registry.d.ts +51 -0
  17. package/dist/llm/model-registry.js +277 -0
  18. package/dist/llm/provider.d.ts +72 -0
  19. package/dist/llm/provider.js +77 -0
  20. package/dist/llm/providers/anthropic.d.ts +23 -0
  21. package/dist/llm/providers/anthropic.js +420 -0
  22. package/dist/llm/providers/google.d.ts +22 -0
  23. package/dist/llm/providers/google.js +336 -0
  24. package/dist/llm/providers/index.d.ts +67 -0
  25. package/dist/llm/providers/index.js +127 -0
  26. package/dist/llm/providers/openai.d.ts +22 -0
  27. package/dist/llm/providers/openai.js +353 -0
  28. package/dist/llm/stream-utils.d.ts +33 -0
  29. package/dist/llm/stream-utils.js +132 -0
  30. package/dist/llm/types.d.ts +129 -0
  31. package/dist/llm/types.js +17 -0
  32. package/dist/sandbox/console-capture.d.ts +29 -0
  33. package/dist/sandbox/console-capture.js +56 -0
  34. package/dist/sandbox/http-bridge.d.ts +47 -0
  35. package/dist/sandbox/http-bridge.js +143 -0
  36. package/dist/sandbox/isolated-vm-sandbox.d.ts +50 -0
  37. package/dist/sandbox/isolated-vm-sandbox.js +226 -0
  38. package/dist/sandbox/tool-bridge.d.ts +42 -0
  39. package/dist/sandbox/tool-bridge.js +98 -0
  40. package/dist/sandbox/types.d.ts +84 -0
  41. package/dist/sandbox/types.js +8 -0
  42. package/dist/skills/constants.d.ts +122 -0
  43. package/dist/skills/constants.js +179 -0
  44. package/dist/skills/disclosure.d.ts +146 -0
  45. package/dist/skills/disclosure.js +788 -0
  46. package/dist/skills/frontmatter.d.ts +27 -0
  47. package/dist/skills/frontmatter.js +177 -0
  48. package/dist/skills/index.d.ts +21 -0
  49. package/dist/skills/index.js +18 -0
  50. package/dist/skills/skill-find-tool.d.ts +41 -0
  51. package/dist/skills/skill-find-tool.js +117 -0
  52. package/dist/skills/skill-registry.d.ts +96 -0
  53. package/dist/skills/skill-registry.js +208 -0
  54. package/dist/skills/skill-tool.d.ts +44 -0
  55. package/dist/skills/skill-tool.js +96 -0
  56. package/dist/skills/tool-policy.d.ts +91 -0
  57. package/dist/skills/tool-policy.js +272 -0
  58. package/dist/skills/types.d.ts +271 -0
  59. package/dist/skills/types.js +8 -0
  60. package/dist/skills/validate.d.ts +37 -0
  61. package/dist/skills/validate.js +264 -0
  62. package/dist/tools/executor.d.ts +54 -0
  63. package/dist/tools/executor.js +206 -0
  64. package/dist/tools/helpers.d.ts +69 -0
  65. package/dist/tools/helpers.js +87 -0
  66. package/dist/tools/registry.d.ts +58 -0
  67. package/dist/tools/registry.js +119 -0
  68. package/dist/tools/types.d.ts +140 -0
  69. package/dist/tools/types.js +7 -0
  70. package/dist/tools/validator.d.ts +46 -0
  71. package/dist/tools/validator.js +210 -0
  72. package/dist/types.d.ts +56 -0
  73. package/dist/types.js +7 -0
  74. package/package.json +115 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * The turn-scoped tool ceiling (spec §5) — what turns `allowed-tools` from a
3
+ * decorative frontmatter field into an executable declaration.
4
+ *
5
+ * PURE FUNCTIONS ONLY. This module does not know which tools the host has; the
6
+ * caller passes `registered` and `floor` in (D-G7). Core owns the SKILL.md
7
+ * FORMAT — which includes knowing that `Read` is what Claude Code calls its
8
+ * read-a-file tool — while the CLI owns the inventory of what actually exists.
9
+ *
10
+ * THE DIRECTION OF FAILURE IS THE WHOLE DESIGN (D-G4). A declaration this host
11
+ * cannot fully resolve waives that skill's contribution ENTIRELY rather than
12
+ * narrowing it: honouring the half we understood would silently constrain an
13
+ * author to something they never wrote, and the reason (one mistyped tool name)
14
+ * would be invisible. Fail-open plus a loud notice is correct here; the
15
+ * fail-closed rule lives on the install gate, where the adversary is a person
16
+ * rather than a typo.
17
+ */
18
+ import { TOOL_POLICY_DENY_ESCALATE_AT } from './constants.js';
19
+ // ---------------------------------------------------------------------------
20
+ // Name resolution (§5.3)
21
+ // ---------------------------------------------------------------------------
22
+ /** Lower-case and strip `_`, `-` and whitespace: `Read`→`read`, `read_file`→`readfile`. */
23
+ export function normalizeToolAlias(name) {
24
+ return name.toLowerCase().replace(/[_\-\s]/g, '');
25
+ }
26
+ /**
27
+ * Claude Code's vocabulary → this host's snake_case names.
28
+ *
29
+ * Keys are already normalized. This table is why `.claude/skills` interop — the
30
+ * README's headline feature — survives enforcement at all: without it every
31
+ * community declaration (`allowed-tools: [Read, Bash]`) would resolve to nothing
32
+ * and every such skill would take the fail-open path, making the whole ceiling
33
+ * ornamental (FG2 / RG2).
34
+ */
35
+ export const SKILL_TOOL_ALIASES = Object.freeze({
36
+ read: 'read_file',
37
+ readfile: 'read_file',
38
+ view: 'read_file',
39
+ cat: 'read_file',
40
+ write: 'write_file',
41
+ writefile: 'write_file',
42
+ create: 'write_file',
43
+ edit: 'edit_file',
44
+ editfile: 'edit_file',
45
+ multiedit: 'edit_file',
46
+ strreplaceeditor: 'edit_file',
47
+ strreplacebasededitor: 'edit_file',
48
+ bash: 'bash',
49
+ shell: 'bash',
50
+ sh: 'bash',
51
+ runcommand: 'bash',
52
+ terminal: 'bash',
53
+ ls: 'list_dir',
54
+ listdir: 'list_dir',
55
+ listdirectory: 'list_dir',
56
+ glob: 'glob',
57
+ findfiles: 'glob',
58
+ grep: 'grep',
59
+ search: 'grep',
60
+ ripgrep: 'grep',
61
+ skill: 'skill',
62
+ skillfind: 'skill_find',
63
+ skillinstall: 'skill_install',
64
+ skillcreate: 'skill_create',
65
+ });
66
+ /**
67
+ * Capabilities this host simply does not have. A declaration naming one is
68
+ * DROPPED, not treated as unresolvable (D-G5).
69
+ *
70
+ * If these counted as "cannot resolve", nearly every community skill would take
71
+ * the fail-open path and enforcement would be theatre. Dropping them changes no
72
+ * executable set — the model could never have called them — while leaving the
73
+ * rest of the declaration in force. That is precision, not a concession.
74
+ *
75
+ * THIS TABLE ONLY GROWS. Removing an entry pushes already-working skills into
76
+ * fail-open with no symptom whatsoever.
77
+ */
78
+ export const SKILL_TOOL_KNOWN_ABSENT = new Set([
79
+ 'webfetch',
80
+ 'websearch',
81
+ 'task',
82
+ 'agent',
83
+ 'todowrite',
84
+ 'todoread',
85
+ 'notebookedit',
86
+ 'notebookread',
87
+ 'bashoutput',
88
+ 'killshell',
89
+ 'slashcommand',
90
+ 'exitplanmode',
91
+ 'askuserquestion',
92
+ 'computer',
93
+ ]);
94
+ /**
95
+ * Resolve one declared name against the live tool list.
96
+ *
97
+ * `string` — a tool on this host.
98
+ * `'absent'` — a real Claude Code tool this host lacks; drop it silently.
99
+ * `null` — unrecognisable; the whole skill then waives its contribution.
100
+ *
101
+ * THE ORDER OF THE FOUR STEPS IS THE SEMANTICS — do not reshuffle it. Exact
102
+ * match first means a host that adds a new tool needs no table maintenance. The
103
+ * alias table is consulted BEFORE the absent table so that mistakenly listing a
104
+ * tool this host actually has could never silently downgrade a skill.
105
+ */
106
+ export function resolveDeclaredTool(name, registered) {
107
+ const trimmed = name.trim();
108
+ if (trimmed.length === 0)
109
+ return null;
110
+ // 1. Exact match against what is registered right now.
111
+ if (registered.includes(trimmed))
112
+ return trimmed;
113
+ const key = normalizeToolAlias(trimmed);
114
+ // 2. Known alias. Resolving to a tool this host does NOT have is a miss, not a
115
+ // drop: the author asked for a capability we cannot grant, and pretending
116
+ // otherwise would narrow their declaration behind their back.
117
+ const candidate = SKILL_TOOL_ALIASES[key];
118
+ if (candidate !== undefined)
119
+ return registered.includes(candidate) ? candidate : null;
120
+ // 3. A real tool elsewhere that does not exist here.
121
+ if (SKILL_TOOL_KNOWN_ABSENT.has(key))
122
+ return 'absent';
123
+ // 4. Anything else — a typo, or a tool nobody here has heard of.
124
+ return null;
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // Decision (§5.2)
128
+ // ---------------------------------------------------------------------------
129
+ function noCeiling(mode, ignored = []) {
130
+ return { mode, allowed: null, sources: [], sourceNames: [], ignored };
131
+ }
132
+ function contributes(record) {
133
+ if (record.disabled || record.invalid)
134
+ return false;
135
+ // Redundant with the frame's own gate, and deliberately so (D-G9): an
136
+ // `activation: always` skill is ambient reference material, not a procedure
137
+ // the model chose to run, so it must never constrain a turn.
138
+ if (record.frontmatter.activation === 'always')
139
+ return false;
140
+ return record.frontmatter.allowedTools.length > 0;
141
+ }
142
+ /**
143
+ * Compute the ceiling for a turn.
144
+ *
145
+ * TWO INVARIANTS THIS FUNCTION OWNS (I-G1 / I-G2, pinned by AC-G23):
146
+ *
147
+ * I-G1 `registered.length === 0` ⇒ `allowed === null`.
148
+ * I-G2 `allowed !== null` ⇒ the set is NON-EMPTY.
149
+ *
150
+ * Both matter because the failure they exclude is catastrophic and silent: an
151
+ * empty permitted set is not "a tight ceiling", it is an agent that can no
152
+ * longer do anything, for a reason no user could ever guess. The construction
153
+ * makes them hold — every unresolved name waives its whole skill, and the final
154
+ * guard below refuses to hand back an empty set — rather than leaving them to be
155
+ * re-derived by whoever refactors this next.
156
+ */
157
+ export function computeToolPolicy(input) {
158
+ const { mode, registered, floor } = input;
159
+ if (mode === 'off')
160
+ return noCeiling(mode);
161
+ const sources = [];
162
+ const ignored = [];
163
+ const granted = new Set();
164
+ for (const record of input.frame) {
165
+ if (!contributes(record))
166
+ continue;
167
+ const declared = record.frontmatter.allowedTools;
168
+ const resolved = [];
169
+ const unresolved = [];
170
+ for (const declaredName of declared) {
171
+ const outcome = resolveDeclaredTool(declaredName, registered);
172
+ if (outcome === null)
173
+ unresolved.push(declaredName);
174
+ else if (outcome !== 'absent')
175
+ resolved.push(outcome);
176
+ }
177
+ if (unresolved.length > 0) {
178
+ ignored.push({ name: record.name, unresolved });
179
+ continue;
180
+ }
181
+ for (const toolName of resolved)
182
+ granted.add(toolName);
183
+ sources.push({
184
+ name: record.name,
185
+ declared: [...declared],
186
+ granted: [...new Set(resolved)].sort((a, b) => a.localeCompare(b, 'en')),
187
+ });
188
+ }
189
+ if (sources.length === 0)
190
+ return noCeiling(mode, ignored);
191
+ sources.sort((a, b) => a.name.localeCompare(b.name, 'en'));
192
+ const allowed = new Set([...floor, ...granted]);
193
+ // I-G2's structural guard. Reachable only when the floor is empty AND every
194
+ // declaration resolved to `absent` — pathological, but the consequence would
195
+ // be a fully locked agent, so it is checked rather than argued about.
196
+ if (allowed.size === 0)
197
+ return noCeiling(mode, ignored);
198
+ return { mode, allowed, sources, sourceNames: sources.map((s) => s.name), ignored };
199
+ }
200
+ // ---------------------------------------------------------------------------
201
+ // Verdict (§5.2)
202
+ // ---------------------------------------------------------------------------
203
+ function sorted(values) {
204
+ return [...values].sort((a, b) => a.localeCompare(b, 'en'));
205
+ }
206
+ /**
207
+ * How to name the skills responsible, for both the model and the user.
208
+ *
209
+ * Multi-source turns list EVERY name. Picking one would be wrong in some
210
+ * arrangement of skills every time, and a confidently wrong attribution is worse
211
+ * than a slightly longer sentence.
212
+ */
213
+ function describeSources(names) {
214
+ if (names.length === 1)
215
+ return `skill "${names[0]}"`;
216
+ return `skills ${names.map((n) => `"${n}"`).join(', ')}`;
217
+ }
218
+ /** The union of what the frame's skills actually asked for, floor excluded. */
219
+ function grantedUnion(decision) {
220
+ return sorted(new Set(decision.sources.flatMap((s) => s.granted)));
221
+ }
222
+ /**
223
+ * Decide a single call. Deterministic, allocation-light, and safe to run on
224
+ * every tool invocation — `allowed === null` short-circuits before any string
225
+ * work, which is the common case.
226
+ */
227
+ export function evaluateToolCall(tool, d) {
228
+ if (d.allowed === null || d.allowed.has(tool))
229
+ return { allow: true };
230
+ const subject = describeSources(d.sourceNames);
231
+ const declaredList = grantedUnion(d).join(', ');
232
+ if (d.mode === 'warn') {
233
+ return {
234
+ allow: true,
235
+ notice: `${tool} is outside the tool ceiling of ${subject} (declared: ${declaredList}). ` +
236
+ 'Running it anyway - skills.toolPolicy is "warn".',
237
+ };
238
+ }
239
+ const message = [
240
+ `Tool "${tool}" is not permitted while ${subject} is in effect.`,
241
+ // The RAW declaration, not the mapped names: the author has to be able to
242
+ // find this word in the frontmatter line they wrote.
243
+ ...d.sources.map((s) => `- "${s.name}" declares allowed-tools: ${s.declared.join(', ')}`),
244
+ `Permitted right now: ${sorted(d.allowed).join(', ')}.`,
245
+ `Use one of those, or tell the user "${tool}" must be added to allowed-tools.`,
246
+ 'Do not retry this tool for the rest of this turn.',
247
+ ].join('\n');
248
+ return {
249
+ allow: false,
250
+ message,
251
+ notice: `Blocked ${tool}: ${subject} limits this turn to ${declaredList}. ` +
252
+ 'Run /skills policy off to disable.',
253
+ };
254
+ }
255
+ /**
256
+ * The line appended to a refusal once the same tool has been refused
257
+ * `TOOL_POLICY_DENY_ESCALATE_AT` times in one turn (§5.5).
258
+ *
259
+ * Appended to the SAME message rather than emitted as a new one: a second
260
+ * message would be one more thing for the model to skim past, whereas a longer
261
+ * version of the text it is already reading is unavoidable.
262
+ */
263
+ export function renderDenyEscalation(tool, count) {
264
+ return (`You have now been refused "${tool}" ${count} times this turn. Stop calling it; ` +
265
+ "tell the user which tool the skill's allowed-tools is missing.");
266
+ }
267
+ /** The matching one-liner for the user, emitted once per (turn, tool). */
268
+ export function renderDenyEscalationNotice(skill, tool, count) {
269
+ return (`Skill "${skill}" blocked ${tool} ${count}x this turn - the skill's allowed-tools ` +
270
+ 'may be wrong (/skills policy off to bypass).');
271
+ }
272
+ export { TOOL_POLICY_DENY_ESCALATE_AT };
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Skill data model (spec §10.1).
3
+ *
4
+ * Pure types + one injected port. This module must stay free of `node:*` — the
5
+ * host filesystem is reached only through `SkillHost`, which the CLI implements
6
+ * (D1). `no-host-coupling.test.ts` enforces that red line mechanically.
7
+ */
8
+ export type SkillScope = 'bundled' | 'user' | 'project' | 'env';
9
+ export type SkillActivation = 'auto' | 'always' | 'manual';
10
+ export interface SkillFrontmatter {
11
+ name: string;
12
+ description: string;
13
+ version: string;
14
+ license?: string;
15
+ author?: string;
16
+ homepage?: string;
17
+ keywords: string[];
18
+ allowedTools: string[];
19
+ activation: SkillActivation;
20
+ /** Every parsed key, including unknown ones (forward compat). */
21
+ raw: Record<string, string | string[]>;
22
+ }
23
+ export interface SkillFileRef {
24
+ /** Path relative to the skill directory, always with `/` separators. */
25
+ path: string;
26
+ bytes: number;
27
+ }
28
+ export interface SkillValidationIssue {
29
+ level: 'error' | 'warn';
30
+ code: string;
31
+ message: string;
32
+ }
33
+ export interface SkillManifestFile {
34
+ path: string;
35
+ bytes: number;
36
+ sha256: string;
37
+ }
38
+ export interface SkillManifestSource {
39
+ kind: 'local-dir' | 'local-file' | 'git' | 'https-md' | 'https-zip' | 'inline';
40
+ url: string;
41
+ ref: string | null;
42
+ subdir: string | null;
43
+ /**
44
+ * The commit sha a git source resolved to at install time; `null` for every
45
+ * other kind and whenever `git rev-parse` did not answer.
46
+ *
47
+ * OPTIONAL ON PURPOSE (F10): `readManifest()` returns `null` for any
48
+ * `schema !== 1`, so bumping the schema would demote every already-installed
49
+ * skill to "no provenance" the moment an older CLI read it. New fields are
50
+ * therefore always additive and always optional.
51
+ */
52
+ resolvedRef?: string | null;
53
+ }
54
+ export interface SkillManifest {
55
+ schema: number;
56
+ name: string;
57
+ version: string;
58
+ installedAt: number;
59
+ installer: string;
60
+ source: SkillManifestSource;
61
+ files: SkillManifestFile[];
62
+ totalBytes: number;
63
+ /** Set by `skills update`; absent for a skill that was never updated. */
64
+ updatedAt?: number;
65
+ /** The version this skill carried before the last update. */
66
+ previousVersion?: string;
67
+ }
68
+ /**
69
+ * How a skill's `SKILL.md` compares to what the install manifest recorded (§8).
70
+ *
71
+ * `unverified` — no manifest, or the check is switched off. Hand-authored,
72
+ * bundled and `.claude/skills` skills live here permanently.
73
+ * `ok` — byte-identical to the approved copy.
74
+ * `modified` — the file on disk is not the one that was approved.
75
+ */
76
+ export type SkillIntegrity = 'unverified' | 'ok' | 'modified';
77
+ export interface SkillUsageStat {
78
+ useCount: number;
79
+ /** epoch ms; `0` means never used. */
80
+ lastUsedAt: number;
81
+ }
82
+ /** Skill name → usage counters. Local-only; see `usage.ts`. */
83
+ export type SkillUsageMap = Record<string, SkillUsageStat>;
84
+ export interface SkillRecord {
85
+ name: string;
86
+ description: string;
87
+ scope: SkillScope;
88
+ /** Absolute skill directory. */
89
+ dir: string;
90
+ /** Absolute path to SKILL.md. */
91
+ entryPath: string;
92
+ frontmatter: SkillFrontmatter;
93
+ /** Lazily filled at Level 2. */
94
+ body: string | null;
95
+ /** Lazily filled at Level 2. */
96
+ files: SkillFileRef[] | null;
97
+ /** Size of SKILL.md in bytes. */
98
+ bytes: number;
99
+ disabled: boolean;
100
+ issues: SkillValidationIssue[];
101
+ invalid: boolean;
102
+ /** Same-name records from lower-precedence roots. */
103
+ shadowed: Array<{
104
+ scope: SkillScope;
105
+ dir: string;
106
+ }>;
107
+ /** Parsed .aragon-skill.json when present. */
108
+ manifest: SkillManifest | null;
109
+ /** False for `bundled`, `env`, and `.claude/skills` roots. */
110
+ writable: boolean;
111
+ /** SKILL.md vs. the manifest, computed during discovery at no extra I/O (§8.1). */
112
+ integrity: SkillIntegrity;
113
+ }
114
+ /**
115
+ * The injected filesystem port — keeps core free of `node:*`.
116
+ *
117
+ * ERROR CONTRACT (P2-8): every method here MAY THROW (the Node implementation
118
+ * wraps sync fs calls: ENOENT / EACCES / EISDIR / ELOOP all surface as
119
+ * exceptions). Core callers are responsible for try/catch; "never throws" is an
120
+ * obligation of a tool's `execute()` (§5.2), never of this port.
121
+ */
122
+ export interface SkillHost {
123
+ readTextFile(path: string): string;
124
+ listDir(path: string): Array<{
125
+ name: string;
126
+ isDirectory: boolean;
127
+ bytes: number;
128
+ }>;
129
+ exists(path: string): boolean;
130
+ join(...parts: string[]): string;
131
+ relative(from: string, to: string): string;
132
+ isAbsolute(p: string): boolean;
133
+ }
134
+ export interface SkillCatalogOptions {
135
+ /** UTF-8 byte ceiling for the whole block. */
136
+ maxBytes?: number;
137
+ /** Character cap for a single entry's description. */
138
+ descLineMax?: number;
139
+ /** Append the `Skill tools: …` footer line. Default true. */
140
+ includeToolLine?: boolean;
141
+ /**
142
+ * Usage counters used to rank entries within a scope (§5.2). Omitting this
143
+ * reproduces the pre-ranking order byte for byte (invariant I-A1).
144
+ */
145
+ usage?: SkillUsageMap;
146
+ /** Injected clock for the recency buckets; defaults to `Date.now()`. */
147
+ now?: number;
148
+ }
149
+ export interface SkillFindOptions {
150
+ /** Total number of installed skills searched, for the `of="N"` attribute. */
151
+ total?: number;
152
+ /**
153
+ * How many skills matched BEFORE the caller applied its own `limit`. Defaults
154
+ * to `matched.length`. Pass it whenever the array handed in has already been
155
+ * sliced, so the `matched="N"` attribute stays truthful and the overflow line
156
+ * still appears — a silently shortened result list is the same failure this
157
+ * whole tool exists to undo.
158
+ */
159
+ matchedTotal?: number;
160
+ /** UTF-8 byte ceiling for the whole result block. */
161
+ maxBytes?: number;
162
+ /** Character cap for a single entry's description. */
163
+ descLineMax?: number;
164
+ /** Near-miss names offered when nothing matched. */
165
+ suggestions?: string[];
166
+ }
167
+ export type SkillToolPolicyMode = 'off' | 'warn' | 'enforce';
168
+ /**
169
+ * The host's shell family. Injected by the CLI (D-G14) — core never reads
170
+ * `process`, because that would make the renderers untestable across platforms:
171
+ * a snapshot taken on Windows could never cover the POSIX branch.
172
+ */
173
+ export type SkillPlatform = 'win32' | 'posix';
174
+ /**
175
+ * One skill's COMPLETE contribution to the ceiling.
176
+ *
177
+ * Not a bare name (evaluated P0-1): the refusal text has to say what THAT skill
178
+ * declared, and a union `allowed` set — which also carries the floor — cannot be
179
+ * reverse-engineered back into per-skill declarations.
180
+ */
181
+ export interface ToolPolicySource {
182
+ name: string;
183
+ /** The raw declaration, verbatim and unmapped, so an author can grep for it. */
184
+ declared: string[];
185
+ /** Declared names that resolved to a tool on this host (ascending, deduped). */
186
+ granted: string[];
187
+ }
188
+ export interface ToolPolicyInput {
189
+ /** Skill records in the current turn's frame (order irrelevant). */
190
+ frame: SkillRecord[];
191
+ /** Tool names actually registered in this session (host-supplied). */
192
+ registered: readonly string[];
193
+ /** Always-permitted tools (host-supplied; D-G6 / D-G7). */
194
+ floor: readonly string[];
195
+ mode: SkillToolPolicyMode;
196
+ }
197
+ export interface ToolPolicyDecision {
198
+ mode: SkillToolPolicyMode;
199
+ /** `null` = no ceiling at all. Non-null is the permitted set. NEVER empty (I-G2). */
200
+ allowed: ReadonlySet<string> | null;
201
+ /** Skills that contributed, ascending by `name`. */
202
+ sources: ToolPolicySource[];
203
+ /** Convenience mirror of `sources.map(s => s.name)` for display / events. */
204
+ sourceNames: string[];
205
+ /** Skills waived wholesale by D-G4, with the names that could not be resolved. */
206
+ ignored: Array<{
207
+ name: string;
208
+ unresolved: string[];
209
+ }>;
210
+ }
211
+ export interface ToolPolicyVerdict {
212
+ allow: boolean;
213
+ /**
214
+ * Present only when `allow === false`: the actionable explanation the MODEL
215
+ * reads. Mutable on purpose — `SkillService` appends the escalation line to it
216
+ * on the Nth refusal rather than emitting a second message (§5.5).
217
+ */
218
+ message?: string;
219
+ /** Present whenever the user should hear about it: one line for the transcript. */
220
+ notice?: string;
221
+ }
222
+ /** The slice of a decision the renderers need. Kept flat so it can be snapshotted. */
223
+ export interface SkillPolicyView {
224
+ mode: SkillToolPolicyMode;
225
+ /** The full permitted set, ascending. */
226
+ allowed: string[];
227
+ }
228
+ export interface SkillBodyOptions {
229
+ bodyMaxBytes?: number;
230
+ /** Hard ceiling on the whole rendered string. */
231
+ resultMaxBytes?: number;
232
+ filesMax?: number;
233
+ alreadyLoaded?: boolean;
234
+ arguments?: string;
235
+ /** `'digest'` omits the body and points at the earlier copy (§7.2). */
236
+ mode?: 'full' | 'digest';
237
+ /**
238
+ * Drives the one-line tool-ceiling statement.
239
+ *
240
+ * OMITTING IT MEANS "SAY NOTHING" — and that is the default for a reason
241
+ * (P0-2 / D-G18). `activation: always` and `--skill` skills are exempt from the
242
+ * ceiling, so telling the model every single turn that "only these tools are
243
+ * permitted" would impose a restriction that does not exist, invisibly.
244
+ */
245
+ policy?: SkillPolicyView;
246
+ platform?: SkillPlatform;
247
+ }
248
+ /** Options for the `/<skill-name>` invocation message (§6). */
249
+ export interface SkillInvocationOptions {
250
+ bodyMaxBytes?: number;
251
+ /** Defaults to `SKILL_INVOCATION_MAX_BYTES`. */
252
+ resultMaxBytes?: number;
253
+ filesMax?: number;
254
+ platform?: SkillPlatform;
255
+ policy?: SkillPolicyView;
256
+ }
257
+ /** A staged (not yet installed) file, as seen by `validateStagedSkill`. */
258
+ export interface StagedFile {
259
+ /** Path relative to the staging root, `/`-separated. */
260
+ path: string;
261
+ bytes: number;
262
+ /** True for symlinks and any non-regular entry. */
263
+ isSymlink?: boolean;
264
+ }
265
+ export interface StagedValidationResult {
266
+ ok: boolean;
267
+ issues: SkillValidationIssue[];
268
+ frontmatter: SkillFrontmatter | null;
269
+ fileCount: number;
270
+ totalBytes: number;
271
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Skill data model (spec §10.1).
3
+ *
4
+ * Pure types + one injected port. This module must stay free of `node:*` — the
5
+ * host filesystem is reached only through `SkillHost`, which the CLI implements
6
+ * (D1). `no-host-coupling.test.ts` enforces that red line mechanically.
7
+ */
8
+ export {};
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Skill validation — frontmatter contract (§4.4) and staged-install safety (§8.3).
3
+ *
4
+ * Pure functions only: no filesystem, no process. The staged validator takes a
5
+ * pre-listed file table so the same rules run identically over a real staging
6
+ * directory (CLI) and over a fixture (tests).
7
+ */
8
+ import type { SkillFrontmatter, SkillValidationIssue, StagedFile, StagedValidationResult } from './types.js';
9
+ /** True when `s` holds a C0/DEL control character. */
10
+ export declare function hasControlChar(s: string): boolean;
11
+ /**
12
+ * Validate a parsed frontmatter map against the §4.2 contract.
13
+ *
14
+ * Error codes are a stable public surface — the UI and the tests both key off
15
+ * them, so renaming one is a breaking change.
16
+ */
17
+ export declare function validateSkillFrontmatter(data: Record<string, string | string[]>, dirName: string): SkillValidationIssue[];
18
+ /** Build the normalized frontmatter view, applying every documented default. */
19
+ export declare function normalizeFrontmatter(data: Record<string, string | string[]>, fallbackName: string): SkillFrontmatter;
20
+ /**
21
+ * Reject a relative path that could escape the staging root or is unusable on
22
+ * Windows. This is the SECOND line of defence — the archive extractor applies
23
+ * the same rules while writing (§9.2.1). Both are required: the extractor
24
+ * guards what lands on disk, this guards what we then promote into the skills
25
+ * root (a plain local-directory source never goes through the extractor).
26
+ */
27
+ export declare function checkStagedPath(path: string): SkillValidationIssue | null;
28
+ /**
29
+ * Validate a staged skill before it is promoted into a skills root.
30
+ *
31
+ * `entryText` is the raw SKILL.md; `files` is the full staged file table
32
+ * (SKILL.md included). Returns `ok: false` if ANY error-level issue is found —
33
+ * the installer must then abort and leave the target untouched.
34
+ */
35
+ export declare function validateStagedSkill(entryText: string | null, files: StagedFile[], dirName: string, parse: (source: string) => {
36
+ data: Record<string, string | string[]>;
37
+ } | null): StagedValidationResult;