Policy DSL Reference (Rego / OPA Authoring Surface)

Advanced 30 minutes Security Engineers / Platform Engineers Security & Policy Advanced Configuration

Author custom org-specific rules in Rego against the chainsaw.policy entrypoint: the input fields a rule can read, the decision shape it returns, the supported actions, and which inputs are populated at which enforcement surface.

Overview

Chainsaw has two policy surfaces. The dashboard / YAML conditions: surface (covered in How to Configure Chainsaw with YAML Configuration Files) is the simpler, built-in one — pick a condition, pick an operator, pick a value. The Policy DSL is the second surface: a Rego / OPA rule set you author as code, sign, and load into the server. It exists for the long tail of org-specific rules that don’t map cleanly onto a single built-in condition — multi-field logic, surface-scoped tightening, and rules that key on caller identity or coordinate patterns.

This page is the authoring reference: the entrypoint, the input fields a rule can read, the decision it returns, and which inputs are populated at which surface.

Built-in signals are already enforced natively. The Go evaluator applies CVSS/EPSS, license, typosquat, malware, freshness, and the rest without any Rego. The DSL is for custom rules layered on top. When both paths produce a verdict, the stricter action wins. If your rule fits a built-in condition, use the YAML / dashboard surface instead — see YAML Conditions vs the Rego DSL to choose.

Prerequisites

  • Admin access to the Chainsaw server and its filesystem (the bundle loads from CHAINSAW_POLICY_BUNDLE)
  • Familiarity with Rego
  • Signing set up before you enforce in production — see Signed Policy Bundles

The entrypoint

Every rule contributes to a single decision query:

data.chainsaw.policy.decision

Your module declares package chainsaw.policy and adds one or more partial decision rules. The engine collects every decision object that fires for a request and reduces them to the strictest action.

package chainsaw.policy

decision contains {
	"action":             "block",
	"rule_id":            "my-rule",
	"message":            "human-readable reason",
	"exception_eligible": true,
} if {
	# conditions that read input.* fields
	input.isKnownMalicious == true
}

The decision object

Each decision your rule emits is an object with these keys:

KeyTypeMeaning
actionstringOne of block, quarantine, monitor, allow, notify_owner. Defaults to block if omitted.
rule_idstringStable identifier recorded on the audit row. (ruleId is also accepted.)
messagestringHuman-readable reason. May sprintf in input.* field values.
exception_eligibleboolWhether operators can grant a per-package waiver for this violation. (exceptionEligible also accepted.)

The actions and their strictness ranking (strictest first):

  • block — refuse the request.
  • quarantine — at the proxy, the artifact may be cached but withheld; at the publish surface there is no stored row to withhold yet, so a quarantine verdict refuses the upload outright (quarantine-at-publish is block-not-log).
  • monitor — record what would have happened without enforcing. Only escalates an otherwise-allow decision.
  • allow — explicit permit.
  • notify_owner — a side-effect action that adds an ownership routing intent without gating the install. It ranks at the same level as allow (it carries no enforcement weight on its own). See How to Route Violations to Owners.
Strictest wins, fail-open on rule errors. A custom rule can only make a decision stricter, never weaker — a monitor verdict cannot downgrade a native block. And if your Rego throws a runtime error, the DSL path fails open (the request is not blocked by the broken rule) and the error is logged. A buggy custom rule must never become a denial-of-service on the data plane.

Input fields

The request is projected onto a single JSON document (input) before your rule runs. Every surface produces the same shape; missing fields stay zero-valued (false, 0, "", or omitted). The field names below are the contract — they are lowerCamelCase so you don’t have to remember Go conventions.

Surface and identity

FieldTypeNotes
input.surfacestring"pr", "proxy", "publish", "promote", "deploy", or "runtime". Scope a rule with input.surface == "publish".
input.repositorystringRepository (proxy) name.
input.ecosystemstringPackage format, lowercased (npm, pypi, cargo, …).
input.packagestringPackage coordinate / name.
input.versionstringPackage version.

