@intentius/chant-lexicon-fountain 0.52.1 → 0.53.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.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * fountain deep-observation noise rules (#1217).
3
+ *
4
+ * Plain data, imported statically by `plugin.ts`, because core applies the
5
+ * identical rules to the *declared* property tree — which no reader ever
6
+ * touches — before it diffs. Burying them inside the read would normalize the
7
+ * two sides differently and report everything as drift.
8
+ *
9
+ * ## Why a static table and not an ownership walk
10
+ *
11
+ * Kubernetes records `managedFields`, so the k8s row can subtract whatever a
12
+ * controller owns. A fountain REST payload never says who wrote a field, so
13
+ * this row follows the GCP precedent (`lexicons/gcp/src/deep-observe-hooks.ts`)
14
+ * — a hand-maintained table naming what the server populates. The cost is
15
+ * explicit: a server-set field nobody has listed reads as drift until it is
16
+ * listed. That is visible and fixable; an over-broad rule silently hides real
17
+ * drift, which is the worse of the two failures.
18
+ *
19
+ * Every value below is read off fountain's own Ecto schemas (`Environment`,
20
+ * `Vault`, `Agent`) and the JSON views that render them, not guessed from a
21
+ * sample payload.
22
+ *
23
+ * ## Secrets
24
+ *
25
+ * chant authors an environment's or vault's `secrets` as an ordered
26
+ * `{key, value}[]`, so the declared node holds real secret material. Core's
27
+ * key-name mask (`SENSITIVE_KEY_PATTERNS` matches `secrets`) collapses the
28
+ * whole node to `[REDACTED]` on both trees before any diff sees it, which is
29
+ * the correct outcome and also the limit of what this row can report: presence
30
+ * — an environment that declares no secrets and has some, or the reverse —
31
+ * never the key set, and never a value. Making the key set diffable needs
32
+ * fountain#148's reference model, where secrets stop being inline values.
33
+ *
34
+ * {@link fountainDeepNormalizationHooks.mask} adds one narrow rule on top:
35
+ * a string matching a known credential shape is collapsed wherever it appears,
36
+ * whatever the key is called. FTN001/FTN012 already refuse those at lint time;
37
+ * this is the backstop for a value that reached the live instance some other
38
+ * way, so a drift row can never print one.
39
+ */
40
+ import type { DeepNormalizationHooks } from "@intentius/chant/deep-observation";
41
+ export declare const ENVIRONMENT_TYPE = "Fountain::V1::Environment";
42
+ export declare const VAULT_TYPE = "Fountain::V1::Vault";
43
+ export declare const AGENT_TYPE = "Fountain::V1::Agent";
44
+ /**
45
+ * Top-level payload fields fountain writes and a caller cannot: the primary
46
+ * key, the `timestamps()` pair, the owning user, the virtual `*_count` rollups
47
+ * the `*_with_counts` reads attach, the avatar's derived media type, and `acp`
48
+ * (computed per request from the runtime, never stored).
49
+ *
50
+ * Pruned on BOTH sides and regardless of what source declared, since a user who
51
+ * writes one is writing something the API overwrites anyway.
52
+ *
53
+ * Matched on the whole pattern rather than its last segment: `name` and `id`
54
+ * also occur *inside* `skills[]` and `repositories[]`, where they are authored
55
+ * configuration. `SERVER_FIELDS` is shared with the import/export path so the
56
+ * two cannot disagree about what a caller may author.
57
+ */
58
+ export declare const FOUNTAIN_SERVER_FIELDS: ReadonlySet<string>;
59
+ /**
60
+ * Per-kind values fountain fills in when the request omits them, keyed by the
61
+ * index-erased pattern from the tree root.
62
+ *
63
+ * Noise only where **source never declared the property** — the
64
+ * `counterpart === "absent"` gate below. A default somebody wrote out
65
+ * explicitly is a fact worth diffing, and a later change away from it has to
66
+ * stay reportable.
67
+ *
68
+ * Straight off the Ecto `schema` blocks. `networking_type: "unrestricted"` is
69
+ * the one worth arguing about: subtracting it means an environment that never
70
+ * states its networking posture does not report one. That case is FTN010's, at
71
+ * lint time, where it is a build finding rather than drift; and the case this
72
+ * row exists for — a reviewed `limited` environment flipped to `unrestricted`
73
+ * in the UI — has a declared counterpart, so the gate keeps it and it reports
74
+ * as `changed`.
75
+ */
76
+ export declare const FOUNTAIN_DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
77
+ export declare const fountainDeepNormalizationHooks: DeepNormalizationHooks;
78
+ //# sourceMappingURL=deep-observe-hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-observe-hooks.d.ts","sourceRoot":"","sources":["../src/deep-observe-hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,KAAK,EAGV,sBAAsB,EACvB,MAAM,mCAAmC,CAAC;AAG3C,eAAO,MAAM,gBAAgB,8BAA8B,CAAC;AAC5D,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAChD,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAiBzF,CAAC;AAoDF,eAAO,MAAM,8BAA8B,EAAE,sBA0E5C,CAAC"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * fountain deep observation (#1217) — the fountain row of the deep-observe
3
+ * contract (#1014).
4
+ *
5
+ * `describeResources()` (./describe-resources.ts) answers whether a declared
6
+ * Environment/Vault/Agent exists and hands back its id and timestamps. That
7
+ * misses the drift the design was written for: an environment hand-edited in
8
+ * the fountain UI from `networking_type: limited` to `unrestricted`, an
9
+ * `allowed_vault_ids` allowlist widened, a skill repointed at an unpinned
10
+ * branch, a secret added to a reviewed sandbox. All of it lives one level
11
+ * down, in properties nobody was reading.
12
+ *
13
+ * ## The read is the thin path's read
14
+ *
15
+ * Transport, endpoint and auth are the applier's, unchanged
16
+ * (`FOUNTAIN_ENDPOINT` / `FOUNTAIN_TOKEN`), so plan reads the instance
17
+ * `fountainApply` writes. And the depth is free: fountain's list endpoints
18
+ * render the full record — `GET /api/environments` returns every configuration
19
+ * field the request schema accepts, not a summary — so there is no per-resource
20
+ * follow-up GET the way the AWS row needs Cloud Control on top of
21
+ * `describe-stack-resources`. One list per declared kind, cached, exactly as
22
+ * the thin path does it.
23
+ *
24
+ * ## The payload passes through
25
+ *
26
+ * fountain's JSON views name their fields the same way the request schema does
27
+ * (`networking_type`, `env_vars`, `skills`), so the live tree and the declared
28
+ * tree already speak one vocabulary — the AWS situation, not temporal's. The
29
+ * payload is therefore forwarded as-is and the noise rules
30
+ * (./deep-observe-hooks.ts) do the rest. A field fountain adds in a later
31
+ * release surfaces as `undeclared` until the table names it, which is the
32
+ * deliberate trade: visible and fixable beats silently dropped.
33
+ *
34
+ * One exception, and it is the reference edge. chant declares an agent's
35
+ * environment as a typed reference (`environment`), fountain stores the id it
36
+ * resolved to (`environment_id`). Passing the id through would report
37
+ * `<undeclared> -> <uuid>` on every clean read, so where source did not author
38
+ * `environment_id` itself the id is resolved back to the environment's name and
39
+ * emitted as `environment` — the same translation `exportResources()` does for
40
+ * the import path.
41
+ *
42
+ * ## Secrets: presence, never keys, never values
43
+ *
44
+ * Values are write-only upstream and are never read here at all. The secrets
45
+ * sub-resource is listed (keys and timestamps only) so that an environment or
46
+ * vault which declares no secrets and has some — somebody adding one to a
47
+ * locked-down sandbox — reports as drift. Core's key-name mask collapses the
48
+ * whole `secrets` node on both trees, so what a diff row can say is that
49
+ * secrets exist, not which. See the hooks module for why the key set itself is
50
+ * not expressible until fountain#148 lands.
51
+ *
52
+ * That listing is one extra request per observed Environment and Vault. A
53
+ * fountain tenant holds a handful of each, and the alternative — inferring
54
+ * presence from the newer payload's `secret_count` — would silently report
55
+ * "no secrets" against any instance predating that field.
56
+ */
57
+ import type { DeepObservationResult } from "@intentius/chant/lexicon";
58
+ import { type FountainHttp } from "./op/activities/fountain-apply.js";
59
+ import { fountainDeepNormalizationHooks } from "./deep-observe-hooks.js";
60
+ export { fountainDeepNormalizationHooks };
61
+ export interface FountainDeepObserveOptions {
62
+ environment: string;
63
+ buildOutput?: string;
64
+ entityNames: string[];
65
+ entities: Map<string, {
66
+ entityType: string;
67
+ props: Record<string, unknown>;
68
+ }>;
69
+ stack?: string;
70
+ /** Restrict to resources carrying the `managed-by: chant` marker. */
71
+ owned?: boolean;
72
+ /** Endpoint override (tests). Defaults to resolveEndpoint(). */
73
+ endpoint?: string;
74
+ }
75
+ /**
76
+ * Read the live property tree for each declared fountain entity.
77
+ *
78
+ * `http` is injectable for tests; the default reuses the applier's fetch client
79
+ * (bearer token from FOUNTAIN_TOKEN). A missing token is the whole-lexicon
80
+ * failure the thin path already names — every declared entity NOT-OBSERVED with
81
+ * `no-credentials`, never an empty tree, which would read as "nothing drifted".
82
+ */
83
+ export declare function observeResourcesDeepFountain(options: FountainDeepObserveOptions, injected?: FountainHttp): Promise<DeepObservationResult>;
84
+ //# sourceMappingURL=deep-observe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-observe.d.ts","sourceRoot":"","sources":["../src/deep-observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAEH,OAAO,KAAK,EACV,qBAAqB,EAGtB,MAAM,0BAA0B,CAAC;AAGlC,OAAO,EAML,KAAK,YAAY,EAClB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,8BAA8B,EAI/B,MAAM,sBAAsB,CAAC;AAM9B,OAAO,EAAE,8BAA8B,EAAE,CAAC;AAW1C,MAAM,WAAW,0BAA0B;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA+ED;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,0BAA0B,EACnC,QAAQ,CAAC,EAAE,YAAY,GACtB,OAAO,CAAC,qBAAqB,CAAC,CAkGhC"}
@@ -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/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export { fountainPlugin } from "./plugin.js";
2
2
  export { fountainSerializer } from "./serializer.js";
3
+ export { observeResourcesDeepFountain } from "./deep-observe.js";
4
+ export type { FountainDeepObserveOptions } from "./deep-observe.js";
5
+ export { fountainDeepNormalizationHooks, FOUNTAIN_SERVER_FIELDS, FOUNTAIN_DEFAULTS, } from "./deep-observe-hooks.js";
3
6
  export * from "./generated/index.js";
4
7
  export { fountainApply, fountainRun, DEFAULT_FOUNTAIN_BASE_URL } from "./op/activities/index.js";
5
8
  export type { FountainApplyArgs, FountainApplySummary, FountainRunArgs, FountainRunResult } from "./op/activities/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAGlD,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGnH,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAIlD,OAAO,EAAE,4BAA4B,EAAE,MAAM,gBAAgB,CAAC;AAC9D,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EACL,8BAA8B,EAC9B,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAG9B,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGnH,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "786fec6cfad4f4d2038f4cac675c486da09a3ed512b8619f43dbdd53ee07f25a",
4
+ "manifest.json": "3c1bc0c06594180eed030af320ad7a1cf16e5beda1a9c42ec44568e8b1e19368",
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": "8b6dab62a2f3e42ad0d8fb770499b7d4c978895e398aec222b97cc3991dee178"
20
+ "composite": "3b6b337bb3b931f76ae5ddac80fb34cfaf53ca9d8fcd6084f09c52f0106bb1fe"
21
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fountain",
3
- "version": "0.52.1",
3
+ "version": "0.53.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fountain",
6
6
  "specVersion": "v0.3.0"
@@ -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;AAuB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aA0J5B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAyB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aAyK5B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.52.1",
3
+ "version": "0.53.0",
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.1",
53
+ "@intentius/chant": "^0.53.0",
54
54
  "typescript": "^5.9.3"
55
55
  },
