Enforce policy gates
This guide takes a policy from idea to enforced build gate: author it, test it against your real dependency estate, roll it out in warn mode, then enforce it in CI — without blocking every engineer on day one.
Before you start
You need an API token and at least one imported repository that has completed a scan. If you want the concepts first, read policies and gates.
The rollout that works
The failure mode with policy enforcement is turning on a strict rule against a codebase that has never been measured, discovering four hundred violations, and having the gate disabled by lunchtime. Do it in this order instead:
Measure before you decide
Look at what a candidate rule would actually catch before you write it. Test the policy against live data (step 3) and read the violation count. A rule that fires on 5 packages is enforceable this week; one that fires on 400 is a roadmap item.
Create it disabled or in warn mode
Land the policy so it records violations without failing anything. You get the reporting immediately and the pressure later.
Burn down the backlog
Fix or grant time-boxed exceptions for existing violations. The count should approach zero.
Switch to blocking
Only once the backlog is clear. From then on the gate only ever fires on new risk, which is the thing the team can actually act on.
Create a policy
Policies are created with POST /api/v1/policies. The rule itself is carried in
conditionsJson, what happens when it matches in actionsJson, and what it covers in
appliesToJson. Scope is set by the workspaceId, teamId, and repositoryId fields —
leave them null to apply tenant-wide.
curl -X POST https://api.ospulse.app/api/v1/policies \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Block critical vulnerabilities",
"description": "No new dependencies with critical advisories.",
"policyType": "vulnerability",
"severity": "critical",
"conditionsJson": "{\"maxSeverity\":\"critical\"}",
"actionsJson": "{\"mode\":\"warn\"}",
"appliesToJson": "{\"ecosystems\":[\"Npm\",\"PyPI\"]}",
"workspaceId": null,
"teamId": null,
"repositoryId": null,
"enabled": true
}'{
"policyId": "b41f8c2e-91a7-4d63-8f2a-0c7e5d914b33",
"name": "Block critical vulnerabilities",
"policyType": "vulnerability",
"scope": "tenant",
"severity": "critical",
"enabled": true,
"createdAtUtc": "2026-07-28T09:14:22Z",
"updatedAtUtc": "2026-07-28T09:14:22Z"
}Condition and action shapes vary by policy type
conditionsJson, actionsJson, and appliesToJson are JSON strings, and the schema
inside each depends on policyType. The example above is illustrative — check the
interactive reference under Policies for the exact shape your
policy type expects, and use the test endpoint to confirm it
behaves as you intended before relying on it.
What to gate on
Start with rules that are unambiguous and cheap to comply with:
| Rule | Why it is a good first gate |
|---|---|
| No new critical advisories | Nobody argues with it, and fixes usually exist |
| No packages flagged as compromised | Zero tolerance is the correct posture |
| Banned licences (AGPL, SSPL, unlicensed) | Binary, legally motivated, easy to check |
| No deprecated packages | The author has explicitly told you to stop |
Leave subjective thresholds — minimum health score, maximum maintainer concentration — until the team trusts the gate. Those generate debate, and debate during a release is what gets gates switched off.
Test before you enforce
POST /api/v1/policies/{policyId}/test evaluates a policy against real data without
recording violations or blocking anything. This is the single most useful endpoint in the
whole workflow — always run it before enabling enforcement.
curl -X POST "https://api.ospulse.app/api/v1/policies/$POLICY_ID/test" \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'Read the result as a blast-radius estimate. If it matches far more than you expected, the
condition is broader than you intended — narrow appliesToJson, or raise the threshold, and
test again. Iterate here, where it is free.
Review violations
Violations are listed with GET /api/v1/policy-violations and paginate with take/skip
like every other collection:
curl "https://api.ospulse.app/api/v1/policy-violations?take=50" \
-H "Authorization: Bearer $OSPULSE_TOKEN"{
"items": [
{
"violationId": "7c2a9e13-4b8f-4a02-9d5e-1f6b3c8a0d27",
"policyId": "b41f8c2e-91a7-4d63-8f2a-0c7e5d914b33",
"repositoryId": "3e91b7d4-2c6a-4f85-b1d0-9a4e2f7c5b18",
"severity": "critical",
"status": "open",
"detectedAtUtc": "2026-07-28T09:20:11Z"
}
],
"totalEstimate": 12
}Fetching a single violation with GET /api/v1/policy-violations/{violationId} returns the
detail you need to act — including evidenceJson, a recommendedAction, and
createdTicketUrl if one has been raised. Update triage state with
PATCH /api/v1/policy-violations/{violationId}.
Grant a time-boxed exception
Sometimes a violation cannot be fixed now. Grant an exception rather than weakening the policy for everyone:
curl -X POST "https://api.ospulse.app/api/v1/policy-violations/$VIOLATION_ID/exception" \
-H "Authorization: Bearer $OSPULSE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reason": "Upstream fix merged, awaiting release. Tracked in SEC-4192.",
"expiresAtUtc": "2026-09-30T00:00:00Z"
}'Always set an expiry
An exception record carries expiresAtUtc, approvedByUserId, a reason, and a review
reminder. Use all of them. A permanent suppression is indistinguishable from having no policy
at all — and six months later nobody remembers why it was granted. A dated exception with a
written justification is an auditable decision; a silent suppression is a hole.
Exceptions can be revoked, and expiry brings the violation back automatically — which is the point. The risk did not disappear, you deferred it.
Wire it into CI
Once the backlog is clear, switch the policy to blocking by updating actionsJson via
PATCH /api/v1/policies/{policyId}, then fail the build on open violations.
The simplest gate queries violations for the repository after a scan completes and exits non-zero if any remain:
#!/usr/bin/env bash
set -euo pipefail
: "${OSPULSE_TOKEN:?OSPULSE_TOKEN is required}"
: "${REPOSITORY_ID:?REPOSITORY_ID is required}"
API="https://api.ospulse.app/api/v1"
AUTH=(-H "Authorization: Bearer $OSPULSE_TOKEN")
# Only count violations that are still open for this repository.
violations=$(curl -sf "${API}/policy-violations?take=100" "${AUTH[@]}")
open_count=$(echo "$violations" | jq --arg repo "$REPOSITORY_ID" \
'[.items[] | select(.repositoryId == $repo and .status == "open")] | length')
if [ "$open_count" -gt 0 ]; then
echo "✗ ${open_count} open policy violation(s) — failing the build."
echo "$violations" | jq --arg repo "$REPOSITORY_ID" -r \
'.items[] | select(.repositoryId == $repo and .status == "open")
| " [\(.severity)] violation \(.violationId) detected \(.detectedAtUtc)"'
exit 1
fi
echo "✓ No open policy violations."Wire that into your pipeline after the scan step:
- name: Enforce OSPulse policy
env:
OSPULSE_TOKEN: ${{ secrets.OSPULSE_TOKEN }}
REPOSITORY_ID: ${{ vars.OSPULSE_REPOSITORY_ID }}
run: ./scripts/ospulse-gate.shSee scan in CI/CD for triggering the scan itself and waiting for it to finish before this gate runs.
Common pitfalls
- Enforcing before measuring. Always run the test endpoint first. See above.
- Gating on the whole estate from one pipeline. Filter violations to the repository being built, or every team's pipeline fails on every other team's debt.
- No exception path. If the only way past a gate is to disable it, engineers will disable it. Make the documented exception route faster than the workaround.
- Treating a
401as a policy failure. Distinguish "the gate says no" from "the gate could not run". A token problem should be a loud infrastructure error, not a silent pass — and definitely not a build failure blamed on dependencies. - Ignoring pagination. The example above takes the first 100 violations. If you expect more, page through them properly — see pagination.