Caller / network identity

FieldTypeNotes
input.clientIdstringCalling client identity.
input.clientGroupsarrayGroups the client belongs to.
input.requestingIpstringSource IP.
input.requestingCountrystringResolved country code.

Package metadata

FieldTypeNotes
input.isInternalboolInternal package.
input.releaseDatestringRFC3339, or empty.
input.licenseSpdxstringDeclared SPDX expression.
input.licenseTagsarrayClassified license tags.

Vulnerability

FieldTypeNotes
input.isVulnerableboolHas at least one known vulnerability.
input.cvssnumberCVSS score.
input.epssnumberEPSS probability (0–1).
input.cvesarrayCVE identifiers.

Supply-chain integrity

FieldTypeNotes
input.hasProvenanceboolProvenance attestation verified.
input.provenanceStatusstringverified / missing / unavailable / failed.
input.isSuspectedTyposquatboolFlagged by typosquat detection.
input.isKnownMaliciousboolIn the OpenSSF malware database.
input.trustScorenumberComposite trust score (0–100). Absent when no score was computed — guard with input.trustScore before comparing, or the rule is simply undefined for unscored packages. A score of 0 is a real score (malware sentinel / checksum mismatch) and IS present.
input.publisherChangedboolPublisher set changed vs prior version.

Attestation (SLSA / Sigstore)

FieldTypeNotes
input.slsaLevelnumber0 when no verified attestation, 1–4 otherwise.
input.attestationBuilderIdstringOIDC subject of the build.
input.attestationIssuerstringOIDC issuer URL.
input.attestationSourceRepostringCanonicalised source repo URL.
input.attestationTransparencyLogstringRekor entry URL.
input.attestationCacheStaleboolServed from stale Sigstore cache.

Install-script and behavior signals

FieldTypeNotes
input.hasInstallScriptboolLifecycle script declared.
input.installScriptFetchesRemoteboolInstall script fetches a remote payload.
input.importTimeExecutionboolRuns code at import/install time (PyPI).
input.maliciousIOCboolEmbeds an exfil sink / stealer string.

Version and Unicode anomalies

FieldTypeNotes
input.versionAnomalyboolAt least one version-sequence flag fired.
input.versionAnomalyFlagsarraye.g. semver_regression, major_skip, timestamp_regression, registry_withdrawn.
input.hasHiddenUnicodeboolHidden Unicode runes found.
input.hiddenUnicodeKindsarraye.g. zero_width, bidi_override, tag.
input.publishVelocity24hnumberVersions published by the publisher set in trailing 24h.

Manifest hygiene and maintainer signals

FieldTypeNotes
input.deprecatedByMaintainerbool
input.shrinkwrapPresentbool
input.manifestConfusionbool
input.gitDependencybool
input.httpTarballDependencybool
input.wildcardDependencyRangebool
input.badDependencySemverbool
input.trivialPackagebool
input.tooManyFilesbool
input.nonExistentAuthorbool
input.firstTimeCollaboratorboolThree-state: omitted when unknown.
input.suspiciousRepoStarsbool
input.maintainerAccountAgeDaysnumberAge of the youngest maintainer account.

Capability scanners (npm; gated)

FieldTypeNotes
input.usesEvalbool
input.networkAccessbool
input.shellAccessbool
input.filesystemAccessbool
input.envVarAccessbool
input.nativeBinaryPresentbool
input.highEntropyStringsbool
input.urlStringsbool
input.minifiedCodebool

AI-artifact signals

FieldTypeNotes
input.artifactSubtypestring
input.dangerousPicklebool
input.unsafeSerializationFormatbool
input.modelCardInjectionbool
input.agentToolDangerousCapabilitybool
input.mcpServerDeclaredbool
input.promptTemplateInjectionbool

Checksum

