@suluk/agents 0.1.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/package.json +33 -0
- package/src/conformance.ts +97 -0
- package/src/context.ts +256 -0
- package/src/index.ts +44 -0
- package/src/lint.ts +118 -0
- package/src/manifest.ts +141 -0
- package/src/model-select.ts +54 -0
- package/src/policy.ts +210 -0
- package/src/project.ts +156 -0
- package/src/resolve.ts +110 -0
- package/src/scope.ts +78 -0
- package/src/skill.ts +39 -0
- package/test/conformance.test.ts +34 -0
- package/test/context.test.ts +167 -0
- package/test/core-boundary.test.ts +38 -0
- package/test/fixtures/conin.ts +112 -0
- package/test/lint.test.ts +62 -0
- package/test/manifest.test.ts +41 -0
- package/test/model-select.test.ts +56 -0
- package/test/policy.test.ts +103 -0
- package/test/project.test.ts +103 -0
- package/test/scope.test.ts +27 -0
- package/test/signing.integration.test.ts +45 -0
- package/tsconfig.json +1 -0
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signable AGENT MANIFEST (C027 marketplace reuse, C021) — a deterministic, canonical descriptor of an agent
|
|
3
|
+
* and its reachable sub-tree, suitable for distribution through the signed marketplace. It deliberately INCLUDES
|
|
4
|
+
* every skill's `contentHash`, so signing the manifest (with @suluk/builder's `signRegistry`) covers the served
|
|
5
|
+
* preprompt: a preprompt that drifts AFTER the signature is minted is a detectable unsigned change — `verifyAgentFreshness`
|
|
6
|
+
* catches it (the C021 supply-chain concern, council open-Q #8). It also carries the effective (intersection) scope of
|
|
7
|
+
* every node + any escalations, so worst-case authz reach is auditable from the signed artifact alone.
|
|
8
|
+
*
|
|
9
|
+
* Pure + crypto-free: the signing/verifying lives in @suluk/builder; this package only produces what gets signed.
|
|
10
|
+
*/
|
|
11
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
12
|
+
import type { ModelCatalog } from "@suluk/models";
|
|
13
|
+
import { agentMap } from "./resolve";
|
|
14
|
+
import { reachableSurface, type ConformanceFinding } from "./conformance";
|
|
15
|
+
import { analyzeScopes, type Scope, type ScopeEscalation } from "./scope";
|
|
16
|
+
import { effectiveUnderPolicies, policiesFor } from "./policy";
|
|
17
|
+
import { contextReport } from "./context";
|
|
18
|
+
import { skillModels } from "./model-select";
|
|
19
|
+
import { contentHash } from "./skill";
|
|
20
|
+
|
|
21
|
+
export interface AgentManifestSkill {
|
|
22
|
+
name: string;
|
|
23
|
+
model: string[];
|
|
24
|
+
tier?: "resident" | "cold-tail";
|
|
25
|
+
source?: string;
|
|
26
|
+
/** the pinned hash of the served instructions — what the signature ends up covering. */
|
|
27
|
+
contentHash?: string;
|
|
28
|
+
version?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface AgentManifestRoute {
|
|
31
|
+
name: string;
|
|
32
|
+
operationRef: string;
|
|
33
|
+
guarantee?: "same-in-same-out" | "idempotent" | "safe";
|
|
34
|
+
}
|
|
35
|
+
/** The operator-effective surface after x-suluk-policy narrowing (present only when a policy governs the agent). */
|
|
36
|
+
export interface AgentManifestGoverned {
|
|
37
|
+
scope: Scope;
|
|
38
|
+
maxDepth?: number;
|
|
39
|
+
nestingForbidden: boolean;
|
|
40
|
+
allowedTools: string[];
|
|
41
|
+
deniedTools: string[];
|
|
42
|
+
allowedSubAgents: string[];
|
|
43
|
+
}
|
|
44
|
+
export interface AgentManifestNode {
|
|
45
|
+
name: string;
|
|
46
|
+
description: string;
|
|
47
|
+
/** effective scope after intersection along the reaching path (null = unconstrained). */
|
|
48
|
+
effectiveScope: Scope;
|
|
49
|
+
skills: AgentManifestSkill[];
|
|
50
|
+
routes: AgentManifestRoute[];
|
|
51
|
+
subAgents: string[];
|
|
52
|
+
/** operator-effective surface after x-suluk-policy (C028) — so the C021 signature covers the operator's caps. */
|
|
53
|
+
governed?: AgentManifestGoverned;
|
|
54
|
+
/** catalog-pinned model selection per skill (present only when agentManifest is given a catalog) — reproducible: the
|
|
55
|
+
* snapshotHash is signed, so a re-pick week-over-week with no author edit is auditable (C027 contentHash discipline). */
|
|
56
|
+
modelSelection?: { skill: string; ids: string[]; from: "declared" | "selected"; snapshotHash: string | null }[];
|
|
57
|
+
}
|
|
58
|
+
export interface AgentManifest {
|
|
59
|
+
manifestVersion: 1;
|
|
60
|
+
agent: string;
|
|
61
|
+
/** the root + every transitively-reachable sub-agent, sorted by name (canonical). */
|
|
62
|
+
nodes: AgentManifestNode[];
|
|
63
|
+
/** the statically-enumerable worst-case reachable surface. */
|
|
64
|
+
reachable: { tools: string[]; agents: string[] };
|
|
65
|
+
/** any per-edge scope escalations (an installable agent has none). */
|
|
66
|
+
escalations: ScopeEscalation[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const byName = <T extends { name: string }>(xs: T[]) => [...xs].sort((a, b) => a.name.localeCompare(b.name));
|
|
70
|
+
|
|
71
|
+
/** Build the canonical, signable manifest for an agent and its reachable sub-tree. Pure; does not throw. */
|
|
72
|
+
export function agentManifest(doc: OpenAPIv4Document, agentName: string, opts: { catalog?: ModelCatalog } = {}): AgentManifest {
|
|
73
|
+
const map = agentMap(doc);
|
|
74
|
+
const { effective, escalations } = analyzeScopes(doc, agentName);
|
|
75
|
+
const reach = reachableSurface(doc, agentName);
|
|
76
|
+
const nodeKeys = [agentName, ...reach.agents].filter((k) => map[k]).sort((a, b) => a.localeCompare(b));
|
|
77
|
+
// per-agent peak load → the hard min-context gate, when a catalog is supplied (drives the model selection fold)
|
|
78
|
+
const minWinByAgent: Record<string, number> = {};
|
|
79
|
+
if (opts.catalog) for (const l of contextReport(doc, { catalog: opts.catalog }).loads) minWinByAgent[l.agent] = l.minWindowRequired;
|
|
80
|
+
|
|
81
|
+
const nodes: AgentManifestNode[] = nodeKeys.map((key) => {
|
|
82
|
+
const a = map[key];
|
|
83
|
+
const skills: AgentManifestSkill[] = byName(
|
|
84
|
+
Object.entries(a.skills ?? {}).map(([name, s]) => ({
|
|
85
|
+
name,
|
|
86
|
+
model: s.model ?? [],
|
|
87
|
+
...(s.tier ? { tier: s.tier } : {}),
|
|
88
|
+
...(s.provenance?.source ? { source: s.provenance.source } : {}),
|
|
89
|
+
...(s.provenance?.contentHash ? { contentHash: s.provenance.contentHash } : {}),
|
|
90
|
+
...(s.provenance?.version ? { version: s.provenance.version } : {}),
|
|
91
|
+
})),
|
|
92
|
+
);
|
|
93
|
+
const routes: AgentManifestRoute[] = byName(
|
|
94
|
+
Object.entries(a.routes ?? {}).map(([name, r]) => ({ name, operationRef: r.operationRef, ...(r.guarantee ? { guarantee: r.guarantee } : {}) })),
|
|
95
|
+
);
|
|
96
|
+
const subAgents = Object.values(a.agents ?? {}).map((r) => r.ref).sort();
|
|
97
|
+
let governed: AgentManifestGoverned | undefined;
|
|
98
|
+
if (policiesFor(doc, key).length > 0) {
|
|
99
|
+
const e = effectiveUnderPolicies(doc, key).effective;
|
|
100
|
+
governed = {
|
|
101
|
+
scope: e.scope,
|
|
102
|
+
...(e.maxDepth !== undefined ? { maxDepth: e.maxDepth } : {}),
|
|
103
|
+
nestingForbidden: e.nestingForbidden,
|
|
104
|
+
allowedTools: [...e.allowedTools].sort(),
|
|
105
|
+
deniedTools: [...e.deniedTools].sort(),
|
|
106
|
+
allowedSubAgents: [...e.allowedSubAgents].sort(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
let modelSelection: AgentManifestNode["modelSelection"];
|
|
110
|
+
if (opts.catalog) {
|
|
111
|
+
modelSelection = Object.keys(a.skills ?? {}).sort().map((sk) => {
|
|
112
|
+
const r = skillModels(doc, key, sk, opts.catalog!, minWinByAgent[key]);
|
|
113
|
+
return { skill: sk, ids: r.ids, from: r.from, snapshotHash: r.snapshotHash };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return { name: key, description: a.description, effectiveScope: effective[key] ?? null, skills, routes, subAgents, ...(governed ? { governed } : {}), ...(modelSelection ? { modelSelection } : {}) };
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { manifestVersion: 1, agent: agentName, nodes, reachable: reach, escalations };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Verify a signed manifest's skills against the CURRENT served snapshots: each skill's signed `contentHash` must
|
|
124
|
+
* equal the hash of its current snapshot. A mismatch ⇒ the served preprompt drifted after the signature was minted
|
|
125
|
+
* (a stale/unsigned change). A skill with no declared `contentHash` ⇒ unpinned (drift undetectable). Snapshots are
|
|
126
|
+
* keyed `"<agentKey>/<skillName>"`; a skill with no provided snapshot is skipped (cannot be checked here).
|
|
127
|
+
*/
|
|
128
|
+
export function verifyAgentFreshness(manifest: AgentManifest, snapshots: Record<string, string>): ConformanceFinding[] {
|
|
129
|
+
const out: ConformanceFinding[] = [];
|
|
130
|
+
for (const node of manifest.nodes) {
|
|
131
|
+
for (const skill of node.skills) {
|
|
132
|
+
const key = `${node.name}/${skill.name}`;
|
|
133
|
+
if (!skill.contentHash) { out.push({ code: "unpinned-skill", detail: `${key}: no contentHash — drift undetectable` }); continue; }
|
|
134
|
+
const snap = snapshots[key];
|
|
135
|
+
if (snap === undefined) continue;
|
|
136
|
+
const now = contentHash(snap);
|
|
137
|
+
if (now !== skill.contentHash) out.push({ code: "stale-skill", detail: `${key}: signed contentHash ${skill.contentHash} ≠ current ${now} — served instructions drifted after mint` });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MODEL-SELECTION seam (C027 × @suluk/models) — a skill declares NEEDS, not a frozen model id, and the catalog
|
|
3
|
+
* picks the best CURRENT model. Requirements are DERIVED from the agent structure (tool-bearing ⇒ needs tool-calling)
|
|
4
|
+
* + the context analyzer's `minWindowRequired` (the agent's multi-round peak load becomes the hard min-context gate)
|
|
5
|
+
* + the skill's explicit `modelRequire` + the C028 `modelAllowlist` MEET across governing policies. Preferences come
|
|
6
|
+
* from the skill's `modelProfile`/`modelPrefer`. An explicit `model[]` (with no profile/prefer) is the author's
|
|
7
|
+
* opt-out — returned verbatim.
|
|
8
|
+
*/
|
|
9
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
10
|
+
import { selectModel, deriveRequirements, type ModelCatalog, type SelectResult, type Preferences } from "@suluk/models";
|
|
11
|
+
import { agentMap } from "./resolve";
|
|
12
|
+
import { policiesFor } from "./policy";
|
|
13
|
+
|
|
14
|
+
export interface SkillModelResolution {
|
|
15
|
+
ids: string[];
|
|
16
|
+
from: "declared" | "selected";
|
|
17
|
+
/** the selector result (filter trace + per-axis why + coverage gaps) when `from === "selected"`. */
|
|
18
|
+
selection?: SelectResult;
|
|
19
|
+
/** the catalog snapshot the pick was made against — reproducibility (null when declared). */
|
|
20
|
+
snapshotHash: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The C028 modelAllowlist MEET across every policy governing this agent (intersection of the present allowlists). */
|
|
24
|
+
function effectiveAllowlist(doc: OpenAPIv4Document, agentName: string): string[] | undefined {
|
|
25
|
+
const lists = policiesFor(doc, agentName).map((p) => p.modelAllowlist).filter((a): a is string[] => Array.isArray(a) && a.length > 0);
|
|
26
|
+
if (!lists.length) return undefined;
|
|
27
|
+
return lists.reduce((acc, a) => acc.filter((x) => a.includes(x)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Run the catalog selector for a skill from its declared NEEDS + the analyzer load. */
|
|
31
|
+
export function resolveSkillModels(doc: OpenAPIv4Document, agentName: string, skillName: string, catalog: ModelCatalog, minWindowRequired?: number): SelectResult {
|
|
32
|
+
const agent = agentMap(doc)[agentName];
|
|
33
|
+
const skill = agent?.skills?.[skillName];
|
|
34
|
+
const allowlist = effectiveAllowlist(doc, agentName);
|
|
35
|
+
const minWin = Math.max(minWindowRequired ?? 0, skill?.modelRequire?.minContext ?? 0);
|
|
36
|
+
const reqs = deriveRequirements({
|
|
37
|
+
minWindowRequired: minWin > 0 ? minWin : undefined,
|
|
38
|
+
hasRoutes: Object.keys(agent?.routes ?? {}).length > 0,
|
|
39
|
+
needsStructured: skill?.modelRequire?.needsStructured,
|
|
40
|
+
inputModalities: skill?.modelRequire?.inputModalities,
|
|
41
|
+
policy: allowlist ? { modelAllowlist: allowlist } : undefined,
|
|
42
|
+
});
|
|
43
|
+
const prefs: Preferences = { profile: skill?.modelProfile, prefer: skill?.modelPrefer };
|
|
44
|
+
return selectModel(reqs, prefs, catalog);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The public seam: the models for a skill — its DECLARED list (opt-out) or the catalog-SELECTED ranked ids. */
|
|
48
|
+
export function skillModels(doc: OpenAPIv4Document, agentName: string, skillName: string, catalog: ModelCatalog, minWindowRequired?: number): SkillModelResolution {
|
|
49
|
+
const skill = agentMap(doc)[agentName]?.skills?.[skillName];
|
|
50
|
+
// explicit model[] with no profile/prefer ⇒ the author opted out of catalog selection
|
|
51
|
+
if (skill?.model?.length && !skill.modelProfile && !skill.modelPrefer) return { ids: skill.model, from: "declared", snapshotHash: null };
|
|
52
|
+
const selection = resolveSkillModels(doc, agentName, skillName, catalog, minWindowRequired);
|
|
53
|
+
return { ids: selection.ranked.map((r) => r.id), from: "selected", selection, snapshotHash: catalog.snapshotHash };
|
|
54
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator governance overlay (C028) — `x-suluk-policy`. An OPERATOR-owned policy NARROWS what an agent
|
|
3
|
+
* self-declares: effective = INTERSECT(operatorPolicy, agentSelfDeclaration), a total, order-independent MEET that
|
|
4
|
+
* NEVER EXCEEDS either input on any axis (scope/tier/model/depth/tools/sub-agents). This is the security floor —
|
|
5
|
+
* monotone-narrowing-only; a widening result is a lint HARD-FAIL, not a precedence tiebreak.
|
|
6
|
+
*
|
|
7
|
+
* SCOPE OF THIS MODULE: the statically-decidable subset only. The `costCeiling` is DECLARED here (the operator's
|
|
8
|
+
* third number, cap/estimate/actual) but the schema cannot ENFORCE it — `enforcedBy` names a runtime adapter, and a
|
|
9
|
+
* cap-breaching run is a NAMED conformance failure, never silently honored. The terminate-at-spend kill-switch is
|
|
10
|
+
* RESERVED, built-by-nobody until the reopen-trigger (C028).
|
|
11
|
+
*/
|
|
12
|
+
import type { OpenAPIv4Document, SulukAgent, SulukPolicy } from "@suluk/core";
|
|
13
|
+
import { agentMap, deepStrings, parsePointer } from "./resolve";
|
|
14
|
+
import { intersectScope, type Scope } from "./scope";
|
|
15
|
+
import type { LintFinding, Severity } from "./lint";
|
|
16
|
+
|
|
17
|
+
const TIER_RANK = { resident: 0, "cold-tail": 1 } as const;
|
|
18
|
+
type Tier = keyof typeof TIER_RANK;
|
|
19
|
+
|
|
20
|
+
/** deny/allow membership: an `allow` list (when present) is the ONLY permitted set; `deny` removes further. */
|
|
21
|
+
const passes = (key: string, f?: { deny?: string[]; allow?: string[] }): boolean => {
|
|
22
|
+
if (!f) return true;
|
|
23
|
+
if (f.allow && !f.allow.includes(key)) return false;
|
|
24
|
+
if (f.deny && f.deny.includes(key)) return false;
|
|
25
|
+
return true;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface EffectiveSkill {
|
|
29
|
+
name: string;
|
|
30
|
+
/** INTERSECT(skill.model, policy.modelAllowlist). */
|
|
31
|
+
model: string[];
|
|
32
|
+
tier?: Tier;
|
|
33
|
+
/** false ⇒ model ∩ allowlist = ∅: the operator's allowlist leaves this skill no model to run. */
|
|
34
|
+
usable: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface EffectiveAgent {
|
|
37
|
+
agent: string;
|
|
38
|
+
/** INTERSECT(agent.scope, policy.scopeAllowlist). */
|
|
39
|
+
scope: Scope;
|
|
40
|
+
maxDepth?: number;
|
|
41
|
+
nestingForbidden: boolean;
|
|
42
|
+
skills: EffectiveSkill[];
|
|
43
|
+
allowedTools: string[];
|
|
44
|
+
deniedTools: string[];
|
|
45
|
+
allowedSubAgents: string[];
|
|
46
|
+
deniedSubAgents: string[];
|
|
47
|
+
}
|
|
48
|
+
export interface PolicyNarrowing {
|
|
49
|
+
axis: "scope" | "tier" | "model" | "maxDepth" | "tools" | "retrievalTools" | "subAgents" | "nesting";
|
|
50
|
+
detail: string;
|
|
51
|
+
}
|
|
52
|
+
export interface PolicyConstrainResult {
|
|
53
|
+
effective: EffectiveAgent;
|
|
54
|
+
narrowings: PolicyNarrowing[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Does this policy govern `agentKey`? (empty/absent appliesTo ⇒ all agents.) */
|
|
58
|
+
export function policyAppliesTo(policy: SulukPolicy, agentKey: string): boolean {
|
|
59
|
+
const refs = policy.appliesTo;
|
|
60
|
+
if (!refs || refs.length === 0) return true;
|
|
61
|
+
return refs.some((r) => { const t = parsePointer(r); return t && t.length === 2 && t[0] === "x-suluk-agents" && t[1] === agentKey; });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** All policies in the document that govern `agentKey`. */
|
|
65
|
+
export function policiesFor(doc: OpenAPIv4Document, agentKey: string): SulukPolicy[] {
|
|
66
|
+
return Object.values(doc["x-suluk-policy"] ?? {}).filter((p) => policyAppliesTo(p, agentKey));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Apply ONE operator policy to an agent — a monotone MEET. Returns the narrowed envelope + an audit of every cut. */
|
|
70
|
+
export function policyConstrain(agentName: string, agent: SulukAgent, policy: SulukPolicy): PolicyConstrainResult {
|
|
71
|
+
const narrowings: PolicyNarrowing[] = [];
|
|
72
|
+
const note = (axis: PolicyNarrowing["axis"], detail: string) => narrowings.push({ axis, detail });
|
|
73
|
+
|
|
74
|
+
// scope: INTERSECT(agent.scope, scopeAllowlist)
|
|
75
|
+
const declaredScope: Scope = agent.scope ?? null;
|
|
76
|
+
const scope = intersectScope(declaredScope, policy.scopeAllowlist ?? null);
|
|
77
|
+
if (declaredScope && scope && scope.length < declaredScope.length)
|
|
78
|
+
note("scope", `scope narrowed ${declaredScope.length}→${scope.length} (removed ${declaredScope.filter((s) => !scope.includes(s)).join(", ")})`);
|
|
79
|
+
|
|
80
|
+
// tier + model per skill
|
|
81
|
+
const skills: EffectiveSkill[] = Object.entries(agent.skills ?? {}).map(([name, s]) => {
|
|
82
|
+
let tier = s.tier as Tier | undefined;
|
|
83
|
+
if (policy.capTier && tier && TIER_RANK[tier] > TIER_RANK[policy.capTier]) { note("tier", `skill "${name}" tier ${tier}→${policy.capTier} (capped)`); tier = policy.capTier; }
|
|
84
|
+
let model = s.model ?? [];
|
|
85
|
+
if (policy.modelAllowlist) {
|
|
86
|
+
const before = model.length;
|
|
87
|
+
model = model.filter((m) => policy.modelAllowlist!.includes(m));
|
|
88
|
+
if (model.length < before) note("model", `skill "${name}" models ${before}→${model.length} (modelAllowlist)`);
|
|
89
|
+
}
|
|
90
|
+
return { name, model, ...(tier ? { tier } : {}), usable: !policy.modelAllowlist || model.length > 0 };
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// tools (routes); retrievalTools is an ADDITIONAL filter on an untrusted tier
|
|
94
|
+
const isUntrusted = agent.trustBoundary === "untrusted";
|
|
95
|
+
const allowedTools: string[] = [], deniedTools: string[] = [];
|
|
96
|
+
for (const rk of Object.keys(agent.routes ?? {})) {
|
|
97
|
+
const ok = passes(rk, policy.tools) && (isUntrusted ? passes(rk, policy.retrievalTools) : true);
|
|
98
|
+
(ok ? allowedTools : deniedTools).push(rk);
|
|
99
|
+
}
|
|
100
|
+
if (deniedTools.length) note(isUntrusted && policy.retrievalTools ? "retrievalTools" : "tools", `denied tools: ${deniedTools.join(", ")}`);
|
|
101
|
+
|
|
102
|
+
// sub-agents: deny/allow, and forbidNesting wipes them all
|
|
103
|
+
const nestingForbidden = !!policy.forbidNesting;
|
|
104
|
+
const allowedSubAgents: string[] = [], deniedSubAgents: string[] = [];
|
|
105
|
+
for (const local of Object.keys(agent.agents ?? {})) {
|
|
106
|
+
const ok = !nestingForbidden && passes(local, policy.agents);
|
|
107
|
+
(ok ? allowedSubAgents : deniedSubAgents).push(local);
|
|
108
|
+
}
|
|
109
|
+
if (nestingForbidden && Object.keys(agent.agents ?? {}).length) note("nesting", "forbidNesting ⇒ all sub-agents removed (effective maxDepth 0)");
|
|
110
|
+
else if (deniedSubAgents.length) note("subAgents", `denied sub-agents: ${deniedSubAgents.join(", ")}`);
|
|
111
|
+
|
|
112
|
+
// maxDepth: min(agent.maxDepth, maxDepthCap); forbidNesting ⇒ 0
|
|
113
|
+
let maxDepth = agent.maxDepth;
|
|
114
|
+
if (nestingForbidden) maxDepth = 0;
|
|
115
|
+
else if (policy.maxDepthCap !== undefined) {
|
|
116
|
+
const capped = Math.min(agent.maxDepth ?? policy.maxDepthCap, policy.maxDepthCap);
|
|
117
|
+
if (capped !== agent.maxDepth) note("maxDepth", `maxDepth ${agent.maxDepth ?? "∞"}→${capped} (maxDepthCap)`);
|
|
118
|
+
maxDepth = capped;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
effective: { agent: agentName, scope, ...(maxDepth !== undefined ? { maxDepth } : {}), nestingForbidden, skills, allowedTools, deniedTools, allowedSubAgents, deniedSubAgents },
|
|
123
|
+
narrowings,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Apply ALL governing policies to an agent (MEET is associative/commutative — compose left-to-right). */
|
|
128
|
+
export function effectiveUnderPolicies(doc: OpenAPIv4Document, agentName: string): PolicyConstrainResult {
|
|
129
|
+
const agent = agentMap(doc)[agentName];
|
|
130
|
+
const policies = policiesFor(doc, agentName);
|
|
131
|
+
if (!agent || policies.length === 0) {
|
|
132
|
+
return { effective: { agent: agentName, scope: agent?.scope ?? null, ...(agent?.maxDepth !== undefined ? { maxDepth: agent.maxDepth } : {}), nestingForbidden: false, skills: Object.entries(agent?.skills ?? {}).map(([name, s]) => ({ name, model: s.model ?? [], ...(s.tier ? { tier: s.tier as Tier } : {}), usable: true })), allowedTools: Object.keys(agent?.routes ?? {}), deniedTools: [], allowedSubAgents: Object.keys(agent?.agents ?? {}), deniedSubAgents: [] }, narrowings: [] };
|
|
133
|
+
}
|
|
134
|
+
// fold: apply each policy in turn by intersecting its result with the running effective via a synthetic agent
|
|
135
|
+
let merged = policyConstrain(agentName, agent, policies[0]);
|
|
136
|
+
for (const p of policies.slice(1)) {
|
|
137
|
+
const next = policyConstrain(agentName, agent, p);
|
|
138
|
+
merged = {
|
|
139
|
+
effective: {
|
|
140
|
+
agent: agentName,
|
|
141
|
+
scope: intersectScope(merged.effective.scope, next.effective.scope),
|
|
142
|
+
...(merged.effective.maxDepth !== undefined || next.effective.maxDepth !== undefined ? { maxDepth: Math.min(merged.effective.maxDepth ?? Infinity, next.effective.maxDepth ?? Infinity) } : {}),
|
|
143
|
+
nestingForbidden: merged.effective.nestingForbidden || next.effective.nestingForbidden,
|
|
144
|
+
skills: merged.effective.skills.map((s) => { const o = next.effective.skills.find((x) => x.name === s.name)!; return { name: s.name, model: s.model.filter((m) => o.model.includes(m)), ...(s.tier ? { tier: s.tier } : {}), usable: s.usable && o.usable }; }),
|
|
145
|
+
allowedTools: merged.effective.allowedTools.filter((t) => next.effective.allowedTools.includes(t)),
|
|
146
|
+
deniedTools: [...new Set([...merged.effective.deniedTools, ...next.effective.deniedTools])].sort(),
|
|
147
|
+
allowedSubAgents: merged.effective.allowedSubAgents.filter((a) => next.effective.allowedSubAgents.includes(a)),
|
|
148
|
+
deniedSubAgents: [...new Set([...merged.effective.deniedSubAgents, ...next.effective.deniedSubAgents])].sort(),
|
|
149
|
+
},
|
|
150
|
+
narrowings: [...merged.narrowings, ...next.narrowings],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return merged;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ───────────────────────────── lint ─────────────────────────────
|
|
157
|
+
|
|
158
|
+
const RUNTIME_EXPR = /\{\s*\$(request|response|method|url|statusCode|inputs)\b/i;
|
|
159
|
+
const microUsd = (amount: number, unit: "micro-usd" | "cents" | "usd") => amount * (unit === "usd" ? 1_000_000 : unit === "cents" ? 10_000 : 1);
|
|
160
|
+
|
|
161
|
+
/** Lint every operator policy: D1 selector-rejection, dangling appliesTo, unsatisfiability, widening, cap<estimate. */
|
|
162
|
+
export function lintPolicy(doc: OpenAPIv4Document): LintFinding[] {
|
|
163
|
+
const out: LintFinding[] = [];
|
|
164
|
+
const map = agentMap(doc);
|
|
165
|
+
const add = (severity: Severity, code: string, agent: string, detail: string, at?: string) => out.push({ severity, code, agent, detail, at });
|
|
166
|
+
|
|
167
|
+
for (const [pname, policy] of Object.entries(doc["x-suluk-policy"] ?? {})) {
|
|
168
|
+
// D1: no request-value selector anywhere in a policy
|
|
169
|
+
for (const { path, value } of deepStrings(policy))
|
|
170
|
+
if (RUNTIME_EXPR.test(value)) add("error", "request-value-selector", pname, `D1: a request-value runtime-expression is forbidden in a policy ("${value}")`, path);
|
|
171
|
+
|
|
172
|
+
// appliesTo must bind by agent name (and resolve)
|
|
173
|
+
for (const ref of policy.appliesTo ?? []) {
|
|
174
|
+
const t = parsePointer(ref);
|
|
175
|
+
if (!t || t.length !== 2 || t[0] !== "x-suluk-agents") add("error", "policy-applies-malformed", pname, `appliesTo "${ref}" must be a #/x-suluk-agents/<key> ref (never a request predicate)`);
|
|
176
|
+
else if (!map[t[1]]) add("error", "policy-applies-dangling", pname, `appliesTo refs a missing agent: ${t[1]}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// per governed agent: unsatisfiability + monotone-narrowing guard + cap<estimate
|
|
180
|
+
for (const [aname, agent] of Object.entries(map)) {
|
|
181
|
+
if (!policyAppliesTo(policy, aname)) continue;
|
|
182
|
+
const { effective } = policyConstrain(aname, agent, policy);
|
|
183
|
+
|
|
184
|
+
for (const s of effective.skills)
|
|
185
|
+
if (!s.usable) add("error", "policy-unsatisfiable", pname, `modelAllowlist leaves skill "${s.name}" of agent "${aname}" no runnable model`, `${aname}.skills.${s.name}`);
|
|
186
|
+
if (Object.keys(agent.routes ?? {}).length > 0 && effective.allowedTools.length === 0)
|
|
187
|
+
add("error", "policy-unsatisfiable", pname, `policy denies EVERY tool of agent "${aname}" — the agent can do nothing`, aname);
|
|
188
|
+
|
|
189
|
+
// monotone guard (defensive — MEET cannot widen; a widening here is a bug)
|
|
190
|
+
if (effective.scope && agent.scope && effective.scope.some((s) => !agent.scope!.includes(s)))
|
|
191
|
+
add("error", "policy-widening", pname, `policy WIDENED agent "${aname}" scope — inadmissible (effective must INTERSECT, never grant)`, aname);
|
|
192
|
+
|
|
193
|
+
// cap-below-estimate cross-facet (pure static; warning — the operator under-budgeted vs the author's own number)
|
|
194
|
+
const cost = agent["x-suluk-cost"] as { estimateMicroUsd?: number; amount?: number } | undefined;
|
|
195
|
+
const est = cost?.estimateMicroUsd ?? cost?.amount;
|
|
196
|
+
if (policy.costCeiling && typeof est === "number") {
|
|
197
|
+
const cap = microUsd(policy.costCeiling.amount, policy.costCeiling.amountUnit);
|
|
198
|
+
if (cap < est) add("warning", "cap-below-estimate", pname, `costCeiling (${cap} µ$) is below agent "${aname}" own estimate (${est} µ$) — operator under-budgeted`, aname);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// honesty: enforcedBy is required by the type, but flag a costCeiling that omits a basis (ambiguous metering)
|
|
203
|
+
if (policy.costCeiling && !policy.costCeiling.basis)
|
|
204
|
+
add("warning", "cost-ceiling-no-basis", pname, "costCeiling has no `basis` — the metering window is ambiguous (declared, not enforced regardless)");
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** True ⇒ no error-severity policy findings. */
|
|
210
|
+
export const policyOk = (findings: LintFinding[]): boolean => !findings.some((f) => f.severity === "error");
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TWIN PROJECTION (C027) — one `x-suluk-agents` declaration → a Claude plugin AND an OpenRouter/OpenAI-compatible
|
|
3
|
+
* manifest. Both are PURE FUNCTIONS of (doc, agentName, opts): zero network at generate time (instruction snapshots
|
|
4
|
+
* are inputs, contentHash-pinned), deterministic, and the map KEY is the stable wire-level tool/function id on both.
|
|
5
|
+
* No credentials ever enter an artifact (the .mcp.json OAuth token is acquired host-side; C020/C023 upheld).
|
|
6
|
+
* Projection refuses a non-installable agent (fail-loud) — so a dangling operationRef or a missing maxDepth does
|
|
7
|
+
* NOT silently emit a broken artifact.
|
|
8
|
+
*/
|
|
9
|
+
import type { OpenAPIv4Document, SchemaOrRef, SulukSkillRef } from "@suluk/core";
|
|
10
|
+
import { agentMap, resolveOperationRef } from "./resolve";
|
|
11
|
+
import { assertAgentInstallable } from "./lint";
|
|
12
|
+
import { contentHash, renderSkillMd } from "./skill";
|
|
13
|
+
|
|
14
|
+
const stable = (v: unknown) => JSON.stringify(v, null, 2);
|
|
15
|
+
|
|
16
|
+
/** The first skill that declares a model — an agent's primary LLM tier (drives the model preference list). */
|
|
17
|
+
function primarySkill(skills?: Record<string, SulukSkillRef>): [string, SulukSkillRef] | null {
|
|
18
|
+
for (const [k, s] of Object.entries(skills ?? {})) if (s.model && s.model.length) return [k, s];
|
|
19
|
+
const first = Object.entries(skills ?? {})[0];
|
|
20
|
+
return first ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ───────────────────────────── Claude plugin projection ─────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface ClaudePluginOptions {
|
|
26
|
+
/** the HTTP MCP endpoint the plugin connects to (e.g. https://host/mcp). */
|
|
27
|
+
mcpUrl: string;
|
|
28
|
+
version?: string;
|
|
29
|
+
displayName?: string;
|
|
30
|
+
homepage?: string;
|
|
31
|
+
keywords?: string[];
|
|
32
|
+
author?: { name: string; email?: string };
|
|
33
|
+
/** instruction snapshots per skill name (the pinned served content); a skill without one emits no SKILL.md. */
|
|
34
|
+
instructions?: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ClaudePluginArtifacts {
|
|
38
|
+
/** path → content; e.g. "plugin.json", ".mcp.json", "skills/operate/SKILL.md". */
|
|
39
|
+
files: Record<string, string>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function projectClaudePlugin(doc: OpenAPIv4Document, agentName: string, opts: ClaudePluginOptions): ClaudePluginArtifacts {
|
|
43
|
+
assertAgentInstallable(doc, agentName);
|
|
44
|
+
const agent = agentMap(doc)[agentName];
|
|
45
|
+
|
|
46
|
+
const pluginJson = {
|
|
47
|
+
name: agentName,
|
|
48
|
+
...(opts.displayName ? { displayName: opts.displayName } : {}),
|
|
49
|
+
description: agent.description,
|
|
50
|
+
version: opts.version ?? "0.1.0",
|
|
51
|
+
...(opts.author ? { author: opts.author } : {}),
|
|
52
|
+
...(opts.homepage ? { homepage: opts.homepage } : {}),
|
|
53
|
+
...(opts.keywords ? { keywords: opts.keywords } : {}),
|
|
54
|
+
mcpServers: "./.mcp.json",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// HTTP MCP with host-side OAuth — NO token embedded (creds never cross the seam; C020/C023).
|
|
58
|
+
const mcpJson = { mcpServers: { [agentName]: { type: "http", url: opts.mcpUrl, oauth: {} } } };
|
|
59
|
+
|
|
60
|
+
const files: Record<string, string> = {
|
|
61
|
+
"plugin.json": stable(pluginJson),
|
|
62
|
+
".mcp.json": stable(mcpJson),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (const [sk, skill] of Object.entries(agent.skills ?? {})) {
|
|
66
|
+
const text = opts.instructions?.[sk];
|
|
67
|
+
if (text === undefined) continue; // no snapshot supplied → no generated SKILL.md (honest: we never invent text)
|
|
68
|
+
files[`skills/${sk}/SKILL.md`] = renderSkillMd({
|
|
69
|
+
name: sk,
|
|
70
|
+
description: skill.whenToUse ?? agent.description,
|
|
71
|
+
instructions: text,
|
|
72
|
+
source: skill.provenance?.source,
|
|
73
|
+
version: skill.provenance?.version,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { files };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ──────────────────────────── OpenRouter / OpenAI-compatible ────────────────────────────
|
|
81
|
+
|
|
82
|
+
export interface OpenRouterFunctionTool {
|
|
83
|
+
type: "function";
|
|
84
|
+
function: { name: string; description: string; parameters: SchemaOrRef };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface OpenRouterAgentManifest {
|
|
88
|
+
name: string;
|
|
89
|
+
/** model preference list (cheap→capable) from the primary skill; the OpenRouter ids to try in order. */
|
|
90
|
+
model: string[];
|
|
91
|
+
tier?: "resident" | "cold-tail";
|
|
92
|
+
/** a POINTER to the served instructions + the pinned hash — never inlined creds, never the full text by default. */
|
|
93
|
+
instructions: { source?: string; contentHash?: string; version?: string };
|
|
94
|
+
/**
|
|
95
|
+
* The DEFAULT tool surface — RESIDENT routes only, plus a synthetic `discover_tools` when cold-tail routes exist.
|
|
96
|
+
* This is the tier-trim: the cheap/lower tier carries a SMALLER tool surface (the conditional context reduction).
|
|
97
|
+
*/
|
|
98
|
+
tools: OpenRouterFunctionTool[];
|
|
99
|
+
/** COLD-TAIL routes — NOT in the default surface; revealed on demand via `discover_tools`. */
|
|
100
|
+
discoverable: OpenRouterFunctionTool[];
|
|
101
|
+
/** sub-agents → one front-door tool each (dispatched as a NEW completion at the child's tier). */
|
|
102
|
+
subAgents: { name: string; ref: string }[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The synthetic meta-tool that reveals cold-tail tools — present in the default surface only when some exist. */
|
|
106
|
+
const DISCOVER_TOOLS_FN: OpenRouterFunctionTool = {
|
|
107
|
+
type: "function",
|
|
108
|
+
function: {
|
|
109
|
+
name: "discover_tools",
|
|
110
|
+
description: "Reveal additional cold-tail tools for this agent on demand. They are kept OUT of the default tool list so the resident surface stays small — call this to widen it (reorder/lazy-load, never widen beyond the declared reachable set).",
|
|
111
|
+
parameters: { type: "object" },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export interface OpenRouterOptions {
|
|
116
|
+
/** instruction snapshots per skill name; when given for the primary skill, the manifest carries the computed hash. */
|
|
117
|
+
instructions?: Record<string, string>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function projectOpenRouter(doc: OpenAPIv4Document, agentName: string, opts: OpenRouterOptions = {}): OpenRouterAgentManifest {
|
|
121
|
+
assertAgentInstallable(doc, agentName);
|
|
122
|
+
const agent = agentMap(doc)[agentName];
|
|
123
|
+
const prim = primarySkill(agent.skills);
|
|
124
|
+
const [primName, primSkill] = prim ?? [undefined, undefined as SulukSkillRef | undefined];
|
|
125
|
+
|
|
126
|
+
const snapshot = primName ? opts.instructions?.[primName] : undefined;
|
|
127
|
+
const instructions = {
|
|
128
|
+
source: primSkill?.provenance?.source,
|
|
129
|
+
contentHash: snapshot !== undefined ? contentHash(snapshot) : primSkill?.provenance?.contentHash,
|
|
130
|
+
version: primSkill?.provenance?.version,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const built = Object.entries(agent.routes ?? {}).map(([rk, route]) => {
|
|
134
|
+
const req = resolveOperationRef(doc, route.operationRef)?.request; // installable ⇒ non-null
|
|
135
|
+
const parameters: SchemaOrRef = (req?.contentSchema ?? req?.parameterSchema?.body ?? { type: "object" }) as SchemaOrRef;
|
|
136
|
+
const description = req?.summary ?? req?.description ?? `route ${rk} (${route.guarantee ?? "declared"})`;
|
|
137
|
+
const fn: OpenRouterFunctionTool = { type: "function", function: { name: rk, description, parameters } };
|
|
138
|
+
return { tier: route.tier ?? "resident", fn };
|
|
139
|
+
});
|
|
140
|
+
const residentTools = built.filter((b) => b.tier !== "cold-tail").map((b) => b.fn);
|
|
141
|
+
const discoverable = built.filter((b) => b.tier === "cold-tail").map((b) => b.fn);
|
|
142
|
+
// the default surface is resident-only; expose `discover_tools` ONLY when there is something to discover
|
|
143
|
+
const tools = discoverable.length ? [...residentTools, DISCOVER_TOOLS_FN] : residentTools;
|
|
144
|
+
|
|
145
|
+
const subAgents = Object.entries(agent.agents ?? {}).map(([local, r]) => ({ name: local, ref: r.ref }));
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
name: agentName,
|
|
149
|
+
model: primSkill?.model ?? [],
|
|
150
|
+
...(primSkill?.tier ? { tier: primSkill.tier } : {}),
|
|
151
|
+
instructions,
|
|
152
|
+
tools,
|
|
153
|
+
discoverable,
|
|
154
|
+
subAgents,
|
|
155
|
+
};
|
|
156
|
+
}
|