Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26.9"
go-version: "1.26.10"
check-latest: true

- name: go mod tidy
Expand Down Expand Up @@ -74,12 +74,14 @@ jobs:

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26.9"
go-version: "1.26.10"
check-latest: true

- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.12.2
# Recompute facts after cached runs misclassified terminating test guards.
skip-cache: true

# golangci-lint run does not execute formatters in v2; fmt does. This
# gates gofumpt (and gofmt) on top of the lint pass above, reusing the
Expand All @@ -98,7 +100,7 @@ jobs:

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26.9"
go-version: "1.26.10"
check-latest: true

# Each invocation selects one target, so keep every FuzzXxx function in
Expand Down Expand Up @@ -127,6 +129,11 @@ jobs:
- name: fuzz record output limit
run: go test -run='^$' -fuzz='^FuzzRecordLimit$' -fuzztime=30s ./internal/output/

- name: fuzz decision policy and response decoding
run: |
go test -run='^$' -fuzz='^FuzzPolicyDecode$' -fuzztime=30s ./internal/decision/
go test -run='^$' -fuzz='^FuzzUniqueJSON$' -fuzztime=30s ./internal/evaluator/

coverage:
name: coverage (informational)
runs-on: ubuntu-latest
Expand All @@ -138,7 +145,7 @@ jobs:

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26.9"
go-version: "1.26.10"
check-latest: true

# Non-gating: report total coverage for visibility, never fail the build
Expand Down Expand Up @@ -173,7 +180,7 @@ jobs:

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26.9"
go-version: "1.26.10"
check-latest: true

- name: install govulncheck
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26.9"
go-version: "1.26.10"
check-latest: true

- name: verify tag is reachable from main
Expand Down
14 changes: 10 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Contributing to numbat

numbat is a single Go module and binary. Contributions should preserve its
deterministic, endpoint-local design and the architecture boundaries below.
deterministic, endpoint-local defaults and the architecture boundaries below.
Optional decision evaluation is explicitly wired by `--decision-config`, not
implicit network access from loading a rule or running validation commands.

## Before you start

Expand All @@ -14,7 +16,7 @@ deterministic, endpoint-local design and the architecture boundaries below.

## Development setup

Use Go 1.26.9 or newer; CI pins 1.26.9.
Use Go 1.26.10 or newer; CI pins 1.26.10.

