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:

VariableDefaultDescription
LOCKLLM_API_KEYrequiredYour LockLLM API key, stored as a repository secret
PROMPT_DIRpromptsDirectory to scan recursively
PROMPT_EXTENSIONS.txt,.md,.promptComma-separated file extensions to include
PROMPT_FILESunsetExplicit space-separated file list. Takes precedence over PROMPT_DIR.
LOCKLLM_SCAN_MODEcombinednormal for threats only, policy_only for your content policies only, combined for both
LOCKLLM_SENSITIVITYmediumlow, medium, or high
LOCKLLM_TIMEOUT_MS60000Per-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_MODE to normal. Useful when your content policies are tuned for end-user input rather than internal templates.
  • Fail on policy violations only - Set LOCKLLM_SCAN_MODE to policy_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.

Updated 7 months ago