Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ The alpha includes ten explainable rules:
- Detection is heuristic and should be reviewed by a human.
- The alpha scans local repositories and configuration files; GitHub, GitLab, Docker and Kubernetes connectors are not implemented yet.
- No runtime proxy, IAM remediation, hosted control plane, dashboard or compliance certification is included.
- JSON and SARIF are the stable report formats for the current slice; CSV and issue export are planned.
- JSON and SARIF are stable report formats for static and runtime findings; CSV and issue export are planned.

SARIF is available now. Use `--fail-on high` or `--fail-on critical` to make findings a CI gate; the default `none` keeps scans informational.
Use `agentctl --help`, `agentctl scan --help`, or `agentctl init --help` for the supported command surface.
Expand Down
33 changes: 31 additions & 2 deletions cmd/agentctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ Scan flags:
Runtime audit flags:
--source format input source: jsonl, otel-json or api-gateway
--fail-on severity return non-zero at this severity or higher
--format format output format: text or json
--format format output format: text, json or sarif
--baseline file suppress findings already present in a runtime JSON report
--inventory file static agentctl JSON report to compare with runtime events
--output file write the audit report to a file instead of stdout
--suppressions file suppress active runtime findings with reason and expiry

The scanner is read-only and metadata-only. It does not execute scanned content.
`
Expand Down Expand Up @@ -403,7 +405,9 @@ func runRuntimeAudit(args []string, stdout, stderr io.Writer) error {
inventoryPath := fs.String("inventory", "", "static agentctl JSON report")
format := fs.String("format", "text", "output format: text or json")
failOn := fs.String("fail-on", "none", "return a non-zero exit code at this severity or higher")
baseline := fs.String("baseline", "", "suppress findings already present in a runtime JSON report")
output := fs.String("output", "", "write the audit report to a file instead of stdout")
suppressions := fs.String("suppressions", "", "suppress active runtime findings with reason and expiry")
if err := fs.Parse(args[1:]); err != nil {
return err
}
Expand All @@ -413,7 +417,7 @@ func runRuntimeAudit(args []string, stdout, stderr io.Writer) error {
if *source != string(runtime.SourceJSONL) && *source != string(runtime.SourceOTelJSON) && *source != string(runtime.SourceAPIGateway) {
return fmt.Errorf("unsupported runtime source %q", *source)
}
if *format != "text" && *format != "json" {
if *format != "text" && *format != "json" && *format != "sarif" {
return fmt.Errorf("unsupported runtime audit format %q", *format)
}
if !validSeverity(*failOn) {
Expand All @@ -439,12 +443,37 @@ func runRuntimeAudit(args []string, stdout, stderr io.Writer) error {
return fmt.Errorf("decode inventory: %w", err)
}
audit := runtime.Audit(runtimeReport, inventory)
if *baseline != "" {
baselineFile, err := os.Open(*baseline)
if err != nil {
return fmt.Errorf("open runtime baseline: %w", err)
}
defer baselineFile.Close()
if err := runtime.ApplyBaseline(&audit, baselineFile); err != nil {
return err
}
}
if *suppressions != "" {
suppressionFile, err := os.Open(*suppressions)
if err != nil {
return fmt.Errorf("open runtime suppressions: %w", err)
}
defer suppressionFile.Close()
if err := runtime.ApplySuppressions(&audit, suppressionFile, time.Now()); err != nil {
return err
}
}
var payload []byte
if *format == "json" {
payload, err = json.MarshalIndent(audit, "", " ")
if err == nil {
payload = append(payload, '\n')
}
} else if *format == "sarif" {
payload, err = audit.SARIF()
if err == nil {
payload = append(payload, '\n')
}
} else {
payload = []byte(runtimeAuditText(audit))
}
Expand Down
4 changes: 2 additions & 2 deletions docs/runtime-audit-mvp.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ Runtime-аудит должен быть **наблюдателем, а не и

В репозитории реализован библиотечный слой `internal/runtime`, который выполняет безопасное чтение нормализованных JSONL-событий, детерминированную агрегацию и сопоставление со статическим `scan.Report`. Добавлены правила `ACP-R001` — `ACP-R004`, включая обнаружение undeclared target, фактического provider, production write activity и неоднозначного сопоставления агента.

Добавлена команда `agentctl runtime-audit <events> --inventory <report.json>`. Флаг `--source` выбирает `jsonl`, `otel-json` или `api-gateway`. Команда поддерживает text/json output, атомарную запись через существующий механизм `--output` и CI-порог `--fail-on`.
Добавлена команда `agentctl runtime-audit <events> --inventory <report.json>`. Флаг `--source` выбирает `jsonl`, `otel-json` или `api-gateway`. Команда поддерживает text/json/SARIF output, `--baseline`, expiring `--suppressions`, атомарную запись через существующий механизм `--output` и CI-порог `--fail-on`.

Адаптеры принимают только metadata-поля. OpenTelemetry spans преобразуются по атрибутам агента, инструмента, окружения, provider и HTTP-статуса. API Gateway записи поддерживают snake_case и camelCase идентификаторы, JSONL и JSON-массив. Payload запроса и ответа не читается и не переносится в нормализованное событие. Provider matching теперь выполняется на уровне конкретного агента: фактический provider считается заявленным только тогда, когда он принадлежит модели, указанной в `agent.models`.
Адаптеры принимают только metadata-поля. OpenTelemetry spans преобразуются по атрибутам агента, инструмента, окружения, provider и HTTP-статуса. API Gateway записи поддерживают snake_case и camelCase идентификаторы, JSONL и JSON-массив. Payload запроса и ответа не читается и не переносится в нормализованное событие. Provider matching теперь выполняется на уровне конкретного агента: фактический provider считается заявленным только тогда, когда он принадлежит модели, указанной в `agent.models`. Normalized JSONL отклоняет sensitive keys (`prompt`, `arguments`, `request_body`, `response_body`, `headers`, `authorization`, `token` и аналогичные) и ограничивает длину metadata fields.

Следующий этап — добавить адаптеры OpenTelemetry и API gateway, не меняя нормализованный контракт событий.

Expand Down
19 changes: 11 additions & 8 deletions internal/runtime/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ import (
)

type Finding struct {
ID string `json:"id"`
RuleID string `json:"rule_id"`
Severity string `json:"severity"`
Message string `json:"message"`
AgentID string `json:"agent_id,omitempty"`
Confidence float64 `json:"confidence"`
Evidence []string `json:"evidence,omitempty"`
RemediationHint string `json:"remediation_hint"`
ID string `json:"id"`
RuleID string `json:"rule_id"`
Severity string `json:"severity"`
Message string `json:"message"`
AgentID string `json:"agent_id,omitempty"`
Confidence float64 `json:"confidence"`
Evidence []string `json:"evidence,omitempty"`
RemediationHint string `json:"remediation_hint"`
Suppressed bool `json:"suppressed,omitempty"`
SuppressionReason string `json:"suppression_reason,omitempty"`
SuppressionExpiresAt string `json:"suppression_expires_at,omitempty"`
}

type AuditReport struct {
Expand Down
21 changes: 21 additions & 0 deletions internal/runtime/ingest.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ func ReadEvents(r io.Reader, options Options) ([]Event, int, error) {
if len(events) >= options.MaxEvents {
return nil, skipped, fmt.Errorf("runtime event limit exceeded: %d", options.MaxEvents)
}
if err := rejectSensitiveKeys(scanner.Bytes()); err != nil {
return nil, skipped, fmt.Errorf("validate runtime event %d: %w", len(events)+skipped+1, err)
}
var event Event
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
return nil, skipped, fmt.Errorf("decode runtime event %d: %w", len(events)+skipped+1, err)
Expand Down Expand Up @@ -68,5 +71,23 @@ func validateEvent(event Event) error {
if strings.TrimSpace(event.Target) == "" {
return fmt.Errorf("target is required")
}
if len(event.RequestID) > 512 || len(event.AgentID) > 512 || len(event.AgentName) > 512 || len(event.Operation) > 256 || len(event.Target) > 1024 || len(event.Provider) > 256 || len(event.Action) > 128 || len(event.Environment) > 128 {
return fmt.Errorf("runtime metadata field exceeds its size limit")
}
return nil
}

func rejectSensitiveKeys(data []byte) error {
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
for key := range fields {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
switch normalized {
case "prompt", "messages", "arguments", "toolarguments", "requestbody", "responsebody", "body", "headers", "authorization", "secret", "secrets", "apikey", "token":
return fmt.Errorf("sensitive field %q is not permitted", key)
}
}
return nil
}
54 changes: 54 additions & 0 deletions internal/runtime/priority2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package runtime

import (
"strings"
"testing"
"time"
)

func TestAuditSARIFIsDeterministic(t *testing.T) {
report := AuditReport{SchemaVersion: "runtime-audit.v1", Findings: []Finding{{ID: "runtime_1", RuleID: "ACP-R001", Severity: "High", Message: "undeclared target", AgentID: "a1", Confidence: 0.9, RemediationHint: "declare target"}}}
first, err := report.SARIF()
if err != nil {
t.Fatalf("SARIF() error = %v", err)
}
second, err := report.SARIF()
if err != nil {
t.Fatalf("SARIF() error = %v", err)
}
if string(first) != string(second) || !strings.Contains(string(first), "ACP-R001") || !strings.Contains(string(first), "error") {
t.Fatalf("unexpected SARIF output: %s", first)
}
}

func TestRuntimeBaselineAndSuppression(t *testing.T) {
report := AuditReport{SchemaVersion: "runtime-audit.v1", Findings: []Finding{{ID: "runtime_keep", RuleID: "ACP-R001", Severity: "High", Message: "keep"}, {ID: "runtime_old", RuleID: "ACP-R002", Severity: "High", Message: "old"}}}
baseline := `{"schema_version":"runtime-audit.v1","findings":[{"id":"runtime_old"}]}`
if err := ApplyBaseline(&report, strings.NewReader(baseline)); err != nil {
t.Fatalf("ApplyBaseline() error = %v", err)
}
if len(report.Findings) != 1 || report.Findings[0].ID != "runtime_keep" {
t.Fatalf("unexpected baseline result: %+v", report.Findings)
}
suppressions := `{"suppressions":[{"finding_id":"runtime_keep","reason":"accepted risk","expires_at":"2099-01-01T00:00:00Z"}]}`
if err := ApplySuppressions(&report, strings.NewReader(suppressions), time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)); err != nil {
t.Fatalf("ApplySuppressions() error = %v", err)
}
if !report.Findings[0].Suppressed || report.Findings[0].SuppressionReason != "accepted risk" {
t.Fatalf("unexpected suppression result: %+v", report.Findings[0])
}
}

func TestReadEventsRejectsSensitiveMetadataKeys(t *testing.T) {
input := `{"timestamp":"2026-01-02T03:04:05Z","agent_id":"a1","operation":"tool_call","target":"crm.search","success":true,"arguments":{"customer_id":"123"}}`
if _, _, err := ReadEvents(strings.NewReader(input), Options{}); err == nil {
t.Fatal("ReadEvents() accepted sensitive arguments field")
}
}

func TestReadEventsRejectsOversizedMetadata(t *testing.T) {
input := `{"timestamp":"2026-01-02T03:04:05Z","agent_id":"a1","operation":"tool_call","target":"crm.search","provider":"` + strings.Repeat("x", 257) + `","success":true}`
if _, _, err := ReadEvents(strings.NewReader(input), Options{}); err == nil {
t.Fatal("ReadEvents() accepted oversized provider")
}
}
86 changes: 86 additions & 0 deletions internal/runtime/sarif.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package runtime

import (
"encoding/json"
"sort"
)

const sarifSchema = "https://json.schemastore.org/sarif-2.1.0.json"

type sarifLog struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []sarifRun `json:"runs"`
}

type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}

type sarifTool struct {
Driver sarifDriver `json:"driver"`
}
type sarifDriver struct {
Name string `json:"name"`
Version string `json:"version"`
InformationURI string `json:"informationUri"`
Rules []sarifRule `json:"rules,omitempty"`
}
type sarifRule struct {
ID string `json:"id"`
Name string `json:"name"`
ShortDescription sarifMessage `json:"shortDescription"`
}
type sarifResult struct {
RuleID string `json:"ruleId"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Properties map[string]any `json:"properties,omitempty"`
Suppressions []sarifSuppression `json:"suppressions,omitempty"`
}
type sarifSuppression struct {
Kind string `json:"kind"`
Justification string `json:"justification,omitempty"`
}
type sarifMessage struct {
Text string `json:"text"`
}

