Back to glossary

Policy as Code (PaC)

Policy as code (PaC) expresses security and compliance rules as version-controlled code that an engine evaluates against JSON input to allow, warn, or block.

Also known as policy-as-code

Policy as code (PaC), also written policy-as-code, is the practice of writing security and compliance rules as machine-evaluable code kept in version control. A policy engine evaluates that code against structured input, usually JSON, and returns a decision that a CI check, a Kubernetes admission controller, or a package installer enforces before anything ships.

Every policy as code system splits the work in two. An engine answers a question, such as "may this pull request merge?", from facts it is handed. Something else acts on the answer: a CI job, the Kubernetes API server, npm install. The Open Policy Agent documentation states the design in one line: "OPA decouples policy decision-making from policy enforcement."

That split is the value of policy as code, and also its blind spot. A policy engine cannot check its own input. When a fact is wrong or missing, the engine returns a confident, versioned, auditable, wrong decision. Our position: the policy language is the solved part. The unsolved parts are where the facts come from and what a rule does when a fact is missing. Deny-style rules fail open by default, and many dependency policies are evaluated over registry metadata that the publisher wrote.

What is policy as code?

#

Policy as code is a decision function: declarative rules, evaluated against a JSON document, that return allow, warn, or deny. The engine takes three things:

  • Input: the thing being judged. A Terraform plan, a Kubernetes Pod spec, the list of packages a pull request adds.
  • Data: reference facts the rules consult. License allowlists, approved registries, packages permitted to run install scripts.
  • Policy: the rules, stored in Git and reviewed like any other code.

The output does not have to be a boolean. Most policies return a set of violation messages, and an empty set means "allow." The enforcement point decides what a non-empty set does.

Nearly every engine converged on the same three enforcement levels. Block stops the action, warn tells a human without stopping anything, and record-only logs the violation where only the security team sees it.

LevelOPA GatekeeperKyvernoKubernetes ValidatingAdmissionPolicyHashiCorp Sentinel
BlockdenyEnforceDenyhard-mandatory, or soft-mandatory with an override
NotifywarnAudit with emitWarning: trueWarnadvisory
Record onlydryrunAuditAuditnone

The convergence follows from how rules get rolled out. A new rule starts in record-only mode, proves it does not fire on known-good changes, moves to warn, and only then blocks. A rule that goes straight to block on day one gets disabled by day three.

A dependency policy you can run

#

A dependency policy shows every moving part in a few dozen lines. Start with a real fact. The npm registry serves an abbreviated metadata document to installers, and it carries a hasInstallScript flag:

Bash
curl -s -H 'Accept: application/vnd.npm.install-v1+json' \
  https://registry.npmjs.org/esbuild \
  | jq '.versions["0.28.2"] | {name, version, hasInstallScript}'
JSON
{
  "name": "esbuild",
  "version": "0.28.2",
  "hasInstallScript": true
}

The registry omits the key entirely when a version has no install script, so whatever builds the policy input has to default it to false. We built input.json from four real packages, with versions, licenses, and publish times copied from the registry on November 8, 2026:

JSON
{
  "now": "2026-11-08T00:00:00Z",
  "packages": [
    {"name": "esbuild", "version": "0.28.2", "license": "MIT",
     "hasInstallScript": true, "published": "2026-08-08T20:00:55.454Z"},
    {"name": "bcrypt", "version": "6.0.0", "license": "MIT",
     "hasInstallScript": true, "published": "2025-05-11T18:06:24.247Z"},
    {"name": "vite", "version": "8.3.6", "license": "MIT",
     "hasInstallScript": false, "published": "2026-11-05T05:40:52.195Z"},
    {"name": "@img/sharp-libvips-linux-x64", "version": "1.3.4",
     "license": "LGPL-3.0-or-later", "hasInstallScript": false,
     "published": "2026-09-27T12:11:24.775Z"}
  ]
}

The policy encodes three rules. Only allowlisted packages may run install scripts. Nothing younger than seven days may enter, which is a dependency cooldown. Every license must be on the approved list, a minimal license compliance check.

rego
package deps

script_allowlist := {"esbuild"}
license_allowlist := {"MIT", "Apache-2.0", "BSD-3-Clause", "ISC"}
min_age_ns := 7 * 24 * 60 * 60 * 1000000000 # 7-day cooldown

