Repository safety checks as a policy-controlled capability

May 30, 2026 · View on GitHub

Agents that can write files, open PRs, or publish artifacts need a clear pattern for running deterministic checks before a high-impact action. agent-kernel already models policy enforcement, capabilities, firewall redaction, and auditable tool calls — a repository-level check fits naturally as a capability that is invoked under explicit policy and recorded in the audit trace.

This page describes the pattern. The runnable companion is examples/repository_safety_check.py, which is deterministic, offline, and depends on no specific checker.

agent-kernel does not implement scanning logic and does not depend on any particular checker. The check is an adapter that shells out to a local command. The example uses a tiny embedded scanner so it runs in CI; in production you point it at a real tool such as VibeGuard.

The pattern

agent wants to publish


repo.code_safety_check  (READ capability → RepositoryCheckDriver shells out)

        ├─ clean  → grant + invoke repo.publish_artifact (WRITE)
        └─ findings → host blocks the publish; check result is still audited

Two capabilities:

CapabilitySafety classBacked byRole
repo.code_safety_checkREADRepositoryCheckDriver (shells out to a checker)Runs a deterministic scan and returns findings.
repo.publish_artifactWRITEany execution driverThe high-impact action, gated behind a passing check.

The shell-out adapter

RepositoryCheckDriver implements the Driver protocol and runs the configured command as [*command, path]:

class RepositoryCheckDriver:
    def __init__(self, command: list[str], *, driver_id: str = "repo_safety") -> None:
        self._command = list(command)
        self._driver_id = driver_id

    @property
    def driver_id(self) -> str:
        return self._driver_id

    async def execute(self, ctx: ExecutionContext) -> RawResult:
        path = ctx.args.get("path")
        if not path:
            raise DriverError("repository check requires a 'path' argument ...")
        proc = await asyncio.create_subprocess_exec(
            *self._command, str(path),
            stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()
        if proc.returncode not in (0, 1):           # checker itself failed
            raise DriverError(f"...: {stderr.decode(errors='replace').strip()}")
        findings = json.loads(stdout or b"[]")      # [] = clean
        return RawResult(capability_id=ctx.capability_id, data=findings,
                         metadata={"exit_code": proc.returncode})

Conventions the adapter relies on (and most scanners follow):

  • Exit 0 = clean, exit 1 = findings present. Any other exit code is treated as the checker failing and surfaces as a DriverError.
  • Findings are a JSON array on stdout. Non-JSON output raises DriverError rather than being silently treated as "clean".

To use VibeGuard instead of the embedded scanner, construct the driver with its command, e.g. RepositoryCheckDriver(command=["vibeguard", "scan", "--json"]).

Reading the verdict from the Frame

The gate decision is made on the firewalled Frame, not on raw driver output. Invoke the check in table mode and treat a non-empty preview as a block:

frame = await kernel.invoke(token, principal=principal,
                            args={"operation": "code_safety_check", "path": path},
                            response_mode="table")
findings = list(frame.table_preview)
passed = not findings

When passed is False, the host simply does not grant repo.publish_artifact. Because the check ran through Kernel.invoke(), it produced an ActionTrace — so the decision to block is auditable via Kernel.explain(action_id), satisfying weaver-spec I-02.

Audit trail

Both the check and (when it passes) the publish are recorded:

check_trace = kernel.explain(check_action_id)      # always available
publish_trace = kernel.explain(publish_action_id)  # only when the check passed

Each ActionTrace records the capability, principal, driver, and timestamp for the step. It also carries a redaction-safe result_summary derived from the firewalled Frame (counts and flags only — never raw findings), so the check's pass/block decision is recorded in the audit trail itself, not merely inferred from whether a later publish happened:

check_trace = kernel.explain(check_action_id)
blocked = (check_trace.result_summary or {}).get("row_count", 0) > 0

result_summary is built only from the post-Firewall Frame, so recording it never widens the I-01 boundary or leaks scanned content into the audit log. A reviewer can therefore confirm both that the publish was preceded by a check and what that check decided.

Non-goals

  • agent-kernel does not implement scanning logic.
  • VibeGuard (or any checker) is never a required dependency.
  • The check does not bypass existing policy enforcement — it is additional to the normal grant/invoke pipeline.