// SARIF returns a deterministic SARIF 2.1.0 report for runtime findings.
func (r AuditReport) SARIF() ([]byte, error) {
ruleNames := make(map[string]string)
for _, finding := range r.Findings {
if _, exists := ruleNames[finding.RuleID]; !exists {
ruleNames[finding.RuleID] = finding.Message
}
}
rules := make([]sarifRule, 0, len(ruleNames))
for id, description := range ruleNames {
rules = append(rules, sarifRule{ID: id, Name: id, ShortDescription: sarifMessage{Text: description}})
}
sort.Slice(rules, func(i, j int) bool { return rules[i].ID < rules[j].ID })
results := make([]sarifResult, 0, len(r.Findings))
for _, finding := range r.Findings {
properties := map[string]any{"agent_id": finding.AgentID, "confidence": finding.Confidence, "remediation_hint": finding.RemediationHint}
result := sarifResult{RuleID: finding.RuleID, Level: sarifLevel(finding.Severity), Message: sarifMessage{Text: finding.Message}, Properties: properties}
if finding.Suppressed {
result.Suppressions = []sarifSuppression{{Kind: "external", Justification: finding.SuppressionReason}}
properties["suppression_expires_at"] = finding.SuppressionExpiresAt
}
results = append(results, result)
}
payload := sarifLog{Schema: sarifSchema, Version: "2.1.0", Runs: []sarifRun{{Tool: sarifTool{Driver: sarifDriver{Name: "Agent Control Plane Runtime Audit", Version: "runtime-audit.v1", InformationURI: "https://github.com/HlinorAI/agent-control-plane", Rules: rules}}, Results: results}}}
return json.MarshalIndent(payload, "", " ")
}