```sh
go build ./cmd/numbat
Expand Down Expand Up @@ -62,6 +64,8 @@ Every change must preserve these project contracts:
- **Observe and fail open by default.** numbat blocks only after an operator
enables enforce mode. A numbat error, panic, or uncertain result suppresses
its deny response; an agent host can still impose a stricter failure policy.
Optional evaluator failures suppress only that participant's contribution;
they must not erase a clean deterministic deny.
- **High precision; no fabrication.** Prefer a documented false negative to a
false positive. Emit only values supported by the source artifact or payload.
- **Verify vendor schemas with evidence.** Agent-adapter changes must cite
Expand All @@ -84,13 +88,15 @@ Every change must preserve these project contracts:
The `internal/archguard` tests enforce the package and dependency boundaries:

- **Core** (`applypatch`, `model`, `redact`, `rule`, `sequence`, `finding`,
`pipeline`, `output`, `winfile`) must not import a plane package.
`pipeline`, `output`, `winfile`, `decision`, `evaluator`) must not import a plane package.
- **Forensics** (`extract`, `discover`, `casebundle`) and **monitoring** (`hook`,
`otel`, `state`) remain independent; neither imports the other.
- Only `state` and `sequence` may import `bbolt`.
- In `cmd/numbat`, scan, timeline, and case files must not import the monitoring
plane or bbolt. Hook, collect, and ship files must not import the forensics
plane. `agents.go` is the sole cross-plane reporting bridge.
plane. `decisions.go` performs offline configuration validation without
provider I/O. `agents.go` is the sole cross-plane
reporting bridge.

The same tests reject cgo, unexpected direct dependencies, `replace` directives,
and unclassified non-test files in `cmd/numbat`. Raise any necessary boundary
Expand Down
29 changes: 18 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ pre-action blocking, and forensic reconstruction.
numbat observes supported desktop, CLI, IDE, and gateway agents through local
hooks and plugins, OTLP/HTTP logs, and on-disk session artifacts. Live and
at-rest activity is normalized into one event model and evaluated by the same
CEL rule engine. Detection runs locally; records can be written to stdout or a
local file and optionally delivered over HTTP.
CEL rule engine. Detection runs locally by default; opt-in decision rules send
selected evidence to a configured model provider. Records can be written to
stdout or a local file and optionally delivered over HTTP.

The [coverage matrix](docs/agent-coverage.md#matrix) is authoritative for each
host and surface. Blocking is off by default and limited to supported
Expand All @@ -19,13 +20,19 @@ synchronous pre-action hooks; see the [enforcement guide](docs/enforcement.md).
- **Endpoint-local detection** with built-in CEL rules, multi-step sequence rules,
and custom YAML rules.
- **Opt-in blocking** through supported pre-action hooks. Enforce mode is
disabled by default and applies only to rules marked `enforce: true`; all
shipped rules are monitor-only.
disabled by default and requires an enforcement-enabled rule; all shipped
rules are monitor-only.
- **Experimental decision rules** for action-only or session-context review
through an explicitly configured HTTPS provider. They use the
[ordinary rule catalog](docs/decision-rules.md) in hooks and
explicitly opted-in scans/collection. Default processing remains local.
Inspect configuration with `numbat decisions check --config FILE --rules-dir DIR`
without model calls.
- **Forensic reconstruction** from supported on-disk session artifacts, without
prior numbat instrumentation.
- **Versioned NDJSON records** for events, findings, enforcement decisions,
- **Versioned NDJSON records** for events, findings, enforcement decisions, assessments,
indicators, and scan summaries. Events and findings retain source references;
[JSON Schemas](docs/schema/v0.5.0/) define the wire format.
[JSON Schemas](docs/schema/v0.6.0/) define the wire format.
- **Read-only artifact scanning** with secret redaction. Normal record output
never includes a complete raw transcript; adding raw evidence files to a case
bundle is opt-in.
Expand All @@ -40,7 +47,7 @@ synchronous pre-action hooks; see the [enforcement guide](docs/enforcement.md).

[Download a release](https://github.com/perplexityai/numbat/releases) for macOS, Linux,
or Windows on amd64 or arm64. Each release includes SHA-256 checksums. You can
also install with Go 1.26.9 or newer:
also install with Go 1.26.10 or newer:

```
go install github.com/perplexityai/numbat/cmd/numbat@latest
Expand Down Expand Up @@ -140,7 +147,7 @@ and execution context. It also matches the high-severity cloud-metadata rule.
"project_path": "/workspace/acme-api",
"record_type": "event",
"run_id": "run-20260724T151125.690671167-fa0a4148090fa1ba",
"schema_version": "0.5.0",
"schema_version": "0.6.0",
"session_id": "agent:research:metadata-review",
"source_agent": "openclaw",
"source_type": "hook",
Expand Down Expand Up @@ -189,7 +196,7 @@ action completed.
"rule_id": "chain.secret_read_then_egress",
"rule_version": "1.4",
"run_id": "run-20260724T144025.562634000-e18f9d375ddb1c1b",
"schema_version": "0.5.0",
"schema_version": "0.6.0",
"session_id": "readme-live-sequence-01",
"severity": "high",
"source_agent": "claude-code",
Expand Down Expand Up @@ -234,7 +241,7 @@ matched the rule and numbat selected the agent-specific deny response. See
"persistence.ssh_authorized_keys"
],
"run_id": "run-20260724T134723.452402000-6886c86cefad57b8",
"schema_version": "0.5.0",
"schema_version": "0.6.0",
"session_id": "sess-doc-codex-enforce-01",
"source_agent": "codex",
"source_type": "hook",
Expand Down Expand Up @@ -284,7 +291,7 @@ reference: [docs/cli.md](docs/cli.md).
- [Enforcement](docs/enforcement.md): blocking semantics and failure behavior.
- [Rules](docs/rules.md): custom rule format, CEL fields, tests, and sequences.
- [Built-in rules](docs/rule-catalog.md): shipped detection coverage.
- [Record schemas](docs/schema/v0.5.0/): JSON Schemas for the current wire format.
- [Record schemas](docs/schema/v0.6.0/): JSON Schemas for the current wire format.

## Scope