56
56
  "devDependencies": {
@@ -0,0 +1,237 @@
1
+ /**
2
+ * fountain deep-observation noise rules (#1217).
3
+ *
4
+ * Plain data, imported statically by `plugin.ts`, because core applies the
5
+ * identical rules to the *declared* property tree — which no reader ever
6
+ * touches — before it diffs. Burying them inside the read would normalize the
7
+ * two sides differently and report everything as drift.
8
+ *
9
+ * ## Why a static table and not an ownership walk
10
+ *
11
+ * Kubernetes records `managedFields`, so the k8s row can subtract whatever a
12
+ * controller owns. A fountain REST payload never says who wrote a field, so
13
+ * this row follows the GCP precedent (`lexicons/gcp/src/deep-observe-hooks.ts`)
14
+ * — a hand-maintained table naming what the server populates. The cost is
15
+ * explicit: a server-set field nobody has listed reads as drift until it is
16
+ * listed. That is visible and fixable; an over-broad rule silently hides real
17
+ * drift, which is the worse of the two failures.
18
+ *
19
+ * Every value below is read off fountain's own Ecto schemas (`Environment`,
20
+ * `Vault`, `Agent`) and the JSON views that render them, not guessed from a
21
+ * sample payload.
22
+ *
23
+ * ## Secrets
24
+ *
25
+ * chant authors an environment's or vault's `secrets` as an ordered
26
+ * `{key, value}[]`, so the declared node holds real secret material. Core's
27
+ * key-name mask (`SENSITIVE_KEY_PATTERNS` matches `secrets`) collapses the
28
+ * whole node to `[REDACTED]` on both trees before any diff sees it, which is
29
+ * the correct outcome and also the limit of what this row can report: presence
30
+ * — an environment that declares no secrets and has some, or the reverse —
31
+ * never the key set, and never a value. Making the key set diffable needs
32
+ * fountain#148's reference model, where secrets stop being inline values.
33
+ *
34
+ * {@link fountainDeepNormalizationHooks.mask} adds one narrow rule on top:
35
+ * a string matching a known credential shape is collapsed wherever it appears,
36
+ * whatever the key is called. FTN001/FTN012 already refuse those at lint time;
37
+ * this is the backstop for a value that reached the live instance some other
38
+ * way, so a drift row can never print one.
39
+ */
40
+
41
+ import type {
42
+ DeepArrayElement,
43
+ DeepNode,
44
+ DeepNormalizationHooks,
45
+ } from "@intentius/chant/deep-observation";
46
+ import { SERVER_FIELDS } from "./import/parser";
47
+
48
+ export const ENVIRONMENT_TYPE = "Fountain::V1::Environment";
49
+ export const VAULT_TYPE = "Fountain::V1::Vault";
50
+ export const AGENT_TYPE = "Fountain::V1::Agent";
51
+
52
+ /**
53
+ * Top-level payload fields fountain writes and a caller cannot: the primary
54
+ * key, the `timestamps()` pair, the owning user, the virtual `*_count` rollups
55
+ * the `*_with_counts` reads attach, the avatar's derived media type, and `acp`
56
+ * (computed per request from the runtime, never stored).
57
+ *
58
+ * Pruned on BOTH sides and regardless of what source declared, since a user who
59
+ * writes one is writing something the API overwrites anyway.
60
+ *
61
+ * Matched on the whole pattern rather than its last segment: `name` and `id`
62
+ * also occur *inside* `skills[]` and `repositories[]`, where they are authored
63
+ * configuration. `SERVER_FIELDS` is shared with the import/export path so the
64
+ * two cannot disagree about what a caller may author.
65
+ */
66
+ export const FOUNTAIN_SERVER_FIELDS: ReadonlySet<string> = new Set([
67
+ ...SERVER_FIELDS,
68
+ "secret_count",
69
+ "agent_count",
70
+ "acp",
71
+ ]);
72
+
73
+ /**
74
+ * Per-kind values fountain fills in when the request omits them, keyed by the
75
+ * index-erased pattern from the tree root.
76
+ *
77
+ * Noise only where **source never declared the property** — the
78
+ * `counterpart === "absent"` gate below. A default somebody wrote out
79
+ * explicitly is a fact worth diffing, and a later change away from it has to
80
+ * stay reportable.
81
+ *
82
+ * Straight off the Ecto `schema` blocks. `networking_type: "unrestricted"` is
83
+ * the one worth arguing about: subtracting it means an environment that never
84
+ * states its networking posture does not report one. That case is FTN010's, at
85
+ * lint time, where it is a build finding rather than drift; and the case this
86
+ * row exists for — a reviewed `limited` environment flipped to `unrestricted`
87
+ * in the UI — has a declared counterpart, so the gate keeps it and it reports
88
+ * as `changed`.
89
+ */
90
+ export const FOUNTAIN_DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
91
+ [ENVIRONMENT_TYPE]: {
92
+ setup_script: "",
93
+ networking_type: "unrestricted",
94
+ repositories: [],
95
+ },
96
+ [VAULT_TYPE]: {
97
+ description: "",
98
+ },
99
+ [AGENT_TYPE]: {
100
+ description: "",
101
+ system: "",
102
+ skills: [],
103
+ // ADR 0023. Not on the committed spec snapshot yet, so an instance that
104
+ // predates it simply never emits the field.
105
+ sandbox_mode: "ephemeral",
106
+ },
107
+ };
108
+
109
+ /**
110
+ * Credential shapes FTN001 refuses in source and FTN012 refuses in `env_vars`.
111
+ * Reused here as the mask's own rule so the two lists cannot drift apart in
112
+ * what they call a credential.
113
+ */
114
+ const CREDENTIAL_VALUE_SHAPES: readonly RegExp[] = [
115
+ /^AKIA[0-9A-Z]{16}$/,
116
+ /^(ghp|gho|ghs|ghu)_[A-Za-z0-9]{20,}$/,
117
+ /^github_pat_[A-Za-z0-9_]{20,}$/,
118
+ /^sk-[A-Za-z0-9_-]{20,}$/,
119
+ /^ftn_[A-Za-z0-9]{16,}$/,
120
+ /^xox[baprs]-[A-Za-z0-9-]{10,}$/,
121
+ /^-----BEGIN [A-Z ]*PRIVATE KEY-----/,
122
+ ];
123
+
124
+ /** Key-order-independent equality, so a default written as a container matches whatever order a payload arrives in. */
125
+ function equalsDefault(expected: unknown, actual: unknown): boolean {
126
+ if (expected === actual) return true;
127
+ if (typeof expected !== typeof actual) return false;
128
+ return canonicalJson(expected) === canonicalJson(actual);
129
+ }
130
+
131
+ function canonicalJson(value: unknown): string {
132
+ return (
133
+ JSON.stringify(value, (_k, v: unknown) =>
134
+ v && typeof v === "object" && !Array.isArray(v)
135
+ ? Object.fromEntries(
136
+ Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
137
+ )
138
+ : v,
139
+ ) ?? ""
140
+ );
141
+ }
142
+
143
+ /** An `{}` with nothing pruned out of it — fountain's spelling for an unset `:map` column. */
144
+ function isEmptyObject(value: unknown): boolean {
145
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
146
+ }
147
+
148
+ function isEmptyArray(value: unknown): boolean {
149
+ return Array.isArray(value) && value.length === 0;
150
+ }
151
+
152
+ /** The final segment of an index-erased pattern (`skills[].name` -> `name`). */
153
+ function lastSegment(pattern: string): string {
154
+ const withoutIndex = pattern.replace(/\[\]$/, "");
155
+ const dot = withoutIndex.lastIndexOf(".");
156
+ return dot === -1 ? withoutIndex : withoutIndex.slice(dot + 1);
157
+ }
158
+
159
+ export const fountainDeepNormalizationHooks: DeepNormalizationHooks = {
160
+ /**
161
+ * A credential-shaped string never reaches a diff row, a log line or a
162
+ * snapshot. Collapsed on both trees, so a value that is the same on both
163
+ * still classifies as unchanged; a changed one classifies as changed without
164
+ * either side being printed. Same contract as the k8s row's Secret mask
165
+ * (#1365 decision 6): presence and paths, never values.
166
+ */
167
+ mask(node: DeepNode): boolean {
168
+ return typeof node.value === "string" && CREDENTIAL_VALUE_SHAPES.some((p) => p.test(node.value as string));
169
+ },
170
+
171
+ prune(node: DeepNode): boolean {
172
+ // Server-written, on either side, declared or not.
173
+ if (FOUNTAIN_SERVER_FIELDS.has(node.pattern)) return true;
174
+
175
+ // An authored-but-empty `secrets` list is not a fountain state: there is no
176
+ // sub-resource to read back for it, so leaving it on the declared side
177
+ // would report `absent` on every clean read.
178
+ if (lastSegment(node.pattern) === "secrets" && isEmptyArray(node.value)) return true;
179
+
180
+ // Below here: values fountain populated that source never asked for.
181
+ // `counterpart` is a tri-state and only `absent` licenses subtraction.
182
+ if (node.side !== "live" || node.counterpart !== "absent") return false;
183
+
184
+ // `name` is the key the read looked the resource up by, so it can never
185
+ // disagree with the declaration; it is undeclared only when the serializer
186
+ // fell back to the chant export name, which is chant reading back its own
187
+ // choice rather than drift.
188
+ if (node.pattern === "name") return true;
189
+
190
+ // fountain's spellings for "never configured": a nullable column and an
191
+ // unset `:map`. `[]` is deliberately NOT in this rule — an empty
192
+ // `allowed_vault_ids` means "no vault may attach", which is a posture
193
+ // somebody chose, not an absence. The empty-list defaults that ARE server
194
+ // defaults (`skills`, `repositories`) are named per kind below.
195
+ if (node.value === null || isEmptyObject(node.value)) return true;
196
+
197
+ const defaults = FOUNTAIN_DEFAULTS[node.entityType];
198
+ if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
199
+ return equalsDefault(defaults[node.pattern], node.value);
200
+ },
201
+
202
+ /**
203
+ * The set-shaped lists. `repositories` and `skills` are keyed by their own
204
+ * identity so one added entry does not rename every entry after it in the
205
+ * flattened diff; the id/host lists are keyed by the element itself.
206
+ *
207
+ * Nothing else is reordered. An array whose elements do not all yield a key
208
+ * keeps the order the payload arrived in, which is the right answer wherever
209
+ * order might carry meaning.
210
+ */
211
+ orderKey(element: DeepArrayElement): string | undefined {
212
+ const name = lastSegment(element.pattern);
213
+ const el = element.element;
214
+
215
+ if (name === "allowed_vault_ids" || name === "allowed_environment_ids" || name === "allowed_hosts") {
216
+ return typeof el === "string" ? el : undefined;
217
+ }
218
+
219
+ if (name === "repositories" && isRecord(el)) {
220
+ return typeof el.mount_path === "string" ? el.mount_path : undefined;
221
+ }
222
+
223
+ if (name === "skills" && isRecord(el)) {
224
+ // An inline entry is identified by its name, a github entry by its repo.
225
+ // Exactly one of the two is set — the server's own changeset enforces it.
226
+ if (typeof el.name === "string") return el.name;
227
+ if (typeof el.source === "string") return el.source;
228
+ return undefined;
229
+ }
230
+
231
+ return undefined;
232
+ },
233
+ };
234
+
235
+ function isRecord(value: unknown): value is Record<string, unknown> {
236
+ return typeof value === "object" && value !== null && !Array.isArray(value);
237
+ }