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

Tutorial: restrict a pipeline with a security policy

Every pipeline author holding an admin token can, by default, use any enabled executor in any pipeline — there’s no way to say “most pipelines may use whatever they like, but this one may not touch webhook or shell.” Security policies are that missing per-pipeline restriction: a named, reusable rule set an operator defines once in config.yaml, that a pipeline opts into by name.

This tutorial is standalone — no agent, no Gateway, no MCP tools. Just the quick start and one policy file.

A pci-dss-lite policy that denies executor: webhook, and a pipeline that opts into it. You’ll trigger a write that violates the policy and watch it get rejected before it ever touches disk — then fix the pipeline and watch the same write succeed.

  • The quick start completed — VectorStep running, .env with your admin/webhook tokens.

Policy files live under security_policies_dir — ./security_policies by default. This is the one step that can’t be done through the API — like security.shell_steps.allowed, policy content is config.yaml/filesystem-only, at every token tier, including admin — a pipeline-write token can never author or edit a policy’s rules, only opt a pipeline into one that already exists.

Terminal window
mkdir -p security_policies
cat > security_policies/pci-dss-lite.yaml <<'EOF'
name: pci-dss-lite
description: No webhook steps for cardholder-data pipelines
default: allow
rules:
- deny: { executor: webhook }
reason: "Arbitrary outbound HTTP calls are not permitted from a PCI-scoped pipeline — use the notify executor instead"
EOF

name: must match the filename stem (pci-dss-lite.yaml → name: pci-dss-lite) — a mismatch is skipped and logged at startup, not a boot failure. default: allow means everything except the explicitly denied executor is permitted; see Restricting pipelines with security policies for when you’d flip that.

2. Point config.yaml at the directory (if it isn’t already the default)

Section titled “2. Point config.yaml at the directory (if it isn’t already the default)”

security_policies_dir defaults to ./security_policies, so if you created the directory above in VectorStep’s working directory, there’s nothing to add. To use a different location:

security_policies_dir: ./security_policies

Reload to pick up the new policy — config.yaml itself only needs a reload here, not a restart, since security_policies_dir was already at its default:

Terminal window
curl -X POST http://localhost:8000/reload \
-H "Authorization: Bearer $(grep VECTORSTEP_ADMIN_TOKEN ~/.vectorstep/.env | cut -d= -f2)"

Confirm it loaded — check the logs for the startup/reload line naming how many policies loaded:

Terminal window
docker compose logs --tail=20 vectorstep | grep -i "security polic"

You should see Loaded 1 security policy from ./security_policies. Or ask the API directly:

Terminal window
curl http://localhost:8000/security-policies \
-H "Authorization: Bearer $(grep VECTORSTEP_ADMIN_TOKEN ~/.vectorstep/.env | cut -d= -f2)"
{"security_policies": [{"name": "pci-dss-lite", "description": "No webhook steps for cardholder-data pipelines", "default": "allow", "rule_count": 1}]}

pipelines/policy-demo.yaml:

name: policy-demo
description: Demonstrates a security-policy write-time rejection
trigger:
match: { event: policy-demo }
security_policies:
- pci-dss-lite
steps:
- name: notify-partner
executor: webhook
executor_config:
url: "https://example.com/hook"

Try to write it through the API — same JSON-body-with-yaml-field shape every pipeline-write endpoint in this series uses:

Terminal window
curl -X POST http://localhost:8000/pipelines \
-H "Authorization: Bearer $(grep VECTORSTEP_ADMIN_TOKEN ~/.vectorstep/.env | cut -d= -f2)" \
-H "Content-Type: application/json" \
-d "$(python3 -c "import json,sys; print(json.dumps({'yaml': open('pipelines/policy-demo.yaml').read()}))")"

A 400, rejected before anything touches disk:

{
"detail": {
"type": "validation",
"message": "Step 'notify-partner' uses executor: webhook, denied by security policy 'pci-dss-lite': Arbitrary outbound HTTP calls are not permitted from a PCI-scoped pipeline — use the notify executor instead"
}
}

Confirm nothing was written:

Terminal window
ls pipelines/ | grep policy-demo # no output — the file was never created

This is the whole point: the rejection names the step, the executor, the policy, and the rule’s reason — an author can fix this themselves without ever needing to ask an operator what went wrong, even though only an operator could have written the policy itself.

Swap the denied executor for one the policy permits — notify is unaffected by pci-dss-lite, only webhook is denied:

name: policy-demo
description: Demonstrates a security-policy write-time rejection
trigger:
match: { event: policy-demo }
security_policies:
- pci-dss-lite
steps:
- name: notify-partner
executor: notify
on_failure: continue
executor_config:
url: "https://example.com/hook"
payload:
text: "policy-demo fired"

Update pipelines/policy-demo.yaml with the content above, then write it again the same way:

Terminal window
curl -X POST http://localhost:8000/pipelines \
-H "Authorization: Bearer $(grep VECTORSTEP_ADMIN_TOKEN ~/.vectorstep/.env | cut -d= -f2)" \
-H "Content-Type: application/json" \
-d "$(python3 -c "import json,sys; print(json.dumps({'yaml': open('pipelines/policy-demo.yaml').read()}))")"

This time it succeeds — a 200 with "committed": false in the body, same as every other pipeline write. The pipeline still declares security_policies: [pci-dss-lite]; it just no longer violates it.

5. See the compliance signal on the pipeline itself

Section titled “5. See the compliance signal on the pipeline itself”
Terminal window
curl http://localhost:8000/pipelines/policy-demo \
-H "Authorization: Bearer $(grep VECTORSTEP_ADMIN_TOKEN ~/.vectorstep/.env | cut -d= -f2)" \
| python3 -c "import json,sys; print(json.load(sys.stdin)['security_policy_status'])"
{"pci-dss-lite": {"resolves": true, "passes": true}}

resolves: true means the policy still exists in the loaded registry; passes: true means the pipeline’s current config doesn’t violate it. Both turn false if an operator later deletes the policy file or tightens its rules and reloads — the pipeline itself doesn’t need to be rewritten for this field to reflect that, since it’s computed live against the current registry, not cached from write time.

  • The rejection happens at write time, before the file exists. A step’s executor: is always a plain literal — never Jinja-rendered — so every v1 violation is knowable the moment a pipeline is written; there’s no class of executor violation only discoverable once a run starts.
  • A runtime backstop exists too, for two cases write-time validation can’t close on its own: a policy tightened after a pipeline already passed validation under the old rules, and a pipeline that reached disk outside the HTTP API entirely (a git-synced file dropped straight into pipeline_config_dir). Either way, the run aborts before any step executes, recorded in the run’s audit trail.
  • Only an operator with filesystem access to security_policies_dir can create, edit, or delete a policy. There is no POST/PUT/DELETE /security-policies* route at all, at any token tier — try one and you’ll get a 405/404, not a 403. GET /security-policies/{name} is how a pipeline author sees what they’re agreeing to before opting in.