Expand Down
48 changes: 44 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ environment values, credentials, and MCP configuration.
- Artifact scans and timelines are read-only. numbat never executes an agent,
package manager, or command found in an artifact. Explicit commands can write
hook configuration, output files, sequence state, and case bundles.
- numbat makes no outbound request unless an operator configures an HTTP sink or
runs `ship`. Non-loopback HTTP destinations require HTTPS unless
- numbat makes no outbound request unless an operator configures an HTTP sink,
runs `ship`, or explicitly enables a decision evaluator through
`--decision-config`. Decision evaluation requires HTTPS; the flag authorizes
transmission of projected tool arguments and results
to the configured model provider. Ordinary rules and offline commands do not
enable it. Non-loopback HTTP sink destinations require HTTPS unless
`--http-allow-insecure` is set explicitly.
- `collect` binds to `127.0.0.1:4318` by default. It provides no client
authentication or TLS when bound to another interface. Do not expose it to an
Expand Down Expand Up @@ -56,6 +60,11 @@ environment values, credentials, and MCP configuration.
- Sequence state can contain event identifiers, evidence references, tags,
confidence, and timestamps. Protect the state database and output files as
sensitive endpoint data.
- Opt-in session decision evaluation additionally retains its bounded inference
projection in the state database. Its directory and database must be private
(0700/0600 on Unix, protected DACLs on Windows). Logical retention does not securely erase bbolt
pages. Raw inference egress is separate from raw output and requires explicit
configuration; redaction is not declassification.
- Copying raw evidence into a case bundle is opt-in. Raw and redacted bundles
can still contain secrets or private content.