deny contains msg if {
	some pkg in input.packages
	pkg.hasInstallScript
	not pkg.name in script_allowlist
	msg := sprintf("%s@%s runs an install script", [pkg.name, pkg.version])
}

deny contains msg if {
	some pkg in input.packages
	age := time.parse_rfc3339_ns(input.now) - time.parse_rfc3339_ns(pkg.published)
	age < min_age_ns
	msg := sprintf("%s@%s is younger than 7 days", [pkg.name, pkg.version])
}

deny contains msg if {
	some pkg in input.packages
	not pkg.license in license_allowlist
	msg := sprintf("%s@%s has license %s", [pkg.name, pkg.version, pkg.license])
}

The deny contains msg if shape is the convention Conftest and most Rego policies use: each rule adds a message to a set, and the caller blocks if the set is non-empty. vite 8.3.6 was published two days and 18 hours before now, so a correct evaluation of data.deps.deny returns three messages:

JSON
[
  "@img/sharp-libvips-linux-x64@1.3.4 has license LGPL-3.0-or-later",
  "bcrypt@6.0.0 runs an install script",
  "vite@8.3.6 is younger than 7 days"
]

That is not what our first run returned.

Why do deny-style policies fail open?

#

A deny-style policy fails open because a rule that cannot be evaluated adds nothing to the deny set. In Rego, an expression that errors or references a missing field is undefined, and an undefined rule body simply does not fire. OPA documents this as the default for built-in function errors: "evaluation will consider that case to be undefined, and move on." OPA's --strict-builtin-errors flag turns that into a hard error, and it is off by default.

We evaluated the policy above with rego-cpp 1.5.2, Microsoft's C++ implementation of Rego, through its regopy Python package. Its time.parse_rfc3339_ns returned undefined for any timestamp ending in Z, while accepting the same instant written as +00:00. RFC 3339 explicitly allows Z as the UTC offset, and every timestamp in the npm registry's time field uses it. The cooldown rule silently disappeared. These are the rego-cpp results for both policy shapes:

Policy shapeTimestamps as served by npm (...Z)Same timestamps rewritten as +00:00
Deny if too young (above)2 denials: license, install script. vite passes.3 denials: license, install script, vite
Deny unless proven old enough (below)4 denials: every package fails the cooldown check1 denial: vite

Nothing errored, nothing logged, and CI would have gone green. The fix is to state the safety property positively, so a missing or unparseable fact denies instead of disappearing:

rego
package deps

min_age_ns := 7 * 24 * 60 * 60 * 1000000000

old_enough(pkg) if {
	age := time.parse_rfc3339_ns(input.now) - time.parse_rfc3339_ns(pkg.published)
	age >= min_age_ns
}

deny contains msg if {
	some pkg in input.packages
	not old_enough(pkg)
	msg := sprintf("%s@%s: cannot prove it is 7+ days old", [pkg.name, pkg.version])
}

Failing closed has a cost, and the table shows it: with the parser bug, the fail-closed rule blocks all four packages. That is a loud outage instead of a silent bypass, which is the right trade for a security control and the wrong one for a lint. Kubernetes made the same call. A ValidatingAdmissionPolicy has a failurePolicy of Fail or Ignore for expressions that cannot be evaluated, and an unset value behaves as Fail, so a broken admission policy rejects requests by default.

Socket's own policy engine documents the same edge. In its rule selectors, a condition on a fact that is absent for an alert fails, including negated conditions such as $ne, and an alert that no rule matches is ignored. A block rule scoped to a manifest path therefore never matches a transitive dependency that has no manifest, and that alert falls through to the baseline rules beneath it. The docs say to use manifest conditions to narrow a rule, not as a catch-all. That is the right model, and it still means rule authors have to know which facts can be absent.

Two habits prevent most fail-open bugs. Write security rules as "allow only if proven," and test policies with fixtures that contain missing fields, malformed timestamps, and unknown licenses, not just the happy path. opa test runs Rego unit tests in CI like any other test suite.

The input is the attack surface

#

A policy is only as trustworthy as the facts it evaluates. The hasInstallScript flag in the demo looks authoritative, but the registry computes it from the manifest the publisher uploaded, not from the package tarball. npm executes the scripts in the tarball's own package.json. The two are stored separately, and a publisher who makes them disagree can hide an install script from every tool that reads registry metadata. That class of bug is manifest confusion, and the 2023 manifest confusion disclosure showed the registry had never fully validated one against the other.

