@intentius/chant-lexicon-fountain 0.33.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.
Files changed (171) hide show
  1. package/README.md +63 -0
  2. package/dist/codegen/docs-cli.d.ts +3 -0
  3. package/dist/codegen/docs-cli.d.ts.map +1 -0
  4. package/dist/codegen/docs.d.ts +16 -0
  5. package/dist/codegen/docs.d.ts.map +1 -0
  6. package/dist/codegen/generate-cli.d.ts +3 -0
  7. package/dist/codegen/generate-cli.d.ts.map +1 -0
  8. package/dist/codegen/generate-lexicon.d.ts +24 -0
  9. package/dist/codegen/generate-lexicon.d.ts.map +1 -0
  10. package/dist/codegen/generate-typescript.d.ts +11 -0
  11. package/dist/codegen/generate-typescript.d.ts.map +1 -0
  12. package/dist/codegen/generate.d.ts +15 -0
  13. package/dist/codegen/generate.d.ts.map +1 -0
  14. package/dist/codegen/naming.d.ts +14 -0
  15. package/dist/codegen/naming.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +17 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/concierge-stack.d.ts +44 -0
  19. package/dist/composites/concierge-stack.d.ts.map +1 -0
  20. package/dist/coverage.d.ts +56 -0
  21. package/dist/coverage.d.ts.map +1 -0
  22. package/dist/describe-resources.d.ts +45 -0
  23. package/dist/describe-resources.d.ts.map +1 -0
  24. package/dist/detect.d.ts +10 -0
  25. package/dist/detect.d.ts.map +1 -0
  26. package/dist/entity-props.d.ts +11 -0
  27. package/dist/entity-props.d.ts.map +1 -0
  28. package/dist/export-resources.d.ts +29 -0
  29. package/dist/export-resources.d.ts.map +1 -0
  30. package/dist/generated/index.d.ts +5 -0
  31. package/dist/generated/index.d.ts.map +1 -0
  32. package/dist/generated/runtime.d.ts +5 -0
  33. package/dist/generated/runtime.d.ts.map +1 -0
  34. package/dist/import/generator.d.ts +15 -0
  35. package/dist/import/generator.d.ts.map +1 -0
  36. package/dist/import/parser.d.ts +19 -0
  37. package/dist/import/parser.d.ts.map +1 -0
  38. package/dist/index.d.ts +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/init-templates.d.ts +13 -0
  41. package/dist/init-templates.d.ts.map +1 -0
  42. package/dist/integrity.json +21 -0
  43. package/dist/lint/audit-catalog.d.ts +15 -0
  44. package/dist/lint/audit-catalog.d.ts.map +1 -0
  45. package/dist/lint/post-synth/ftn010-networking-explicit.d.ts +11 -0
  46. package/dist/lint/post-synth/ftn010-networking-explicit.d.ts.map +1 -0
  47. package/dist/lint/post-synth/ftn011-no-unrestricted-networking.d.ts +11 -0
  48. package/dist/lint/post-synth/ftn011-no-unrestricted-networking.d.ts.map +1 -0
  49. package/dist/lint/post-synth/ftn012-no-cloud-credential-env.d.ts +3 -0
  50. package/dist/lint/post-synth/ftn012-no-cloud-credential-env.d.ts.map +1 -0
  51. package/dist/lint/post-synth/ftn013-substitution-resolvable.d.ts +3 -0
  52. package/dist/lint/post-synth/ftn013-substitution-resolvable.d.ts.map +1 -0
  53. package/dist/lint/post-synth/ftn014-vault-shadowing.d.ts +3 -0
  54. package/dist/lint/post-synth/ftn014-vault-shadowing.d.ts.map +1 -0
  55. package/dist/lint/post-synth/ftn015-mcp-secret-env-substitution.d.ts +3 -0
  56. package/dist/lint/post-synth/ftn015-mcp-secret-env-substitution.d.ts.map +1 -0
  57. package/dist/lint/post-synth/ftn016-runtime-model-valid.d.ts +3 -0
  58. package/dist/lint/post-synth/ftn016-runtime-model-valid.d.ts.map +1 -0
  59. package/dist/lint/post-synth/ftn017-unique-resource-names.d.ts +11 -0
  60. package/dist/lint/post-synth/ftn017-unique-resource-names.d.ts.map +1 -0
  61. package/dist/lint/post-synth/index.d.ts +3 -0
  62. package/dist/lint/post-synth/index.d.ts.map +1 -0
  63. package/dist/lint/rules/ftn001-no-secret-literals.d.ts +3 -0
  64. package/dist/lint/rules/ftn001-no-secret-literals.d.ts.map +1 -0
  65. package/dist/lint/rules/index.d.ts +5 -0
  66. package/dist/lint/rules/index.d.ts.map +1 -0
  67. package/dist/lsp/completions.d.ts +7 -0
  68. package/dist/lsp/completions.d.ts.map +1 -0
  69. package/dist/lsp/hover.d.ts +4 -0
  70. package/dist/lsp/hover.d.ts.map +1 -0
  71. package/dist/manifest.json +6 -0
  72. package/dist/meta.json +69 -0
  73. package/dist/op/activities/fountain-apply.d.ts +76 -0
  74. package/dist/op/activities/fountain-apply.d.ts.map +1 -0
  75. package/dist/op/activities/fountain-run.d.ts +36 -0
  76. package/dist/op/activities/fountain-run.d.ts.map +1 -0
  77. package/dist/op/activities/index.d.ts +11 -0
  78. package/dist/op/activities/index.d.ts.map +1 -0
  79. package/dist/package-cli.d.ts +3 -0
  80. package/dist/package-cli.d.ts.map +1 -0
  81. package/dist/plugin.d.ts +8 -0
  82. package/dist/plugin.d.ts.map +1 -0
  83. package/dist/reference-catalog.d.ts +16 -0
  84. package/dist/reference-catalog.d.ts.map +1 -0
  85. package/dist/rules/ftn001-no-secret-literals.ts +71 -0
  86. package/dist/rules/ftn010-networking-explicit.ts +37 -0
  87. package/dist/rules/ftn011-no-unrestricted-networking.ts +36 -0
  88. package/dist/rules/ftn012-no-cloud-credential-env.ts +58 -0
  89. package/dist/rules/ftn013-substitution-resolvable.ts +93 -0
  90. package/dist/rules/ftn014-vault-shadowing.ts +61 -0
  91. package/dist/rules/ftn015-mcp-secret-env-substitution.ts +49 -0
  92. package/dist/rules/ftn016-runtime-model-valid.ts +50 -0
  93. package/dist/rules/ftn017-unique-resource-names.ts +43 -0
  94. package/dist/serializer.d.ts +3 -0
  95. package/dist/serializer.d.ts.map +1 -0
  96. package/dist/skills/chant-fountain-locked-sandboxes.md +34 -0
  97. package/dist/skills/chant-fountain-secrets.md +29 -0
  98. package/dist/skills/chant-fountain.md +45 -0
  99. package/dist/spec/fetch.d.ts +28 -0
  100. package/dist/spec/fetch.d.ts.map +1 -0
  101. package/dist/spec/parse.d.ts +58 -0
  102. package/dist/spec/parse.d.ts.map +1 -0
  103. package/dist/types/index.d.ts +60 -0
  104. package/dist/validate-cli.d.ts +3 -0
  105. package/dist/validate-cli.d.ts.map +1 -0
  106. package/dist/validate.d.ts +15 -0
  107. package/dist/validate.d.ts.map +1 -0
  108. package/package.json +61 -0
  109. package/src/codegen/docs-cli.ts +7 -0
  110. package/src/codegen/docs.ts +278 -0
  111. package/src/codegen/generate-cli.ts +8 -0
  112. package/src/codegen/generate-lexicon.ts +58 -0
  113. package/src/codegen/generate-typescript.ts +69 -0
  114. package/src/codegen/generate.ts +109 -0
  115. package/src/codegen/naming.ts +45 -0
  116. package/src/codegen/package.ts +32 -0
  117. package/src/composites/composites.test.ts +35 -0
  118. package/src/composites/concierge-stack.ts +71 -0
  119. package/src/coverage.test.ts +63 -0
  120. package/src/coverage.ts +178 -0
  121. package/src/describe-resources.test.ts +203 -0
  122. package/src/describe-resources.ts +192 -0
  123. package/src/detect.ts +37 -0
  124. package/src/entity-props.ts +22 -0
  125. package/src/export-resources.ts +103 -0
  126. package/src/generated/.gitkeep +0 -0
  127. package/src/generated/index.d.ts +60 -0
  128. package/src/generated/index.ts +10 -0
  129. package/src/generated/lexicon-fountain.json +69 -0
  130. package/src/generated/runtime.ts +4 -0
  131. package/src/import/generator.ts +112 -0
  132. package/src/import/parser.ts +83 -0
  133. package/src/import/roundtrip.test.ts +113 -0
  134. package/src/index.ts +17 -0
  135. package/src/init-templates.ts +60 -0
  136. package/src/lint/audit-catalog.ts +107 -0
  137. package/src/lint/post-synth/ftn010-networking-explicit.ts +37 -0
  138. package/src/lint/post-synth/ftn011-no-unrestricted-networking.ts +36 -0
  139. package/src/lint/post-synth/ftn012-no-cloud-credential-env.ts +58 -0
  140. package/src/lint/post-synth/ftn013-substitution-resolvable.ts +93 -0
  141. package/src/lint/post-synth/ftn014-vault-shadowing.ts +61 -0
  142. package/src/lint/post-synth/ftn015-mcp-secret-env-substitution.ts +49 -0
  143. package/src/lint/post-synth/ftn016-runtime-model-valid.ts +50 -0
  144. package/src/lint/post-synth/ftn017-unique-resource-names.ts +43 -0
  145. package/src/lint/post-synth/index.ts +21 -0
  146. package/src/lint/post-synth/post-synth.test.ts +133 -0
  147. package/src/lint/rules/ftn001-no-secret-literals.ts +71 -0
  148. package/src/lint/rules/index.ts +7 -0
  149. package/src/lint/rules/rules.test.ts +126 -0
  150. package/src/lsp/completions.test.ts +51 -0
  151. package/src/lsp/completions.ts +21 -0
  152. package/src/lsp/hover.test.ts +47 -0
  153. package/src/lsp/hover.ts +70 -0
  154. package/src/op/activities/fountain-apply.test.ts +208 -0
  155. package/src/op/activities/fountain-apply.ts +249 -0
  156. package/src/op/activities/fountain-run.ts +95 -0
  157. package/src/op/activities/index.ts +28 -0
  158. package/src/package-cli.ts +26 -0
  159. package/src/plugin.test.ts +73 -0
  160. package/src/plugin.ts +170 -0
  161. package/src/reference-catalog.ts +27 -0
  162. package/src/serializer.test.ts +109 -0
  163. package/src/serializer.ts +143 -0
  164. package/src/skills/chant-fountain-locked-sandboxes.md +34 -0
  165. package/src/skills/chant-fountain-secrets.md +29 -0
  166. package/src/skills/chant-fountain.md +45 -0
  167. package/src/spec/fetch.ts +53 -0
  168. package/src/spec/fountain-openapi.snapshot.json +1 -0
  169. package/src/spec/parse.ts +309 -0
  170. package/src/validate-cli.ts +13 -0
  171. package/src/validate.ts +34 -0
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Fountain template parser.
3
+ *
4
+ * Turns fountain manifests (the `fountain apply -f` YAML, single or
5
+ * multi-document) or the serializer's fountain-plan.json into the import IR
6
+ * for conversion to chant TypeScript. Server-written read-only fields are
7
+ * dropped so the IR is the declared shape. `logicalId` is `metadata.name`
8
+ * (manifests) or the plan's entity name.
9
+ */
10
+
11
+ import type { TemplateParser, TemplateIR, ResourceIR } from "@intentius/chant/import/parser";
12
+ import { BaseValueParser } from "@intentius/chant/import/base-parser";
13
+ import { parseYAML } from "@intentius/chant/yaml";
14
+ import { isFountainPlan } from "../detect";
15
+
16
+ /** Server-written read-only fields — never part of the authored shape. */
17
+ export const SERVER_FIELDS = ["id", "inserted_at", "updated_at", "user_id", "conversation_count", "avatar_media_type"];
18
+
19
+ const KIND_TO_TYPE: Record<string, string> = {
20
+ Environment: "Fountain::V1::Environment",
21
+ Vault: "Fountain::V1::Vault",
22
+ Agent: "Fountain::V1::Agent",
23
+ };
24
+
25
+ function isRecord(v: unknown): v is Record<string, unknown> {
26
+ return typeof v === "object" && v !== null && !Array.isArray(v);
27
+ }
28
+
29
+ export class FountainParser extends BaseValueParser implements TemplateParser {
30
+ protected dispatchIntrinsic(): unknown | null {
31
+ // fountain manifests have no intrinsic functions; `${VAR}` substitution
32
+ // is fountain's own runtime concern and passes through as a string.
33
+ return null;
34
+ }
35
+
36
+ parse(input: string): TemplateIR {
37
+ const trimmed = input.trim();
38
+ if (!trimmed) return { resources: [], parameters: [] };
39
+
40
+ // fountain-plan.json
41
+ if (trimmed.startsWith("{")) {
42
+ try {
43
+ const data = JSON.parse(trimmed) as unknown;
44
+ if (isFountainPlan(data)) {
45
+ const resources: ResourceIR[] = [];
46
+ for (const [entityName, entry] of Object.entries(data as Record<string, { kind: string; spec: Record<string, unknown> }>)) {
47
+ const type = KIND_TO_TYPE[entry.kind];
48
+ if (!type) continue;
49
+ resources.push(this.resource(type, entityName, entry.spec));
50
+ }
51
+ return { resources, parameters: [] };
52
+ }
53
+ } catch {
54
+ // fall through to YAML
55
+ }
56
+ }
57
+
58
+ // Manifest YAML (single or multi-document).
59
+ const resources: ResourceIR[] = [];
60
+ for (const docText of trimmed.split(/^---\s*$/m)) {
61
+ if (!docText.trim()) continue;
62
+ const doc = parseYAML(docText);
63
+ const kind = typeof doc.kind === "string" ? doc.kind : "";
64
+ const type = KIND_TO_TYPE[kind];
65
+ if (!type) continue;
66
+ const meta = isRecord(doc.metadata) ? doc.metadata : {};
67
+ const name = typeof meta.name === "string" ? meta.name : kind.toLowerCase();
68
+ const spec = isRecord(doc.spec) ? doc.spec : {};
69
+ resources.push(this.resource(type, name, spec));
70
+ }
71
+ return { resources, parameters: [] };
72
+ }
73
+
74
+ private resource(type: string, logicalId: string, spec: Record<string, unknown>): ResourceIR {
75
+ const properties: Record<string, unknown> = {};
76
+ for (const [key, value] of Object.entries(spec)) {
77
+ if (SERVER_FIELDS.includes(key)) continue;
78
+ if (value === undefined) continue;
79
+ properties[key] = this.parseValue(value);
80
+ }
81
+ return { logicalId, type, properties };
82
+ }
83
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FountainParser } from "./parser";
3
+ import { FountainGenerator } from "./generator";
4
+ import { detectFountainTemplate } from "../detect";
5
+ import { exportResources } from "../export-resources";
6
+ import type { FountainHttp } from "../op/activities/fountain-apply";
7
+
8
+ const MANIFESTS = `apiVersion: fountain.dev/v1
9
+ kind: Environment
10
+ metadata:
11
+ name: concierge-env
12
+ spec:
13
+ name: concierge-env
14
+ networking_type: limited
15
+ networking_config:
16
+ allowed_hosts:
17
+ - github.com
18
+ repositories:
19
+ - url: https://github.com/org/repo
20
+ mount_path: /app
21
+ ---
22
+ apiVersion: fountain.dev/v1
23
+ kind: Agent
24
+ metadata:
25
+ name: researcher
26
+ spec:
27
+ name: researcher
28
+ model: anthropic/claude-sonnet-4-6
29
+ runtime: claude
30
+ environment: concierge-env
31
+ `;
32
+
33
+ describe("detect", () => {
34
+ it("detects manifest YAML and plan JSON, rejects noise", () => {
35
+ expect(detectFountainTemplate(MANIFESTS)).toBe(true);
36
+ expect(detectFountainTemplate('{"e":{"kind":"Environment","spec":{}}}')).toBe(true);
37
+ expect(detectFountainTemplate("apiVersion: v1\nkind: Pod")).toBe(false);
38
+ expect(detectFountainTemplate("{}")).toBe(false);
39
+ });
40
+ });
41
+
42
+ describe("parser", () => {
43
+ it("parses multi-document manifests into IR", () => {
44
+ const ir = new FountainParser().parse(MANIFESTS);
45
+ expect(ir.resources.map((r) => r.type)).toEqual([
46
+ "Fountain::V1::Environment",
47
+ "Fountain::V1::Agent",
48
+ ]);
49
+ expect(ir.resources[0].logicalId).toBe("concierge-env");
50
+ expect(ir.resources[0].properties.networking_type).toBe("limited");
51
+ });
52
+
53
+ it("parses the fountain-plan.json sidecar", () => {
54
+ const ir = new FountainParser().parse(
55
+ JSON.stringify({ v: { kind: "Vault", spec: { name: "staging", id: "drop-me" } } }),
56
+ );
57
+ expect(ir.resources).toHaveLength(1);
58
+ expect(ir.resources[0].properties.name).toBe("staging");
59
+ expect(ir.resources[0].properties.id).toBeUndefined();
60
+ });
61
+ });
62
+
63
+ describe("generator", () => {
64
+ it("emits typed constructors with Repository wrapping", () => {
65
+ const ir = new FountainParser().parse(MANIFESTS);
66
+ const [file] = new FountainGenerator().generate(ir);
67
+ expect(file.content).toContain('from "@intentius/chant-lexicon-fountain"');
68
+ expect(file.content).toContain("export const conciergeEnv = new Environment({");
69
+ expect(file.content).toContain("new Repository({");
70
+ expect(file.content).toContain("export const researcher = new Agent({");
71
+ });
72
+ });
73
+
74
+ describe("exportResources", () => {
75
+ it("strips server fields, resolves env refs, and warns on secrets", async () => {
76
+ const warnings: string[] = [];
77
+ const http: FountainHttp = async (_m, path) => {
78
+ if (path === "/api/environments")
79
+ return {
80
+ status: 200,
81
+ json: { data: [{ id: "env-1", name: "e", inserted_at: "x", metadata: { "managed-by": "chant" } }] },
82
+ };
83
+ if (path === "/api/environments/env-1/secrets")
84
+ return { status: 200, json: { data: [{ key: "K" }] } };
85
+ if (path === "/api/vaults") return { status: 200, json: { data: [] } };
86
+ if (path === "/api/agents")
87
+ return {
88
+ status: 200,
89
+ json: { data: [{ id: "a-1", name: "r", environment_id: "env-1", model: "a/m", runtime: "claude" }] },
90
+ };
91
+ throw new Error(`unrouted ${path}`);
92
+ };
93
+
94
+ const ir = await exportResources({ environment: "local", http, warn: (m) => warnings.push(m) });
95
+ const agent = ir.resources.find((r) => r.type === "Fountain::V1::Agent")!;
96
+ expect(agent.properties.environment).toBe("e");
97
+ expect(agent.properties.environment_id).toBeUndefined();
98
+ expect(agent.properties.id).toBeUndefined();
99
+ expect(warnings.some((w) => w.includes("1 secret"))).toBe(true);
100
+ });
101
+
102
+ it("owned filter drops unmarked resources", async () => {
103
+ const http: FountainHttp = async (_m, path) => {
104
+ if (path === "/api/environments") return { status: 200, json: { data: [] } };
105
+ if (path === "/api/vaults") return { status: 200, json: { data: [] } };
106
+ if (path === "/api/agents")
107
+ return { status: 200, json: { data: [{ id: "a-1", name: "r", metadata: {} }] } };
108
+ throw new Error(`unrouted ${path}`);
109
+ };
110
+ const ir = await exportResources({ environment: "local", owned: true, http });
111
+ expect(ir.resources).toHaveLength(0);
112
+ });
113
+ });
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Plugin
2
+ export { fountainPlugin } from "./plugin";
3
+
4
+ // Serializer
5
+ export { fountainSerializer } from "./serializer";
6
+
7
+ // Generated resources — Environment, Vault, Agent, and property types.
8
+ export * from "./generated/index";
9
+
10
+ // Op activities — the native applier and conversation runner. Also
11
+ // resolvable by name via loadActivities(["fountain"]).
12
+ export { fountainApply, fountainRun, DEFAULT_FOUNTAIN_BASE_URL } from "./op/activities";
13
+ export type { FountainApplyArgs, FountainApplySummary, FountainRunArgs, FountainRunResult } from "./op/activities";
14
+
15
+ // Composites — secure-by-construction bundles.
16
+ export { ConciergeStack } from "./composites/concierge-stack";
17
+ export type { ConciergeStackOpts, ConciergeStackResources } from "./composites/concierge-stack";
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `chant init` scaffolding for fountain projects.
3
+ *
4
+ * Two templates. The default is the concierge posture — a locked sandbox
5
+ * declared through `ConciergeStack`, since an agent environment that reaches
6
+ * anything real should start closed and be opened by explicit parameter. The
7
+ * `open` template is the loose counterpart for a research sandbox that touches
8
+ * nothing sensitive, and says so in a comment rather than leaving a reader to
9
+ * infer that the difference was deliberate.
10
+ */
11
+
12
+ import type { InitTemplateSet } from "@intentius/chant/lexicon";
13
+
14
+ const CONCIERGE = `import { ConciergeStack } from "@intentius/chant-lexicon-fountain";
15
+
16
+ // A locked-down Environment + Agent pair. The defaults are the closed ones:
17
+ // deny-all egress (limited networking with an empty allowlist), no vault may
18
+ // override the reviewed environment at spawn, and the managed-by: chant marker
19
+ // on both so owned-only reconcile and drift see them.
20
+ //
21
+ // Loosening any of it is an explicit, reviewable parameter — add hosts to
22
+ // allowedHosts, ids to allowedVaultIds. Give the sandbox no cloud credentials:
23
+ // anything readable inside it is exfiltratable by prompt injection. Services
24
+ // the agent needs live outside the sandbox behind their own auth.
25
+ export const { environment, agent } = ConciergeStack({
26
+ name: "concierge",
27
+ model: "anthropic/claude-sonnet-4-6",
28
+ allowedHosts: ["registry.npmjs.org", "github.com"],
29
+ });
30
+ `;
31
+
32
+ const OPEN = `import { Environment, Agent } from "@intentius/chant-lexicon-fountain";
33
+
34
+ // A research sandbox that touches nothing sensitive. networking_type is set
35
+ // explicitly because FTN010 requires the choice to be a reviewed one, not a
36
+ // default nobody looked at; FTN011 will still warn on unrestricted, which is
37
+ // the intended nudge — switch to limited with an allowed_hosts allowlist as
38
+ // soon as this environment holds anything worth stealing.
39
+ export const env = new Environment({
40
+ name: "research-env",
41
+ networking_type: "unrestricted",
42
+ packages: { node: "24" },
43
+ metadata: { "managed-by": "chant" },
44
+ });
45
+
46
+ export const researcher = new Agent({
47
+ name: "researcher",
48
+ model: "anthropic/claude-sonnet-4-6",
49
+ runtime: "claude",
50
+ environment: env,
51
+ metadata: { "managed-by": "chant" },
52
+ });
53
+ `;
54
+
55
+ export function fountainInitTemplates(template?: string): InitTemplateSet {
56
+ if (template === "open") {
57
+ return { src: { "fountain.ts": OPEN } };
58
+ }
59
+ return { src: { "fountain.ts": CONCIERGE } };
60
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The fountain lexicon's chant audit catalog — metadata for the FTN rules,
3
+ * contributed via `fountainPlugin.auditCatalog()` (#687).
4
+ *
5
+ * Every entry carries `yamlBased: false`, which is not an oversight: all of
6
+ * fountain's checks read the chant model (`ctx.entities`), not the emitted
7
+ * manifests (`ctx.outputs`), because the facts they need — which Environment
8
+ * an Agent references, which keys a Vault shadows — live in the typed graph
9
+ * and are flattened by the time YAML exists. So they fire on `chant build`
10
+ * and cannot fire on an audit of standalone fountain YAML. `auditRule()`
11
+ * hardcodes `yamlBased: true`, so these are constructed directly.
12
+ */
13
+
14
+ import type { Authority, RuleMeta } from "@intentius/chant/audit/catalog";
15
+
16
+ const FOUNTAIN_PRIMITIVES: Authority = {
17
+ name: "fountain — Environment, Vault, and Agent primitives",
18
+ url: "https://github.com/BinaryBourbon/fountain/blob/main/docs/primitives.md",
19
+ };
20
+
21
+ const OWASP_LLM_INJECTION: Authority = {
22
+ name: "OWASP Top 10 for LLM Applications — LLM01: Prompt Injection",
23
+ url: "https://owasp.org/www-project-top-10-for-large-language-model-applications/",
24
+ };
25
+
26
+ /** Entity-based rule: everything fountain ships. */
27
+ function rule(
28
+ id: string,
29
+ tier: RuleMeta["tier"],
30
+ category: RuleMeta["category"],
31
+ title: string,
32
+ remediation: string,
33
+ authority?: Authority[],
34
+ ): RuleMeta {
35
+ return { id, tier, fixKind: "guidance", category, title, remediation, authority, yamlBased: false };
36
+ }
37
+
38
+ export const fountainAuditCatalog: Record<string, RuleMeta> = {
39
+ FTN001: rule(
40
+ "FTN001",
41
+ "merge-worthy",
42
+ "security",
43
+ "Literal credential in a fountain declaration",
44
+ "Use a ${VAR} substitution reference or an environment secret; never a literal in source.",
45
+ [OWASP_LLM_INJECTION],
46
+ ),
47
+ FTN010: rule(
48
+ "FTN010",
49
+ "merge-worthy",
50
+ "security",
51
+ "Environment does not set networking_type explicitly",
52
+ "Set networking_type — an open sandbox by silence is not a reviewed decision.",
53
+ [FOUNTAIN_PRIMITIVES],
54
+ ),
55
+ FTN011: rule(
56
+ "FTN011",
57
+ "merge-worthy",
58
+ "security",
59
+ "Environment uses networking_type: unrestricted",
60
+ "Prefer limited with an allowed_hosts allowlist; an empty list denies all egress.",
61
+ [FOUNTAIN_PRIMITIVES],
62
+ ),
63
+ FTN012: rule(
64
+ "FTN012",
65
+ "merge-worthy",
66
+ "security",
67
+ "Cloud-credential-shaped key or value in Environment env_vars",
68
+ "env_vars is plaintext config — move credentials to a secret, or serve the capability outside the sandbox.",
69
+ [OWASP_LLM_INJECTION],
70
+ ),
71
+ FTN013: rule(
72
+ "FTN013",
73
+ "report-only",
74
+ "correctness",
75
+ "Agent ${VAR} reference does not resolve against its environment",
76
+ "Declare the key on the environment, or confirm a vault supplies it at conversation create.",
77
+ ),
78
+ FTN014: rule(
79
+ "FTN014",
80
+ "report-only",
81
+ "correctness",
82
+ "Vault key shadows a declared Environment key",
83
+ "Vault values win on key collision silently — rename the key or confirm the override is intended.",
84
+ ),
85
+ FTN015: rule(
86
+ "FTN015",
87
+ "merge-worthy",
88
+ "security",
89
+ "Secret-shaped MCP env key is a literal, not a ${VAR} reference",
90
+ "Reference the value with ${VAR} so it resolves at spawn instead of living in source.",
91
+ [OWASP_LLM_INJECTION],
92
+ ),
93
+ FTN016: rule(
94
+ "FTN016",
95
+ "merge-worthy",
96
+ "correctness",
97
+ "Agent runtime or model is not a valid value",
98
+ "Use a known runtime and a canonical provider/model_id.",
99
+ ),
100
+ FTN017: rule(
101
+ "FTN017",
102
+ "merge-worthy",
103
+ "correctness",
104
+ "Two declarations of one kind resolve to the same fountain name",
105
+ "fountain reconciles by name — rename one, or the second silently overwrites the first.",
106
+ ),
107
+ };
@@ -0,0 +1,37 @@
1
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
2
+ import { propsOf } from "../../entity-props";
3
+
4
+ /**
5
+ * FTN010: Environments must state their networking intent.
6
+ *
7
+ * Fountain defaults `networking_type` to `unrestricted` when omitted —
8
+ * an open-network sandbox by silence. Every declared Environment must set
9
+ * `networking_type` explicitly so the security posture is a reviewed,
10
+ * diffable decision.
11
+ */
12
+ export const networkingExplicitCheck: PostSynthCheck = {
13
+ id: "FTN010",
14
+ description: "Environment declarations must set networking_type explicitly",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [name, entity] of ctx.entities) {
20
+ if (entity.entityType !== "Fountain::V1::Environment") continue;
21
+ const props = propsOf(entity);
22
+ if (props.networking_type === undefined) {
23
+ diagnostics.push({
24
+ checkId: "FTN010",
25
+ severity: "warning",
26
+ message:
27
+ `Environment "${name}" does not set networking_type — fountain defaults to ` +
28
+ `"unrestricted" (open network). State the intent explicitly.`,
29
+ entity: name,
30
+ lexicon: "fountain",
31
+ });
32
+ }
33
+ }
34
+
35
+ return diagnostics;
36
+ },
37
+ };
@@ -0,0 +1,36 @@
1
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
2
+ import { propsOf } from "../../entity-props";
3
+
4
+ /**
5
+ * FTN011: `unrestricted` networking is a warning by default.
6
+ *
7
+ * Fountain sandboxes run untrusted agent code; an open-network sandbox is
8
+ * a real posture some workloads need, but it should read as a deliberate
9
+ * exception in review. Projects hosting untrusted-agent environments
10
+ * (concierge-class) should promote this to error via project rules.
11
+ */
12
+ export const noUnrestrictedNetworkingCheck: PostSynthCheck = {
13
+ id: "FTN011",
14
+ description: "Environments should prefer networking_type: limited over unrestricted",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+ for (const [name, entity] of ctx.entities) {
19
+ if (entity.entityType !== "Fountain::V1::Environment") continue;
20
+ const props = propsOf(entity);
21
+ if (props.networking_type === "unrestricted") {
22
+ diagnostics.push({
23
+ checkId: "FTN011",
24
+ severity: "warning",
25
+ message:
26
+ `Environment "${name}" uses networking_type: unrestricted — an open-network ` +
27
+ `sandbox. Prefer "limited" with allowed_hosts; keep unrestricted only as a ` +
28
+ `reviewed exception.`,
29
+ entity: name,
30
+ lexicon: "fountain",
31
+ });
32
+ }
33
+ }
34
+ return diagnostics;
35
+ },
36
+ };
@@ -0,0 +1,58 @@
1
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
2
+ import { propsOf } from "../../entity-props";
3
+
4
+ /**
5
+ * FTN012: no cloud-credential-shaped values in Environment env_vars.
6
+ *
7
+ * Everything materialized into a sandbox must be presumed exfiltrated the
8
+ * moment untrusted agent code starts. `env_vars` is plaintext config —
9
+ * anything credential-shaped belongs in secrets at minimum, and long-lived
10
+ * cloud credentials should not enter the sandbox at all (see
11
+ * BinaryBourbon/fountain#148 for the token-only model).
12
+ */
13
+
14
+ const CREDENTIAL_KEYS = /^(AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|AWS_SESSION_TOKEN|GOOGLE_APPLICATION_CREDENTIALS|AZURE_CLIENT_SECRET)$/i;
15
+ const CREDENTIAL_VALUES = /^(AKIA[0-9A-Z]{16}|(ghp|gho|ghs|ghu)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,})$/;
16
+
17
+ export const noCloudCredentialEnvCheck: PostSynthCheck = {
18
+ id: "FTN012",
19
+ description: "Environment env_vars must not carry cloud-credential-shaped keys or values",
20
+
21
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ for (const [name, entity] of ctx.entities) {
25
+ if (entity.entityType !== "Fountain::V1::Environment") continue;
26
+ const envVars = propsOf(entity).env_vars as Record<string, unknown> | undefined;
27
+ if (!envVars || typeof envVars !== "object") continue;
28
+
29
+ for (const [key, value] of Object.entries(envVars)) {
30
+ if (CREDENTIAL_KEYS.test(key)) {
31
+ diagnostics.push({
32
+ checkId: "FTN012",
33
+ severity: "error",
34
+ message:
35
+ `Environment "${name}" env_vars carries cloud-credential key "${key}" — ` +
36
+ `credentials must not be materialized into the sandbox`,
37
+ entity: name,
38
+ lexicon: "fountain",
39
+ });
40
+ continue;
41
+ }
42
+ if (typeof value === "string" && CREDENTIAL_VALUES.test(value)) {
43
+ diagnostics.push({
44
+ checkId: "FTN012",
45
+ severity: "error",
46
+ message:
47
+ `Environment "${name}" env_vars["${key}"] looks like a literal credential — ` +
48
+ `use secrets or a \${VAR} reference, never a plaintext value`,
49
+ entity: name,
50
+ lexicon: "fountain",
51
+ });
52
+ }
53
+ }
54
+ }
55
+
56
+ return diagnostics;
57
+ },
58
+ };
@@ -0,0 +1,93 @@
1
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
2
+ import { propsOf } from "../../entity-props";
3
+
4
+ /**
5
+ * FTN013: `${VAR}` references in an Agent's config should resolve against
6
+ * its Environment's declared keys.
7
+ *
8
+ * Fountain validates substitution fail-complete at *spawn* time; this
9
+ * moves the same check to build time. Warning severity: a vault attached
10
+ * at conversation create can legitimately supply keys the environment
11
+ * does not declare (vault values win on collision), so an unresolved
12
+ * reference is suspicious, not certainly wrong.
13
+ */
14
+
15
+ const VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
16
+
17
+ function collectVars(node: unknown, acc: Set<string>): void {
18
+ if (typeof node === "string") {
19
+ for (const m of node.matchAll(VAR_RE)) acc.add(m[1]);
20
+ return;
21
+ }
22
+ if (Array.isArray(node)) {
23
+ for (const item of node) collectVars(item, acc);
24
+ return;
25
+ }
26
+ if (node && typeof node === "object") {
27
+ for (const v of Object.values(node)) collectVars(v, acc);
28
+ }
29
+ }
30
+
31
+ function declaredKeys(env: Record<string, unknown>): Set<string> {
32
+ const keys = new Set<string>();
33
+ const envVars = env.env_vars;
34
+ if (envVars && typeof envVars === "object") {
35
+ for (const k of Object.keys(envVars)) keys.add(k);
36
+ }
37
+ const secrets = env.secrets;
38
+ if (Array.isArray(secrets)) {
39
+ for (const s of secrets) {
40
+ const key = (s as { key?: unknown })?.key;
41
+ if (typeof key === "string") keys.add(key);
42
+ }
43
+ }
44
+ return keys;
45
+ }
46
+
47
+ export const substitutionResolvableCheck: PostSynthCheck = {
48
+ id: "FTN013",
49
+ description: "Agent ${VAR} references should resolve against the environment's declared keys",
50
+
51
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
52
+ const diagnostics: PostSynthDiagnostic[] = [];
53
+
54
+ for (const [name, entity] of ctx.entities) {
55
+ if (entity.entityType !== "Fountain::V1::Agent") continue;
56
+ const agent = propsOf(entity);
57
+
58
+ // Resolve the referenced environment entity when it is declared here.
59
+ const envRef = agent.environment;
60
+ let envEntity: Record<string, unknown> | undefined;
61
+ if (envRef && typeof envRef === "object") {
62
+ envEntity = propsOf(envRef);
63
+ } else if (typeof envRef === "string") {
64
+ const byName = ctx.entities.get(envRef);
65
+ if (byName?.entityType === "Fountain::V1::Environment") {
66
+ envEntity = propsOf(byName);
67
+ }
68
+ }
69
+ if (!envEntity) continue; // external environment — nothing to check against
70
+
71
+ const keys = declaredKeys(envEntity);
72
+ const used = new Set<string>();
73
+ collectVars(agent.mcp_servers, used);
74
+ collectVars(agent.system, used);
75
+
76
+ for (const v of used) {
77
+ if (!keys.has(v)) {
78
+ diagnostics.push({
79
+ checkId: "FTN013",
80
+ severity: "warning",
81
+ message:
82
+ `Agent "${name}" references \${${v}} but its environment declares no such ` +
83
+ `key — it will only resolve if a vault supplies it at conversation create`,
84
+ entity: name,
85
+ lexicon: "fountain",
86
+ });
87
+ }
88
+ }
89
+ }
90
+
91
+ return diagnostics;
92
+ },
93
+ };
@@ -0,0 +1,61 @@
1
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
2
+ import { propsOf } from "../../entity-props";
3
+
4
+ /**
5
+ * FTN014: a Vault key that collides with a declared Environment key.
6
+ *
7
+ * Vault values win on key collision at spawn — silently. Shadowing a
8
+ * reviewed environment value is sometimes exactly the point (staging
9
+ * overrides) and sometimes an accident; either way it should be visible
10
+ * in review, so collisions warn.
11
+ */
12
+
13
+ function keysOf(spec: Record<string, unknown>): Set<string> {
14
+ const keys = new Set<string>();
15
+ const envVars = spec.env_vars;
16
+ if (envVars && typeof envVars === "object") for (const k of Object.keys(envVars)) keys.add(k);
17
+ const secrets = spec.secrets;
18
+ if (Array.isArray(secrets)) {
19
+ for (const s of secrets) {
20
+ const key = (s as { key?: unknown })?.key;
21
+ if (typeof key === "string") keys.add(key);
22
+ }
23
+ }
24
+ return keys;
25
+ }
26
+
27
+ export const vaultShadowingCheck: PostSynthCheck = {
28
+ id: "FTN014",
29
+ description: "Vault keys shadowing a declared Environment key should be visible in review",
30
+
31
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
32
+ const diagnostics: PostSynthDiagnostic[] = [];
33
+ const environments: Array<[string, Set<string>]> = [];
34
+ const vaults: Array<[string, Set<string>]> = [];
35
+
36
+ for (const [name, entity] of ctx.entities) {
37
+ const spec = propsOf(entity);
38
+ if (entity.entityType === "Fountain::V1::Environment") environments.push([name, keysOf(spec)]);
39
+ if (entity.entityType === "Fountain::V1::Vault") vaults.push([name, keysOf(spec)]);
40
+ }
41
+
42
+ for (const [vaultName, vaultKeys] of vaults) {
43
+ for (const [envName, envKeys] of environments) {
44
+ const shadowed = [...vaultKeys].filter((k) => envKeys.has(k));
45
+ if (shadowed.length > 0) {
46
+ diagnostics.push({
47
+ checkId: "FTN014",
48
+ severity: "warning",
49
+ message:
50
+ `Vault "${vaultName}" shadows ${shadowed.map((k) => `"${k}"`).join(", ")} from ` +
51
+ `environment "${envName}" — vault values win silently on collision at spawn`,
52
+ entity: vaultName,
53
+ lexicon: "fountain",
54
+ });
55
+ }
56
+ }
57
+ }
58
+
59
+ return diagnostics;
60
+ },
61
+ };