@intentius/chant-lexicon-fountain 0.52.0 → 0.52.2
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/dist/import/local-agents.d.ts +94 -0
- package/dist/import/local-agents.d.ts.map +1 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/import/generator.ts +16 -0
- package/src/import/local-agents.test.ts +216 -0
- package/src/import/local-agents.ts +364 -0
- package/src/plugin.ts +5 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-express a machine's local agent configuration as fountain resources.
|
|
3
|
+
*
|
|
4
|
+
* `chant audit --agents` answers "what is configured on this machine". This is
|
|
5
|
+
* the other half: taking that inventory and turning it into chant code, so a
|
|
6
|
+
* configuration that accumulated by hand over months becomes a reviewable,
|
|
7
|
+
* version-controlled, reproducible declaration.
|
|
8
|
+
*
|
|
9
|
+
* The mapping is close to 1:1 because fountain's `Agent` models the same four
|
|
10
|
+
* ideas the harnesses do:
|
|
11
|
+
*
|
|
12
|
+
* | local agent config | fountain |
|
|
13
|
+
* |-------------------------------|-----------------------|
|
|
14
|
+
* | CLAUDE.md / AGENTS.md | `Agent.system` |
|
|
15
|
+
* | `mcpServers` / `mcp_servers` | `Agent.mcp_servers` |
|
|
16
|
+
* | `skills/*/SKILL.md` | `Agent.skills` |
|
|
17
|
+
* | settings `env` | `Environment.env_vars`|
|
|
18
|
+
*
|
|
19
|
+
* Three places where it is deliberately *not* a transcription:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Egress is derived, not copied.** A local agent runs with the machine's
|
|
22
|
+
* full network access; a fountain sandbox must declare intent (FTN010). The
|
|
23
|
+
* hosts the config's own remote MCP servers use are exactly the egress it
|
|
24
|
+
* demonstrably needs, so those become the `allowed_hosts` allowlist and
|
|
25
|
+
* everything else is denied. Copying "unrestricted" would launder an
|
|
26
|
+
* implicit local permission into an explicit remote one.
|
|
27
|
+
* 2. **Secrets are not carried over.** A literal credential found in local
|
|
28
|
+
* config (AGT002) is rewritten to a `${VAR}` reference in the emitted code.
|
|
29
|
+
* Generated chant code gets committed; transcribing a live secret into it
|
|
30
|
+
* would turn a local mistake into a repository one.
|
|
31
|
+
* 3. **Skills are inlined by content where possible.** A local skill is text
|
|
32
|
+
* on this machine with no upstream, so `{name, content}` is the only form
|
|
33
|
+
* that reproduces it elsewhere. Remote skills keep `{source, ref}`.
|
|
34
|
+
*
|
|
35
|
+
* Cursor is discovered by the scanner but has no fountain `runtime` value, so
|
|
36
|
+
* its sites are reported as skipped rather than silently mapped onto a
|
|
37
|
+
* different agent runtime.
|
|
38
|
+
*/
|
|
39
|
+
import type { AgentConfigSite, McpServerDecl, SkillDecl } from "@intentius/chant/agents";
|
|
40
|
+
import type { AgentImportOutcome } from "@intentius/chant/agents/importer";
|
|
41
|
+
/** Runtimes fountain's `Agent.runtime` accepts. `cursor` is absent by design. */
|
|
42
|
+
export declare const MAPPABLE_RUNTIMES: readonly ["claude", "codex", "gemini", "opencode"];
|
|
43
|
+
export type MappableRuntime = (typeof MAPPABLE_RUNTIMES)[number];
|
|
44
|
+
/**
|
|
45
|
+
* Default model per runtime, used when the local config pins none.
|
|
46
|
+
*
|
|
47
|
+
* `Agent.model` is required by fountain, and most local configs leave the model
|
|
48
|
+
* to the harness's own default — a value that isn't written down anywhere this
|
|
49
|
+
* scanner can read. Emitting a documented default that the user edits is more
|
|
50
|
+
* honest than inventing a pin and calling it discovered; `unmappedModel` in the
|
|
51
|
+
* result reports every site this applied to.
|
|
52
|
+
*/
|
|
53
|
+
export declare const DEFAULT_MODEL: Record<MappableRuntime, string>;
|
|
54
|
+
/**
|
|
55
|
+
* Build the `system` prompt from the site's instruction files.
|
|
56
|
+
*
|
|
57
|
+
* Provenance is kept as a comment header per file. A user reading the generated
|
|
58
|
+
* code needs to know which of their three CLAUDE.md files a paragraph came
|
|
59
|
+
* from, and a single concatenated blob without headers makes that unrecoverable.
|
|
60
|
+
*/
|
|
61
|
+
export declare function buildSystem(site: AgentConfigSite): string | undefined;
|
|
62
|
+
/** Project the normalized MCP declarations back into fountain's `mcp_servers` map. */
|
|
63
|
+
export declare function toMcpServers(servers: McpServerDecl[], onRedact: () => void): Record<string, unknown> | undefined;
|
|
64
|
+
/**
|
|
65
|
+
* Project skills into fountain's two accepted forms.
|
|
66
|
+
*
|
|
67
|
+
* fountain requires exactly one of `content` or `source` per entry. A local
|
|
68
|
+
* skill has no upstream to install from, so its text is inlined — that is what
|
|
69
|
+
* makes the emitted code reproduce the configuration on a machine that has
|
|
70
|
+
* never seen this one.
|
|
71
|
+
*/
|
|
72
|
+
export declare function toSkills(skills: SkillDecl[]): Record<string, unknown>[] | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Hosts the config's own remote MCP servers reach.
|
|
75
|
+
*
|
|
76
|
+
* This is the evidence-based egress allowlist: every host here is one the
|
|
77
|
+
* configuration already talks to, so the sandbox stays functional while
|
|
78
|
+
* everything else stays denied.
|
|
79
|
+
*/
|
|
80
|
+
export declare function derivedAllowedHosts(servers: McpServerDecl[]): string[];
|
|
81
|
+
/** Canonicalize a local model name into fountain's `provider/model_id` form. */
|
|
82
|
+
export declare function canonicalModel(local: string | undefined, runtime: MappableRuntime): {
|
|
83
|
+
model: string;
|
|
84
|
+
defaulted: boolean;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Convert discovered agent config sites into fountain import IR.
|
|
88
|
+
*
|
|
89
|
+
* Each mappable site yields an `Agent`, plus an `Environment` when it has
|
|
90
|
+
* anything environmental to declare (env vars, or remote MCP hosts to
|
|
91
|
+
* allowlist). Feed the result to `FountainGenerator` to get chant TypeScript.
|
|
92
|
+
*/
|
|
93
|
+
export declare function sitesToTemplateIR(sites: AgentConfigSite[]): AgentImportOutcome;
|
|
94
|
+
//# sourceMappingURL=local-agents.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"local-agents.d.ts","sourceRoot":"","sources":["../../src/import/local-agents.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAGH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACzF,OAAO,KAAK,EAAE,kBAAkB,EAAe,MAAM,kCAAkC,CAAC;AAExF,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,oDAAqD,CAAC;AACpF,MAAM,MAAM,eAAe,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAEjE;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,eAAe,EAAE,MAAM,CAKzD,CAAC;AA0BF;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,eAAe,GAAG,MAAM,GAAG,SAAS,CAIrE;AAuGD,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,OAAO,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAsBhH;AAED;;;;;;;GAOG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,SAAS,CAanF;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,aAAa,EAAE,GAAG,MAAM,EAAE,CAYtE;AAED,gFAAgF;AAChF,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,eAAe,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAE,CAKzH;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,kBAAkB,CA8E9E"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "bace62b6de51e843f06dbc2de56ec34020ca13898a12102e2629971b91c599a8",
|
|
5
5
|
"meta.json": "6666b7a77db9210a329219c6a5e107e5ae8794a34542f7310ce16d9bf95c64f5",
|
|
6
6
|
"types/index.d.ts": "1dfdcba184fffcf464dac7d1d71ef7abed2ae02a8d638c0de50e0b0317b7eb01",
|
|
7
7
|
"rules/ftn001-no-secret-literals.ts": "897a4ce1ec790b3c1540d32892603bd33ff4bf30eb6ba4cd2565dee356d4962e",
|
|
@@ -17,5 +17,5 @@
|
|
|
17
17
|
"skills/chant-fountain-secrets.md": "27e349a91589510a92e518c7d7824a4a322cab5ef242cf5799373a55cbfcd1cd",
|
|
18
18
|
"skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
|
|
19
19
|
},
|
|
20
|
-
"composite": "
|
|
20
|
+
"composite": "4ab7aac826621cd338e16b85a2d8be23007185e49d3e71aef7ad3e533e9560ec"
|
|
21
21
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAwB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aA8J5B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-fountain",
|
|
3
|
-
"version": "0.52.
|
|
3
|
+
"version": "0.52.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"bundle": "tsx src/package-cli.ts"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@intentius/chant": "^0.52.
|
|
53
|
+
"@intentius/chant": "^0.52.2",
|
|
54
54
|
"typescript": "^5.9.3"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
package/src/import/generator.ts
CHANGED
|
@@ -82,11 +82,27 @@ function formatNested(value: unknown, indent: number, spec: Nested, imports: Set
|
|
|
82
82
|
return `[\n${items.join("\n")}\n${closePad}]`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* A cross-resource reference in the IR: `{__ref: "<logicalId>"}` renders as the
|
|
87
|
+
* bare variable name that resource was declared under, so `agent.environment`
|
|
88
|
+
* comes out as a typed ref (`environment: userClaudeEnv`) rather than a copy of
|
|
89
|
+
* the environment's properties. Mirrors the `__intrinsic` sentinel the
|
|
90
|
+
* CloudFormation importer uses for `Ref`.
|
|
91
|
+
*/
|
|
92
|
+
function asRef(value: unknown): string | undefined {
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
94
|
+
const ref = (value as Record<string, unknown>).__ref;
|
|
95
|
+
return typeof ref === "string" ? ref : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
85
98
|
function formatValue(value: unknown, indent: number): string {
|
|
86
99
|
if (value === null || value === undefined) return "undefined";
|
|
87
100
|
if (typeof value === "string") return JSON.stringify(value);
|
|
88
101
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
89
102
|
|
|
103
|
+
const ref = asRef(value);
|
|
104
|
+
if (ref !== undefined) return camelCase(ref);
|
|
105
|
+
|
|
90
106
|
if (Array.isArray(value)) {
|
|
91
107
|
if (value.length === 0) return "[]";
|
|
92
108
|
const pad = " ".repeat(indent + 1);
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mapper's contract is that generated code is safe to commit and faithful
|
|
3
|
+
* to the config it came from — in that order.
|
|
4
|
+
*
|
|
5
|
+
* The redaction tests are the load-bearing ones. Running the importer against a
|
|
6
|
+
* real machine produced `Authorization: "Bearer rnd_…"` in the output: a live
|
|
7
|
+
* token written into a file whose whole point is to be checked into version
|
|
8
|
+
* control. Every path that can carry a credential is covered here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect } from "vitest";
|
|
12
|
+
import { sitesToTemplateIR, derivedAllowedHosts, canonicalModel, buildSystem, toSkills, DEFAULT_MODEL } from "./local-agents";
|
|
13
|
+
import { FountainGenerator } from "./generator";
|
|
14
|
+
import type { AgentConfigSite } from "@intentius/chant/agents";
|
|
15
|
+
|
|
16
|
+
function site(overrides: Partial<AgentConfigSite> = {}): AgentConfigSite {
|
|
17
|
+
return {
|
|
18
|
+
id: "user-claude",
|
|
19
|
+
scope: "user",
|
|
20
|
+
runtime: "claude",
|
|
21
|
+
root: "/home/u",
|
|
22
|
+
sources: ["/home/u/.claude/settings.json"],
|
|
23
|
+
instructions: [],
|
|
24
|
+
mcpServers: [],
|
|
25
|
+
skills: [],
|
|
26
|
+
subagents: [],
|
|
27
|
+
commands: [],
|
|
28
|
+
plugins: [],
|
|
29
|
+
env: {},
|
|
30
|
+
settings: {},
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The generated `Agent` resource for a single-site conversion. */
|
|
36
|
+
function agentOf(s: AgentConfigSite) {
|
|
37
|
+
const { ir } = sitesToTemplateIR([s]);
|
|
38
|
+
const agent = ir.resources.find((r) => r.type === "Fountain::V1::Agent");
|
|
39
|
+
if (!agent) throw new Error("no Agent resource generated");
|
|
40
|
+
return agent;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("runtime mapping", () => {
|
|
44
|
+
test("maps the four runtimes fountain accepts", () => {
|
|
45
|
+
for (const runtime of ["claude", "codex", "gemini", "opencode"] as const) {
|
|
46
|
+
const { ir, skipped } = sitesToTemplateIR([site({ id: `user-${runtime}`, runtime })]);
|
|
47
|
+
expect(skipped).toEqual([]);
|
|
48
|
+
expect(ir.resources[0].properties.runtime).toBe(runtime);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("skips cursor with a stated reason rather than mapping it onto another runtime", () => {
|
|
53
|
+
const { ir, skipped } = sitesToTemplateIR([site({ id: "user-cursor", runtime: "cursor" })]);
|
|
54
|
+
expect(ir.resources).toEqual([]);
|
|
55
|
+
expect(skipped[0].siteId).toBe("user-cursor");
|
|
56
|
+
expect(skipped[0].reason).toContain("cursor");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("model canonicalization", () => {
|
|
61
|
+
test("expands a harness alias to fountain's provider/model_id form", () => {
|
|
62
|
+
expect(canonicalModel("opus", "claude")).toEqual({ model: "anthropic/claude-opus-4-6", defaulted: false });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("passes an already-qualified model through untouched", () => {
|
|
66
|
+
expect(canonicalModel("anthropic/claude-sonnet-4-6", "claude").model).toBe("anthropic/claude-sonnet-4-6");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("defaults when the local config pins no model, and reports having done so", () => {
|
|
70
|
+
const { unmappedModel } = sitesToTemplateIR([site()]);
|
|
71
|
+
expect(unmappedModel).toEqual(["user-claude"]);
|
|
72
|
+
expect(agentOf(site()).properties.model).toBe(DEFAULT_MODEL.claude);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("secret redaction", () => {
|
|
77
|
+
test("replaces a literal token in env with an env reference", () => {
|
|
78
|
+
const s = site({ mcpServers: [{ name: "api", transport: "stdio", source: "/c", command: "x", env: { API_KEY: "sk-ant-abcdefghijklmnopqrstuvwxyz01" } }] });
|
|
79
|
+
const servers = agentOf(s).properties.mcp_servers as Record<string, { env: Record<string, string> }>;
|
|
80
|
+
expect(servers.api.env.API_KEY).toBe("${API_KEY}");
|
|
81
|
+
expect(sitesToTemplateIR([s]).redactedSecrets).toEqual(["user-claude"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("replaces a bearer token in headers, which arrive via `extra`", () => {
|
|
85
|
+
// The regression that motivated this file: `extra` was copied verbatim, so
|
|
86
|
+
// a live token landed in code destined for version control.
|
|
87
|
+
const s = site({
|
|
88
|
+
mcpServers: [{ name: "nebula", transport: "http", source: "/c", url: "https://n/mcp", extra: { headers: { Authorization: "Bearer nbla_xbIEER2743B8Csrs" } } }],
|
|
89
|
+
});
|
|
90
|
+
const servers = agentOf(s).properties.mcp_servers as Record<string, { headers: Record<string, string> }>;
|
|
91
|
+
expect(servers.nebula.headers.Authorization).toBe("${NEBULA_AUTH_TOKEN}");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("namespaces a redacted header by server, so two servers don't collide", () => {
|
|
95
|
+
const s = site({
|
|
96
|
+
mcpServers: [
|
|
97
|
+
{ name: "render", transport: "http", source: "/c", url: "https://r/mcp", extra: { headers: { Authorization: "Bearer rnd_aaaaaaaaaaaaaaaaa" } } },
|
|
98
|
+
{ name: "xhawk", transport: "http", source: "/c", url: "https://x/mcp", extra: { headers: { Authorization: "Bearer xhk_bbbbbbbbbbbbbbbbb" } } },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
const servers = agentOf(s).properties.mcp_servers as Record<string, { headers: Record<string, string> }>;
|
|
102
|
+
expect(servers.render.headers.Authorization).toBe("${RENDER_AUTH_TOKEN}");
|
|
103
|
+
expect(servers.xhawk.headers.Authorization).toBe("${XHAWK_AUTH_TOKEN}");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("does not double-prefix a key that already names its server", () => {
|
|
107
|
+
const s = site({
|
|
108
|
+
mcpServers: [{ name: "cleanjobdata", transport: "stdio", source: "/c", command: "x", env: { CLEANJOBDATA_API_KEY: "0123456789abcdefghijklmnopqrstuvwxyz" } }],
|
|
109
|
+
});
|
|
110
|
+
const servers = agentOf(s).properties.mcp_servers as Record<string, { env: Record<string, string> }>;
|
|
111
|
+
expect(servers.cleanjobdata.env.CLEANJOBDATA_API_KEY).toBe("${CLEANJOBDATA_API_KEY}");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("leaves non-secret configuration alone", () => {
|
|
115
|
+
const s = site({ mcpServers: [{ name: "api", transport: "stdio", source: "/c", command: "x", env: { LOG_LEVEL: "debug" } }] });
|
|
116
|
+
const servers = agentOf(s).properties.mcp_servers as Record<string, { env: Record<string, string> }>;
|
|
117
|
+
expect(servers.api.env.LOG_LEVEL).toBe("debug");
|
|
118
|
+
expect(sitesToTemplateIR([s]).redactedSecrets).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("no generated output contains a value that looked like a credential", () => {
|
|
122
|
+
const s = site({
|
|
123
|
+
env: { GLOBAL_TOKEN: "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" },
|
|
124
|
+
mcpServers: [{ name: "a", transport: "http", source: "/c", url: "https://a/mcp", extra: { headers: { Authorization: "Bearer secret-value-here-000" } } }],
|
|
125
|
+
});
|
|
126
|
+
const [file] = new FountainGenerator().generate(sitesToTemplateIR([s]).ir);
|
|
127
|
+
expect(file.content).not.toContain("ghp_");
|
|
128
|
+
expect(file.content).not.toContain("secret-value-here");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("environment derivation", () => {
|
|
133
|
+
test("derives the egress allowlist from the config's own remote MCP hosts", () => {
|
|
134
|
+
expect(
|
|
135
|
+
derivedAllowedHosts([
|
|
136
|
+
{ name: "a", transport: "http", source: "/c", url: "https://mcp.posthog.com/mcp" },
|
|
137
|
+
{ name: "b", transport: "http", source: "/c", url: "https://mcp.render.com/mcp" },
|
|
138
|
+
{ name: "c", transport: "stdio", source: "/c", command: "local" },
|
|
139
|
+
]),
|
|
140
|
+
).toEqual(["mcp.posthog.com", "mcp.render.com"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("emits an Environment with explicit limited networking (FTN010)", () => {
|
|
144
|
+
const s = site({ mcpServers: [{ name: "a", transport: "http", source: "/c", url: "https://x.dev/mcp" }] });
|
|
145
|
+
const { ir } = sitesToTemplateIR([s]);
|
|
146
|
+
const env = ir.resources.find((r) => r.type === "Fountain::V1::Environment");
|
|
147
|
+
expect(env?.properties.networking_type).toBe("limited");
|
|
148
|
+
expect(env?.properties.networking_config).toEqual({ allowed_hosts: ["x.dev"] });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("omits the Environment when there is nothing environmental to declare", () => {
|
|
152
|
+
const { ir } = sitesToTemplateIR([site()]);
|
|
153
|
+
expect(ir.resources.filter((r) => r.type === "Fountain::V1::Environment")).toEqual([]);
|
|
154
|
+
expect(ir.resources[0].properties.environment).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("links the Agent to its Environment by reference, not by copy", () => {
|
|
158
|
+
const s = site({ env: { A: "1" } });
|
|
159
|
+
const [file] = new FountainGenerator().generate(sitesToTemplateIR([s]).ir);
|
|
160
|
+
// A typed ref renders as the variable name the Environment was declared under.
|
|
161
|
+
expect(file.content).toContain("environment: userClaudeEnv");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("system prompt", () => {
|
|
166
|
+
test("uses the single instruction file verbatim", () => {
|
|
167
|
+
expect(buildSystem(site({ instructions: [{ path: "/a", content: "be brief\n", bytes: 9 }] }))).toBe("be brief");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("keeps per-file provenance when several files merge", () => {
|
|
171
|
+
const system = buildSystem(
|
|
172
|
+
site({ instructions: [{ path: "/a", content: "one", bytes: 3 }, { path: "/b", content: "two", bytes: 3 }] }),
|
|
173
|
+
);
|
|
174
|
+
expect(system).toContain("# /a");
|
|
175
|
+
expect(system).toContain("# /b");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("is undefined when there are no instructions", () => {
|
|
179
|
+
expect(buildSystem(site())).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("skills", () => {
|
|
184
|
+
test("inlines a local skill's content, the only form that reproduces it elsewhere", () => {
|
|
185
|
+
expect(toSkills([{ name: "s", origin: "local", path: "/p", content: "do the thing" }])).toEqual([
|
|
186
|
+
{ name: "s", content: "do the thing" },
|
|
187
|
+
]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("keeps a remote skill as a source, with its ref when pinned", () => {
|
|
191
|
+
expect(toSkills([{ name: "s", origin: "marketplace", source: "o/r", ref: "v1" }])).toEqual([
|
|
192
|
+
{ source: "o/r", name: "s", ref: "v1" },
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("drops a skill with neither content nor source rather than emitting one fountain would reject", () => {
|
|
197
|
+
expect(toSkills([{ name: "s", origin: "local" }])).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("end-to-end", () => {
|
|
202
|
+
test("generates compilable-shaped chant code with the ownership marker", () => {
|
|
203
|
+
const s = site({
|
|
204
|
+
model: "opus",
|
|
205
|
+
instructions: [{ path: "/home/u/CLAUDE.md", content: "be brief", bytes: 8 }],
|
|
206
|
+
mcpServers: [{ name: "posthog", transport: "http", source: "/c", url: "https://mcp.posthog.com/mcp" }],
|
|
207
|
+
skills: [{ name: "deploy", origin: "local", path: "/p", content: "steps" }],
|
|
208
|
+
});
|
|
209
|
+
const [file] = new FountainGenerator().generate(sitesToTemplateIR([s]).ir);
|
|
210
|
+
expect(file.path).toBe("main.ts");
|
|
211
|
+
expect(file.content).toContain('import { Agent, Environment } from "@intentius/chant-lexicon-fountain";');
|
|
212
|
+
expect(file.content).toContain("export const userClaude = new Agent({");
|
|
213
|
+
expect(file.content).toContain('"managed-by": "chant"');
|
|
214
|
+
expect(file.content).toContain('runtime: "claude"');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-express a machine's local agent configuration as fountain resources.
|
|
3
|
+
*
|
|
4
|
+
* `chant audit --agents` answers "what is configured on this machine". This is
|
|
5
|
+
* the other half: taking that inventory and turning it into chant code, so a
|
|
6
|
+
* configuration that accumulated by hand over months becomes a reviewable,
|
|
7
|
+
* version-controlled, reproducible declaration.
|
|
8
|
+
*
|
|
9
|
+
* The mapping is close to 1:1 because fountain's `Agent` models the same four
|
|
10
|
+
* ideas the harnesses do:
|
|
11
|
+
*
|
|
12
|
+
* | local agent config | fountain |
|
|
13
|
+
* |-------------------------------|-----------------------|
|
|
14
|
+
* | CLAUDE.md / AGENTS.md | `Agent.system` |
|
|
15
|
+
* | `mcpServers` / `mcp_servers` | `Agent.mcp_servers` |
|
|
16
|
+
* | `skills/*/SKILL.md` | `Agent.skills` |
|
|
17
|
+
* | settings `env` | `Environment.env_vars`|
|
|
18
|
+
*
|
|
19
|
+
* Three places where it is deliberately *not* a transcription:
|
|
20
|
+
*
|
|
21
|
+
* 1. **Egress is derived, not copied.** A local agent runs with the machine's
|
|
22
|
+
* full network access; a fountain sandbox must declare intent (FTN010). The
|
|
23
|
+
* hosts the config's own remote MCP servers use are exactly the egress it
|
|
24
|
+
* demonstrably needs, so those become the `allowed_hosts` allowlist and
|
|
25
|
+
* everything else is denied. Copying "unrestricted" would launder an
|
|
26
|
+
* implicit local permission into an explicit remote one.
|
|
27
|
+
* 2. **Secrets are not carried over.** A literal credential found in local
|
|
28
|
+
* config (AGT002) is rewritten to a `${VAR}` reference in the emitted code.
|
|
29
|
+
* Generated chant code gets committed; transcribing a live secret into it
|
|
30
|
+
* would turn a local mistake into a repository one.
|
|
31
|
+
* 3. **Skills are inlined by content where possible.** A local skill is text
|
|
32
|
+
* on this machine with no upstream, so `{name, content}` is the only form
|
|
33
|
+
* that reproduces it elsewhere. Remote skills keep `{source, ref}`.
|
|
34
|
+
*
|
|
35
|
+
* Cursor is discovered by the scanner but has no fountain `runtime` value, so
|
|
36
|
+
* its sites are reported as skipped rather than silently mapped onto a
|
|
37
|
+
* different agent runtime.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { ResourceIR } from "@intentius/chant/import/parser";
|
|
41
|
+
import type { AgentConfigSite, McpServerDecl, SkillDecl } from "@intentius/chant/agents";
|
|
42
|
+
import type { AgentImportOutcome, SkippedSite } from "@intentius/chant/agents/importer";
|
|
43
|
+
|
|
44
|
+
/** Runtimes fountain's `Agent.runtime` accepts. `cursor` is absent by design. */
|
|
45
|
+
export const MAPPABLE_RUNTIMES = ["claude", "codex", "gemini", "opencode"] as const;
|
|
46
|
+
export type MappableRuntime = (typeof MAPPABLE_RUNTIMES)[number];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default model per runtime, used when the local config pins none.
|
|
50
|
+
*
|
|
51
|
+
* `Agent.model` is required by fountain, and most local configs leave the model
|
|
52
|
+
* to the harness's own default — a value that isn't written down anywhere this
|
|
53
|
+
* scanner can read. Emitting a documented default that the user edits is more
|
|
54
|
+
* honest than inventing a pin and calling it discovered; `unmappedModel` in the
|
|
55
|
+
* result reports every site this applied to.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_MODEL: Record<MappableRuntime, string> = {
|
|
58
|
+
claude: "anthropic/claude-sonnet-4-6",
|
|
59
|
+
codex: "openai/gpt-5.1-codex",
|
|
60
|
+
gemini: "google/gemini-2.5-pro",
|
|
61
|
+
opencode: "anthropic/claude-sonnet-4-6",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Local model aliases → fountain's `provider/model_id` form.
|
|
66
|
+
*
|
|
67
|
+
* Harnesses accept short names (`opus`, `sonnet`); fountain wants the canonical
|
|
68
|
+
* id. An alias not listed here passes through untouched — a user who pinned an
|
|
69
|
+
* exact provider-qualified model already has the right shape.
|
|
70
|
+
*/
|
|
71
|
+
const MODEL_ALIASES: Record<string, string> = {
|
|
72
|
+
opus: "anthropic/claude-opus-4-6",
|
|
73
|
+
sonnet: "anthropic/claude-sonnet-4-6",
|
|
74
|
+
haiku: "anthropic/claude-haiku-4-5",
|
|
75
|
+
"gpt-5.5": "openai/gpt-5.5",
|
|
76
|
+
"gpt-5.1-codex": "openai/gpt-5.1-codex",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
function isMappable(runtime: string): runtime is MappableRuntime {
|
|
80
|
+
return (MAPPABLE_RUNTIMES as readonly string[]).includes(runtime);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A cross-resource reference the fountain generator renders as a bare variable. */
|
|
84
|
+
function ref(logicalId: string): { __ref: string } {
|
|
85
|
+
return { __ref: logicalId };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the `system` prompt from the site's instruction files.
|
|
90
|
+
*
|
|
91
|
+
* Provenance is kept as a comment header per file. A user reading the generated
|
|
92
|
+
* code needs to know which of their three CLAUDE.md files a paragraph came
|
|
93
|
+
* from, and a single concatenated blob without headers makes that unrecoverable.
|
|
94
|
+
*/
|
|
95
|
+
export function buildSystem(site: AgentConfigSite): string | undefined {
|
|
96
|
+
if (site.instructions.length === 0) return undefined;
|
|
97
|
+
if (site.instructions.length === 1) return site.instructions[0].content.trim();
|
|
98
|
+
return site.instructions.map((f) => `# ${f.path}\n\n${f.content.trim()}`).join("\n\n---\n\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Env-var reference form for a value that must not be inlined. */
|
|
102
|
+
function envRef(key: string): string {
|
|
103
|
+
return `\${${key}}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Value shapes that are already indirection rather than a literal. */
|
|
107
|
+
const INDIRECT = /^\s*(?:\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|\$\(.+\))\s*$/;
|
|
108
|
+
const SECRET_KEY = /(?:^|_)(?:token|secret|password|passwd|api_?key|access_?key|credential|private_?key)s?(?:$|_)/i;
|
|
109
|
+
const DIGEST_KEY = /(?:sha\d*|checksum|digest|fingerprint|thumbprint|hash|etag)/i;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Header and field names that carry a credential without ever saying "token".
|
|
113
|
+
*
|
|
114
|
+
* `Authorization` is the important one: it is where remote MCP servers put
|
|
115
|
+
* their bearer tokens, it matches none of the "looks like a secret" key
|
|
116
|
+
* heuristics, and it is the single most likely thing in an agent config to be a
|
|
117
|
+
* live credential.
|
|
118
|
+
*/
|
|
119
|
+
const AUTH_KEY = /^(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api-key|apikey|x-auth-token|auth)$/i;
|
|
120
|
+
|
|
121
|
+
/** An HTTP authorization value: a scheme followed by the credential itself. */
|
|
122
|
+
const AUTH_SCHEME_VALUE = /^\s*(?:Bearer|Basic|Token|ApiKey)\s+\S+/i;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The env-var name a redacted value is replaced with. Namespaced by the thing
|
|
126
|
+
* it belongs to, so two servers' `Authorization` headers don't collapse onto
|
|
127
|
+
* one variable that can only hold one of them.
|
|
128
|
+
*/
|
|
129
|
+
function secretVarName(context: string, key: string): string {
|
|
130
|
+
const norm = (s: string) => s.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
131
|
+
const scope = norm(context);
|
|
132
|
+
const name = norm(key);
|
|
133
|
+
// A header like `Authorization` says nothing on its own, so it takes the
|
|
134
|
+
// server's name. A key that already carries it (`CLEANJOBDATA_API_KEY` on the
|
|
135
|
+
// `cleanjobdata` server) is left alone rather than doubled.
|
|
136
|
+
if (AUTH_KEY.test(key)) return `${scope}_AUTH_TOKEN`;
|
|
137
|
+
return name.includes(scope) || scope.includes(name) ? name : `${scope}_${name}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Decide whether a value is a literal credential that must not be written to
|
|
142
|
+
* disk, and if so, what to replace it with.
|
|
143
|
+
*
|
|
144
|
+
* Deliberately more eager than the audit check: this decides what gets written
|
|
145
|
+
* into a file destined for version control, so a false positive costs the user
|
|
146
|
+
* one edit while a false negative commits a live secret.
|
|
147
|
+
*/
|
|
148
|
+
function redactedValue(key: string, value: string, context: string): string | undefined {
|
|
149
|
+
if (INDIRECT.test(value)) return undefined;
|
|
150
|
+
if (DIGEST_KEY.test(key)) return undefined;
|
|
151
|
+
if (AUTH_KEY.test(key) || AUTH_SCHEME_VALUE.test(value)) return envRef(secretVarName(context, key));
|
|
152
|
+
if (SECRET_KEY.test(key) || value.length >= 32) return envRef(secretVarName(context, key));
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Replace literal credential values in a flat string map with `${VAR}` references. */
|
|
157
|
+
function redactEnv(
|
|
158
|
+
env: Record<string, string> | undefined,
|
|
159
|
+
context: string,
|
|
160
|
+
onRedact: () => void,
|
|
161
|
+
): Record<string, string> | undefined {
|
|
162
|
+
if (!env) return undefined;
|
|
163
|
+
const out: Record<string, string> = {};
|
|
164
|
+
for (const [key, value] of Object.entries(env)) {
|
|
165
|
+
const replacement = redactedValue(key, value, context);
|
|
166
|
+
if (replacement !== undefined) {
|
|
167
|
+
out[key] = replacement;
|
|
168
|
+
onRedact();
|
|
169
|
+
} else {
|
|
170
|
+
out[key] = value;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Redact credentials anywhere in a nested structure.
|
|
178
|
+
*
|
|
179
|
+
* The passthrough fields a harness accepts (`headers`, auth blocks, whatever a
|
|
180
|
+
* future version adds) are exactly the ones this model does not name, so
|
|
181
|
+
* redaction cannot be a fixed list of keys — it has to walk whatever is there.
|
|
182
|
+
* Everything non-credential is preserved verbatim.
|
|
183
|
+
*/
|
|
184
|
+
function redactDeep(value: unknown, key: string, context: string, onRedact: () => void): unknown {
|
|
185
|
+
if (typeof value === "string") {
|
|
186
|
+
const replacement = redactedValue(key, value, context);
|
|
187
|
+
if (replacement === undefined) return value;
|
|
188
|
+
onRedact();
|
|
189
|
+
return replacement;
|
|
190
|
+
}
|
|
191
|
+
if (Array.isArray(value)) return value.map((v) => redactDeep(v, key, context, onRedact));
|
|
192
|
+
if (value && typeof value === "object") {
|
|
193
|
+
const out: Record<string, unknown> = {};
|
|
194
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
195
|
+
out[k] = redactDeep(v, k, context, onRedact);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Project the normalized MCP declarations back into fountain's `mcp_servers` map. */
|
|
203
|
+
export function toMcpServers(servers: McpServerDecl[], onRedact: () => void): Record<string, unknown> | undefined {
|
|
204
|
+
if (servers.length === 0) return undefined;
|
|
205
|
+
const out: Record<string, unknown> = {};
|
|
206
|
+
for (const server of servers) {
|
|
207
|
+
const entry: Record<string, unknown> = {};
|
|
208
|
+
if (server.command) entry.command = server.command;
|
|
209
|
+
if (server.args?.length) entry.args = server.args;
|
|
210
|
+
if (server.url) entry.url = server.url;
|
|
211
|
+
if (server.transport === "sse" || server.transport === "http") entry.type = server.transport;
|
|
212
|
+
const env = redactEnv(server.env, server.name, onRedact);
|
|
213
|
+
if (env && Object.keys(env).length > 0) entry.env = env;
|
|
214
|
+
// `extra` is where `headers` (and anything else the harness accepted that
|
|
215
|
+
// this model doesn't name) lands — so it gets the same redaction as `env`,
|
|
216
|
+
// not a verbatim copy.
|
|
217
|
+
if (server.extra) {
|
|
218
|
+
for (const [key, value] of Object.entries(server.extra)) {
|
|
219
|
+
entry[key] = redactDeep(value, key, server.name, onRedact);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
out[server.name] = entry;
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Project skills into fountain's two accepted forms.
|
|
229
|
+
*
|
|
230
|
+
* fountain requires exactly one of `content` or `source` per entry. A local
|
|
231
|
+
* skill has no upstream to install from, so its text is inlined — that is what
|
|
232
|
+
* makes the emitted code reproduce the configuration on a machine that has
|
|
233
|
+
* never seen this one.
|
|
234
|
+
*/
|
|
235
|
+
export function toSkills(skills: SkillDecl[]): Record<string, unknown>[] | undefined {
|
|
236
|
+
if (skills.length === 0) return undefined;
|
|
237
|
+
const out: Record<string, unknown>[] = [];
|
|
238
|
+
for (const skill of skills) {
|
|
239
|
+
if (skill.source) {
|
|
240
|
+
out.push({ source: skill.source, name: skill.name, ...(skill.ref ? { ref: skill.ref } : {}) });
|
|
241
|
+
} else if (skill.content) {
|
|
242
|
+
out.push({ name: skill.name, content: skill.content });
|
|
243
|
+
}
|
|
244
|
+
// A skill with neither is unreproducible; it is left out rather than
|
|
245
|
+
// emitted as an entry fountain would reject at apply time.
|
|
246
|
+
}
|
|
247
|
+
return out.length > 0 ? out : undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Hosts the config's own remote MCP servers reach.
|
|
252
|
+
*
|
|
253
|
+
* This is the evidence-based egress allowlist: every host here is one the
|
|
254
|
+
* configuration already talks to, so the sandbox stays functional while
|
|
255
|
+
* everything else stays denied.
|
|
256
|
+
*/
|
|
257
|
+
export function derivedAllowedHosts(servers: McpServerDecl[]): string[] {
|
|
258
|
+
const hosts = new Set<string>();
|
|
259
|
+
for (const server of servers) {
|
|
260
|
+
if (!server.url) continue;
|
|
261
|
+
try {
|
|
262
|
+
hosts.add(new URL(server.url).hostname);
|
|
263
|
+
} catch {
|
|
264
|
+
// A malformed URL contributes no host — the server simply isn't reachable
|
|
265
|
+
// from the sandbox until someone adds the right one by hand.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return [...hosts].sort();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Canonicalize a local model name into fountain's `provider/model_id` form. */
|
|
272
|
+
export function canonicalModel(local: string | undefined, runtime: MappableRuntime): { model: string; defaulted: boolean } {
|
|
273
|
+
if (!local) return { model: DEFAULT_MODEL[runtime], defaulted: true };
|
|
274
|
+
const alias = MODEL_ALIASES[local.toLowerCase()];
|
|
275
|
+
if (alias) return { model: alias, defaulted: false };
|
|
276
|
+
return { model: local, defaulted: false };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Convert discovered agent config sites into fountain import IR.
|
|
281
|
+
*
|
|
282
|
+
* Each mappable site yields an `Agent`, plus an `Environment` when it has
|
|
283
|
+
* anything environmental to declare (env vars, or remote MCP hosts to
|
|
284
|
+
* allowlist). Feed the result to `FountainGenerator` to get chant TypeScript.
|
|
285
|
+
*/
|
|
286
|
+
export function sitesToTemplateIR(sites: AgentConfigSite[]): AgentImportOutcome {
|
|
287
|
+
const resources: ResourceIR[] = [];
|
|
288
|
+
const skipped: SkippedSite[] = [];
|
|
289
|
+
const unmappedModel: string[] = [];
|
|
290
|
+
const redactedSecrets: string[] = [];
|
|
291
|
+
|
|
292
|
+
for (const site of sites) {
|
|
293
|
+
if (!isMappable(site.runtime)) {
|
|
294
|
+
skipped.push({
|
|
295
|
+
siteId: site.id,
|
|
296
|
+
reason: `fountain has no "${site.runtime}" runtime — its Agent.runtime accepts ${MAPPABLE_RUNTIMES.join(", ")}. The config was audited but not re-expressed.`,
|
|
297
|
+
});
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let redacted = false;
|
|
302
|
+
const onRedact = () => {
|
|
303
|
+
redacted = true;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const { model, defaulted } = canonicalModel(site.model, site.runtime);
|
|
307
|
+
if (defaulted) unmappedModel.push(site.id);
|
|
308
|
+
|
|
309
|
+
const allowedHosts = derivedAllowedHosts(site.mcpServers);
|
|
310
|
+
const envVars = redactEnv(site.env, site.id, onRedact) ?? {};
|
|
311
|
+
const hasEnvironment = Object.keys(envVars).length > 0 || allowedHosts.length > 0;
|
|
312
|
+
|
|
313
|
+
const metadata: Record<string, unknown> = {
|
|
314
|
+
"managed-by": "chant",
|
|
315
|
+
"chant.io/imported-from": "local-agent-config",
|
|
316
|
+
"chant.io/scope": site.scope,
|
|
317
|
+
"chant.io/runtime": site.runtime,
|
|
318
|
+
"chant.io/root": site.root,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
let environmentId: string | undefined;
|
|
322
|
+
if (hasEnvironment) {
|
|
323
|
+
environmentId = `${site.id}-env`;
|
|
324
|
+
resources.push({
|
|
325
|
+
logicalId: environmentId,
|
|
326
|
+
type: "Fountain::V1::Environment",
|
|
327
|
+
properties: {
|
|
328
|
+
name: environmentId,
|
|
329
|
+
// FTN010: intent must be explicit. `limited` with a derived
|
|
330
|
+
// allowlist — an empty list is deny-all, which is the right default
|
|
331
|
+
// for a config whose network needs we could not observe.
|
|
332
|
+
networking_type: "limited",
|
|
333
|
+
networking_config: { allowed_hosts: allowedHosts },
|
|
334
|
+
...(Object.keys(envVars).length > 0 ? { env_vars: envVars } : {}),
|
|
335
|
+
metadata,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const mcpServers = toMcpServers(site.mcpServers, onRedact);
|
|
341
|
+
const skills = toSkills(site.skills);
|
|
342
|
+
const system = buildSystem(site);
|
|
343
|
+
|
|
344
|
+
resources.push({
|
|
345
|
+
logicalId: site.id,
|
|
346
|
+
type: "Fountain::V1::Agent",
|
|
347
|
+
properties: {
|
|
348
|
+
name: site.id,
|
|
349
|
+
model,
|
|
350
|
+
runtime: site.runtime,
|
|
351
|
+
...(environmentId ? { environment: ref(environmentId) } : {}),
|
|
352
|
+
...(system !== undefined ? { system } : {}),
|
|
353
|
+
...(mcpServers ? { mcp_servers: mcpServers } : {}),
|
|
354
|
+
...(skills ? { skills } : {}),
|
|
355
|
+
description: `Imported from ${site.scope}-scope ${site.runtime} configuration at ${site.root}`,
|
|
356
|
+
metadata,
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
if (redacted) redactedSecrets.push(site.id);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { ir: { resources, parameters: [] }, skipped, unmappedModel, redactedSecrets };
|
|
364
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { detectFountainTemplate } from "./detect";
|
|
|
18
18
|
import { fountainInitTemplates } from "./init-templates";
|
|
19
19
|
import { FountainParser } from "./import/parser";
|
|
20
20
|
import { FountainGenerator } from "./import/generator";
|
|
21
|
+
import { sitesToTemplateIR } from "./import/local-agents";
|
|
21
22
|
import { completions } from "./lsp/completions";
|
|
22
23
|
import { hover } from "./lsp/hover";
|
|
23
24
|
|
|
@@ -156,6 +157,10 @@ export const fountainPlugin: LexiconPlugin = {
|
|
|
156
157
|
return new FountainGenerator();
|
|
157
158
|
},
|
|
158
159
|
|
|
160
|
+
agentConfigImporter() {
|
|
161
|
+
return { toTemplateIR: sitesToTemplateIR };
|
|
162
|
+
},
|
|
163
|
+
|
|
159
164
|
async exportResources(options) {
|
|
160
165
|
const { exportResources } = await import("./export-resources");
|
|
161
166
|
return exportResources(options);
|