npm publish sends two things the registry stores separately and compares only on name and version

What the policy reads

Uploaded manifest, served as registry metadata

// manifest uploaded at publish
"scripts": {}

// abbreviated metadata served to installers
{
  "name": "example-pkg",
  "version": "1.0.1"
}
// no hasInstallScript key, so false
↓ policy evaluates metadata
deny set is empty: ALLOW

What the installer runs

package.json inside the tarball

{
  "name": "example-pkg",
  "version": "1.0.1",
  "scripts": {
    "postinstall": "node setup.js"
  }
}
↓ npm install extracts tarball
setup.js runs on the machine
Figure 1. Illustrative package. The policy is evaluated correctly over false input: metadata derived from the publisher's manifest hides a script that the tarball contains and npm executes.

Figure 1 shows the gap. The policy reads the registry's abbreviated metadata, which says hasInstallScript: false because the uploaded manifest has no scripts. The installer downloads the tarball, whose package.json contains "postinstall": "node setup.js", and runs it. The policy evaluated correctly and returned "allow." The input was false.

The same problem applies to vulnerability data. In the axios compromise on March 31, 2026, attackers published axios@1.14.1 and axios@0.30.4 with a new dependency, plain-crypto-js@4.2.1, whose postinstall hook ran a dropper. A policy keyed on CVE severity had nothing to match, because malware does not get a CVE. Socket flagged the malicious package within six minutes of publication. A rule that blocks known malware would have stopped every install after detection. A rule that blocks install scripts outside an allowlist would have stopped it from the first minute, provided its input reflected the tarball. A rule that blocks critical CVEs would have waved it through.

This is why we analyze what is actually in the package, not what its metadata claims. Socket's dependency analysis reads the package.json inside the tarball, the file package managers actually execute, and has a dedicated alert for packages whose manifest and tarball disagree. For anyone writing their own dependency policy, the rule is the same: derive facts from the artifact that will run, and treat publisher-supplied metadata as a claim.

How do you implement policy as code in CI/CD?

#

You implement policy as code in CI/CD by evaluating versioned rules at each point where an artifact changes hands, and by knowing what each point cannot see. Good CI/CD security needs at least three of them.

1 · before merge

Pull request

JudgesThe diff: Terraform plan JSON, manifests, added packages

Typical engineConftest, dependency PR check

Cannot seeVersions resolved later, npx runs, other jobs

2 · before code runs

Install

JudgesEach package as it is fetched and before its scripts run

Typical enginenpm v12 and pnpm 11 settings, package firewall

Cannot seeOther package managers, traffic that bypasses it

3 · before deploy

Admission

JudgesThe Pod spec sent to the Kubernetes API server

Typical engineValidatingAdmissionPolicy, Kyverno, Gatekeeper

Cannot seeThe code inside image layers

Same versioned rules, evaluated at each hand-off: record-only first, then warn, then block
Figure 2. Three enforcement points in a delivery pipeline, what each one judges, and what it is blind to.

Figure 2 shows the three points in order. The pull request judges the diff and cannot see versions resolved later. Install judges each package as it is fetched, before its scripts run, and cannot see other package managers or traffic that bypasses it. Admission judges the Pod spec and cannot see the code inside image layers.

Pull request. Policies run against the proposed change before merge. For infrastructure, Conftest evaluates Rego against the JSON from terraform show -json plan.out, where each entry in resource_changes carries the planned actions and the after state. For dependencies, a PR check evaluates the packages a change adds. Pull request checks do not cover anything resolved later: a floating version range, a fresh npx run, or an install in a different job.

Install time. The installer is where install scripts execute, so policy at this point is the last chance before code runs on a developer laptop or CI runner. Default-on lifecycle scripts were a design mistake, and package managers are now reversing it: pnpm 10 blocked lifecycle scripts by default in January 2025. As of npm v12, released July 8, 2026, dependency lifecycle scripts no longer run unless allowed, strict-allow-scripts turns a skipped script into a hard error for CI, and git and URL dependencies do not resolve unless allowed. pnpm 11 sets minimumReleaseAge to 1440 minutes by default and blocks transitive dependencies from non-registry sources. These are policy as code expressed as package manager config. They cover one package manager each: a pip install in the same job ignores .npmrc. A package firewall applies one policy across ecosystems at the network layer, but only for traffic that goes through it.

