Repository navigation
ChangeSafe v0.5.0
A trust-model fix in @changesafe/core, a new Terraform policy that replaces
a prose-only skip with a real one, changesafe eval gaining Kubernetes
support, and two security-relevant fixes to the Kubernetes analysis prompt.
Policy behavior changed. CORE_POLICY_VERSION moves to core-v0.2.0 and
TERRAFORM_POLICY_VERSION moves to terraform-v0.2.0. A v0.5.0 receipt is
not directly comparable to a v0.4.x one — that is the point of the version
existing, not an oversight.
Universal-policy skip legitimacy, enforced in core
A domain adapter's skippedUniversalPolicies could previously name any of
the five universal policies, not just the two whose shape assumes a
model-authored proposal or an in-place patch (ROLLBACK_COMPLETE,
VERIFICATION_REQUIRED). The only check against this lived in the app's own
runtime registration (features/domains/runtime.ts), not in published core —
so a hand-written adapter, or changesafe gate run directly against one,
got no enforcement at all. A probe adapter that skipped all five universal
policies passed evaluatePolicies cleanly with zero findings and LOW risk,
despite the proposal carrying an injection attempt, invented evidence, no
rollback, and no verification.
replacedBy was also a bare string, so nothing verified a skip's claimed
replacement existed. Terraform's VERIFICATION_REQUIRED skip named "the pull request review" — prose, not a policy.
Fixed:
evaluatePoliciesandpolicyOrderboth validate every skip before doing
anything else, and throw for an illegitimatepolicyId, a duplicate skip,
or a replacement that names a policy the adapter does not declare (or that
collides with a universal policy id, whichpolicyOrder's id filter could
not otherwise distinguish from the skip itself). Only
ROLLBACK_COMPLETE/VERIFICATION_REQUIREDmay ever be skipped —
PATCH_SCHEMA,BLAST_RADIUS, andUNTRUSTED_INSTRUCTIONare structurally
answerable by every domain and no adapter may skip them.SkipReplacementis a typed shape ({ kind: "domain-policy", policyId })
instead of a string. A replacement that named a non-mechanical process
rather than a real policy would have produced no finding of its own — the
gate would pass with a genuine verdict gap rather than merely an
honestly-labeled one — so that option does not exist at all.- Every receipt now carries
policyCoverage: the exact ordered policy ids
that ran, plus what was skipped and why. A verifier reads this from the
signed payload instead of fetching and trusting the adapter's source to
interpret an absent policy id. The field is optional on
ChangeReceiptSchemaso a receipt issued before it existed (v0.4.1 and
earlier) still parses for hash and signature verification —
createReceiptalways sets it going forward, andcanonicalizedrops an
undefinedproperty, so a legacy receipt's hash is unaffected.
Terraform gains PLAN_CONTEXT_REQUIRED
Terraform's proposal is derived mechanically from the plan with no model
involved, so it can never declare its own precondition or postcheck steps —
that is the honest reason it skips VERIFICATION_REQUIRED. What replaces it
used to be prose. It is now a real policy: PLAN_CONTEXT_REQUIRED warns when
a destructive change carries zero PR or commit context, because the pull
request review this domain relies on for verification needs something to
review. A plan that destroys nothing, or that carries any context entry,
passes.
changesafe eval measures Kubernetes too
The benchmark previously imported networkAnalysisPrompt and networkDomain
only, so it measured one domain while the corpus spans three. Kubernetes now
has its own hardened prompt (packages/ai/src/prompts/kubernetes.ts) and is
registered in ANALYSIS_DOMAINS; eval --domain kubernetes resolves it
instead of the hardcoded network path. Terraform stays out by design — its
plan already is the proposal, so there is nothing for a model to propose.
Report schema moves to version 3, recording corpus.domain, so a report is
only ever compared against another from the same domain.
Two fixes in the same change
Landed as part of getting Kubernetes eval support production-honest, not
found independently:
- Kubernetes eval input is normalized. The strict, already-normalized
KubernetesSnapshotSchemawas being parsed directly against raw,
collector-shaped scenario fixtures — every bundled Kubernetes scenario
failed before ever reaching the provider.eval's Kubernetes domain now
tries the strict parse first and falls back tonormalizeSnapshot, the
same boundarychangesafe gatealready used. - Pod-label and Service-selector values stay inside the untrusted
block. The Kubernetes prompt's trusted preamble — the section the model
is told to trust, ahead of<untrusted_snapshot_data>— was echoing
podLabelsandselectorvalues into it. Those areRecord<string, string>with no charset restriction beyond length, so an instruction-like
value there bypassed the untrusted-content boundary the system prompt
describes. They are no longer copied into the preamble; the full snapshot
already carries them inside the untrusted block. - Rollback references are validated too. The Kubernetes prompt's local
cross-check validated a forwardreplaceagainst the snapshot but never a
rollbackreplace, and accepted any rollbackremoveregardless of
whether it undid something the proposal actually added. A model response
with an ungrounded rollback reference was countedacceptedby the
benchmark before the gate'sPATCH_SCHEMApolicy caught it downstream —
inflating the grounded-output metric for a proposal that was always going
to be blocked. - Prompt/adapter pairing is typed. The analysis domain registry paired
each prompt with its adapter through two independentas unknown as never
casts, so nothing prevented registering, say, the Kubernetes prompt against
the network adapter. A single generic function now binds both under one
type parameter, so a mismatched pairing fails to typecheck.
Known limits
- Terraform still cannot catch command smuggling: it is an external-diff
domain with no simulator, and no Terraform policy inspects planned
attribute values for command payloads. Tracked, not papered over with a
scenario the gate would not actually refuse. K8S_SERVICE_SELECTORstill only re-checks Services that existed before a
change; whether it should also check newly created Services is an open
decision, not yet made.