Organizational Policy
Show the whole prompt
Read https://intentius.io/chant/guide/organizational-policy/.
Write the policy I describe as a post-synth check in `policies/`, register it under `lint.policies` in `chant.config.ts`, and add a `policyGate()` step before the apply phase of any Op that deploys.
Run `npx chant build src --env <env>` for each environment I name and `npx chant lint`, and show me what each policy reports.
Open a pull request with the policy and the config. Do not suppress a finding to make a build pass.
Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.Optional: hand this page to your coding agent
Read https://intentius.io/chant/guide/organizational-policy/.Write the policy I describe as a post-synth check in `policies/`, register it under `lint.policies` in `chant.config.ts`, and add a `policyGate()` step before the apply phase of any Op that deploys.Run `npx chant build src --env <env>` for each environment I name and `npx chant lint`, and show me what each policy reports.Open a pull request with the policy and the config. Do not suppress a finding to make a build pass.Never deploy to a real cloud account or shared environment (with `chant run`, `chant components` or a platform's own CLI), approve (a pull request review, `chant approve` or `chant run approve`), override a policy denial (`chant approve policy-override`), or merge; never cut or publish a release (`chant release cut --yes` or `chant release publish`), push or delete a `ci/*` tag (`chant ci tick` pushes them), or change the value of an existing flag; never touch `.chant/allowed_signers` or `chant/lifecycle`.Lexicon lint checks that a resource is valid for its domain. Organizational policy is your org’s own rules: “no public load balancers in prod,” “every workload carries a cost-center tag,” “instance types from this allowlist.” Such rules span lexicons, belong to one org, and often branch on environment.
In chant, organizational policy is
project-authored post-synth checks,
run by the existing engine, with the current environment in context. They use the
PostSynthCheck shape lexicons ship as domain rules, with no separate policy language.
Writing a policy
Section titled “Writing a policy”A policy reads the resolved resources and returns diagnostics. A diagnostic with
severity: "error" fails chant build, and that is the gate. The check receives
ctx.env, so a rule can branch on the environment.
Both checks below read ctx.docs, the parsed-output view (chant #975,
Post-Synth Check Guide),
instead of splitting ctx.outputs YAML by hand. A lexicon’s own post-synth
checks read the same view, so an org policy gets the same parse-once manifest
list without a lexicon-internal import.
// Organizational policy — project-authored post-synth checks.//// These are the same `PostSynthCheck` shape lexicons ship as domain rules, but// authored by *your org* and registered via `lint.policies` in chant.config.ts.// They run during `chant build` over the resolved resources, with the current// `--env` in context, so a policy can branch on environment. A check returning// `severity: "error"` fails the build — the gate.import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic,} from "@intentius/chant/lint/post-synth";
interface Manifest { kind?: string; metadata?: { name?: string; labels?: Record<string, string> }; spec?: { tls?: unknown[] };}
/** * Every parsed Kubernetes manifest across all lexicon outputs, via * `ctx.docs` (chant #975) — the shared, parse-once view every check and * policy gets, in place of hand-splitting `ctx.outputs` YAML/JSON. `d.error` * marks a document `parseOutputDocs` could not make sense of; skip it * rather than pass `undefined` along as a "manifest". */function manifests(ctx: PostSynthContext): Manifest[] { return (ctx.docs ?? []) .filter((d) => !d.error) .map((d) => d.value as Manifest);}
const COST_CENTER = "acme.io/cost-center";
/** Every workload must be attributable to a cost center — in every environment. */export const costCenterRequired: PostSynthCheck = { id: "ORG-COST-CENTER", description: "every Deployment must carry an acme.io/cost-center label", check(ctx): PostSynthDiagnostic[] { const diags: PostSynthDiagnostic[] = []; for (const m of manifests(ctx)) { if (m.kind !== "Deployment") continue; // Literal key rather than `[COST_CENTER]` — EVL003 requires computed // keys to be statically evaluable literals. if (!m.metadata?.labels?.["acme.io/cost-center"]) { diags.push({ checkId: "ORG-COST-CENTER", severity: "error", message: `Deployment "${m.metadata?.name}" is missing the ${COST_CENTER} label`, entity: m.metadata?.name, }); } } return diags; },};
/** Production ingress must terminate TLS. Lower environments may skip it. */export const tlsRequiredInProd: PostSynthCheck = { id: "ORG-PROD-TLS", description: "in prod, every Ingress must terminate TLS", check(ctx): PostSynthDiagnostic[] { if (ctx.env !== "prod") return []; // the environment-aware branch const diags: PostSynthDiagnostic[] = []; for (const m of manifests(ctx)) { if (m.kind !== "Ingress") continue; const tls = m.spec?.tls; if (!Array.isArray(tls) || tls.length === 0) { diags.push({ checkId: "ORG-PROD-TLS", severity: "error", message: `Ingress "${m.metadata?.name}" must terminate TLS in prod`, entity: m.metadata?.name, }); } } return diags; },};ORG-COST-CENTER runs in every environment; ORG-PROD-TLS only fires when the
build is for prod.
Registering it
Section titled “Registering it”Add the policy file(s) to lint.policies in chant.config.ts. Policies differ
from lint.plugins (declarative lint rules) in who writes them and when they
run: they read the resolved resources during build. The engine is the same.
import type { ChantConfig } from "@intentius/chant";
// `lint.policies` registers project-authored organizational policy checks. They// run during `chant build` over the resolved resources, with `--env` in// context, and fail the build on violation. Distinct from `plugins` (declarative// lint rules) by authorship and phase — same engine.export default { lexicons: ["k8s"], ownership: { stack: "storefront" }, lint: { policies: ["policies/org.ts"] },} satisfies ChantConfig;Running it
Section titled “Running it”Policy runs during chant build. Pass the environment with --env (or set
ownership.env in the config):
chant build src # cost-center enforced; prod-only rules dormantchant build src --env dev # same — ORG-PROD-TLS skippedchant build src --env prod # ORG-PROD-TLS now enforcedFor the example above, the prod build fails:
error: [policy:ORG-PROD-TLS] [storefront] Ingress "storefront" must terminate TLS in prodchant lint runs the same policies over the same in-memory build, after
each lexicon’s own post-synth checks, and reports a finding under the policy’s
id (ORG-PROD-TLS) at the file that declared the entity it names.
Policy evaluates the synthesized artifact offline and never touches the
cloud. For live checks, use chant lifecycle.
Suppressing a check
Section titled “Suppressing a check”Turn a check off (or down to warning) with lint.rules, keyed by the
check’s own id, the same mechanism as any lexicon lint rule:
export default { lexicons: ["k8s"], lint: { policies: ["policies/org.ts"], rules: { "ORG-PROD-TLS": "off" }, },} satisfies ChantConfig;chant-disable source comments do not apply to a policy finding; see Post-
Synth Checks and Policies
for why. A suppressed finding is counted, not dropped: chant build and
policyGate both print how many lint.rules suppressed.
Under --sandbox
Section titled “Under --sandbox”A policy is your code, and chant build --sandbox runs it in an isolated child
process rather than in the CLI’s own, after the build is merged and
serialized, over the same resources, with the same env, producing the same
diagnostics. You write it the same way.
What changes is what it may do while running: it may read the project
directory and nothing else, it may not write files or spawn processes, and it
sees only PATH and CHANT_ENV in its environment. A policy that writes a
report or shells out to a scanner fails under --sandbox, naming the file and
the operation. See Sandboxed
Execution for the
full contract, including the handful of ways the resource view is narrower.
Cookbook: the same shape against CloudFormation
Section titled “Cookbook: the same shape against CloudFormation”Everything above works for any lexicon: a policy reads ctx.docs, whatever
lexicon produced it. Two more examples, in the style CDK’s cdk-nag /
CfnGuardValidator compliance packs cover for CloudFormation: a bucket must
not allow public access in prod, and every bucket must declare encryption.
(For reusing an existing CloudFormation Guard/OPA ruleset instead of writing
one of these by hand, see the aws lexicon’s Policy
Validation guide; the two work
together.)
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
interface CfnResource { Type?: string; Properties?: Record<string, unknown>;}
/** Every `AWS::S3::Bucket` across every parsed CloudFormation template, via `ctx.docs`. */function s3Buckets(ctx: PostSynthContext): Array<[string, CfnResource]> { const buckets: Array<[string, CfnResource]> = []; for (const doc of ctx.docs ?? []) { if (doc.error) continue; const resources = (doc.value as { Resources?: Record<string, CfnResource> })?.Resources ?? {}; for (const [logicalId, resource] of Object.entries(resources)) { if (resource.Type === "AWS::S3::Bucket") buckets.push([logicalId, resource]); } } return buckets;}
const BLOCK_FLAGS = ["BlockPublicAcls", "BlockPublicPolicy", "IgnorePublicAcls", "RestrictPublicBuckets"] as const;
/** In prod, every bucket must fully block public access. Lower environments may skip it. */export const noPublicBucketsInProd: PostSynthCheck = { id: "ORG-NO-PUBLIC-BUCKETS-PROD", description: "in prod, every S3 bucket must block all public access", check(ctx): PostSynthDiagnostic[] { if (ctx.env !== "prod") return []; // the environment-aware branch const diags: PostSynthDiagnostic[] = []; for (const [logicalId, resource] of s3Buckets(ctx)) { const pab = resource.Properties?.PublicAccessBlockConfiguration as Record<string, unknown> | undefined; const blocked = !!pab && BLOCK_FLAGS.every((flag) => pab[flag] === true); if (!blocked) { diags.push({ checkId: "ORG-NO-PUBLIC-BUCKETS-PROD", severity: "error", message: `S3 bucket "${logicalId}" must block all public access in prod`, entity: logicalId, }); } } return diags; },};
/** Every bucket, in every environment, must declare server-side encryption. */export const bucketEncryptionRequired: PostSynthCheck = { id: "ORG-BUCKET-ENCRYPTION", description: "every S3 bucket must declare BucketEncryption", check(ctx): PostSynthDiagnostic[] { const diags: PostSynthDiagnostic[] = []; for (const [logicalId, resource] of s3Buckets(ctx)) { if (!resource.Properties?.BucketEncryption) { diags.push({ checkId: "ORG-BUCKET-ENCRYPTION", severity: "error", message: `S3 bucket "${logicalId}" has no BucketEncryption configured`, entity: logicalId, }); } } return diags; },};Register both the same way: add the file to lint.policies in
chant.config.ts (lexicons: ["aws"] instead of ["k8s"]). ORG-BUCKET- ENCRYPTION runs in every environment; ORG-NO-PUBLIC-BUCKETS-PROD only fires
in prod, the same environment-aware branch ORG-PROD-TLS uses above.
Cookbook: column rules over a SQL schema
Section titled “Cookbook: column rules over a SQL schema”The sql lexicon exports a typed read API over the parsed schema,
@intentius/chant-lexicon-sql/read. A policy can use it to check columns, keys
and engines rather than SQL text. Two such rules are “every table has
created_at timestamptz NOT NULL” and “no Nullable column in a ClickHouse sort
key”. See the sql lexicon’s
Your Own Schema Rules for both
dialects.
Why not a separate engine
Section titled “Why not a separate engine”chant already has the machinery: a post-synth phase that reasons about resolved resources, project-authored rule loading, and the Op gate model for the apply side. A separate policy language (Rego/OPA-style) would duplicate the lint engine and break “TypeScript is the one language.” Org policy is post-synth checks the org writes.
Gating an apply
Section titled “Gating an apply”chant build already fails on a policy violation, so any pipeline that builds
before applying is gated. To gate an apply inside an Op,
add a policyGate() step before the apply phase:
import { Op, phase, build, policyGate } from "@intentius/chant/op";import { kubectlApply } from "@intentius/chant-lexicon-k8s/op/builders";
export default Op({ name: "deploy", phases: [ phase("Build", [build(".")]), // Re-runs lint.policies over the resolved resources; a violation fails the // workflow here, so nothing is applied. phase("Policy", [policyGate({ env: "prod" })]), phase("Apply", [kubectlApply("k8s.yaml")]), ],});policyGate builds the project, runs lint.policies with the given env (or
ownership.env), and blocks on any violation. It runs once, with no retry
(a deterministic violation is not worth retrying). It is a plain
activity, so it gates the same wherever the run is hosted. A clean evaluation
passes through to the apply.
The build behind the gate is assembled from the same resolution chant build
uses, so it is the same project: folded by default, with the project’s config
reaching the serializers and its buildRoots contributing their entities. A gate
that passes is a gate over a build chant build would also accept.
chant refuses one combination outright. chant run <op> --sandbox
on an Op containing a policyGate step exits before any phase runs: the gate
builds the project and imports its lint.policies modules in the chant process,
which is what --sandbox promises does not happen. Run the Op without
--sandbox, or build separately with chant build --sandbox. A project whose
chant.config.ts sets build.sandbox: true still gets a gate that builds
in-process, and the gate says so on stderr.
When the gated apply runs as an Op waves job whose
spec has notify, a blocked wave fails its job, and the job posts a failed
event to Slack, Teams or a signed webhook (Notify).
policyGate blocks until the policy is loosened or the violation is fixed. A
tool that lets a person apply a denied plan anyway records the decision on the
override gate. The override is
bound to the plan digest and the rules that denied it, and it counts once.
Try it
Section titled “Try it”The full example is
lexicons/k8s/examples/org-policy
and CI runs it (clean in dev, blocked in prod).