Admission. In Kubernetes, the API server is the enforcement point for container security rules: no privileged Pods, images only from approved registries, required signatures. ValidatingAdmissionPolicy runs CEL expressions in-process and has been stable since Kubernetes 1.30. Kyverno, which graduated from the CNCF on March 24, 2026, and OPA Gatekeeper run as admission webhooks with richer policy languages. Admission policies judge the Pod spec, not the code inside the image layers.

Rollout and testing. Every enforcement point needs a way to preview a rule. Gatekeeper has dryrun, Kyverno has Audit, and Socket's policy API accepts ?dry_run=true on rule writes, validating the request and returning the body a real write would return without saving it. As of November 2026, Socket policies are ordered rule lists where the first match decides the action, layered over Essential, Balanced, or Comprehensive baseline presets, with rules written as JSON selectors:

Bash
curl -X POST "https://api.socket.dev/v0/orgs/$ORG/alert-policies/default/rules?dry_run=true" \
  -H "Authorization: Bearer $SOCKET_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Block high and critical vulns outside the sandbox",
    "action": "error",
    "vigil_selector": {
      "$and": [
        { "finding.category": "vulnerability" },
        { "finding.severity": { "$in": ["critical", "high"] } },
        { "$not": { "location.repo": "my-org/sandbox" } }
      ]
    }
  }'

The rule is data, so it can live in Git, go through review, and be applied from CI like any other policy.

Which policy engine should you use?

#

The right policy engine depends on where the decision has to happen, because each engine is strongest at one enforcement point.

EngineLanguageWhere it decidesStrongest atMain limitation
Open Policy Agent, with Conftest and GatekeeperRegoAnywhere: CI, admission, service authorizationOne language across every layerUndefined-means-no-decision semantics make deny rules fail open unless written carefully
KyvernoYAML patterns and CELKubernetes admission, background scans, CLIKubernetes policies without a new languageKubernetes resources only
Kubernetes ValidatingAdmissionPolicyCELInside the API serverNo webhook to deploy or keep availableValidates Kubernetes API requests only
HashiCorp SentinelSentinelHashiCorp commercial products, such as HCP TerraformEnforcement levels built into the languageTied to HashiCorp products
CedarCedarApplication authorizationAnalyzable permit and forbid rules, open-sourced by AWS on May 10, 2023Built for "who can do what," not config validation
Package manager settings (npm v12, pnpm 11)Config keysThe installerZero infrastructure; runs where code executesCoarse, one ecosystem each, facts from metadata

Verdict. Use OPA when the same rules must run in CI and at admission, and invest in tests for missing input. Use ValidatingAdmissionPolicy for simple Kubernetes invariants, and Kyverno when those invariants outgrow CEL. Use Sentinel only if HashiCorp's commercial products are already your control plane. Use Cedar for application permissions, not pipelines. Turn on the package manager defaults everywhere, because they cost nothing, then put a dependency policy that sees package contents in front of them.

Policy as code, infrastructure as code, and security as code

#

Infrastructure as code declares the state infrastructure should be in: Terraform modules, CloudFormation templates, Kubernetes manifests. Policy as code declares which states are acceptable and evaluates infrastructure as code output, such as a Terraform plan, before it is applied. The two are complementary. Infrastructure as code makes infrastructure reviewable as a diff, and policy as code makes the review automatic.

Security as code is the broader practice of expressing security controls as versioned, testable code in the delivery pipeline: scanner gates, hardened base configurations, secret detection rules, and the policies that decide what happens when a control fires. Policy as code is the enforcement core of security as code. Compliance as code applies the same machinery to a framework's controls and keeps the evaluation results as audit evidence.

The decision layer is now a commodity. Rego, CEL, and Cedar can all express "block packages that run code at install time" in a few lines. What no engine provides is a trustworthy answer to "does this package run code at install time?" That fact comes from whoever produced the input, and today that is usually the publisher. Treat input facts as part of the policy: version where they come from, test what happens when they are missing, and prefer facts derived from what an artifact does over what its publisher says it does.