CI/CD Integration
Scan your prompt templates and fixtures on every pull request. Catch prompt injection, jailbreaks, and policy violations in CI before they reach production.
Link to section: Why Scan in CI?Why Scan in CI?
Prompt templates are code. They get edited, reviewed, and shipped like any other file, and they can pick up the same problems: an injected instruction pasted in from a support ticket, a jailbreak that survives into a fixture, a system prompt that quietly grants more than it should.
Scanning in CI moves that check left. Instead of finding out in production that a template forwards an attacker's instructions, the pull request fails and the author fixes it before merge.
What this is good for:
- Prompt templates and system prompts checked into your repository
- Test fixtures and evaluation datasets
- Seed data for retrieval pipelines
- Any file whose contents eventually reach a model
Link to section: Quick StartQuick Start
Add your LockLLM API key as a repository secret named LOCKLLM_API_KEY, then add the workflow below.
Link to section: GitHub ActionsGitHub Actions
Create .github/workflows/lockllm.yml:
name: LockLLM Prompt Scan
on:
pull_request:
push:
branches: [main]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Scan prompt files
env:
LOCKLLM_API_KEY: ${{ secrets.LOCKLLM_API_KEY }}
PROMPT_DIR: 'prompts'
run: node .github/scripts/lockllm-scan.mjs
Link to section: The Scan ScriptThe Scan Script
Create .github/scripts/lockllm-scan.mjs. It has no dependencies and runs on any recent Node version:
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const API_KEY = process.env.LOCKLLM_API_KEY;
const DIR = process.env.PROMPT_DIR || 'prompts';
const EXTENSIONS = (process.env.PROMPT_EXTENSIONS || '.txt,.md,.prompt')
.split(',')
.map((e) => e.trim());
const SENSITIVITY = process.env.LOCKLLM_SENSITIVITY || 'medium';
const SCAN_MODE = process.env.LOCKLLM_SCAN_MODE || 'combined';
const TIMEOUT_MS = Number(process.env.LOCKLLM_TIMEOUT_MS || 60000);
if (!API_KEY) {
console.error('LOCKLLM_API_KEY is not set. Add it as a repository secret.');
process.exit(1);
}
// Plain recursive walk so this runs on any Node 18+ without dependencies.
function collect(dir) {
let out = [];
let entries;
try {
entries = readdirSync(dir);
} catch {
return out;
}
for (const entry of entries) {
if (entry === 'node_modules' || entry.startsWith('.')) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
out = out.concat(collect(full));
} else if (EXTENSIONS.includes(extname(full))) {
out.push(full);
}
}
return out;
}
const files = process.env.PROMPT_FILES
? process.env.PROMPT_FILES.trim().split(/\s+/).filter(Boolean)
: collect(DIR);
if (files.length === 0) {
console.log(`No prompt files found in ${DIR}. Nothing to scan.`);
process.exit(0);
}
async function scan(text) {
// Always bound the request. A CI job with no timeout waits on the network
// until the runner itself is killed, which looks like a hung build.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch('https://api.lockllm.com/v1/scan', {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
'x-lockllm-scan-mode': SCAN_MODE,
'x-lockllm-sensitivity': SENSITIVITY,
},
body: JSON.stringify({ input: text }),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`Scan request failed with status ${res.status}`);
}
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`Scan timed out after ${TIMEOUT_MS}ms`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
let failures = 0;
let skipped = 0;
for (const file of files) {
const text = readFileSync(file, 'utf8');
if (!text.trim()) continue;
try {
const result = await scan(text);
const violations = result.policy_violations || [];
if (result.safe && violations.length === 0) {
console.log(`PASS ${file}`);
continue;
}
failures++;
console.error(`FAIL ${file}`);
if (!result.safe) {
console.error(` Threat detected (confidence ${result.confidence})`);
}
for (const v of violations) {
const names = (v.violated_categories || []).map((c) => c.name).join(', ');
console.error(` Policy violation: ${v.policy_name}${names ? ` (${names})` : ''}`);
}
} catch (err) {
// Do not fail the build because the scan could not run. Surface it and
// keep going, so an outage never blocks every pull request.
skipped++;
console.warn(`SKIP ${file} - could not scan: ${err.message}`);
}
}
const checked = files.length - skipped;
console.log(`\nChecked ${checked} of ${files.length} file(s), ${failures} failed, ${skipped} skipped.`);
// Worth saying out loud: a run where nothing could be checked is not a pass,
// even though it is not a failure either. Silence here reads as "all clear".
if (skipped === files.length && files.length > 0) {
console.warn('No files could be checked. The scan did not run for this commit.');
}
process.exit(failures > 0 ? 1 : 0);
Link to section: ConfigurationConfiguration
Set these as environment variables in the workflow step:
| Variable | Default | Description |
|---|---|---|
LOCKLLM_API_KEY | required | Your LockLLM API key, stored as a repository secret |
PROMPT_DIR | prompts | Directory to scan recursively |
PROMPT_EXTENSIONS | .txt,.md,.prompt | Comma-separated file extensions to include |
PROMPT_FILES | unset | Explicit space-separated file list. Takes precedence over PROMPT_DIR. |
LOCKLLM_SCAN_MODE | combined | normal for threats only, policy_only for your content policies only, combined for both |
LOCKLLM_SENSITIVITY | medium | low, medium, or high |
LOCKLLM_TIMEOUT_MS | 60000 | Per-file request timeout in milliseconds. A file that times out is skipped, not failed. |
Start with medium sensitivity. If legitimate prompt templates are being flagged, move to low for that repository rather than removing the check.
Link to section: Scanning Only Changed FilesScanning Only Changed Files
On large repositories, scanning every prompt on every run is wasteful. Restrict the scan to files changed in the pull request:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Collect changed prompt files
id: changed
run: |
FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'prompts/**' | tr '\n' ' ')
echo "files=$FILES" >> "$GITHUB_OUTPUT"
- name: Scan changed prompt files
if: steps.changed.outputs.files != ''
env:
LOCKLLM_API_KEY: ${{ secrets.LOCKLLM_API_KEY }}
PROMPT_FILES: ${{ steps.changed.outputs.files }}
run: node .github/scripts/lockllm-scan.mjs
The script already prefers PROMPT_FILES when it is set, so no change is needed.
Link to section: GitLab CIGitLab CI
The same script works unchanged. Add the API key as a masked CI/CD variable:
lockllm-scan:
image: node:20
script:
- node .github/scripts/lockllm-scan.mjs
variables:
PROMPT_DIR: "prompts"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Link to section: Pre-Commit HookPre-Commit Hook
To catch issues before they even reach CI, run the same script from a pre-commit hook:
#!/bin/sh
# .git/hooks/pre-commit
CHANGED=$(git diff --cached --name-only --diff-filter=ACM -- 'prompts/*')
if [ -n "$CHANGED" ]; then
PROMPT_FILES="$CHANGED" node .github/scripts/lockllm-scan.mjs || exit 1
fi
Keep the CI check even if you use a hook. Local hooks can be bypassed with --no-verify, so CI remains the gate that actually enforces the policy.
Link to section: Choosing What to Fail OnChoosing What to Fail On
The script above fails on both threat detections and policy violations. Depending on your repository you may want different behavior:
- Fail on threats only - Set
LOCKLLM_SCAN_MODEtonormal. Useful when your content policies are tuned for end-user input rather than internal templates. - Fail on policy violations only - Set
LOCKLLM_SCAN_MODEtopolicy_only. Useful for repositories of user-facing copy, where brand and content rules matter more than injection. - Warn without failing - Change the final line to
process.exit(0)and rely on the log output. A good first step when introducing the check to an existing repository with a backlog of findings.
Link to section: CostCost
Scanning is free when nothing is found. You are only charged when a scan detects something, so a repository whose prompts are all clean costs nothing to check on every pull request. See pricing for detection rates.
Link to section: RelatedRelated
- API Reference - Full scan endpoint documentation
- Custom Content Policies - Define the rules CI enforces
- Red Teaming - Assess a running deployment, not just its source
- Best Practices - Broader guidance on securing LLM applications