Lookout
Lookout is the guardrail layer that sits between agent inputs/outputs and the LLM. It is four independent scanners composed into a middleware:- injection — heuristic prompt-injection detector (role-override, system-prompt leak, jailbreak tropes, confusable unicode).
- args — JSON Schema validation of tool-call arguments before they reach the tool implementation.
- output — structured-output parser that strips markdown fences, validates against schema, and produces a corrective re-prompt.
- secrets — regex-based secrets redactor for outbound text.
guardrail.input_blocked or guardrail.output_blocked into the Crew Journal so the action is auditable. The matched secret value is NEVER persisted in the entry payload — only the finding kind and a stable redacted detail string.
Verdicts
Input guard
Runs on every user/tool-result message before it reaches the LLM:injection.go):
The guard is wired into
llm.Middleware so every Complete() call is scanned. The Stream() path runs through the same lookoutCaller chain (internal/llm/middleware.go), scanning every user/tool message synchronously before the first token flows.
Per-routine action policy
The default is hard-block on any high/critical finding. Routines whose upstream produces text that occasionally trips the heuristic on benign content (translations, security write-ups, adversarial-prompt research) can opt into a softer mode via DSL:
The journal entry fires for ALL modes — log mode is observability, not “guard disabled”. The
GuardListener integration hook (below) fires for all modes too.
Sanitize uses offset-based replacement (Finding.Position + Finding.MatchEnd) so long matches and synthetic unicode findings (zero-width, RTL override) are properly redacted. An earlier substring-based implementation silently let those through.
Integration callback
Wire a callback to a notification target (Slack, PagerDuty, the hooks subsystem) so guardrail trips don’t just land in the journal:internal/pipeline/runner_llm.go) already wires this to hooks.Dispatch(EventOnGuardrailTriggered, ...) when both the DB and a journal emitter are available — see the on_guardrail_triggered hook event.
Output guard
The default output policy is sanitize-and-pass. The secrets scanner runs over every outbound text and returns a redacted copy alongside findings:sk-xxxx slipped through is too disruptive; redaction preserves the response while surfacing the finding in the journal. Callers that want hard-block semantics should re-scan the returned text and refuse downstream.
Note: the output guard is NOT wired into llm.Middleware. Scanning output there would mutate text while leaving the provider-reported token counts intact — a desync. Output scanning lives in the orchestrator streaming pipeline where text mutations are visible to the agent loop.
Secrets detectors
Defined insecrets.go:
The stored finding carries
kind and a redacted detail like "openai API key (prefix: sk-...)". The raw match is never emitted.
Tool-arg schema validation
lookout.ValidateArgs(schema, args) runs a minimal JSON-Schema-shaped validator (type / properties / required / items / enum / additionalProperties) over a tool call’s arguments. schema is a lookout.Schema struct, not a raw JSON string. Use it before dispatching:
ValidateArgs returns error (nil on pass) — concretely *ArgsInvalidError,
whose Path points at the offending dotted JSON path. Type mismatches, missing
required fields, and out-of-enum values all produce a non-nil error;
unknown keys are rejected only when the schema sets additionalProperties: false. Empty schema (Type == "") = pass.
Adding a detector
Add a newKind constant in types.go, a regex/detector function in the relevant layer file (injection.go, secrets.go, output.go), and register it in the scanner’s internal rule list. Tests in lookout_test.go use table-driven cases — add one for every new kind.
Guidelines:
- Detectors must be pure (no network, no state). The one exception, Lakera, is gated behind an explicit
WithLakeraAPIKeyoption. - Never put the raw matched value in the
Finding.Matchedfield for a secret detector. Use a prefix or a kind string. - Severity should reflect operational risk, not “how confident the regex is”. A false-positive
secret_anthropicatwarnis fine; one atcriticalwould flood oncall.
Gotchas
More edge cases
More edge cases
- Scope is required.
emitGuardEntrysilently no-ops iflookout.ScopeFromContext(ctx)returns zero. Always wrap your request context withlookout.WithScope(ctx, scope)before invoking the guards — the HTTP handler chain does this for you. - Sanitize verdict is not a block. The returned text is the safe version; the
erris non-nil only if the journal emit fell over.
Related
- LLM middleware — where
InputGuardis composed. - Harbormaster — complementary: approvals gate the action, Lookout sanitises the content.
- Crew Journal —
guardrail.input_blocked/guardrail.output_blocked.