func sarifLevel(severity string) string {
switch severity {
case "Critical", "High":
return "error"
case "Medium":
return "warning"
default:
return "note"
}
}
96 changes: 96 additions & 0 deletions internal/runtime/suppressions.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package runtime

import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
)

type suppressionEntry struct {
FindingID string `json:"finding_id"`
Reason string `json:"reason"`
ExpiresAt string `json:"expires_at"`
}
type suppressionDocument struct {
Suppressions *[]suppressionEntry `json:"suppressions"`
}

func ApplyBaseline(report *AuditReport, baseline io.Reader) error {
if report == nil {
return fmt.Errorf("baseline requires a runtime audit report")
}
var previous AuditReport
decoder := json.NewDecoder(baseline)
if err := decoder.Decode(&previous); err != nil {
return fmt.Errorf("decode runtime baseline: %w", err)
}
if previous.SchemaVersion == "" {
return fmt.Errorf("baseline is not a runtime audit report")
}
known := make(map[string]bool, len(previous.Findings))
for _, finding := range previous.Findings {
if finding.ID != "" {
known[finding.ID] = true
}
}
filtered := report.Findings[:0]
for _, finding := range report.Findings {
if !known[finding.ID] {
filtered = append(filtered, finding)
}
}
report.Findings = filtered
return nil
}

