@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.
- package/README.md +63 -0
- package/dist/codegen/docs-cli.d.ts +3 -0
- package/dist/codegen/docs-cli.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts +16 -0
- package/dist/codegen/docs.d.ts.map +1 -0
- package/dist/codegen/generate-cli.d.ts +3 -0
- package/dist/codegen/generate-cli.d.ts.map +1 -0
- package/dist/codegen/generate-lexicon.d.ts +24 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -0
- package/dist/codegen/generate-typescript.d.ts +11 -0
- package/dist/codegen/generate-typescript.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts +15 -0
- package/dist/codegen/generate.d.ts.map +1 -0
- package/dist/codegen/naming.d.ts +14 -0
- package/dist/codegen/naming.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +17 -0
- package/dist/codegen/package.d.ts.map +1 -0
- package/dist/composites/concierge-stack.d.ts +44 -0
- package/dist/composites/concierge-stack.d.ts.map +1 -0
- package/dist/coverage.d.ts +56 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +45 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/detect.d.ts +10 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/entity-props.d.ts +11 -0
- package/dist/entity-props.d.ts.map +1 -0
- package/dist/export-resources.d.ts +29 -0
- package/dist/export-resources.d.ts.map +1 -0
- package/dist/generated/index.d.ts +5 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/generated/runtime.d.ts +5 -0
- package/dist/generated/runtime.d.ts.map +1 -0
- package/dist/import/generator.d.ts +15 -0
- package/dist/import/generator.d.ts.map +1 -0
- package/dist/import/parser.d.ts +19 -0
- package/dist/import/parser.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/init-templates.d.ts +13 -0
- package/dist/init-templates.d.ts.map +1 -0
- package/dist/integrity.json +21 -0
- package/dist/lint/audit-catalog.d.ts +15 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/lint/post-synth/ftn010-networking-explicit.d.ts +11 -0
- package/dist/lint/post-synth/ftn010-networking-explicit.d.ts.map +1 -0
- package/dist/lint/post-synth/ftn011-no-unrestricted-networking.d.ts +11 -0
- package/dist/lint/post-synth/ftn011-no-unrestricted-networking.d.ts.map +1 -0
- package/dist/lint/post-synth/ftn012-no-cloud-credential-env.d.ts +3 -0
- package/dist/lint/post-synth/ftn012-no-cloud-credential-env.d.ts.map +1 -0
- package/dist/lint/post-synth/ftn013-substitution-resolvable.d.ts +3 -0
- package/dist/lint/post-synth/ftn013-substitution-resolvable.d.ts.map +1 -0
- package/dist/lint/post-synth/ftn014-vault-shadowing.d.ts +3 -0
- package/dist/lint/post-synth/ftn014-vault-shadowing.d.ts.map +1 -0
- package/dist/lint/post-synth/ftn015-mcp-secret-env-substitution.d.ts +3 -0
- package/dist/lint/post-synth/ftn015-mcp-secret-env-substitution.d.ts.map +1 -0
- package/dist/lint/post-synth/ftn016-runtime-model-valid.d.ts +3 -0
- package/dist/lint/post-synth/ftn016-runtime-model-valid.d.ts.map +1 -0
- package/dist/lint/post-synth/ftn017-unique-resource-names.d.ts +11 -0
- package/dist/lint/post-synth/ftn017-unique-resource-names.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts +3 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -0
- package/dist/lint/rules/ftn001-no-secret-literals.d.ts +3 -0
- package/dist/lint/rules/ftn001-no-secret-literals.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +5 -0
- package/dist/lint/rules/index.d.ts.map +1 -0
- package/dist/lsp/completions.d.ts +7 -0
- package/dist/lsp/completions.d.ts.map +1 -0
- package/dist/lsp/hover.d.ts +4 -0
- package/dist/lsp/hover.d.ts.map +1 -0
- package/dist/manifest.json +6 -0
- package/dist/meta.json +69 -0
- package/dist/op/activities/fountain-apply.d.ts +76 -0
- package/dist/op/activities/fountain-apply.d.ts.map +1 -0
- package/dist/op/activities/fountain-run.d.ts +36 -0
- package/dist/op/activities/fountain-run.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +11 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/package-cli.d.ts +3 -0
- package/dist/package-cli.d.ts.map +1 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/reference-catalog.d.ts +16 -0
- package/dist/reference-catalog.d.ts.map +1 -0
- package/dist/rules/ftn001-no-secret-literals.ts +71 -0
- package/dist/rules/ftn010-networking-explicit.ts +37 -0
- package/dist/rules/ftn011-no-unrestricted-networking.ts +36 -0
- package/dist/rules/ftn012-no-cloud-credential-env.ts +58 -0
- package/dist/rules/ftn013-substitution-resolvable.ts +93 -0
- package/dist/rules/ftn014-vault-shadowing.ts +61 -0
- package/dist/rules/ftn015-mcp-secret-env-substitution.ts +49 -0
- package/dist/rules/ftn016-runtime-model-valid.ts +50 -0
- package/dist/rules/ftn017-unique-resource-names.ts +43 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/skills/chant-fountain-locked-sandboxes.md +34 -0
- package/dist/skills/chant-fountain-secrets.md +29 -0
- package/dist/skills/chant-fountain.md +45 -0
- package/dist/spec/fetch.d.ts +28 -0
- package/dist/spec/fetch.d.ts.map +1 -0
- package/dist/spec/parse.d.ts +58 -0
- package/dist/spec/parse.d.ts.map +1 -0
- package/dist/types/index.d.ts +60 -0
- package/dist/validate-cli.d.ts +3 -0
- package/dist/validate-cli.d.ts.map +1 -0
- package/dist/validate.d.ts +15 -0
- package/dist/validate.d.ts.map +1 -0
- package/package.json +61 -0
- package/src/codegen/docs-cli.ts +7 -0
- package/src/codegen/docs.ts +278 -0
- package/src/codegen/generate-cli.ts +8 -0
- package/src/codegen/generate-lexicon.ts +58 -0
- package/src/codegen/generate-typescript.ts +69 -0
- package/src/codegen/generate.ts +109 -0
- package/src/codegen/naming.ts +45 -0
- package/src/codegen/package.ts +32 -0
- package/src/composites/composites.test.ts +35 -0
- package/src/composites/concierge-stack.ts +71 -0
- package/src/coverage.test.ts +63 -0
- package/src/coverage.ts +178 -0
- package/src/describe-resources.test.ts +203 -0
- package/src/describe-resources.ts +192 -0
- package/src/detect.ts +37 -0
- package/src/entity-props.ts +22 -0
- package/src/export-resources.ts +103 -0
- package/src/generated/.gitkeep +0 -0
- package/src/generated/index.d.ts +60 -0
- package/src/generated/index.ts +10 -0
- package/src/generated/lexicon-fountain.json +69 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.ts +112 -0
- package/src/import/parser.ts +83 -0
- package/src/import/roundtrip.test.ts +113 -0
- package/src/index.ts +17 -0
- package/src/init-templates.ts +60 -0
- package/src/lint/audit-catalog.ts +107 -0
- package/src/lint/post-synth/ftn010-networking-explicit.ts +37 -0
- package/src/lint/post-synth/ftn011-no-unrestricted-networking.ts +36 -0
- package/src/lint/post-synth/ftn012-no-cloud-credential-env.ts +58 -0
- package/src/lint/post-synth/ftn013-substitution-resolvable.ts +93 -0
- package/src/lint/post-synth/ftn014-vault-shadowing.ts +61 -0
- package/src/lint/post-synth/ftn015-mcp-secret-env-substitution.ts +49 -0
- package/src/lint/post-synth/ftn016-runtime-model-valid.ts +50 -0
- package/src/lint/post-synth/ftn017-unique-resource-names.ts +43 -0
- package/src/lint/post-synth/index.ts +21 -0
- package/src/lint/post-synth/post-synth.test.ts +133 -0
- package/src/lint/rules/ftn001-no-secret-literals.ts +71 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/rules.test.ts +126 -0
- package/src/lsp/completions.test.ts +51 -0
- package/src/lsp/completions.ts +21 -0
- package/src/lsp/hover.test.ts +47 -0
- package/src/lsp/hover.ts +70 -0
- package/src/op/activities/fountain-apply.test.ts +208 -0
- package/src/op/activities/fountain-apply.ts +249 -0
- package/src/op/activities/fountain-run.ts +95 -0
- package/src/op/activities/index.ts +28 -0
- package/src/package-cli.ts +26 -0
- package/src/plugin.test.ts +73 -0
- package/src/plugin.ts +170 -0
- package/src/reference-catalog.ts +27 -0
- package/src/serializer.test.ts +109 -0
- package/src/serializer.ts +143 -0
- package/src/skills/chant-fountain-locked-sandboxes.md +34 -0
- package/src/skills/chant-fountain-secrets.md +29 -0
- package/src/skills/chant-fountain.md +45 -0
- package/src/spec/fetch.ts +53 -0
- package/src/spec/fountain-openapi.snapshot.json +1 -0
- package/src/spec/parse.ts +309 -0
- package/src/validate-cli.ts +13 -0
- package/src/validate.ts +34 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
2
|
+
import { propsOf } from "../../entity-props";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* FTN015: secret-shaped MCP server env keys must use `${VAR}` references.
|
|
6
|
+
*
|
|
7
|
+
* A literal value under TOKEN/SECRET/KEY/PASSWORD-shaped keys in
|
|
8
|
+
* `mcp_servers.*.env` is a credential in source and in fountain's stored
|
|
9
|
+
* agent config. FTN001 catches known credential *shapes* at the AST;
|
|
10
|
+
* this catches the key-name signal regardless of value shape.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const SECRET_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|CREDENTIAL)/i;
|
|
14
|
+
const SUBSTITUTION_RE = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/;
|
|
15
|
+
|
|
16
|
+
export const mcpSecretEnvSubstitutionCheck: PostSynthCheck = {
|
|
17
|
+
id: "FTN015",
|
|
18
|
+
description: "Secret-shaped MCP env keys must be ${VAR} references, not literals",
|
|
19
|
+
|
|
20
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
21
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
22
|
+
|
|
23
|
+
for (const [name, entity] of ctx.entities) {
|
|
24
|
+
if (entity.entityType !== "Fountain::V1::Agent") continue;
|
|
25
|
+
const servers = propsOf(entity).mcp_servers as Record<string, unknown> | undefined;
|
|
26
|
+
if (!servers || typeof servers !== "object") continue;
|
|
27
|
+
|
|
28
|
+
for (const [serverName, server] of Object.entries(servers)) {
|
|
29
|
+
const env = (server as { env?: Record<string, unknown> })?.env;
|
|
30
|
+
if (!env || typeof env !== "object") continue;
|
|
31
|
+
for (const [key, value] of Object.entries(env)) {
|
|
32
|
+
if (!SECRET_KEY_RE.test(key)) continue;
|
|
33
|
+
if (typeof value === "string" && SUBSTITUTION_RE.test(value)) continue;
|
|
34
|
+
diagnostics.push({
|
|
35
|
+
checkId: "FTN015",
|
|
36
|
+
severity: "error",
|
|
37
|
+
message:
|
|
38
|
+
`Agent "${name}" mcp_servers.${serverName}.env.${key} looks secret-shaped ` +
|
|
39
|
+
`but is not a \${VAR} reference — literals here land in stored agent config`,
|
|
40
|
+
entity: name,
|
|
41
|
+
lexicon: "fountain",
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return diagnostics;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
2
|
+
import { propsOf } from "../../entity-props";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* FTN016: runtime and model must be valid before apply.
|
|
6
|
+
*
|
|
7
|
+
* The generated types make these compile-time checks for typed authoring;
|
|
8
|
+
* this backstops untyped construction (imported templates, hand-built
|
|
9
|
+
* plans) so a typo fails the build instead of a 422 at apply.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const RUNTIMES = new Set(["claude", "codex", "gemini", "opencode"]);
|
|
13
|
+
const MODEL_RE = /^[a-z0-9_-]+\/[a-z0-9._-]+$/;
|
|
14
|
+
|
|
15
|
+
export const runtimeModelValidCheck: PostSynthCheck = {
|
|
16
|
+
id: "FTN016",
|
|
17
|
+
description: "Agent runtime must be a known runtime and model canonical provider/model_id",
|
|
18
|
+
|
|
19
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
20
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
21
|
+
|
|
22
|
+
for (const [name, entity] of ctx.entities) {
|
|
23
|
+
if (entity.entityType !== "Fountain::V1::Agent") continue;
|
|
24
|
+
const agent = propsOf(entity) as { runtime?: unknown; model?: unknown };
|
|
25
|
+
|
|
26
|
+
if (typeof agent.runtime === "string" && !RUNTIMES.has(agent.runtime)) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "FTN016",
|
|
29
|
+
severity: "error",
|
|
30
|
+
message:
|
|
31
|
+
`Agent "${name}" runtime "${agent.runtime}" is not one of ` +
|
|
32
|
+
`${[...RUNTIMES].join(", ")}`,
|
|
33
|
+
entity: name,
|
|
34
|
+
lexicon: "fountain",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (typeof agent.model === "string" && !MODEL_RE.test(agent.model)) {
|
|
38
|
+
diagnostics.push({
|
|
39
|
+
checkId: "FTN016",
|
|
40
|
+
severity: "error",
|
|
41
|
+
message: `Agent "${name}" model "${agent.model}" is not canonical provider/model_id form`,
|
|
42
|
+
entity: name,
|
|
43
|
+
lexicon: "fountain",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return diagnostics;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
2
|
+
import { propsOf } from "../../entity-props";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* FTN017: resource names must be unique per kind.
|
|
6
|
+
*
|
|
7
|
+
* fountainApply reconciles by name; two entities of the same kind
|
|
8
|
+
* resolving to the same fountain name would fight over one live
|
|
9
|
+
* resource — the second apply step silently becomes an update of the
|
|
10
|
+
* first one's creation.
|
|
11
|
+
*/
|
|
12
|
+
export const uniqueResourceNamesCheck: PostSynthCheck = {
|
|
13
|
+
id: "FTN017",
|
|
14
|
+
description: "Two declarations of the same kind must not resolve to the same fountain name",
|
|
15
|
+
|
|
16
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
17
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
18
|
+
const seen = new Map<string, string>();
|
|
19
|
+
|
|
20
|
+
for (const [entityName, entity] of ctx.entities) {
|
|
21
|
+
if (!entity.entityType.startsWith("Fountain::V1::")) continue;
|
|
22
|
+
const props = propsOf(entity);
|
|
23
|
+
const resourceName = typeof props.name === "string" ? props.name : entityName;
|
|
24
|
+
const key = `${entity.entityType}/${resourceName}`;
|
|
25
|
+
const first = seen.get(key);
|
|
26
|
+
if (first) {
|
|
27
|
+
diagnostics.push({
|
|
28
|
+
checkId: "FTN017",
|
|
29
|
+
severity: "error",
|
|
30
|
+
message:
|
|
31
|
+
`"${entityName}" and "${first}" both resolve to ${key} — apply would ` +
|
|
32
|
+
`reconcile them onto one live resource`,
|
|
33
|
+
entity: entityName,
|
|
34
|
+
lexicon: "fountain",
|
|
35
|
+
});
|
|
36
|
+
} else {
|
|
37
|
+
seen.set(key, entityName);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return diagnostics;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Code generated by chant generate. DO NOT EDIT.
|
|
2
|
+
import type { PostSynthCheck } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import { networkingExplicitCheck } from "./ftn010-networking-explicit";
|
|
4
|
+
import { noUnrestrictedNetworkingCheck } from "./ftn011-no-unrestricted-networking";
|
|
5
|
+
import { noCloudCredentialEnvCheck } from "./ftn012-no-cloud-credential-env";
|
|
6
|
+
import { substitutionResolvableCheck } from "./ftn013-substitution-resolvable";
|
|
7
|
+
import { vaultShadowingCheck } from "./ftn014-vault-shadowing";
|
|
8
|
+
import { mcpSecretEnvSubstitutionCheck } from "./ftn015-mcp-secret-env-substitution";
|
|
9
|
+
import { runtimeModelValidCheck } from "./ftn016-runtime-model-valid";
|
|
10
|
+
import { uniqueResourceNamesCheck } from "./ftn017-unique-resource-names";
|
|
11
|
+
|
|
12
|
+
export const postSynthChecks: PostSynthCheck[] = [
|
|
13
|
+
networkingExplicitCheck,
|
|
14
|
+
noUnrestrictedNetworkingCheck,
|
|
15
|
+
noCloudCredentialEnvCheck,
|
|
16
|
+
substitutionResolvableCheck,
|
|
17
|
+
vaultShadowingCheck,
|
|
18
|
+
mcpSecretEnvSubstitutionCheck,
|
|
19
|
+
runtimeModelValidCheck,
|
|
20
|
+
uniqueResourceNamesCheck,
|
|
21
|
+
];
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
3
|
+
import type { Declarable } from "@intentius/chant";
|
|
4
|
+
// From the check modules, not the barrel — the barrel is generated and
|
|
5
|
+
// exports only the `postSynthChecks` array.
|
|
6
|
+
import { noUnrestrictedNetworkingCheck } from "./ftn011-no-unrestricted-networking";
|
|
7
|
+
import { substitutionResolvableCheck } from "./ftn013-substitution-resolvable";
|
|
8
|
+
import { vaultShadowingCheck } from "./ftn014-vault-shadowing";
|
|
9
|
+
import { mcpSecretEnvSubstitutionCheck } from "./ftn015-mcp-secret-env-substitution";
|
|
10
|
+
import { runtimeModelValidCheck } from "./ftn016-runtime-model-valid";
|
|
11
|
+
import { uniqueResourceNamesCheck } from "./ftn017-unique-resource-names";
|
|
12
|
+
|
|
13
|
+
function ctx(entities: Record<string, Record<string, unknown>>): PostSynthContext {
|
|
14
|
+
const map = new Map<string, Declarable>();
|
|
15
|
+
for (const [name, props] of Object.entries(entities)) {
|
|
16
|
+
map.set(name, { lexicon: "fountain", ...props } as unknown as Declarable);
|
|
17
|
+
}
|
|
18
|
+
return { outputs: new Map(), entities: map, buildResult: { warnings: [], errors: [] } } as unknown as PostSynthContext;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ENV = "Fountain::V1::Environment";
|
|
22
|
+
const VAULT = "Fountain::V1::Vault";
|
|
23
|
+
const AGENT = "Fountain::V1::Agent";
|
|
24
|
+
|
|
25
|
+
describe("FTN011 no-unrestricted-networking", () => {
|
|
26
|
+
it("warns on unrestricted, silent on limited", () => {
|
|
27
|
+
expect(noUnrestrictedNetworkingCheck.check(ctx({ e: { entityType: ENV, networking_type: "unrestricted" } }))).toHaveLength(1);
|
|
28
|
+
expect(noUnrestrictedNetworkingCheck.check(ctx({ e: { entityType: ENV, networking_type: "limited" } }))).toHaveLength(0);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("FTN013 substitution-resolvable", () => {
|
|
33
|
+
const env = { entityType: ENV, name: "e", env_vars: { DECLARED: "x" }, secrets: [{ key: "SECRET_ONE", value: "v" }] };
|
|
34
|
+
|
|
35
|
+
it("warns on an unresolvable reference", () => {
|
|
36
|
+
const diags = substitutionResolvableCheck.check(
|
|
37
|
+
ctx({ e: env, a: { entityType: AGENT, environment: "e", mcp_servers: { s: { env: { T: "${MISSING}" } } } } }),
|
|
38
|
+
);
|
|
39
|
+
expect(diags).toHaveLength(1);
|
|
40
|
+
expect(diags[0].message).toContain("MISSING");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("resolves against env_vars and secrets keys", () => {
|
|
44
|
+
const diags = substitutionResolvableCheck.check(
|
|
45
|
+
ctx({ e: env, a: { entityType: AGENT, environment: "e", mcp_servers: { s: { env: { A: "${DECLARED}", B: "${SECRET_ONE}" } } } } }),
|
|
46
|
+
);
|
|
47
|
+
expect(diags).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("silent when the environment is external", () => {
|
|
51
|
+
const diags = substitutionResolvableCheck.check(
|
|
52
|
+
ctx({ a: { entityType: AGENT, environment: "not-declared-here", mcp_servers: { s: { env: { T: "${X}" } } } } }),
|
|
53
|
+
);
|
|
54
|
+
expect(diags).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("FTN014 vault-shadowing", () => {
|
|
59
|
+
it("warns when a vault key collides with an environment key", () => {
|
|
60
|
+
const diags = vaultShadowingCheck.check(
|
|
61
|
+
ctx({
|
|
62
|
+
e: { entityType: ENV, env_vars: { DATABASE_URL: "prod" } },
|
|
63
|
+
v: { entityType: VAULT, secrets: [{ key: "DATABASE_URL", value: "staging" }] },
|
|
64
|
+
}),
|
|
65
|
+
);
|
|
66
|
+
expect(diags).toHaveLength(1);
|
|
67
|
+
expect(diags[0].message).toContain("DATABASE_URL");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("silent without collisions", () => {
|
|
71
|
+
const diags = vaultShadowingCheck.check(
|
|
72
|
+
ctx({
|
|
73
|
+
e: { entityType: ENV, env_vars: { A: "1" } },
|
|
74
|
+
v: { entityType: VAULT, secrets: [{ key: "B", value: "2" }] },
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
expect(diags).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("FTN015 mcp-secret-env-substitution", () => {
|
|
82
|
+
it("errors on a literal under a secret-shaped key", () => {
|
|
83
|
+
const diags = mcpSecretEnvSubstitutionCheck.check(
|
|
84
|
+
ctx({ a: { entityType: AGENT, mcp_servers: { gh: { env: { GITHUB_TOKEN: "literal" } } } } }),
|
|
85
|
+
);
|
|
86
|
+
expect(diags).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("accepts ${VAR} references and non-secret keys", () => {
|
|
90
|
+
const diags = mcpSecretEnvSubstitutionCheck.check(
|
|
91
|
+
ctx({ a: { entityType: AGENT, mcp_servers: { gh: { env: { GITHUB_TOKEN: "${GH}", LOG_LEVEL: "debug" } } } } }),
|
|
92
|
+
);
|
|
93
|
+
expect(diags).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("FTN016 runtime-model-valid", () => {
|
|
98
|
+
it("errors on unknown runtime and malformed model", () => {
|
|
99
|
+
const diags = runtimeModelValidCheck.check(
|
|
100
|
+
ctx({ a: { entityType: AGENT, runtime: "cursor", model: "not-canonical" } }),
|
|
101
|
+
);
|
|
102
|
+
expect(diags).toHaveLength(2);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("silent on valid values", () => {
|
|
106
|
+
const diags = runtimeModelValidCheck.check(
|
|
107
|
+
ctx({ a: { entityType: AGENT, runtime: "claude", model: "anthropic/claude-sonnet-4-6" } }),
|
|
108
|
+
);
|
|
109
|
+
expect(diags).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("FTN017 unique-resource-names", () => {
|
|
114
|
+
it("errors when two entities of a kind share a fountain name", () => {
|
|
115
|
+
const diags = uniqueResourceNamesCheck.check(
|
|
116
|
+
ctx({
|
|
117
|
+
a: { entityType: ENV, name: "same" },
|
|
118
|
+
b: { entityType: ENV, name: "same" },
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
expect(diags).toHaveLength(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("allows the same name across kinds", () => {
|
|
125
|
+
const diags = uniqueResourceNamesCheck.check(
|
|
126
|
+
ctx({
|
|
127
|
+
a: { entityType: ENV, name: "same" },
|
|
128
|
+
b: { entityType: VAULT, name: "same" },
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
expect(diags).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* FTN001: no secret literals in fountain resource declarations.
|
|
6
|
+
*
|
|
7
|
+
* Environment/Vault secrets and MCP server env values must be substitution
|
|
8
|
+
* references (`${VAR}`) or provider references — never literal credential
|
|
9
|
+
* values. A literal in source is a credential in git history.
|
|
10
|
+
*
|
|
11
|
+
* Fires on string literals matching well-known credential shapes anywhere
|
|
12
|
+
* inside a `new Environment/Vault/Agent(...)` expression.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const FOUNTAIN_KINDS = new Set(["Environment", "Vault", "Agent"]);
|
|
16
|
+
|
|
17
|
+
const CREDENTIAL_SHAPES: Array<{ pattern: RegExp; label: string }> = [
|
|
18
|
+
{ pattern: /^AKIA[0-9A-Z]{16}$/, label: "AWS access key id" },
|
|
19
|
+
{ pattern: /^(ghp|gho|ghs|ghu)_[A-Za-z0-9]{20,}$/, label: "GitHub token" },
|
|
20
|
+
{ pattern: /^github_pat_[A-Za-z0-9_]{20,}$/, label: "GitHub fine-grained token" },
|
|
21
|
+
{ pattern: /^sk-[A-Za-z0-9_-]{20,}$/, label: "secret API key (sk-)" },
|
|
22
|
+
{ pattern: /^ftn_[A-Za-z0-9]{16,}$/, label: "fountain API key" },
|
|
23
|
+
{ pattern: /^xox[baprs]-[A-Za-z0-9-]{10,}$/, label: "Slack token" },
|
|
24
|
+
{ pattern: /^-----BEGIN [A-Z ]*PRIVATE KEY-----/, label: "private key material" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const noSecretLiteralsRule: LintRule = {
|
|
28
|
+
id: "FTN001",
|
|
29
|
+
severity: "error",
|
|
30
|
+
category: "security",
|
|
31
|
+
description:
|
|
32
|
+
"No literal credential values in fountain declarations — use ${VAR} substitution or a secret provider",
|
|
33
|
+
|
|
34
|
+
check(context: LintContext): LintDiagnostic[] {
|
|
35
|
+
const diagnostics: LintDiagnostic[] = [];
|
|
36
|
+
const source = context.sourceFile;
|
|
37
|
+
|
|
38
|
+
const visit = (node: ts.Node, insideFountainNew: boolean) => {
|
|
39
|
+
let inside = insideFountainNew;
|
|
40
|
+
|
|
41
|
+
if (ts.isNewExpression(node)) {
|
|
42
|
+
const name = node.expression.getText(source);
|
|
43
|
+
const short = name.split(".").pop() ?? name;
|
|
44
|
+
if (FOUNTAIN_KINDS.has(short)) inside = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (inside && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))) {
|
|
48
|
+
const value = node.text;
|
|
49
|
+
for (const { pattern, label } of CREDENTIAL_SHAPES) {
|
|
50
|
+
if (pattern.test(value)) {
|
|
51
|
+
const { line, character } = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
52
|
+
diagnostics.push({
|
|
53
|
+
ruleId: "FTN001",
|
|
54
|
+
severity: "error",
|
|
55
|
+
message: `Literal ${label} in a fountain declaration — use \${VAR} substitution or a secret provider`,
|
|
56
|
+
file: context.filePath,
|
|
57
|
+
line: line + 1,
|
|
58
|
+
column: character + 1,
|
|
59
|
+
});
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ts.forEachChild(node, (child) => visit(child, inside));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
visit(source, false);
|
|
69
|
+
return diagnostics;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LintRule } from "@intentius/chant/lint/rule";
|
|
2
|
+
import { noSecretLiteralsRule } from "./ftn001-no-secret-literals";
|
|
3
|
+
|
|
4
|
+
export { noSecretLiteralsRule } from "./ftn001-no-secret-literals";
|
|
5
|
+
|
|
6
|
+
/** All lint rules provided by this lexicon (imported by plugin.ts's lintRules()). */
|
|
7
|
+
export const rules: LintRule[] = [noSecretLiteralsRule];
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { noSecretLiteralsRule } from "./ftn001-no-secret-literals";
|
|
4
|
+
import { networkingExplicitCheck } from "../post-synth/ftn010-networking-explicit";
|
|
5
|
+
import { noCloudCredentialEnvCheck } from "../post-synth/ftn012-no-cloud-credential-env";
|
|
6
|
+
import type { LintContext } from "@intentius/chant/lint/rule";
|
|
7
|
+
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
8
|
+
import type { Declarable } from "@intentius/chant";
|
|
9
|
+
|
|
10
|
+
function lintCtx(code: string): LintContext {
|
|
11
|
+
return {
|
|
12
|
+
sourceFile: ts.createSourceFile("infra.ts", code, ts.ScriptTarget.Latest, true),
|
|
13
|
+
entities: [],
|
|
14
|
+
filePath: "infra.ts",
|
|
15
|
+
lexicon: "fountain",
|
|
16
|
+
} as LintContext;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function synthCtx(entities: Record<string, Record<string, unknown>>): PostSynthContext {
|
|
20
|
+
const map = new Map<string, Declarable>();
|
|
21
|
+
for (const [name, props] of Object.entries(entities)) {
|
|
22
|
+
map.set(name, { lexicon: "fountain", ...props } as unknown as Declarable);
|
|
23
|
+
}
|
|
24
|
+
return { outputs: new Map(), entities: map, buildResult: { warnings: [], errors: [] } } as unknown as PostSynthContext;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("FTN001 no-secret-literals", () => {
|
|
28
|
+
it("flags a literal AWS key inside a fountain declaration", () => {
|
|
29
|
+
const diags = noSecretLiteralsRule.check(
|
|
30
|
+
lintCtx(`export const e = new Environment({ env_vars: { K: "AKIAIOSFODNN7EXAMPLE" } });`),
|
|
31
|
+
);
|
|
32
|
+
expect(diags).toHaveLength(1);
|
|
33
|
+
expect(diags[0].ruleId).toBe("FTN001");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("flags a GitHub token inside an Agent's mcp env", () => {
|
|
37
|
+
const diags = noSecretLiteralsRule.check(
|
|
38
|
+
lintCtx(
|
|
39
|
+
`export const a = new Agent({ mcp_servers: { gh: { env: { T: "ghp_abcdefghijklmnopqrstuv" } } } });`,
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
expect(diags).toHaveLength(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("does not flag substitution references", () => {
|
|
46
|
+
const diags = noSecretLiteralsRule.check(
|
|
47
|
+
lintCtx(`export const a = new Agent({ mcp_servers: { gh: { env: { T: "\${GITHUB_PAT}" } } } });`),
|
|
48
|
+
);
|
|
49
|
+
expect(diags).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("ignores credential-shaped literals outside fountain declarations", () => {
|
|
53
|
+
const diags = noSecretLiteralsRule.check(
|
|
54
|
+
lintCtx(`const fixture = "AKIAIOSFODNN7EXAMPLE";`),
|
|
55
|
+
);
|
|
56
|
+
expect(diags).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("FTN010 networking-explicit", () => {
|
|
61
|
+
it("warns on an Environment without networking_type", () => {
|
|
62
|
+
const diags = networkingExplicitCheck.check(
|
|
63
|
+
synthCtx({ env: { entityType: "Fountain::V1::Environment", name: "e" } }),
|
|
64
|
+
);
|
|
65
|
+
expect(diags).toHaveLength(1);
|
|
66
|
+
expect(diags[0].checkId).toBe("FTN010");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("is silent when networking_type is explicit", () => {
|
|
70
|
+
const diags = networkingExplicitCheck.check(
|
|
71
|
+
synthCtx({
|
|
72
|
+
env: { entityType: "Fountain::V1::Environment", name: "e", networking_type: "limited" },
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
expect(diags).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("ignores non-Environment kinds", () => {
|
|
79
|
+
const diags = networkingExplicitCheck.check(
|
|
80
|
+
synthCtx({ v: { entityType: "Fountain::V1::Vault", name: "v" } }),
|
|
81
|
+
);
|
|
82
|
+
expect(diags).toHaveLength(0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("FTN012 no-cloud-credential-env", () => {
|
|
87
|
+
it("errors on a credential key in env_vars", () => {
|
|
88
|
+
const diags = noCloudCredentialEnvCheck.check(
|
|
89
|
+
synthCtx({
|
|
90
|
+
env: {
|
|
91
|
+
entityType: "Fountain::V1::Environment",
|
|
92
|
+
name: "e",
|
|
93
|
+
env_vars: { AWS_SECRET_ACCESS_KEY: "whatever" },
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
expect(diags).toHaveLength(1);
|
|
98
|
+
expect(diags[0].severity).toBe("error");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("errors on a credential-shaped value under an innocent key", () => {
|
|
102
|
+
const diags = noCloudCredentialEnvCheck.check(
|
|
103
|
+
synthCtx({
|
|
104
|
+
env: {
|
|
105
|
+
entityType: "Fountain::V1::Environment",
|
|
106
|
+
name: "e",
|
|
107
|
+
env_vars: { UPSTREAM: "AKIAIOSFODNN7EXAMPLE" },
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
expect(diags).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("is silent on ordinary env vars", () => {
|
|
115
|
+
const diags = noCloudCredentialEnvCheck.check(
|
|
116
|
+
synthCtx({
|
|
117
|
+
env: {
|
|
118
|
+
entityType: "Fountain::V1::Environment",
|
|
119
|
+
name: "e",
|
|
120
|
+
env_vars: { LOG_LEVEL: "info" },
|
|
121
|
+
},
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
expect(diags).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { CompletionContext } from "@intentius/chant/lsp/types";
|
|
3
|
+
import { completions } from "./completions";
|
|
4
|
+
|
|
5
|
+
function ctx(partial: Partial<CompletionContext>): CompletionContext {
|
|
6
|
+
return {
|
|
7
|
+
uri: "file:///infra.ts",
|
|
8
|
+
content: "",
|
|
9
|
+
position: { line: 0, character: 0 },
|
|
10
|
+
wordAtCursor: "",
|
|
11
|
+
linePrefix: "",
|
|
12
|
+
...partial,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("LSP completions", () => {
|
|
17
|
+
it("suggests the fountain kinds after `new `", () => {
|
|
18
|
+
const items = completions(ctx({ linePrefix: "export const e = new " }));
|
|
19
|
+
const labels = items.map((i) => i.label);
|
|
20
|
+
|
|
21
|
+
expect(labels).toContain("Environment");
|
|
22
|
+
expect(labels).toContain("Vault");
|
|
23
|
+
expect(labels).toContain("Agent");
|
|
24
|
+
expect(items.every((i) => i.kind === "resource")).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("narrows on a typed prefix", () => {
|
|
28
|
+
const items = completions(ctx({ linePrefix: "new Env", wordAtCursor: "Env" }));
|
|
29
|
+
|
|
30
|
+
expect(items.map((i) => i.label)).toEqual(["Environment"]);
|
|
31
|
+
expect(items[0].detail).toBe("Fountain::V1::Environment");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("suggests properties inside a constructor", () => {
|
|
35
|
+
const content = "const a = new Agent({\n ";
|
|
36
|
+
const items = completions(
|
|
37
|
+
ctx({ content, position: { line: 1, character: 2 }, linePrefix: " " }),
|
|
38
|
+
);
|
|
39
|
+
const labels = items.map((i) => i.label);
|
|
40
|
+
|
|
41
|
+
// Property names come off propertyConstraints — the key the core
|
|
42
|
+
// LexiconEntry contract reads. A rename there silently empties this.
|
|
43
|
+
expect(labels).toContain("runtime");
|
|
44
|
+
expect(labels).toContain("model");
|
|
45
|
+
expect(items.every((i) => i.kind === "property")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns nothing in an unrelated position", () => {
|
|
49
|
+
expect(completions(ctx({ linePrefix: "const x = 1" }))).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createRequire } from "module";
|
|
2
|
+
import type { CompletionContext, CompletionItem } from "@intentius/chant/lsp/types";
|
|
3
|
+
import { LexiconIndex, lexiconCompletions, type LexiconEntry } from "@intentius/chant/lsp/lexicon-providers";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
|
|
6
|
+
let cachedIndex: LexiconIndex | null = null;
|
|
7
|
+
|
|
8
|
+
function getIndex(): LexiconIndex {
|
|
9
|
+
if (cachedIndex) return cachedIndex;
|
|
10
|
+
const data = require("../generated/lexicon-fountain.json") as Record<string, LexiconEntry>;
|
|
11
|
+
cachedIndex = new LexiconIndex(data);
|
|
12
|
+
return cachedIndex;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Provide LSP completions for fountain resources — class names after
|
|
17
|
+
* `new `, property names inside a constructor.
|
|
18
|
+
*/
|
|
19
|
+
export function completions(ctx: CompletionContext): CompletionItem[] {
|
|
20
|
+
return lexiconCompletions(ctx, getIndex(), "fountain resource");
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { HoverContext } from "@intentius/chant/lsp/types";
|
|
3
|
+
import { hover } from "./hover";
|
|
4
|
+
|
|
5
|
+
function ctx(word: string): HoverContext {
|
|
6
|
+
return {
|
|
7
|
+
uri: "file:///infra.ts",
|
|
8
|
+
content: "",
|
|
9
|
+
position: { line: 0, character: 0 },
|
|
10
|
+
word,
|
|
11
|
+
lineText: "",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("LSP hover", () => {
|
|
16
|
+
it("returns undefined for an unknown word", () => {
|
|
17
|
+
expect(hover(ctx("Conversation"))).toBeUndefined();
|
|
18
|
+
expect(hover(ctx(""))).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("describes a resource with its fountain type", () => {
|
|
22
|
+
const info = hover(ctx("Environment"));
|
|
23
|
+
|
|
24
|
+
expect(info?.contents).toContain("**Environment**");
|
|
25
|
+
expect(info?.contents).toContain("Fountain::V1::Environment");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("carries the networking semantics an author cannot infer from the prop name", () => {
|
|
29
|
+
const info = hover(ctx("Environment"));
|
|
30
|
+
|
|
31
|
+
expect(info?.contents).toContain("denies all egress");
|
|
32
|
+
expect(info?.contents).toContain("`networking_type`: `unrestricted` | `limited`");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("spells out allowed_vault_ids' three-state meaning", () => {
|
|
36
|
+
const info = hover(ctx("Agent"));
|
|
37
|
+
|
|
38
|
+
expect(info?.contents).toContain("allowed_vault_ids");
|
|
39
|
+
expect(info?.contents).toContain("`runtime`: `claude` | `codex` | `gemini` | `opencode`");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("marks property types as non-declarable", () => {
|
|
43
|
+
const info = hover(ctx("Repository"));
|
|
44
|
+
|
|
45
|
+
expect(info?.contents).toContain("Property type");
|
|
46
|
+
});
|
|
47
|
+
});
|