Expand All @@ -65,13 +74,44 @@ environment values, credentials, and MCP configuration.
User-scoped configuration, state, and output remain mutable or readable by
that user unless stronger OS controls protect them.
- Monitor mode is the default. Enforce mode is opt-in, applies only to supported
synchronous pre-action hooks and rules marked `enforce: true`, and asks the
agent host to deny an action. The host remains the enforcement point.
synchronous pre-action hooks and enforcement-enabled rules, and asks the
agent host to deny an action.
The host remains the enforcement point.
- A decoding error, relevant evaluation error, or output failure suppresses
numbat's deny response. Unavailable sequence state prevents a sequence-based
deny but does not suppress an independent clean stateless deny. The host can
still prompt, deny, or fail closed under its own hook contract. See
[docs/enforcement.md](docs/enforcement.md).
- An optional evaluator error, timeout, malformed answer, or abstention
contributes no semantic deny and never cancels an independently proven
deterministic deny. A model's non-matching answer is not host authorization.
Semantic enforcement requires rule and global enforcement opt-ins,
complete input and, for contextual rules, complete/fresh observed history.
It uses the existing host-control contract. See the
[decision rule guide](docs/decision-rules.md).
- Decision configuration and credentials must be protected from other users.
Protect rule directories as for deterministic enforcement. Current-user
ownership is supported and offers no protection against that same user.
Even root-owned credentials readable by a hook are readable
by other processes with its OS identity; use a narrowly scoped credential.
Automatic rule/request digests bind the loaded definition and support audit;
they are not signatures, approval records or a substitute for file ownership.
- Hook payloads and writable evidence state are not authenticated against the
agent's OS identity. A same-user process can forge observations, alter context,
or force fail-open. Protected configuration and random state generations do
not make the captured prefix tamper-resistant.
- Complete-prefix status describes observed callback consistency, not guaranteed
delivery. A proposal lost before state observation may remain invisible until
an unpaired outcome exposes it; a fully lost callback cannot be inferred.
Contextual evaluation is best-effort, not a gap-free execution ledger.
- Identified delegation invalidates parent contextual enforcement where the
adapter can establish the parent. Known child histories are separate. Partial
parent/child assessments remain available. A malformed `agent_id` or a child
boundary without identity records a gap, not main-agent evidence. An ordinary
tool callback without any child marker relies on the host's identity contract.
Child and background action-view rules use the ordinary pre-action gate;
their effects cannot establish a complete parent history. Unknown lineage
and lost callbacks remain capture limitations, not a containment boundary.
- Hook input is limited to 4 MiB. Oversized or malformed input produces a
diagnostic and no numbat deny response.
- `hook status` verifies numbat-owned configuration on disk; it does not prove
Expand Down
3 changes: 3 additions & 0 deletions cmd/numbat/case.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ func runCaseBuild(args []string, stdout, stderr io.Writer) int {
if res.Enforcements > 0 {
summary += fmt.Sprintf(", %d enforcement decisions", res.Enforcements)
}
if res.Assessments > 0 {
summary += fmt.Sprintf(", %d assessments", res.Assessments)
}
summary += fmt.Sprintf(", %d events", res.Events)
if mode != casebundle.EvidenceNone {
summary += fmt.Sprintf(", %d evidence files", res.EvidenceFiles)
Expand Down
50 changes: 37 additions & 13 deletions cmd/numbat/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"google.golang.org/protobuf/encoding/protowire"

"github.com/perplexityai/numbat/internal/evaluator"
"github.com/perplexityai/numbat/internal/finding"
"github.com/perplexityai/numbat/internal/otel"
"github.com/perplexityai/numbat/internal/output"
Expand All @@ -46,6 +47,11 @@ const contentTypeProtobuf = "application/x-protobuf"
// exhaust memory. 4 MiB comfortably holds a normal export batch.
const maxOTLPBody = 4 << 20

// Optional network classification must not consume a fresh deadline for every
// record in a large export. Leave room under the server's 30s write timeout for
// local processing and output; this does not bound those independent operations.
const collectDecisionBudget = 25 * time.Second

// runCollect implements `numbat collect`: a long-running, in-process OTLP/HTTP
// receiver. It listens on a loopback address (default :4318), decodes each
// incoming OTLP logs export, maps every record into a model.Event
Expand Down Expand Up @@ -77,6 +83,7 @@ func runCollect(args []string, stdout, stderr io.Writer) int {
httpGzip := fs.Bool("http-gzip", false, "gzip the HTTP POST body")
var rf ruleFlags
rf.register(fs)
decisionConfig := fs.String("decision-config", "", "enable model classification using this provider configuration; sends selected evidence to its endpoint")
fs.Usage = func() {
fmt.Fprintln(stderr, "usage: numbat collect [--addr 127.0.0.1:4318] [--emit KIND ...] [--content preview|full|raw] [--content-scope all|messages] [--output SINK ...] [--case-id ID] [--rules-dir DIR ...] [--no-builtin-rules]")
fmt.Fprintln(stderr, "\nReceives live OTLP/HTTP protobuf logs from supported AI agents and emits")
Expand Down Expand Up @@ -163,12 +170,13 @@ func runCollect(args []string, stdout, stderr io.Writer) int {
rid := runID()
em := output.NewWithSink(sink, stderr, rid, contentEmitterOptions(content, scope, *maxRecordBytes)...)
rcv, err := newCollector(collectorConfig{
emit: em,
runID: rid,
caseID: *caseID,
sel: sel,
ruleDirs: rf.dirs,
noBuiltin: rf.noBuiltin,
emit: em,
runID: rid,
caseID: *caseID,
sel: sel,
ruleDirs: rf.dirs,
noBuiltin: rf.noBuiltin,
decisionConfig: *decisionConfig,
})
if err != nil {
fmt.Fprintf(stderr, "collect: %v\n", err)
Expand Down Expand Up @@ -274,12 +282,13 @@ func warnIfOffLoopback(addr string, stderr io.Writer) {

// collectorConfig carries the resolved dependencies a collector needs.
type collectorConfig struct {
emit *output.Emitter
runID string
caseID string
sel emitSelection
ruleDirs multiFlag
noBuiltin bool
decisionConfig string
emit *output.Emitter
runID string
caseID string
sel emitSelection
ruleDirs multiFlag
noBuiltin bool
}

// collector holds the per-receiver state: the shared pipeline every record flows
Expand Down Expand Up @@ -331,6 +340,19 @@ func newCollector(cfg collectorConfig) (*collector, error) {
Findings: cfg.sel.findings,
Indicators: cfg.sel.indicators,
}, finding.Options{}, c.indicators)
if cfg.decisionConfig != "" {
if !cfg.sel.findings {
return nil, fmt.Errorf("--decision-config requires findings output")
}
decisions, err := evaluator.LoadConfig(cfg.decisionConfig)
if err == nil {
err = decisions.ConfigureRules(eng.DecisionRules())
}
if err != nil {
return nil, err
}
c.pipe.WithDecisionObserver(decisions.Monitor(cfg.emit))
}
// Sequence rules correlate across the receiver's lifetime: one tracker for
// the whole run, internally locked, so the concurrent OTLP handlers feed
// session windows safely. Only built when findings are evaluated and the
Expand All @@ -351,6 +373,8 @@ func newCollector(cfg collectorConfig) (*collector, error) {
// failure is a client error (the exporter sent a malformed batch), never a
// crash.
func (c *collector) handleLogs(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), collectDecisionBudget)
defer cancel()
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeOTLPError(w, http.StatusMethodNotAllowed, "method not allowed: use POST")
Expand Down Expand Up @@ -402,7 +426,7 @@ func (c *collector) handleLogs(w http.ResponseWriter, r *http.Request) {
if ev.CaseID == "" {
ev.CaseID = c.caseID
}
if err := c.pipe.Process(ev, "otel:"+res.SourceAgent); err != nil {
if err := c.pipe.ProcessContext(ctx, ev, "otel:"+res.SourceAgent); err != nil {
c.skippedN.Add(1)
rejected++
c.emit.Diag("warn", "collect: "+err.Error())
Expand Down
Loading
Loading