Skip to content
Beta. VectorStep is in beta.Let us know if something breaks.

Security policies

Anyone holding an admin token can author any pipeline, using any enabled executor — security.allow_shell_checks and security.shell_steps.enabled are deployment-wide toggles, on or off for everyone, with no way to say “most pipelines may use shell steps, but anything touching cardholder data may not.” security_policies is that missing per-pipeline layer: named, reusable restriction sets an operator defines once and a pipeline opts into by name, without needing to know or be able to see the policy’s actual rules.

A pipeline author experiences a policy purely as “my pipeline was rejected, and the error told me which policy and why” or “my pipeline saved and runs fine.” The latter is the compliance signal — if a pipeline declaring a policy runs at all, it adheres to it.

It’s entirely optional. A pipeline with no security_policies: field is unaffected regardless of what policies exist in the deployment.

v1 scope: which executors a pipeline may use

Section titled “v1 scope: which executors a pipeline may use”

Deliberately narrow. A security policy restricts which step executors a pipeline’s steps may use — nothing else, in this version. Destination allowlisting (webhook URLs, notification channels), trust/grounding floors, and cost ceilings are real, likely follow-ups, but each is a materially different enforcement shape and isn’t built yet — don’t assume a policy can restrict anything beyond executor: today.

One YAML file per policy, in security_policies_dir (./security_policies by default):

security_policies/pci-dss-lite.yaml
name: pci-dss-lite # must match the filename stem
description: No shell/webhook steps for cardholder-data pipelines
default: allow # allow | deny — applies when no rule matches
rules:
- deny: { executor: shell }
reason: "Shell steps run on the service host — out of scope here"
- deny: { executor: webhook }
reason: "Arbitrary outbound calls are not permitted from a PCI-scoped pipeline"
Field Description
name Must match the filename stem (pci-dss-lite.yaml → name: pci-dss-lite). Mismatched files are skipped and logged at startup.
description Free text — shown on GET /security-policies.
default allow or deny — the decision when no rule matches. Defaults to allow.
rules Ordered list. First match wins.
rules[].allow / rules[].deny Exactly one per rule — {executor: <name>}, the only match key in v1.
rules[].reason Required on deny (surfaced in the write-time rejection). Optional on allow.

This is the same allow/deny, first-match-wins, default-fallback vocabulary Gateway tool policy already uses — deliberately not a second rule DSL for the same product, just scoped to one match key (executor) instead of four.

A pipeline opts in by name:

name: alert-triage-pci
stage: production
security_policies:
- pci-dss-lite
steps:
- name: triage
executor: gateway # permitted — only shell/webhook are denied above
...

Bare names only — there is no way to write policy rules inline in a pipeline’s own YAML. Referencing a name that doesn’t exist in the loaded registry is itself a write-time rejection, not a silent no-op: a pipeline claiming compliance with a policy that doesn’t exist is worse than being ignored, since the false claim is the harm a policy exists to prevent.

Policy content is filesystem/config.yaml-only — never HTTP-writable, at any token tier, including admin. If a policy’s rules could be authored or edited through the same POST/PUT /pipelines trust tier a pipeline itself is written through, an admin-token caller could simply write a toothless policy and trivially “comply” with it — the entire “operator-owned floor” premise collapses. This is the identical posture security.shell_steps.allowed already has, and the identical reasoning Gateway tool policy already established for the same product. Read access is a different story — see Read-only endpoints below: a pipeline author can see exactly what a named policy restricts before opting in, they just can’t change it. Changing a policy needs filesystem/config access and a reload, the same as adding a shell_steps.allowed entry.

Write time is the primary gate. A step’s executor: field is always a plain literal in pipeline YAML — never Jinja-rendered, unlike a webhook URL — so every v1 violation is knowable the moment a pipeline is written. POST/PUT /pipelines (and their /validate counterparts) reject a write that would violate a declared policy, naming the step, the executor, the policy, and the rule’s reason:

Step 'notify_partner' uses executor: webhook, denied by security policy
'pci-dss-lite': Arbitrary outbound calls are not permitted from a
PCI-scoped pipeline

Runtime enforcement exists too, as a defense-in-depth backstop — not because an executor can somehow become dynamic, but because write-time validation can’t close two gaps on its own:

  1. An operator tightens a policy’s rules after a pipeline already passed validation under the old, looser version — the pipeline sits on disk, unchanged, now non-compliant, until its next write.
  2. A pipeline reaches disk outside the HTTP API entirely — a shipped sample, a git-synced file dropped straight into pipeline_config_dir — and was never validated at all.

The runner re-checks at execution time against the live policy registry (refreshed on every POST /reload/SIGHUP), and a violation aborts the run immediately, before any step executes — recorded loudly in the run’s audit trail, never silently folded into a step’s confidence score.

An operator can require certain pipelines to opt in, rather than trusting every pipeline author to remember:

config.yaml
security_policies:
required:
production: [pci-dss-lite] # every stage: production pipeline must declare this
# testing: [] # omit or empty — no requirement at that stage

A stage: production pipeline missing a required policy from its own security_policies: list is rejected at write time — the same boundary that already enforces the executor restrictions above, not a second mechanism. There’s no separate /promote endpoint: stage: is just a field a pipeline author sets via PUT /pipelines, so this check runs at that identical call site.

Different boundary, same product — worth knowing, no code coupling. A security policy here denying executor: gateway blocks a pipeline from running any agent-based step at all: coarse, VectorStep-side, pre-execution. tool_policy governs something finer, entirely inside the Gateway, only reachable once a gateway step is already running: which tools that agent may call. A pipeline permitted to use executor: gateway by its security policy can still be further restricted by whatever tool_policy the Gateway deployment enforces — the two never conflict, since they gate different, non-overlapping moments, but the error message tells you which layer rejected you: a security_policies denial names the step’s executor and the VectorStep policy; a tool_policy denial (surfaced in the agent’s own trace) names the tool and the Gateway policy. Fixing one means editing security_policies: in the pipeline YAML; fixing the other means editing what tools the agent is scoped to, in a different repo entirely.

GET /security-policies # name, description, default, rule count per policy
GET /security-policies/{name} # full rules — the transparency a pipeline
# author needs before opting in

Role: viewer for both. No POST/PUT/DELETE exists for this prefix at any token tier — see The one invariant that matters most above.

GET /pipelines/{name} also gains a security_policy_status field: for each policy the pipeline declares, whether it currently resolves (exists in the loaded registry) and passes — the same compliance signal GET /security-policies/{name} implies, without cross-referencing two endpoints by hand.

Like security.shell_steps.allowed, policy files are read once at startup and refreshed only by POST /reload or SIGHUP — not on every request. Editing a policy file and expecting the very next write to see it without a reload is the most likely surprise here.