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.
What you’ll build
Section titled “What you’ll build”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.
Prerequisites
Section titled “Prerequisites”- The quick start completed —
VectorStep running,
.envwith your admin/webhook tokens.
1. Write the policy
Section titled “1. Write the policy”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.
mkdir -p security_policiescat > security_policies/pci-dss-lite.yaml <<'EOF'name: pci-dss-litedescription: No webhook steps for cardholder-data pipelinesdefault: allowrules: - deny: { executor: webhook } reason: "Arbitrary outbound HTTP calls are not permitted from a PCI-scoped pipeline — use the notify executor instead"EOFname: 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_policiesReload 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:
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:
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:
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}]}3. Write a pipeline that violates it
Section titled “3. Write a pipeline that violates it”pipelines/policy-demo.yaml:
name: policy-demodescription: Demonstrates a security-policy write-time rejectiontrigger: 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:
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()}))")"What you should see
Section titled “What you should see”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:
ls pipelines/ | grep policy-demo # no output — the file was never createdThis 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.
4. Fix it and write again
Section titled “4. Fix it and write again”Swap the denied executor for one the policy permits — notify is
unaffected by pci-dss-lite, only webhook is denied:
name: policy-demodescription: Demonstrates a security-policy write-time rejectiontrigger: 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:
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”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.
What’s actually enforced here
Section titled “What’s actually enforced here”- 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_dircan create, edit, or delete a policy. There is noPOST/PUT/DELETE /security-policies*route at all, at any token tier — try one and you’ll get a405/404, not a403.GET /security-policies/{name}is how a pipeline author sees what they’re agreeing to before opting in.
Where next
Section titled “Where next”- Security policies — the full reference: schema, both enforcement points, mandatory-by-stage, and the relationship to Gateway tool policy.
- Restricting pipelines with security
policies —
how to scope a policy well, and when to make one mandatory for
stage: productionrather than purely opt-in. - Configuration reference
—
security_policies_dirandsecurity_policies.requiredin full. - Run deterministic logic as a pipeline step — the other executor-level trust boundary in this series, restricted deployment-wide rather than per-pipeline.