PeerLabs plsec
Design principles

Six rules the architecture follows

Engine as coordination layer, tools as adapters

plsec is not a scanner. It orchestrates scanners. Each external tool is an adapter behind a common engine interface.

Policy/mechanism separation

"What to check" (rules, thresholds, patterns) is distinct from "how to check" (scanner invocation, parsing, normalisation). Policy is declarative. Mechanism is imperative.

Findings as typed intermediate representation

All engines produce Finding objects. The correlation engine and reporting layer consume Finding objects. No scanner-specific output leaks past the adapter boundary.

Layer as engine group, not just a label

Each of the 5 layers is an EngineGroup containing one or more engines. The orchestrator walks layers in order, feeding prior findings forward.

Presets determine execution mode, not just scan scope

At strict/paranoid, the agent runs inside a container automatically. The preset is the single knob the user turns; everything else follows from it.

Graceful degradation by default

Missing tools degrade to a skip, not an error. The engine reports what it could not check, so the operator knows the gap.

Core abstractions

Seven types the system is built on

The engine architecture is implemented across 13 modules in src/plsec/engine/ (2,815 lines). These are the core abstractions.

Finding

engine/types.py

Universal intermediate representation. Frozen dataclass with deterministic ID via content hash. with_severity() and with_suppressed() for safe copies. Every scanner output normalises to this.

Engine

engine/base.py

Abstract base for detection/control engines. Stateless. Declares engine_id, layer, presets, dependencies. Methods: check_available(ctx), execute(ctx).

EngineGroup

engine/base.py

Manages engines within a single security layer. Filters by preset, gates on availability. Handles parallel or sequential execution within the layer.

Orchestrator

engine/orchestrator.py

Scan lifecycle coordinator. Detects environment, builds context, walks layers, correlates, applies policy, computes verdict. The entry point for plsec scan.

Policy

engine/policy.py

Post-detection filter. Answers "which findings matter?" Applies severity floor and suppression rules. Separates detection from judgment.

Verdict

engine/verdict.py

Outcome interpreter. Three strategies: ThresholdVerdictStrategy (minimal/balanced), StrictVerdictStrategy (strict/paranoid), AuditVerdictStrategy (audit mode).

CorrelationEngine

engine/correlation.py

Cross-layer risk detection. Runs after all layers, produces synthetic findings for compound risks. Example: secret found + no egress control = CRITICAL.

EngineRegistry

engine/registry.py

Central catalog. register(), group_for(layer), get(id), build_default_registry(). Adding a scanner means adding one registry entry.

Engine pipeline

How a scan executes

The Orchestrator resolves the active preset, builds an engine plan from the registry, checks tool availability, then walks the layer pipeline. Each layer's findings flow forward to downstream layers. After all layers complete, the Correlation Engine detects compound risks, Policy filters by severity and suppressions, and the Verdict strategy interprets the result.

plsec.yaml + preset resolution
  |
  v
Orchestrator
  |  1. Load config + resolve preset
  |  2. Build engine plan from Registry
  |  3. Check availability (report gaps)
  v
Layer Pipeline (walks in order, feeds findings forward)
  |
  |  Layer 1: STATIC   ── TrivySecrets, Bandit, Semgrep
  |  Layer 2: CONFIG   ── TrivyMisconfig
  |  Layer 3: ISOLATION ── ContainerIsolation
  |  Layer 4: RUNTIME  ── (planned)
  |  Layer 5: AUDIT    ── (planned)
  |
  v
Correlation Engine
  |  Cross-layer compound risk detection
  |  (secret + no egress control = CRITICAL)
  v
Policy
  |  Severity floor + suppression rules
  v
Verdict  ──  PASS / WARN / FAIL / ERROR
Implemented engines

5 engines at 100% test coverage

Each engine implements the Engine abstract base: declare dependencies, check availability, execute scan, return Finding objects. Detection engines (Layers 1-2) scan for vulnerabilities. Control validation engines (Layers 3-5) verify security infrastructure is in place.

