How the Security Guards CI Job Validates Bash Command Permissions in ai-job-search

The security guards CI job validates bash command permissions by comparing entries in .claude/settings.json against a static ALLOWED_PERMISSIONS whitelist defined in tools/security_guards.py, failing the build if any unauthorized command pattern is detected.

The MadsLorentzen/ai-job-search repository uses Claude Code for AI-assisted development, requiring strict controls over which Bash commands can execute without user prompts. The security guards CI job validates bash command permissions by cross-referencing runtime configurations against a static whitelist before allowing any merge.

CI Workflow Configuration

The validation process begins in .github/workflows/ci.yml at lines 54–63, where the security-guards job is defined. This job checks out the repository, configures Python 3.12, and executes the validation script.


# .github/workflows/ci.yml

security-guards:
  name: Security guards (permissions, gitignore, manifests)
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-python@v5
      with:
        python-version: "3.12"
    - run: python tools/security_guards.py

The Permission Allow-List

Inside tools/security_guards.py, the constant ALLOWED_PERMISSIONS (lines 37–47) defines the complete set of pre-approved Bash command patterns and skills that may run without prompting. This static whitelist acts as the source of truth for all automated permission grants.


# tools/security_guards.py

ALLOWED_PERMISSIONS = {
    "Skill(job-application-assistant)",
    "Bash(bun run:*)",
    "Bash(python salary_lookup.py:*)",
    "Bash(python3 salary_lookup.py:*)",
    "Bash(python tools/verify_pdf.py:*)",
    "Bash(python3 tools/verify_pdf.py:*)",
    "Bash(pdftotext:*)",
}

Each entry follows the pattern Bash(command:*) or Skill(name), where wildcards allow argument variations while restricting the base command execution.

Validation Logic in check_permissions()

The check_permissions() function (lines 49–99) performs the actual validation by reading .claude/settings.json and verifying each entry in the permissions.allow array against the whitelist.

The function extracts the permissions array and iterates through every entry:


# tools/security_guards.py – core check

def check_permissions() -> None:
    path = ROOT / ".claude" / "settings.json"
    data = json.loads(path.read_text(encoding="utf-8"))
    allow = data.get("permissions", {}).get("allow", [])
    for entry in allow:
        if entry not in ALLOWED_PERMISSIONS:
            errors.append(
                f".claude/settings.json: permission not in the reviewed allowlist: {entry!r}. "
                "Pre‑approved permissions run without prompting on every fork. "
                "If this entry is intentional, add it to ALLOWED_PERMISSIONS in "
                "tools/security_guards.py in the same PR."
            )

If a permission entry is not found in ALLOWED_PERMISSIONS, the function appends an error message to the errors list, causing the CI job to fail and block the merge. Conversely, if the whitelist contains entries absent from settings.json, the script prints a non-fatal note (lines 95–99) indicating stale allow-list items.

How to Add and Validate New Bash Command Permissions

To authorize a new Bash command, you must update both the configuration file and the Python whitelist in the same pull request. For example, to add scripts/cleanup.sh:

First, update .claude/settings.json:

{
  "permissions": {
    "allow": [
      "Skill(job-application-assistant)",
      "Bash(bun run:*)",
      "Bash(python salary_lookup.py:*)",
      "Bash(python3 salary_lookup.py:*)",
      "Bash(python tools/verify_pdf.py:*)",
      "Bash(python3 tools/verify_pdf.py:*)",
      "Bash(pdftotext:*)",
      "Bash(scripts/cleanup.sh:*)"
    ]
  }
}

Then, add the pattern to ALLOWED_PERMISSIONS in tools/security_guards.py:

ALLOWED_PERMISSIONS = {
    "Skill(job-application-assistant)",
    "Bash(bun run:*)",
    "Bash(python salary_lookup.py:*)",
    "Bash(python3 salary_lookup.py:*)",
    "Bash(python tools/verify_pdf.py:*)",
    "Bash(python3 tools/verify_pdf.py:*)",
    "Bash(pdftotext:*)",
    "Bash(scripts/cleanup.sh:*)",   # ← newly added entry

}

When the CI security-guards job re-runs, it detects the matching entries in both locations and passes validation.

Summary

  • The security guards CI job runs automatically on every pull request via .github/workflows/ci.yml.
  • It executes tools/security_guards.py to validate that all permissions in .claude/settings.json appear in the static ALLOWED_PERMISSIONS whitelist.
  • Unauthorized commands trigger immediate CI failures, preventing merges until the whitelist is updated.
  • Dual updates are required: both settings.json and security_guards.py must be modified in the same PR to authorize new Bash commands.
  • This architecture ensures that every auto-approved command undergoes code review before deployment to forked repositories.

Frequently Asked Questions

What triggers a failure in the security guards CI job?

The CI job fails when .claude/settings.json contains a permission entry not present in the ALLOWED_PERMISSIONS set defined in tools/security_guards.py. The check_permissions() function appends an error to the validation log, causing the Python script to exit with a failure status that blocks the GitHub Actions workflow.

Can I add wildcards to Bash command permissions?

Yes, the whitelist supports wildcard patterns such as Bash(bun run:*) and Bash(python salary_lookup.py:*). These patterns allow variable arguments while restricting execution to specific base commands, enabling flexibility without compromising security boundaries.

Why must I update both settings.json and security_guards.py?

The repository enforces a two-source verification model to ensure all auto-approved commands undergo explicit review. Updating only settings.json causes the CI job to fail because the new permission appears unauthorized. This design prevents accidental privilege escalation and guarantees that every approved command is tracked in version-controlled source code.

Where does the CI job get the list of allowed permissions?

The CI job derives allowed permissions from the ALLOWED_PERMISSIONS constant in tools/security_guards.py (lines 37–47). This Python set contains the master whitelist that the check_permissions() function compares against the runtime configuration stored in .claude/settings.json.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →