Enterprise Edition feature. A valid Visor EE license is required. Contact hello@probelabs.com for licensing.
The OPA (Open Policy Agent) policy engine provides fine-grained, role-based access control over Visor workflows. Policies are written in Rego and evaluated locally via WebAssembly (WASM) or against a remote OPA server.
- Overview
- What It Controls
- Installation
- Dependencies
- License Setup
- Configuration Reference
- Writing Rego Policies
- Policy Scopes
- Input Document Reference
- Per-Step Policy Overrides
- Local WASM Mode
- Remote OPA Server Mode
- Fallback Behavior
- How It Works
- Relationship to Author Permissions
- Troubleshooting
- Examples
The policy engine sits between your .visor.yaml configuration and check execution. Before a check runs, a tool is invoked, or AI capabilities are assembled, the engine evaluates an OPA policy to decide whether the action is allowed.
Key properties:
- Deny by default: Policies can be configured with
fallback: denyso that any evaluation failure or unrecognized role is blocked. - Role-based: Roles are resolved from GitHub
author_association, team slugs, or explicit usernames, then passed into OPA asinput.actor.roles. - Per-step overrides: Individual steps can declare
policy.requireandpolicy.denyin YAML without writing any Rego. - Two evaluation backends: Local WASM (zero network, ~1ms per evaluation) or remote OPA server (shared policy management).
- Graceful degradation: Without a valid license, the engine silently disables and all checks run normally.
| Scope | When Evaluated | What It Does |
|---|---|---|
Check execution (check.execute) |
Before each check runs | Gate which checks can run based on the actor's role |
MCP tool access (tool.invoke) |
Before each MCP tool call | Allow or block specific MCP methods per role |
AI capabilities (capability.resolve) |
When assembling AI provider config | Restrict allowBash, allowEdit, and tool lists per role |
npm install @probelabs/visor@eeOr as a global tool:
npm install -g @probelabs/visor@eeThe EE build is a superset of the OSS build. All OSS functionality works identically. The enterprise code is inert without a license.
The OPA CLI is needed only if you use .rego files with the local engine mode. Visor compiles .rego to .wasm at startup using the opa CLI.
macOS (Homebrew):
brew install opaLinux (binary):
curl -L -o /usr/local/bin/opa \
https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static
chmod +x /usr/local/bin/opaDocker:
docker pull openpolicyagent/opa:latestVerify installation:
opa version
# Expected: Version: 0.70.0 or laterNote: If you pre-compile your
.regofiles into a.wasmbundle (see Pre-compiling WASM bundles), the OPA CLI is not needed at runtime.
The @open-policy-agent/opa-wasm npm package is an optional dependency of the EE build. It is installed automatically when you install @probelabs/visor@ee. If for some reason it's missing:
npm install @open-policy-agent/opa-wasm| Dependency | Required? | Purpose |
|---|---|---|
@probelabs/visor@ee |
Yes | Visor Enterprise Edition build |
| Valid EE license (JWT) | Yes | Activates the policy engine |
opa CLI |
Only for local mode with .rego files |
Compiles Rego to WASM at startup |
@open-policy-agent/opa-wasm |
Only for local mode |
Evaluates WASM policies in-process |
| OPA server | Only for remote mode |
External policy evaluation via HTTP |
Rego is OPA's declarative policy language. Key resources:
- Rego language reference
- Rego playground (interactive editor and tester)
- OPA documentation
- Rego style guide
The policy engine requires a valid Visor EE license (a JWT signed by ProbeLabs). Visor looks for the license in this order:
VISOR_LICENSEenvironment variable (the JWT string directly)VISOR_LICENSE_FILEenvironment variable (path to a file containing the JWT).visor-licensefile in the project root~/.config/visor/.visor-license(user-level default)
# .github/workflows/visor.yml
- uses: probelabs/visor@v1
env:
VISOR_LICENSE: ${{ secrets.VISOR_LICENSE }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}# Option A: environment variable
export VISOR_LICENSE="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..."
# Option B: file in project root
echo "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." > .visor-license
# Option C: user-level config
mkdir -p ~/.config/visor
echo "eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." > ~/.config/visor/.visor-licenseImportant: Add
.visor-licenseto your.gitignoreto avoid committing your license key.
Your license JWT encodes which features are available. The policy engine requires the policy feature. If your license doesn't include this feature, the engine falls back to the default (all-allow) behavior.
When a license expires, Visor provides a 72-hour grace period during which the policy engine continues to work. A warning is logged:
[visor:enterprise] License has expired but is within the 72-hour grace period.
Please renew your license.
After the grace period, the policy engine silently disables.
Add a policy: block to your .visor.yaml:
version: "1.0"
policy:
engine: local
rules: ./policies/
fallback: deny
timeout: 5000
roles:
admin:
author_association: [OWNER]
users: [cto-username]
developer:
author_association: [MEMBER, COLLABORATOR]
external:
author_association: [FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, NONE]| Field | Type | Default | Description |
|---|---|---|---|
engine |
local | remote | disabled |
disabled |
Evaluation backend |
rules |
string | string[] |
— | Path to .rego files, a directory, or a .wasm bundle (local mode only) |
data |
string |
— | Path to a JSON file to load as the OPA data document (local mode only). Contents are available in Rego via data.<key>. |
url |
string |
— | OPA server URL (remote mode only) |
fallback |
allow | deny | warn |
deny |
Default decision when policy evaluation fails or times out. warn enables audit mode: violations are logged but checks are not blocked. |
timeout |
number |
5000 |
Evaluation timeout in milliseconds |
roles |
map |
— | Role definitions (see below) |
Roles map GitHub metadata to named roles that your Rego policies reference via input.actor.roles.
roles:
admin:
author_association: [OWNER] # GitHub author associations
users: [alice, bob] # Explicit GitHub usernames
teams: [platform-team] # GitHub team slugs (requires API access)
developer:
author_association: [MEMBER, COLLABORATOR]
external:
author_association: [FIRST_TIME_CONTRIBUTOR, NONE]| Sub-field | Type | Description |
|---|---|---|
author_association |
string[] |
GitHub author association values: OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR, FIRST_TIME_CONTRIBUTOR, FIRST_TIMER, NONE |
users |
string[] |
Explicit GitHub usernames |
teams |
string[] |
GitHub team slugs (reserved for future use; see note below) |
Note: The
teamsfield is reserved for future use and is not currently enforced. Team-based role resolution (matching GitHub team slugs via the GitHub API) is not yet implemented. If you configureteams, a validation warning will be emitted. Onlyauthor_associationandusersare currently used for role resolution.
A user is assigned a role if they match any of the identity criteria (OR logic). A user can have multiple roles.
When Visor runs via Slack (socket mode), the actor's Slack user ID, email, and channel are available for role resolution. Three additional sub-fields are supported in role definitions:
roles:
admin:
author_association: [OWNER]
users: [cto-username]
slack_users: [U0123ADMIN] # Slack user IDs
emails: [admin@company.com] # Email addresses (case-insensitive)
eng-channel:
slack_channels: [C0123ENG] # Channel gate: role only applies from this channel
slack_users: [U0123ALICE, U0123BOB]| Sub-field | Type | Description |
|---|---|---|
slack_users |
string[] |
Slack user IDs (e.g., U0123ABC). Matched against the triggering Slack user. |
emails |
string[] |
Email addresses. Matched case-insensitively against the Slack user's email. Requires the Slack bot to have the users:read.email OAuth scope. |
slack_channels |
string[] |
Slack channel IDs (e.g., C0123ENG). Acts as a gate: the role only applies when the action is triggered from one of these channels. Applied as an AND with any identity match. |
Identity matching (users, slack_users, emails) is OR — matching any one is sufficient. Channel gating (slack_channels) is AND — if set on a role, the role only applies when the trigger comes from one of the listed channels.
When Visor runs outside of Slack (e.g., GitHub Actions, CLI), the Slack fields are simply not present and roles that only define Slack criteria will not match.
Create a policies/ directory (or any name you choose) with .rego files:
your-project/
.visor.yaml
policies/
check_execute.rego # Check execution gating (default scope)
tool_invoke.rego # MCP tool access control
capability_resolve.rego # AI capability restrictions
deploy_production.rego # Custom rule for production deploys (optional)
Every policy file:
- Declares a
packagematching the scope (e.g.,package visor.check.execute) - Exports an
allowedboolean (forcheck.executeandtool.invokescopes) - Optionally exports a
reasonstring for denial messages - Optionally exports a
capabilitiesobject (forcapability.resolvescope)
package visor.check.execute
# Default: deny everything
default allowed = false
# Admin can run anything
allowed {
input.actor.roles[_] == "admin"
}
# Developers can run non-production deployments
allowed {
input.actor.roles[_] == "developer"
not startswith(input.check.id, "deploy-production")
}
# Provide a reason when denied
reason = "insufficient role for this check" { not allowed }Iterating over roles: Use input.actor.roles[_] to check if any role matches:
# Any of the actor's roles is "admin"
allowed {
input.actor.roles[_] == "admin"
}Per-step YAML deny list: When a step declares policy.deny, use a denied helper to block matching roles. Add not denied to every allowed rule so deny always takes precedence:
# Deny takes precedence — any actor role in the deny list blocks the check
denied {
some i, j
input.check.policy.deny[i] == input.actor.roles[j]
}
# All allowed rules must include `not denied`
allowed {
not denied
input.actor.roles[_] == "admin"
}Per-step YAML requirements: When a step declares policy.require, check it in Rego:
# String require (e.g., require: admin)
allowed {
not denied
required := input.check.policy.require
is_string(required)
input.actor.roles[_] == required
}
# Array require (e.g., require: [developer, admin])
allowed {
not denied
required := input.check.policy.require
is_array(required)
required[_] == input.actor.roles[_]
}Local mode bypass: Allow broader access when running locally, but only for checks that have no explicit policy.require setting. This keeps sensitive steps (e.g. production deployments) protected even during local development:
# Checks WITHOUT policy.require are allowed in local mode (convenience).
# Checks WITH policy.require still enforce roles (security).
allowed {
input.actor.isLocalMode == true
not input.check.policy
}WASM compilation safety: Some Rego patterns are not supported by OPA's WASM compiler. Avoid not set[_] == X — use helper rules instead:
# BAD: unsafe for WASM compilation
allowed = false {
not input.actor.roles[_] == "admin"
}
# GOOD: use a helper rule
is_admin { input.actor.roles[_] == "admin" }
allowed = false {
not is_admin
}Use the OPA CLI to test your policies before deploying:
# Evaluate a policy with test input
echo '{"actor":{"roles":["developer"],"isLocalMode":false},"check":{"id":"deploy-staging"}}' | \
opa eval -d policies/ -i /dev/stdin 'data.visor.check.execute.allowed'
# Run OPA unit tests (if you have _test.rego files)
opa test policies/ -vVisor includes a built-in policy validation command that checks your .rego files for syntax errors and WASM compilation compatibility in one step. This command does not require a license.
# Validate a directory of .rego files
visor policy-check ./policies/
# Validate a single .rego file
visor policy-check ./policies/check_execute.rego
# Use the policy.rules path from .visor.yaml automatically
visor policy-check
# Use a specific config file
visor policy-check --config .visor.yaml
# Validate and evaluate against sample input
visor policy-check ./policies/ --input sample-input.json
# Verbose output (shows the exact opa commands being run)
visor policy-check ./policies/ --verboseThe command performs three checks:
- Syntax validation (
opa check): Verifies each.regofile has valid Rego syntax. - WASM compilation (
opa build -t wasm -e visor): Confirms the policies can be compiled to WebAssembly, catching WASM-unsafe patterns early. - Sample evaluation (optional,
--input): Evaluates all three policy scopes (check.execute,tool.invoke,capability.resolve) against a provided JSON input file.
Example sample input file for testing:
{
"actor": {
"login": "alice",
"authorAssociation": "MEMBER",
"roles": ["developer"],
"isLocalMode": false
},
"check": {
"id": "security-scan",
"type": "ai",
"policy": {
"require": "developer"
}
},
"repository": {
"owner": "myorg",
"name": "myrepo",
"branch": "feat/new-feature",
"baseBranch": "main",
"event": "pull_request"
},
"pullRequest": {}
}Note: The
pullRequestobject is shown as empty because those fields are not currently populated by the policy engine. See the Input Document Reference for details.
Exit codes:
0: All checks passed1: One or more checks failed (syntax errors, WASM compilation failure, or missing files)
For faster startup (skip compilation at runtime), pre-compile your policies:
# Compile all .rego files into a WASM bundle
opa build -t wasm -e visor -d policies/ -o bundle.tar.gz
# Extract the WASM file
tar -xzf bundle.tar.gz /policy.wasm
# Reference the .wasm file in config
# policy:
# rules: ./policy.wasmImportant: When compiling with
opa build, always use-e visoras the entrypoint. Visor navigates the WASM result tree starting from thevisorpackage root.
When: Before each check runs, after if condition evaluation
Package: package visor.check.execute
Decision: allowed (boolean), reason (string)
package visor.check.execute
default allowed = false
# Deny list from YAML policy.deny (see Per-Step Policy Overrides)
denied {
some i, j
input.check.policy.deny[i] == input.actor.roles[j]
}
allowed {
not denied
input.actor.roles[_] == "admin"
}
allowed {
not denied
input.actor.roles[_] == "developer"
not startswith(input.check.id, "deploy-production")
}
reason = "role is in the deny list for this check" { denied }
reason = "insufficient role for this check" { not denied; not allowed }When a check is denied, it is skipped with skipReason: policy_denied. The denial reason appears in the execution stats and JSON output.
When: Before each MCP tool/method call
Package: package visor.tool.invoke
Decision: allowed (boolean), reason (string)
package visor.tool.invoke
default allowed = true
# Block destructive methods for non-admins
allowed = false {
endswith(input.tool.methodName, "_delete")
not is_admin
}
is_admin { input.actor.roles[_] == "admin" }
reason = "tool access denied by policy" { not allowed }This scope works as an overlay on top of the static allowedMethods/blockedMethods configuration in McpServerConfig. Static filtering is applied first, then OPA filtering.
When: When assembling AI provider configuration
Package: package visor.capability.resolve
Decision: capabilities (object with allowEdit, allowBash, allowedTools keys)
package visor.capability.resolve
# Helper rules for WASM-safe role checks
is_developer { input.actor.roles[_] == "developer" }
is_admin { input.actor.roles[_] == "admin" }
# Disable file editing for non-developers
capabilities["allowEdit"] = false {
not is_developer
not is_admin
}
# Disable bash for external contributors
capabilities["allowBash"] = false {
input.actor.roles[_] == "external"
}Returned capability restrictions are merged with the YAML configuration. OPA can only restrict capabilities (set to false or reduce allowedTools), never grant more than the YAML config allows.
Your Rego policies receive an input document with these fields:
{
"scope": "check.execute",
"check": {
"id": "deploy-production",
"type": "command",
"group": "deployment",
"tags": ["deploy", "production"],
"criticality": "external",
"sandbox": "docker-image",
"policy": {
"require": "admin",
"deny": ["external"],
"rule": "visor/deploy/production"
}
},
"tool": {
"serverName": "github",
"methodName": "search_repositories",
"transport": "stdio"
},
"capability": {
"allowEdit": true,
"allowBash": true,
"allowedTools": ["search_*"],
"enableDelegate": false,
"sandbox": "docker-image"
},
"actor": {
"login": "alice",
"authorAssociation": "MEMBER",
"roles": ["developer"],
"isLocalMode": false,
"slack": {
"userId": "U0123ALICE",
"email": "alice@company.com",
"channelId": "C0123ENG",
"channelType": "channel"
}
},
"repository": {
"owner": "probelabs",
"name": "visor",
"branch": "feat/new-feature",
"baseBranch": "main",
"event": "pull_request"
// "action": "synchronize" // Not currently populated
},
"pullRequest": {
// The following fields are not currently populated:
// "number": 42,
// "labels": ["approved", "ready-to-merge"],
// "draft": false,
// "changedFiles": 5
}
}Note: Only the fields relevant to each scope are populated. For example,
checkis populated forcheck.execute,toolis populated fortool.invoke, etc.Important: Several fields in the
repositoryandpullRequestobjects are currently not populated and are reserved for future use:
repository.action- Would contain the GitHub event action (e.g., "synchronize", "opened")pullRequest.number,pullRequest.labels,pullRequest.draft,pullRequest.changedFiles- Would contain PR metadataThese fields are defined in the input schema but are not currently set by the policy engine during initialization. They are documented here for future compatibility. The
OpaPolicyEngineclass has asetActorContext()method that could be used to enrich this context after PR data is fetched, but this is not yet implemented in the main codebase.For now, use the
repository.owner,repository.name,repository.branch,repository.baseBranch, andrepository.eventfields, along withactorfields, which are reliably populated from GitHub Actions environment variables.
| Path | Type | Description |
|---|---|---|
scope |
string | The policy scope being evaluated |
check.id |
string | Step/check ID from .visor.yaml |
check.type |
string | Provider type (ai, command, mcp, etc.) |
check.group |
string | Comment group name |
check.tags |
string[] | Tags assigned to the check |
check.criticality |
string | external, internal, policy, or info |
check.sandbox |
string | Sandbox type if configured |
check.policy |
object | Per-step policy override from YAML |
tool.serverName |
string | MCP server name |
tool.methodName |
string | MCP method being invoked |
tool.transport |
string | MCP transport type (stdio, sse, http) |
actor.login |
string | GitHub username |
actor.authorAssociation |
string | Raw GitHub author association |
actor.roles |
string[] | Resolved roles from policy.roles config |
actor.isLocalMode |
boolean | true when running outside GitHub Actions |
actor.slack.userId |
string | Slack user ID (e.g., U0123ABC). Present only when triggered from Slack. |
actor.slack.email |
string | Slack user's email address. Requires the bot's users:read.email OAuth scope. |
actor.slack.channelId |
string | Slack channel ID where the action was triggered (e.g., C0123ENG). |
actor.slack.channelType |
string | Channel type: channel, dm, or group. |
repository.owner |
string | Repository owner/organization (from GITHUB_REPOSITORY_OWNER) |
repository.name |
string | Repository name (from GITHUB_REPOSITORY) |
repository.branch |
string | Current/head branch (from GITHUB_HEAD_REF) |
repository.baseBranch |
string | Base branch for PRs (from GITHUB_BASE_REF) |
repository.event |
string | GitHub event type (from GITHUB_EVENT_NAME) |
repository.action |
string | Not currently populated. Reserved for future use. |
pullRequest.number |
number | Not currently populated. Would require a GITHUB_PR_NUMBER env var, which is a custom variable that must be set manually (it is not provided by GitHub Actions by default). When running as a GitHub Action, PR context is also enriched automatically from the PR info, but this field is not yet wired up. Reserved for future use. |
pullRequest.labels |
string[] | Not currently populated. Reserved for future use (requires PR data enrichment). |
pullRequest.draft |
boolean | Not currently populated. Reserved for future use (requires PR data enrichment). |
pullRequest.changedFiles |
number | Not currently populated. Reserved for future use (requires PR data enrichment). |
Individual steps can declare policy requirements directly in YAML. This is a convenience shortcut that works without writing Rego (though your Rego must handle the input.check.policy field for it to take effect).
checks:
deploy-staging:
type: command
exec: ./deploy.sh staging
policy:
require: [developer, admin] # Any of these roles can run this step
deny: [external] # These roles are explicitly blocked
rule: visor/deploy/staging # Optional: custom OPA rule pathNote:
steps:is a supported alias forchecks:for backward compatibility. This documentation useschecks:as the primary key.
| Field | Type | Description |
|---|---|---|
require |
string | string[] |
Role(s) required to run the step (any match suffices) |
deny |
string[] |
Role(s) explicitly denied from running the step (deny takes precedence over allow) |
rule |
string |
Custom OPA rule path (overrides the default scope-based path) |
The deny field is an explicit blocklist. If any of the actor's resolved roles appear in the deny array, the check is unconditionally blocked -- even if the actor also has a role that would otherwise satisfy require or a hardcoded allowed rule. Deny always takes precedence over allow.
Example: A user with both developer and external roles attempts to run this step:
deploy-staging:
type: command
exec: ./deploy.sh staging
policy:
require: [developer, admin]
deny: [external]Even though the user has the developer role (which satisfies require), the step is blocked because they also have the external role, which appears in deny.
Rego implementation: The deny field is passed to OPA as input.check.policy.deny. Your Rego rules must include a denied helper that checks this field. The example policy in examples/enterprise-policy/policies/check_execute.rego includes this enforcement:
# Explicit deny list from YAML policy.deny
denied {
some i, j
input.check.policy.deny[i] == input.actor.roles[j]
}
# Every allowed rule includes `not denied` so deny takes precedence
allowed {
not denied
input.actor.roles[_] == "admin"
}Important: The
denyfield only takes effect if your Rego policy readsinput.check.policy.denyand uses it to block thealloweddecision. The YAML field alone does nothing -- it is data that your policy must act on. The example policies shipped with Visor include this enforcement out of the box.
When a check is denied via policy.deny, the denial reason will be "role is in the deny list for this check" (distinct from the generic "insufficient role for this check" reason).
By default, all check execution policies are evaluated against the visor/check/execute rule path (corresponding to package visor.check.execute in Rego). The policy.rule field lets you override this default and route a specific check to a completely different Rego package with its own specialized logic.
Use a custom rule path when a check needs specialized policy logic that differs from the general check.execute rules. Common scenarios include:
- Production deployments that require additional safeguards beyond role checks (e.g., branch restrictions, time-of-day controls)
- Sensitive operations that need a dedicated approval workflow
- Environment-specific gates where staging and production have different policy requirements
- Compliance checks that must enforce domain-specific regulations
When a check declares policy.rule, the engine's resolveRulePath method returns the custom path instead of the default visor/check/execute. For WASM evaluation, the engine navigates the compiled result tree using the custom path segments. For remote OPA, the path is sent as the HTTP endpoint.
The flow is:
- Check config has
policy.rule: visor/deploy/production - Engine calls
resolveRulePath('check.execute', 'visor/deploy/production') - The override is returned as-is:
visor/deploy/production - For WASM: the engine strips the
visor/prefix and navigatesresult.deploy.production - For remote OPA: a POST is sent to
${url}/v1/data/visor/deploy/production
Step 1: Declare the custom rule in your .visor.yaml:
checks:
deploy-production:
type: command
exec: ./deploy.sh production
criticality: external
policy:
require: admin
rule: visor/deploy/productionStep 2: Create the corresponding Rego file. The package name must match the rule path, with slashes converted to dots:
# policies/deploy_production.rego
package visor.deploy.production
default allowed = false
# Helper: check if actor is an admin (WASM-safe pattern)
is_admin { input.actor.roles[_] == "admin" }
# Only admins can deploy to production
allowed {
is_admin
}
# Additionally require the PR to target the main branch
allowed {
is_admin
input.repository.baseBranch == "main"
}
reason = "only admins can deploy to production" { not allowed }Step 3: Place the file in the same directory as your other policies (the directory referenced by policy.rules in your top-level config). OPA compiles all .rego files in that directory together, so the custom package is automatically included.
- Package name must match the rule path: The Rego
packagedeclaration uses dots as separators, while the YAMLrulefield uses slashes. They must correspond:visor/deploy/productionin YAML maps topackage visor.deploy.productionin Rego. - The
visor/prefix is recommended: If omitted, Visor will auto-prepend it (e.g.,deploy/productionbecomesvisor/deploy/production). Visor compiles WASM bundles with-e visoras the entrypoint, so all rule paths must ultimately start withvisor/for the engine to navigate the result tree correctly. - Custom Rego files go in the policy directory: The file must be in the same directory (or listed in the same
policy.rulesarray) as your other.regofiles. OPA compiles all files together into one WASM bundle. - Custom rules must define
allowed: Like the default scopes, custom rules must export anallowedboolean. Optionally export areasonstring for denial messages. - The full input document is available: Custom rules receive the same
inputdocument as the defaultcheck.executescope, includinginput.actor,input.repository, andinput.check(with thepolicysub-object containingrequire,deny, andrule). - Only one rule per check: Each check can specify at most one
policy.rule. If omitted, the defaultvisor/check/executepath is used.
Use the OPA CLI to test your custom rule in isolation:
# Test the custom deploy rule with an admin actor
echo '{
"actor": {"roles": ["admin"], "isLocalMode": false},
"check": {"id": "deploy-production", "type": "command"},
"repository": {"baseBranch": "main"}
}' | opa eval -d policies/ -i /dev/stdin 'data.visor.deploy.production.allowed'
# Expected output: true
# Test with a non-admin actor (should be denied)
echo '{
"actor": {"roles": ["developer"], "isLocalMode": false},
"check": {"id": "deploy-production", "type": "command"},
"repository": {"baseBranch": "main"}
}' | opa eval -d policies/ -i /dev/stdin 'data.visor.deploy.production.allowed'
# Expected output: false
# Check the denial reason
echo '{
"actor": {"roles": ["developer"], "isLocalMode": false},
"check": {"id": "deploy-production", "type": "command"},
"repository": {"baseBranch": "main"}
}' | opa eval -d policies/ -i /dev/stdin 'data.visor.deploy.production.reason'
# Expected output: "only admins can deploy to production"See examples/enterprise-policy/policies/deploy_production.rego for the full working example.
Local mode compiles Rego policies into WebAssembly and evaluates them in-process. This is the recommended mode for most deployments.
policy:
engine: local
rules: ./policies/ # Directory of .rego files
fallback: deny- At startup, Visor finds all
.regofiles in the specified path - It compiles them to WASM using
opa build -t wasm -e visor - The WASM module is loaded into the Node.js process via
@open-policy-agent/opa-wasm - Each policy evaluation takes ~1ms (no network round-trip)
| Value | Example | Description |
|---|---|---|
| Directory | ./policies/ |
All .rego files in the directory are compiled together |
| Single file | ./policies/main.rego |
A single .rego file |
| Multiple files | [./policies/check.rego, ./policies/tool.rego] |
Array of .rego files |
| WASM bundle | ./policy.wasm |
Pre-compiled WASM (skips opa build at startup) |
You can load an external JSON file as the OPA data document using the data option. This makes the file's contents available in your Rego policies via data.<key>, allowing you to externalize dynamic configuration (allowed lists, thresholds, feature flags, etc.) without modifying your .rego files.
policy:
engine: local
rules: ./policies/
data: ./policies/data.jsonThe JSON file must contain a top-level object. For example:
{
"allowed_repos": ["visor", "probe"],
"protected_checks": {
"deploy-production": true,
"deploy-staging": true
},
"max_concurrent_deploys": 3
}You can then reference these values in your Rego policies:
package visor.check.execute
# Use external data for dynamic configuration
allowed {
data.protected_checks[input.check.id]
input.actor.roles[_] == "admin"
}
# Non-protected checks are allowed for developers
allowed {
not data.protected_checks[input.check.id]
input.actor.roles[_] == "developer"
}Note: The
dataoption is only supported inlocalmode. Forremotemode, load data directly into your OPA server using OPA's bundle or data APIs.
Remote mode sends evaluation requests to an external OPA server via HTTP. This is useful for centralized policy management across multiple services.
policy:
engine: remote
url: http://opa:8181
fallback: deny
timeout: 3000- Visor sends POST requests to
${url}/v1/data/visor/<scope> - The request body is
{ "input": <policy-input-document> } - The response contains
{ "result": { "allowed": true/false, ... } }
# Run OPA as a server with your policies
opa run --server --addr :8181 ./policies/
# Or with Docker
docker run -p 8181:8181 \
-v $(pwd)/policies:/policies \
openpolicyagent/opa:latest \
run --server --addr :8181 /policies/- Centralized policy management across multiple repositories
- Policy bundles pulled from a registry
- Audit logging at the OPA server level
- Policies shared with other services (not just Visor)
The fallback setting controls what happens when policy evaluation fails or a policy denies an action:
| Setting | Behavior |
|---|---|
allow (default) |
On error/timeout, allow the action |
deny |
On error/timeout, deny the action |
warn |
Evaluate policies normally but never block execution. Denied actions are allowed to proceed, and a warning is logged instead. Use this for gradual policy rollout. |
The warn fallback is designed for gradual policy rollout. When deploying new policies, set fallback: warn to observe what would be denied without actually blocking any checks. This lets you:
- Validate that your Rego policies match your intent before enforcing them
- Identify unexpected denials in production without disrupting workflows
- Gradually roll out policies: start with
warn, review logs, then switch todeny
In warn mode:
- All policy evaluations run normally
- Denied decisions are overridden to allowed, with the original reason prefixed by
audit: - Warnings are emitted to the log:
[PolicyEngine] Audit: check '<id>' would be denied: <reason> - If policy evaluation fails (error/timeout), the action is still allowed and a warning is logged
# Example: observe policy decisions before enforcing
policy:
engine: local
rules: ./policies/
fallback: warn # log violations, don't block
roles:
admin:
author_association: [OWNER]
developer:
author_association: [MEMBER, COLLABORATOR]Evaluation can fail due to:
- WASM compilation errors (invalid Rego syntax)
- Timeout exceeded
- Remote OPA server unreachable
- Missing or invalid
.regofiles - Runtime evaluation errors in Rego
If no valid license is found, the policy engine is silently disabled. All checks run as normal with no policy enforcement. No error is raised. This means:
- The OSS build works exactly as before
- The EE build without a license works exactly as the OSS build
- Expired licenses (past the 72h grace period) behave as if no license is present
.visor.yaml (policy: block)
|
v
src/policy/types.ts PolicyEngine interface (OSS)
src/policy/default-engine.ts No-op implementation (OSS, always allows)
|
v (dynamic import, license-gated)
src/enterprise/loader.ts Sole import boundary
src/enterprise/policy/
opa-policy-engine.ts Wraps WASM + HTTP evaluators
opa-wasm-evaluator.ts @open-policy-agent/opa-wasm
opa-http-evaluator.ts REST client for OPA server
policy-input-builder.ts Builds OPA input documents
- Engine startup: If
config.policy.engineis notdisabled, Visor dynamically importssrc/enterprise/loader.ts - License check: The loader validates the JWT license. If invalid or missing, returns
DefaultPolicyEngine(no-op) - OPA initialization: For
localmode, compiles.regoto WASM. Forremotemode, validates the OPA server URL - Check execution: Before each check runs (after
ifconditions), the engine callspolicyEngine.evaluateCheckExecution() - Decision: If denied, the check is skipped with
policy_deniedreason. If allowed, execution proceeds normally
The enterprise code is strictly isolated. OSS code never imports from src/enterprise/ directly. The sole boundary is src/enterprise/loader.ts, loaded via dynamic await import(). This is enforced by an ESLint rule.
Visor provides two mechanisms for permission-based workflow control:
| Feature | Author Permissions (OSS) | Policy Engine (EE) |
|---|---|---|
| License | None (OSS) | EE license required |
| Mechanism | JavaScript expressions in if/fail_if |
OPA Rego policies |
| Scope | Per-step if conditions |
Pre-execution gating, tool filtering, capability restriction |
| Enforcement | Evaluated inline (can be bypassed by config changes) | Centralized, auditable, separable from config |
| Role system | Uses hasMinPermission(), isMember(), etc. |
Custom roles resolved from policy.roles config |
| Complexity | Simple, inline | Full policy language (Rego) with testing tools |
- Author Permissions: Simple permission checks embedded in step conditions. Good for small teams with straightforward rules.
- Policy Engine: Centralized, auditable policy enforcement. Good for organizations that need compliance, separation of duties, or complex role hierarchies.
The two systems complement each other. Author permission functions remain available in if/fail_if expressions even when the policy engine is active. The policy engine evaluates first (before if conditions for check execution gating), providing an additional layer of control.
See Author Permissions for the OSS permission functions.
Symptom: Checks run without policy enforcement even with policy: configured.
- Check your license: Ensure
VISOR_LICENSEis set or.visor-licenseexists - Verify the feature: Your license must include the
policyfeature - Check the engine setting: Ensure
policy.engineislocalorremote(notdisabled) - Run with debug:
visor --debugshows policy initialization messages
Symptom: Error: opa command not found at startup.
- Install the OPA CLI: see Installation
- Or pre-compile your policies to
.wasmto avoid needing the CLI at runtime
Symptom: opa build fails at startup.
- Run
visor policy-check ./policies/to validate syntax and WASM compatibility in one step - Check your Rego syntax:
opa check policies/ - Avoid WASM-unsafe patterns (see WASM compilation safety)
- Ensure the entrypoint package exists: your
.regofiles must declarepackage visor.*packages
Symptom: Every check shows skipReason: policy_denied.
- Verify your role definitions match the actor's GitHub association
- Check
fallback: denyvsfallback: allow—denyblocks on any evaluation error - Test your policy with
opa eval:echo '{"actor":{"roles":["developer"]}}' | \ opa eval -d policies/ -i /dev/stdin 'data.visor.check.execute.allowed'
Symptom: All checks are allowed/denied (based on fallback) when using remote mode.
- Verify the OPA server URL is correct and reachable
- Check firewall rules and network connectivity
- Increase
timeoutif the server is slow - Check OPA server logs for errors
Use the --dump-policy-input flag to see the exact JSON input that Visor sends to OPA for a given check. This is invaluable for debugging Rego policies because you can feed the output directly into opa eval.
# Print the OPA input document for the "deploy-production" check
visor --dump-policy-input deploy-production
# With a specific config file
visor --dump-policy-input deploy-production --config ./my-visor.yamlExample output:
{
"scope": "check.execute",
"check": {
"id": "deploy-production",
"type": "command",
"group": "deployment",
"tags": ["deploy", "production"],
"criticality": "external",
"policy": {
"require": "admin"
}
},
"actor": {
"login": "alice",
"roles": ["developer"],
"isLocalMode": true
},
"repository": {}
}You can pipe this directly into opa eval to test your policy:
visor --dump-policy-input deploy-production | \
opa eval -d policies/ -i /dev/stdin 'data.visor.check.execute'Notes:
- This flag requires the Enterprise Edition build (the
src/enterprise/modules must be present) but does not require a valid license key. It is a debugging tool that works without activation. - In OSS-only builds (where
src/enterprise/is stripped), this flag is not available and will exit with an error. - Actor context is derived from environment variables (
VISOR_AUTHOR_LOGIN,GITHUB_ACTOR,VISOR_AUTHOR_ASSOCIATION,GITHUB_ACTIONS). - Repository context is derived from GitHub environment variables (
GITHUB_REPOSITORY_OWNER,GITHUB_REPOSITORY,GITHUB_HEAD_REF,GITHUB_BASE_REF,GITHUB_EVENT_NAME). repository.actionis not populated from any environment variable and will be absent.pullRequest.numberwould require aGITHUB_PR_NUMBERenvironment variable, which is a custom env var that must be set manually (it is not provided by GitHub Actions by default and is not set by Visor). When running as a GitHub Action, PR context is enriched automatically from the PR info, but this field is not yet wired up to the policy input. It will be absent.pullRequest.labels,pullRequest.draft, andpullRequest.changedFilesare not currently populated and will be absent.- When running locally (outside GitHub Actions),
actor.isLocalModeistrueand most repository fields will be empty.
Symptom: Policy evaluations timing out.
- For local mode: this is rare (~1ms per evaluation). Check if
.regofiles are very complex - For remote mode: increase
timeoutor check network latency to the OPA server - Set
fallback: allowif timeouts should not block execution
# .visor.yaml
version: "1.0"
policy:
engine: local
rules: ./policies/
fallback: deny
roles:
admin:
author_association: [OWNER]
developer:
author_association: [MEMBER, COLLABORATOR]
checks:
security-scan:
type: ai
prompt: "Review for security issues"
policy:
require: developer# policies/check_execute.rego
package visor.check.execute
default allowed = false
# Explicit deny list (policy.deny in YAML) — deny takes precedence
denied {
some i, j
input.check.policy.deny[i] == input.actor.roles[j]
}
allowed {
not denied
input.actor.roles[_] == "admin"
}
allowed {
not denied
required := input.check.policy.require
is_string(required)
input.actor.roles[_] == required
}
# Local mode: allow checks without explicit policy requirements
allowed {
not denied
input.actor.isLocalMode == true
not input.check.policy
}
reason = "role is in the deny list" { denied }
reason = "insufficient role" { not denied; not allowed }
⚠️ Important: ThepullRequest.labels,pullRequest.draft, andpullRequest.changedFilesfields are not currently populated by the policy engine. The example below shows how these fields would be used if they were available, but attempting to use them now will result in empty/undefined values. See the Input Document Reference section for details on which fields are currently populated.This example is provided for future compatibility and to illustrate the intended design. If you need PR metadata in your policies today, you will need to extend Visor to fetch PR data and call
setActorContext()with the enriched context.
# policies/check_execute.rego
# FUTURE EXAMPLE - These fields are not yet populated
package visor.check.execute
# Only allow deploy-production if PR has the "approved" label
has_approved_label {
input.pullRequest.labels[_] == "approved"
}
allowed {
not denied
input.check.id == "deploy-production"
has_approved_label
input.actor.roles[_] == "admin"
}
# Block all checks on draft PRs
allowed = false {
input.pullRequest.draft == true
}
# Deny checks that change too many files (e.g., > 100)
allowed = false {
input.pullRequest.changedFiles > 100
input.check.id == "full-review"
}
reason = "PR must have 'approved' label for production deploy" {
input.check.id == "deploy-production"
not has_approved_label
}
reason = "checks are blocked on draft PRs" {
input.pullRequest.draft == true
}See examples/enterprise-policy/ for a complete working example with all three policy scopes, role definitions, and a ready-to-use .visor.yaml.
# .github/workflows/visor.yml
name: Visor
on:
pull_request: { types: [opened, synchronize] }
permissions:
contents: read
pull-requests: write
checks: write
jobs:
visor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: probelabs/visor@v1
env:
VISOR_LICENSE: ${{ secrets.VISOR_LICENSE }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}Questions? Need a license? Contact hello@probelabs.com