FieldTypeNotes
input.checksumUnavailablebool
Per-ecosystem support still applies. A signal field is only ever true for ecosystems where Chainsaw produces it. If you write a rule keyed on input.hasInstallScript and apply it to a Maven proxy (where there is no lifecycle-script concept), the clause simply stays false and the rule is inert. Your server reports the per-ecosystem support grid: run chainsaw policy preflight, which reads the same matrix the dashboard warns from. Per-ecosystem totals are published in docs/ecosystems.md.

Which inputs are available at which surface

The input shape is identical at every surface, but the intelligence-derived signal fields are only populated when the intelligence providers have run for that request.

Surfaceinput.surfaceSignal fields populated?
Registry proxy fetchproxyYes — the full intelligence fan-out runs on the request path.
GitHub-Actions PR checkprCoordinate + the signals the PR scanner computes. No tarball-side signals it didn’t scan.
K8s admission webhookdeployCoordinate / identity, plus cached intelligence where available.
Package-manager install hookruntimeCoordinate / identity; some signals arrive only after a tarball-side scan.
Env→env promotion gatepromoteCoordinate / identity.
Pre-publish gatepublishSparse. A bounded, synchronous Tier-1 pre-run hydrates coordinate-keyed signals (isVulnerable, isKnownMalicious, isSuspectedTyposquat, cves); byte-requiring Tier-2 signals are not available pre-upload.
The publish surface is the sparse one. No full intelligence run happens before an upload is stored. The pre-run is best-effort and fails open (on timeout / error / disabled intelligence it evaluates the sparse context rather than hanging). Write publish-surface rules that key on what is always known pre-store — repository, ecosystem, package coordinate, client identity, IP — plus the Tier-1 signals above. A publish rule that depends on a Tier-2 byte-level signal (e.g. input.hasHiddenUnicode) will not fire reliably at upload time.

Worked examples

The examples below are adapted from the demo bundle that ships in the repo at policies/chainsaw_policy.rego — use that file as your starting point.

Example 1 — young maintainer + install script

The canonical demo rule: block packages that declare an install script and have a maintainer account younger than 90 days. Authored once, enforced at every surface where both signals are populated.

package chainsaw.policy

decision contains {
	"action":             "block",
	"rule_id":            "young-maintainer-with-install-script",
	"message":            sprintf("youngest maintainer is %d days old; package declares install script", [input.maintainerAccountAgeDays]),
	"exception_eligible": true,
} if {
	input.maintainerAccountAgeDays > 0
	input.maintainerAccountAgeDays <= 90
	input.hasInstallScript == true
}

Example 2 — harder verdict on remote-fetching install scripts

Same shape, no exception, when the install script fetches a remote payload — that’s the exfil signature, not just a build step.

decision contains {
	"action":             "block",
	"rule_id":            "young-maintainer-with-remote-fetching-install-script",
	"message":            "young maintainer + install script that fetches remote payload",
	"exception_eligible": false,
} if {
	input.maintainerAccountAgeDays > 0
	input.maintainerAccountAgeDays <= 90
	input.installScriptFetchesRemote == true
}

Example 3 — surface-scoped tightening

React to input.surface. At runtime install, flag young maintainers in monitor mode even when the install-script signal is missing (some ecosystems only get it after a tarball-side scan).

decision contains {
	"action":             "monitor",
	"rule_id":            "runtime-young-maintainer",
	"message":            "young maintainer at install time — flagging for review",
	"exception_eligible": true,
} if {
	input.surface == "runtime"
	input.maintainerAccountAgeDays > 0
	input.maintainerAccountAgeDays <= 30
}

Audit trail

Every decision the DSL contributes is stamped with the bundle’s content digest, so a verdict is reproducible against a known rule-set version. Each decision your rule emits surfaces in the audit trail as the rule_id and message you set, and that bundle digest is carried into the audit trail — see Signed Policy Bundles.

Next Steps