Engine ID Layer Type Purpose
trivy-secrets STATIC Detection Leaked credentials, API keys, tokens in source files
bandit STATIC Detection Python static analysis (B-class security issues)
semgrep STATIC Detection Multi-language SAST (pattern-based security rules)
trivy-misconfig CONFIG Detection IaC misconfiguration (Dockerfiles, Kubernetes, Terraform)
container-isolation ISOLATION Control Container runtime and config check (Podman/Docker presence)
Planned engines (7 remaining)
Engine Layer Priority Purpose
DependencyEngine STATIC High pip-audit wrapper for dependency vulnerability scanning
AgentConstraintEngine CONFIG High Validates CLAUDE.md/opencode.json deploy and preset match
DenyPatternEngine CONFIG Medium File/command deny list verification
EgressProxyEngine RUNTIME Medium Validates Pipelock network proxy (strict/paranoid)
AuditLogEngine AUDIT Medium Verifies structured audit logging is active
SandboxEngine ISOLATION Low macOS sandbox-exec check
DLPEngine RUNTIME Low Data loss prevention on agent responses (paranoid only)
Preset mapping

What runs at each security level

The preset determines both which engines scan and how the agent executes. At strict/paranoid, plsec run starts the agent inside a container automatically. The user sets the preset; everything else follows.

Engine Type Minimal Balanced Strict Paranoid
TrivySecrets Detection x x x x
Bandit Detection x x x
Semgrep Detection x x x
TrivyMisconfig Detection x x x
ContainerIsolation Control x x
Execution mode
Preset Execution Network Verdict strategy Fail threshold
minimal Host Unrestricted Threshold CRITICAL
balanced Host Unrestricted Threshold HIGH
strict Container Restricted Strict Any finding
paranoid Container Egress proxy Strict Any finding
Defensive patterns

plsec is a security tool -- never trust external output

All engines invoking external tools use the extract_json() pattern. External scanners contaminate stdout with progress bars, status messages, and ANSI escapes. The three-state handler survives this:

# Three-state JSON extraction (engine/base.py)

def extract_json(stdout: str, returncode: int) -> dict | None:
    # State 1: Clean JSON in stdout
    #   Parse normally, return findings
    # State 2: No JSON, exit 0
    #   Clean scan (no findings), return empty
    # State 3: No JSON, non-zero exit
    #   Tool failure -- produce a Finding of
    #   category TOOL_ERROR, don't crash

    # Handles: progress bars before JSON,
    # status lines mixed with output,
    # ANSI escapes in error messages

This is documented in detail in docs/secure-tool-handling.md. Every engine test suite covers JSON parsing failures, non-zero exit codes, and prefixed stdout recovery.

Codebase (as of March 2026)

Module inventory and test coverage

45
Python modules
11,645
Lines of source
1,516
Tests passing
5 / 12
Engines (100% cov)
Module breakdown
Subsystem Modules Lines Scope
engine/ 13 2,815 Scan pipeline: types, base, registry, orchestrator, 5 engines, policy, correlation, verdict
core/ 15 3,574 Business logic: config, tools, agents, processes, health, inventory, detector, adapters
commands/ 12 3,734 CLI modules: 11 subcommands as thin wrappers over core
configs/ 3 1,418 Embedded templates: CLAUDE.md, opencode.json, trivy configs, wrappers
Test suites
Suite Count Scope
pytest 1,232 Python CLI across 3 tiers: pure logic, filesystem, subprocess mocking
BATS unit 152 Bootstrap shell script unit tests
BATS integration 88 Bootstrap end-to-end tests
Assembler 44 Template escaping edge cases
Registry model

Declare once, consume everywhere

Beyond the engine registry, plsec uses a registry-driven architecture for all entities. Agents, scanners, and processes are declared as dataclasses in registry modules. Commands iterate registries; they do not hardcode entity knowledge. Adding a new AI agent means adding one AgentSpec entry. No if/elif changes in command files.

This was the result of a deliberate refactoring driven by testing: Phase 3 subprocess-mocking tests exposed structural problems (scattered metadata, 35+ files to change for a new agent). The registries reduced "add a new agent" to a single file change.

# Three registries, three entity types

AGENTS: dict[str, AgentSpec]       # claude, opencode, gemini...
SCANNERS: dict[str, ScannerSpec]   # trivy-secrets, bandit, semgrep...
PROCESSES: dict[str, ProcessSpec]  # pipelock, container runtimes...

# Commands iterate registries:
for agent in resolve_agent_ids(config.agent.type):
    spec = AGENTS[agent]
    template = spec.get_template(preset)
    deploy(project / spec.config_filename, template)