Skip to content

Custom rules

vlotpipe's 24 baseline rules are compiled into the binary — there's no plugin system, no scripting runtime. For most policy, that's fine: the baseline pack covers the well-established, broadly-applicable cases. But every org eventually has a policy that's specific to them, and shouldn't need a fork and a rebuild to express. custom_rules: in .vlotpipe.yml is that escape hatch.

The motivating example

TIMEOUT001 checks one thing: is timeout-minutes set at all. It has no opinion on what it's set to. Someone can technically satisfy it while missing the point entirely:

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 360 # satisfies TIMEOUT001; is not actually a bound

360 is GitHub's own default cap — setting it explicitly to that value is functionally identical to not setting it at all. No baseline rule can catch this without becoming opinionated about a specific number, which is exactly the kind of judgment call that should live in a company's own config, not in vlotpipe's source:

# .vlotpipe.yml
custom_rules:
  - code: ORG001
    severity: warning
    scope: job
    field: timeout-minutes
    equals: "360"
    message: "timeout-minutes is set to GitHub's own default; pick a real bound"

Shape of a rule

custom_rules:
  - code: <your code>       # required — any string; ORG001-style is a reasonable convention
    severity: <severity>    # required — blocker, warning, or info
    message: <message>      # required — shown in the report
    scope: job | step       # optional, defaults to "job"
    field: <field name>     # required — see the field list below
    equals: <value>         # optional
    not_equals: <value>     # optional
    matches: <regex>        # optional
    exists: true | false    # optional

At least one of equals, not_equals, matches, exists is required. Specifying more than one combines them with AND — exists: true plus equals: "x" requires the field to be both present and equal to "x".

A bad config (unknown field, invalid severity, missing matcher, a regex that doesn't compile) fails the scan immediately with a message naming the offending rule's code — not a silent no-op.

Supported fields

Deliberately a small, explicit, curated list rather than every Go struct field reflected automatically — this is the contract config authors write against, so it should stay stable and legible.

scope: job (the default):

Field Matches against
timeout-minutes the job's timeout-minutes value, as a string; absent (not "exists") if unset
runs-on the job's runs-on value
name the job's name: (or its ID, if name: isn't set)
id the job's YAML key under jobs:
if the job's if: condition
permissions-set "true"/"false" — whether the job declares its own permissions: block

scope: step:

Field Matches against
uses the step's uses: value
run the step's run: script
name the step's name:
if the step's if: condition

More examples

Requiring every third-party action to come from an internally-vetted mirror namespace:

custom_rules:
  - code: ORG002
    severity: blocker
    scope: step
    field: uses
    matches: '^(actions/|internal-org/)'
    message: "third-party actions must be actions/* or go through the internal-org/* mirror"

Note matches fires when the field contains a match (regexp.MatchString semantics), not on a full match — anchor with ^/$ if that's not what you want. There's no not_matches: Go's regexp package (RE2, the same engine Go itself uses) doesn't support negative lookahead, so "flag anything that doesn't match this pattern" can't be written as one matches regex. not_equals covers the exact-string case; a does-this-follow-a-convention check across arbitrary patterns is outside what a single field comparison can express — see "What this doesn't replace" below.

Flagging any self-hosted-labeled runner outside a known-safe set, using not_equals for the one-exact-value case:

custom_rules:
  - code: ORG004
    severity: warning
    field: runs-on
    matches: '^self-hosted'
    not_equals: "self-hosted-approved-pool"
    message: "self-hosted runner label isn't the approved pool"

What this doesn't replace

This is deliberately field-comparison only — no boolean combinators across multiple fields, no cross-job logic, no access to the full untrusted-context detection SEC004/SEC012 use internally. A policy that needs "if trigger is X and step Y does Z and permissions don't include W" is baseline-rule territory, not custom_rules: territory — open an issue or contribute a Go rule (see internal/rules/baseline/ for the pattern every existing rule follows) rather than trying to force it through field matchers.

Suppressing a custom rule's own finding

Works exactly like any baseline rule — .vlotpipe.yml's ignore: list or an inline # vlotpipe: ignore[ORG001] comment, since custom rule violations flow through the same severity floor, ignore list, and inline-suppression logic as everything else.