@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
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { agentManifest, verifyAgentFreshness, contentHash } from "../src/index";
|
|
3
|
+
import { coninDoc, coninInstructions } from "./fixtures/conin";
|
|
4
|
+
|
|
5
|
+
describe("C027 signable agent manifest", () => {
|
|
6
|
+
const m = agentManifest(coninDoc, "conin");
|
|
7
|
+
|
|
8
|
+
test("canonical: root + reachable sub-tree, sorted, no escalations", () => {
|
|
9
|
+
expect(m.manifestVersion).toBe(1);
|
|
10
|
+
expect(m.agent).toBe("conin");
|
|
11
|
+
expect(m.nodes.map((n) => n.name)).toEqual(["conin", "coninRetrieval"]);
|
|
12
|
+
expect(m.escalations).toEqual([]);
|
|
13
|
+
expect(m.reachable.tools).toEqual(["find_comparables", "generate_deliverable", "run_core_primitive", "search_library"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("carries each skill's contentHash (so a signature over the manifest covers preprompt drift)", () => {
|
|
17
|
+
const conin = m.nodes.find((n) => n.name === "conin")!;
|
|
18
|
+
expect(conin.skills[0].contentHash).toBe("sha256-9f2c0000deadbeef");
|
|
19
|
+
expect(conin.effectiveScope).toEqual(["project:read", "deliverable:write", "library:read"]);
|
|
20
|
+
const retr = m.nodes.find((n) => n.name === "coninRetrieval")!;
|
|
21
|
+
expect(retr.effectiveScope).toEqual(["library:read"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("deterministic — twice equal", () => {
|
|
25
|
+
expect(agentManifest(coninDoc, "conin")).toEqual(m);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("freshness: a drifted served snapshot is caught against the signed contentHash", () => {
|
|
29
|
+
// declared hashes in the fixture are placeholders → any real snapshot drifts
|
|
30
|
+
const drift = verifyAgentFreshness(m, { "conin/operate": coninInstructions.operate });
|
|
31
|
+
expect(drift.map((f) => f.code)).toContain("stale-skill");
|
|
32
|
+
// a snapshot whose hash MATCHES the signed one is fresh
|
|
33
|
+
const matchHash = m.nodes.find((n) => n.name === "conin")!.skills[0].contentHash!;
|
|
34
|
+
const synthetic = "X".repeat(3);
|
|
35
|
+
// build a manifest whose skill hash equals the synthetic snapshot's hash, then verify fresh
|
|
36
|
+
const m2 = structuredClone(m);
|
|
37
|
+
m2.nodes.find((n) => n.name === "conin")!.skills[0].contentHash = contentHash(synthetic);
|
|
38
|
+
expect(verifyAgentFreshness(m2, { "conin/operate": synthetic }).filter((f) => f.code === "stale-skill")).toEqual([]);
|
|
39
|
+
expect(matchHash.startsWith("sha256-")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
3
|
+
import { skillModels, resolveSkillModels, SEED_CATALOG } from "../src/index";
|
|
4
|
+
|
|
5
|
+
/** An agent with routes (⇒ needs tool-calling) and two skills: a needs-based one + an explicit opt-out. */
|
|
6
|
+
function doc(): OpenAPIv4Document {
|
|
7
|
+
return {
|
|
8
|
+
openapi: "4.0.0-candidate",
|
|
9
|
+
info: { title: "x", version: "0" },
|
|
10
|
+
paths: { "v1/s": { requests: { search: { method: "get", responses: { ok: { status: 200 } } } } } },
|
|
11
|
+
"x-suluk-agents": {
|
|
12
|
+
conin: {
|
|
13
|
+
description: "orchestrator with a tool",
|
|
14
|
+
routes: { search: { operationRef: "#/paths/v1~1s/requests/search" } },
|
|
15
|
+
skills: {
|
|
16
|
+
operate: { modelProfile: "cheap-fast" }, // needs-based ⇒ catalog selects
|
|
17
|
+
legacy: { model: ["anthropic/claude-opus-4"] }, // explicit list ⇒ opt-out, returned verbatim
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
} as OpenAPIv4Document;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("C027 × @suluk/models — the model-selection seam", () => {
|
|
25
|
+
test("a needs-based skill resolves to a catalog SELECTION (tool-calling derived from the agent's routes)", () => {
|
|
26
|
+
const r = skillModels(doc(), "conin", "operate", SEED_CATALOG);
|
|
27
|
+
expect(r.from).toBe("selected");
|
|
28
|
+
expect(r.ids.length).toBeGreaterThan(0);
|
|
29
|
+
expect(r.snapshotHash).toBe(SEED_CATALOG.snapshotHash); // reproducible pin
|
|
30
|
+
// hasRoutes ⇒ needsTools was derived ⇒ the winner passed the tool-calling filter
|
|
31
|
+
expect(r.selection!.ranked[0].why.passedFilters).toContain("tool-calling");
|
|
32
|
+
// cheap-fast ⇒ a cheap model, never the premium ones
|
|
33
|
+
expect(["anthropic/claude-opus-4", "openai/gpt-5"]).not.toContain(r.ids[0]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("an explicit model[] with no profile is the author's OPT-OUT — returned verbatim", () => {
|
|
37
|
+
const r = skillModels(doc(), "conin", "legacy", SEED_CATALOG);
|
|
38
|
+
expect(r.from).toBe("declared");
|
|
39
|
+
expect(r.ids).toEqual(["anthropic/claude-opus-4"]);
|
|
40
|
+
expect(r.snapshotHash).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("the analyzer's minWindowRequired flows in as a hard min-context filter", () => {
|
|
44
|
+
// require a 500k window ⇒ only the 1M-window seed models survive
|
|
45
|
+
const sel = resolveSkillModels(doc(), "conin", "operate", SEED_CATALOG, 500000);
|
|
46
|
+
const ids = sel.ranked.map((x) => x.id);
|
|
47
|
+
expect(ids.every((id) => ["google/gemini-2.5-flash", "google/gemini-2.5-pro", "meta-llama/llama-4-maverick"].includes(id))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the C028 modelAllowlist is the TERMINAL MEET — selection is restricted to it", () => {
|
|
51
|
+
const d = doc();
|
|
52
|
+
d["x-suluk-policy"] = { fleet: { appliesTo: ["#/x-suluk-agents/conin"], modelAllowlist: ["google/gemini-2.5-flash"] } };
|
|
53
|
+
const r = skillModels(d, "conin", "operate", SEED_CATALOG);
|
|
54
|
+
expect(r.ids).toEqual(["google/gemini-2.5-flash"]); // even though cheap-fast might prefer gpt-4o-mini
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
3
|
+
import {
|
|
4
|
+
effectiveUnderPolicies, policyConstrain, lintPolicy, policyOk,
|
|
5
|
+
agentManifest, assertServedSubsetGoverned,
|
|
6
|
+
} from "../src/index";
|
|
7
|
+
import { coninDoc } from "./fixtures/conin";
|
|
8
|
+
|
|
9
|
+
/** Conin under an operator policy that narrows every axis. */
|
|
10
|
+
function governed(): OpenAPIv4Document {
|
|
11
|
+
const d = structuredClone(coninDoc);
|
|
12
|
+
d["x-suluk-policy"] = {
|
|
13
|
+
"acme-fleet": {
|
|
14
|
+
appliesTo: ["#/x-suluk-agents/conin", "#/x-suluk-agents/coninRetrieval"],
|
|
15
|
+
scopeAllowlist: ["project:read", "library:read"], // drops deliverable:write
|
|
16
|
+
tools: { deny: ["run_core_primitive"] },
|
|
17
|
+
capTier: "resident", // conin.operate is cold-tail → resident
|
|
18
|
+
modelAllowlist: ["google/gemini-2.5-flash"], // drops opus
|
|
19
|
+
maxDepthCap: 0,
|
|
20
|
+
forbidNesting: true, // removes the retrieval sub-agent
|
|
21
|
+
costCeiling: { amount: 5000, amountUnit: "micro-usd", basis: "per-request", enforcedBy: "adapter" },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
return d;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("C028 policyConstrain — monotone-narrowing MEET (effective = INTERSECT(operator, agent))", () => {
|
|
28
|
+
const d = governed();
|
|
29
|
+
const { effective, narrowings } = effectiveUnderPolicies(d, "conin");
|
|
30
|
+
|
|
31
|
+
test("narrows scope, model, tier, tools, depth, nesting — never widens", () => {
|
|
32
|
+
expect(effective.scope).toEqual(["project:read", "library:read"]); // deliverable:write removed
|
|
33
|
+
const operate = effective.skills.find((s) => s.name === "operate")!;
|
|
34
|
+
expect(operate.model).toEqual(["google/gemini-2.5-flash"]); // opus dropped
|
|
35
|
+
expect(operate.tier).toBe("resident"); // capped from cold-tail
|
|
36
|
+
expect(operate.usable).toBe(true);
|
|
37
|
+
expect(effective.allowedTools).toEqual(["generate_deliverable"]);
|
|
38
|
+
expect(effective.deniedTools).toEqual(["run_core_primitive"]);
|
|
39
|
+
expect(effective.nestingForbidden).toBe(true);
|
|
40
|
+
expect(effective.maxDepth).toBe(0);
|
|
41
|
+
expect(effective.deniedSubAgents).toEqual(["retrieval"]);
|
|
42
|
+
expect(narrowings.map((n) => n.axis).sort()).toEqual(["model", "nesting", "scope", "tier", "tools"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("the MEET never grants a capability the agent did not self-declare", () => {
|
|
46
|
+
// effective scope ⊆ agent scope
|
|
47
|
+
const declared = d["x-suluk-agents"]!.conin.scope!;
|
|
48
|
+
expect(effective.scope!.every((s) => declared.includes(s))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("C028 lintPolicy", () => {
|
|
53
|
+
test("a satisfiable, well-formed policy lints clean (no errors)", () => {
|
|
54
|
+
const findings = lintPolicy(governed());
|
|
55
|
+
expect(findings.filter((f) => f.severity === "error")).toEqual([]);
|
|
56
|
+
expect(policyOk(findings)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("NAMED failure: a modelAllowlist that excludes every model of a skill is unsatisfiable", () => {
|
|
60
|
+
const d = governed();
|
|
61
|
+
d["x-suluk-policy"]!["acme-fleet"].modelAllowlist = ["openai/gpt-9"]; // matches no Conin skill model
|
|
62
|
+
expect(lintPolicy(d).some((f) => f.code === "policy-unsatisfiable")).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("appliesTo must resolve to a real agent (dangling caught)", () => {
|
|
66
|
+
const d = governed();
|
|
67
|
+
d["x-suluk-policy"]!["acme-fleet"].appliesTo = ["#/x-suluk-agents/ghost"];
|
|
68
|
+
expect(lintPolicy(d).some((f) => f.code === "policy-applies-dangling")).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("D1: a request-value selector smuggled into a policy is forbidden", () => {
|
|
72
|
+
const d = governed();
|
|
73
|
+
d["x-suluk-policy"]!["acme-fleet"]["x-when"] = "{$request.body#/tier}";
|
|
74
|
+
expect(lintPolicy(d).some((f) => f.code === "request-value-selector")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("cap-below-estimate: an operator cap under the agent's own estimate is flagged (cross-facet, static)", () => {
|
|
78
|
+
const d = governed();
|
|
79
|
+
d["x-suluk-agents"]!.conin["x-suluk-cost"] = { estimateMicroUsd: 10000 };
|
|
80
|
+
// cap = 5000 µ$ < estimate 10000 µ$
|
|
81
|
+
expect(lintPolicy(d).some((f) => f.code === "cap-below-estimate")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("C028 manifest + conformance folds", () => {
|
|
86
|
+
test("the signed manifest carries the operator-effective surface (so the signature covers caps)", () => {
|
|
87
|
+
const m = agentManifest(governed(), "conin");
|
|
88
|
+
const conin = m.nodes.find((n) => n.name === "conin")!;
|
|
89
|
+
expect(conin.governed).toBeDefined();
|
|
90
|
+
expect(conin.governed!.allowedTools).toEqual(["generate_deliverable"]);
|
|
91
|
+
expect(conin.governed!.deniedTools).toEqual(["run_core_primitive"]);
|
|
92
|
+
expect(conin.governed!.nestingForbidden).toBe(true);
|
|
93
|
+
expect(conin.governed!.maxDepth).toBe(0);
|
|
94
|
+
// an ungoverned agent has no `governed` field
|
|
95
|
+
expect(agentManifest(coninDoc, "conin").nodes.find((n) => n.name === "conin")!.governed).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("NAMED failure: serving a policy-DENIED tool is an over-serve (operator cap not holding on the wire)", () => {
|
|
99
|
+
const findings = assertServedSubsetGoverned(governed(), "conin", ["generate_deliverable", "run_core_primitive"]);
|
|
100
|
+
expect(findings.map((f) => f.code)).toEqual(["policy-denied-served"]);
|
|
101
|
+
expect(findings[0].detail).toContain("run_core_primitive");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { projectClaudePlugin, projectOpenRouter, contentHash, residentSurface, assertDefaultServedResident } from "../src/index";
|
|
3
|
+
import { coninDoc, coninInstructions, coninDayOne } from "./fixtures/conin";
|
|
4
|
+
|
|
5
|
+
describe("C027 Claude-plugin projection", () => {
|
|
6
|
+
const plug = projectClaudePlugin(coninDoc, "conin", {
|
|
7
|
+
mcpUrl: "https://construction-intelligence.saastemly.com/mcp",
|
|
8
|
+
version: "1.0.0",
|
|
9
|
+
homepage: "https://construction-intelligence.saastemly.com",
|
|
10
|
+
instructions: coninInstructions,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("emits plugin.json + .mcp.json + a generated SKILL.md", () => {
|
|
14
|
+
expect(Object.keys(plug.files).sort()).toEqual([".mcp.json", "plugin.json", "skills/operate/SKILL.md"]);
|
|
15
|
+
const pj = JSON.parse(plug.files["plugin.json"]);
|
|
16
|
+
expect(pj.name).toBe("conin");
|
|
17
|
+
expect(pj.mcpServers).toBe("./.mcp.json");
|
|
18
|
+
expect(pj.description.length).toBeGreaterThan(10);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test(".mcp.json is HTTP MCP with host-side OAuth and NO embedded credential", () => {
|
|
22
|
+
const mj = JSON.parse(plug.files[".mcp.json"]);
|
|
23
|
+
expect(mj.mcpServers.conin.type).toBe("http");
|
|
24
|
+
expect(mj.mcpServers.conin.url).toContain("/mcp");
|
|
25
|
+
expect(mj.mcpServers.conin.oauth).toEqual({});
|
|
26
|
+
// no token / bearer / secret may ever appear in a projected artifact
|
|
27
|
+
expect(plug.files[".mcp.json"]).not.toMatch(/bearer|token|secret|api[_-]?key/i);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("SKILL.md carries the contentHash + version staleness stamp (the genuinely-missing feature)", () => {
|
|
31
|
+
const md = plug.files["skills/operate/SKILL.md"];
|
|
32
|
+
expect(md).toContain("name: operate");
|
|
33
|
+
expect(md).toContain(`contentHash: ${contentHash(coninInstructions.operate)}`);
|
|
34
|
+
expect(md).toContain("version: 2026-06-11");
|
|
35
|
+
expect(md).toContain("source: https://construction-intelligence.saastemly.com/v1/instructions");
|
|
36
|
+
expect(md.trimEnd().endsWith(coninInstructions.operate)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("projection is a PURE FUNCTION — same contract in, byte-identical artifacts out", () => {
|
|
40
|
+
const again = projectClaudePlugin(coninDoc, "conin", {
|
|
41
|
+
mcpUrl: "https://construction-intelligence.saastemly.com/mcp",
|
|
42
|
+
version: "1.0.0",
|
|
43
|
+
homepage: "https://construction-intelligence.saastemly.com",
|
|
44
|
+
instructions: coninInstructions,
|
|
45
|
+
});
|
|
46
|
+
expect(again).toEqual(plug);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("C027 OpenRouter projection", () => {
|
|
51
|
+
const m = projectOpenRouter(coninDoc, "conin", { instructions: coninInstructions });
|
|
52
|
+
|
|
53
|
+
test("routes → function tools keyed by the wire id; model preference from the primary skill", () => {
|
|
54
|
+
expect(m.model).toEqual(["anthropic/claude-opus-4", "google/gemini-2.5-flash"]);
|
|
55
|
+
expect(m.tier).toBe("cold-tail");
|
|
56
|
+
expect(m.tools.map((t) => t.function.name).sort()).toEqual(["generate_deliverable", "run_core_primitive"]);
|
|
57
|
+
expect(m.tools.every((t) => t.type === "function")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("instructions is a pointer + a pinned contentHash, never the raw text by default", () => {
|
|
61
|
+
expect(m.instructions.source).toContain("/v1/instructions");
|
|
62
|
+
expect(m.instructions.contentHash).toBe(contentHash(coninInstructions.operate));
|
|
63
|
+
expect(m.instructions.version).toBe("2026-06-11");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("sub-agents surface as front-door dispatch targets, by name", () => {
|
|
67
|
+
expect(m.subAgents).toEqual([{ name: "retrieval", ref: "#/x-suluk-agents/coninRetrieval" }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("deterministic — twice equal", () => {
|
|
71
|
+
expect(projectOpenRouter(coninDoc, "conin", { instructions: coninInstructions })).toEqual(m);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("C027 tier-trim — cold-tail routes leave the default served surface (the real context reduction)", () => {
|
|
76
|
+
const tiered = structuredClone(coninDoc);
|
|
77
|
+
tiered["x-suluk-agents"]!.conin.routes!.run_core_primitive.tier = "cold-tail";
|
|
78
|
+
const m = projectOpenRouter(tiered, "conin", { instructions: coninInstructions });
|
|
79
|
+
|
|
80
|
+
test("default tools[] = resident + discover_tools; cold-tail moves to discoverable[]", () => {
|
|
81
|
+
expect(m.tools.map((t) => t.function.name).sort()).toEqual(["discover_tools", "generate_deliverable"]);
|
|
82
|
+
expect(m.discoverable.map((t) => t.function.name)).toEqual(["run_core_primitive"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("no discover_tools meta when there is nothing to discover (all resident)", () => {
|
|
86
|
+
const allResident = projectOpenRouter(coninDoc, "conin", { instructions: coninInstructions });
|
|
87
|
+
expect(allResident.tools.some((t) => t.function.name === "discover_tools")).toBe(false);
|
|
88
|
+
expect(allResident.discoverable).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("residentSurface + the cold-tail-in-default conformance auditor", () => {
|
|
92
|
+
expect(residentSurface(tiered, "conin")).toEqual(["generate_deliverable"]);
|
|
93
|
+
expect(assertDefaultServedResident(tiered, "conin", ["generate_deliverable"])).toEqual([]);
|
|
94
|
+
expect(assertDefaultServedResident(tiered, "conin", ["generate_deliverable", "run_core_primitive"]).map((f) => f.code)).toEqual(["cold-tail-in-default"]);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("projection refuses a non-installable agent (fail-loud, not a broken artifact)", () => {
|
|
99
|
+
test("Conin's day-one dangling operationRef throws on BOTH targets", () => {
|
|
100
|
+
expect(() => projectOpenRouter(coninDayOne(), "conin")).toThrow(/does not install|run_core_primitive|dangling/);
|
|
101
|
+
expect(() => projectClaudePlugin(coninDayOne(), "conin", { mcpUrl: "https://x/mcp" })).toThrow(/does not install|run_core_primitive|dangling/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { intersectScope, analyzeScopes, lintAgents } from "../src/index";
|
|
3
|
+
import { coninDoc, escalationDoc } from "./fixtures/conin";
|
|
4
|
+
|
|
5
|
+
describe("C027 scope intersection (least-privilege by construction)", () => {
|
|
6
|
+
test("intersectScope treats null as unconstrained", () => {
|
|
7
|
+
expect(intersectScope(null, ["a", "b"])).toEqual(["a", "b"]);
|
|
8
|
+
expect(intersectScope(["a", "b"], null)).toEqual(["a", "b"]);
|
|
9
|
+
expect(intersectScope(["a", "b", "c"], ["b", "c", "d"])).toEqual(["b", "c"]);
|
|
10
|
+
expect(intersectScope(null, null)).toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("a child's effective scope is the intersection with its caller's", () => {
|
|
14
|
+
const { effective, escalations } = analyzeScopes(coninDoc, "conin");
|
|
15
|
+
expect(escalations).toEqual([]);
|
|
16
|
+
expect(effective["conin"]).toEqual(["project:read", "deliverable:write", "library:read"]);
|
|
17
|
+
// retrieval declares only library:read; caller grants it → effective = library:read
|
|
18
|
+
expect(effective["coninRetrieval"]).toEqual(["library:read"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("NAMED failure: a child needing a permission the caller doesn't grant is an escalation (confused-deputy)", () => {
|
|
22
|
+
const { escalations } = analyzeScopes(escalationDoc(), "conin");
|
|
23
|
+
expect(escalations).toEqual([{ parent: "conin", childLocal: "retrieval", child: "coninRetrieval", perms: ["library:read"] }]);
|
|
24
|
+
// and it blocks installation (an error-severity lint)
|
|
25
|
+
expect(lintAgents(escalationDoc()).some((f) => f.severity === "error" && f.code === "scope-escalation")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { signRegistry, verifyRegistrySignature, generateSigningKeypair } from "@suluk/builder";
|
|
3
|
+
import { agentManifest, verifyAgentFreshness, contentHash } from "../src/index";
|
|
4
|
+
import { coninDoc, coninInstructions } from "./fixtures/conin";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The C027 marketplace supply-chain loop (council open-Q #8): an agent manifest is signed through the SAME
|
|
8
|
+
* @suluk/builder ECDSA-P256 registry signing (C021). Because the manifest carries each skill's contentHash, the
|
|
9
|
+
* signature COVERS the served preprompt — a preprompt that drifts after mint is detectable (verifyAgentFreshness),
|
|
10
|
+
* and any structural tamper breaks the signature. One mechanism, reused unchanged.
|
|
11
|
+
*/
|
|
12
|
+
describe("C027 × C021 — a signed agent manifest covers preprompt drift", () => {
|
|
13
|
+
const snapshot = coninInstructions.operate;
|
|
14
|
+
|
|
15
|
+
test("sign → verify → preprompt-drift caught → structural-tamper caught", async () => {
|
|
16
|
+
// pin the operate skill's contentHash to the actual served snapshot, then mint a signature over the manifest
|
|
17
|
+
const manifest = agentManifest(coninDoc, "conin");
|
|
18
|
+
manifest.nodes.find((n) => n.name === "conin")!.skills[0].contentHash = contentHash(snapshot);
|
|
19
|
+
|
|
20
|
+
const { publicKey, privateKey } = await generateSigningKeypair();
|
|
21
|
+
const sig = await signRegistry(manifest, privateKey);
|
|
22
|
+
|
|
23
|
+
// (1) a faithfully-distributed manifest verifies, and its skills are fresh against the current snapshot
|
|
24
|
+
expect(await verifyRegistrySignature(manifest, sig, publicKey)).toBe(true);
|
|
25
|
+
expect(verifyAgentFreshness(manifest, { "conin/operate": snapshot }).filter((f) => f.code === "stale-skill")).toEqual([]);
|
|
26
|
+
|
|
27
|
+
// (2) PREPROMPT DRIFT after mint: the served text changes but the signed manifest does not — the signature still
|
|
28
|
+
// verifies (nothing in the artifact changed), yet the freshness check catches the drift via the signed hash
|
|
29
|
+
const drifted = snapshot + " ← edited on the server after signing";
|
|
30
|
+
expect(await verifyRegistrySignature(manifest, sig, publicKey)).toBe(true);
|
|
31
|
+
expect(verifyAgentFreshness(manifest, { "conin/operate": drifted }).map((f) => f.code)).toContain("stale-skill");
|
|
32
|
+
|
|
33
|
+
// (3) STRUCTURAL TAMPER: mutate the manifest (redirect a route) — the signature no longer verifies
|
|
34
|
+
const tampered = structuredClone(manifest);
|
|
35
|
+
tampered.nodes.find((n) => n.name === "conin")!.routes[0].operationRef = "#/paths/evil/requests/x";
|
|
36
|
+
expect(await verifyRegistrySignature(tampered, sig, publicKey)).toBe(false);
|
|
37
|
+
|
|
38
|
+
// (4) key-order independence: same content, different top-level insertion order, still verifies (canonicalJson)
|
|
39
|
+
const reordered = {
|
|
40
|
+
escalations: manifest.escalations, reachable: manifest.reachable,
|
|
41
|
+
nodes: manifest.nodes, agent: manifest.agent, manifestVersion: manifest.manifestVersion,
|
|
42
|
+
};
|
|
43
|
+
expect(await verifyRegistrySignature(reordered, sig, publicKey)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|