func ApplySuppressions(report *AuditReport, source io.Reader, now time.Time) error {
if report == nil {
return fmt.Errorf("suppressions require a runtime audit report")
}
var document suppressionDocument
decoder := json.NewDecoder(source)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&document); err != nil {
return fmt.Errorf("decode runtime suppressions: %w", err)
}
if document.Suppressions == nil || len(*document.Suppressions) == 0 {
return fmt.Errorf("runtime suppression document must contain at least one suppression")
}
known := make(map[string]bool, len(report.Findings))
for _, finding := range report.Findings {
known[finding.ID] = true
}
active := make(map[string]suppressionEntry, len(*document.Suppressions))
for index, entry := range *document.Suppressions {
entry.FindingID = strings.TrimSpace(entry.FindingID)
entry.Reason = strings.TrimSpace(entry.Reason)
if entry.FindingID == "" || entry.Reason == "" {
return fmt.Errorf("runtime suppression %d requires finding_id and reason", index+1)
}
if len(entry.Reason) > 500 {
return fmt.Errorf("runtime suppression %q reason exceeds 500 characters", entry.FindingID)
}
expires, err := time.Parse(time.RFC3339, strings.TrimSpace(entry.ExpiresAt))
if err != nil || !expires.After(now) {
return fmt.Errorf("runtime suppression %q has invalid or expired expires_at", entry.FindingID)
}
if !known[entry.FindingID] {
return fmt.Errorf("runtime suppression %q does not match a finding", entry.FindingID)
}
if _, exists := active[entry.FindingID]; exists {
return fmt.Errorf("duplicate runtime suppression for %q", entry.FindingID)
}
entry.ExpiresAt = expires.UTC().Format(time.RFC3339)
active[entry.FindingID] = entry
}
for index := range report.Findings {
if entry, ok := active[report.Findings[index].ID]; ok {
report.Findings[index].Suppressed = true
report.Findings[index].SuppressionReason = entry.Reason
report.Findings[index].SuppressionExpiresAt = entry.ExpiresAt
}
}
return nil
}
Loading