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
112 changes: 110 additions & 2 deletions cmd/agentctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/HlinorAI/agent-control-plane/internal/config"
"github.com/HlinorAI/agent-control-plane/internal/runtime"
"github.com/HlinorAI/agent-control-plane/internal/scan"
)

Expand All @@ -23,6 +24,7 @@ Usage:
agentctl version
agentctl init <path>
agentctl scan <path> [flags]
agentctl runtime-audit <events> [flags]

Scan flags:
--baseline file suppress findings already present in a JSON report
Expand All @@ -32,7 +34,14 @@ Scan flags:
--fail-on severity fail when findings meet severity: none, low, medium, high, critical
--format format output format: text, json or sarif
--output file write the report to a file instead of stdout
--suppressions file suppress active findings with reason and expiry from a JSON file
--suppressions file suppress active findings with reason and expiry from a JSON file

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
--inventory file static agentctl JSON report to compare with runtime events
--output file write the audit report to a file instead of stdout

The scanner is read-only and metadata-only. It does not execute scanned content.
`
Expand Down Expand Up @@ -65,8 +74,11 @@ func run(args []string, stdout, stderr io.Writer) error {
if args[0] == "init" {
return runInit(args[1:], stdout, stderr)
}
if args[0] == "runtime-audit" {
return runRuntimeAudit(args[1:], stdout, stderr)
}
if args[0] != "scan" {
return fmt.Errorf("unknown command %q; available commands are init and scan", args[0])
return fmt.Errorf("unknown command %q; available commands are init, scan and runtime-audit", args[0])
}
if len(args) == 2 && (args[1] == "--help" || args[1] == "-h") {
_, err := io.WriteString(stdout, usage)
Expand Down Expand Up @@ -381,6 +393,102 @@ func runInit(args []string, stdout, stderr io.Writer) error {
return err
}

func runRuntimeAudit(args []string, stdout, stderr io.Writer) error {
if len(args) < 1 {
return errors.New("runtime-audit requires an events JSONL path")
}
fs := flag.NewFlagSet("runtime-audit", flag.ContinueOnError)
fs.SetOutput(stderr)
source := fs.String("source", string(runtime.SourceJSONL), "input source: jsonl, otel-json or api-gateway")
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")
output := fs.String("output", "", "write the audit report to a file instead of stdout")
if err := fs.Parse(args[1:]); err != nil {
return err
}
if *inventoryPath == "" {
return errors.New("runtime-audit requires --inventory")
}
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" {
return fmt.Errorf("unsupported runtime audit format %q", *format)
}
if !validSeverity(*failOn) {
return fmt.Errorf("unsupported fail-on severity %q", *failOn)
}
eventsFile, err := os.Open(args[0])
if err != nil {
return fmt.Errorf("open runtime events: %w", err)
}
defer eventsFile.Close()
events, skipped, err := runtime.ReadSource(eventsFile, runtime.Source(*source), runtime.Options{})
if err != nil {
return err
}
inventoryFile, err := os.Open(*inventoryPath)
if err != nil {
return fmt.Errorf("open inventory: %w", err)
}
defer inventoryFile.Close()
var inventory scan.Report
decoder := json.NewDecoder(inventoryFile)
if err := decoder.Decode(&inventory); err != nil {
return fmt.Errorf("decode inventory: %w", err)
}
audit := runtime.Audit(runtime.Aggregate(events, skipped), inventory)
var payload []byte
if *format == "json" {
payload, err = json.MarshalIndent(audit, "", " ")
if err == nil {
payload = append(payload, '\n')
}
} else {
payload = []byte(runtimeAuditText(audit))
}
if err != nil {
return err
}
if *output != "" {
if err := writeOutputFile(*output, payload); err != nil {
return err
}
} else if _, err := stdout.Write(payload); err != nil {
return err
}
if runtimeFindingsMeetThreshold(audit.Findings, *failOn) {
return fmt.Errorf("runtime audit found findings at or above %s severity", *failOn)
}
return nil
}

func runtimeAuditText(report runtime.AuditReport) string {
var b strings.Builder
fmt.Fprintf(&b, "Agent Control Plane runtime audit\nSchema: %s\nEvents: %d\nMatched agents: %d\nUnmatched agents: %d\nFindings: %d\n", report.SchemaVersion, report.Runtime.EventsRead, report.MatchedAgents, report.Unmatched, len(report.Findings))
for _, finding := range report.Findings {
fmt.Fprintf(&b, "- [%s] %s: %s\n", finding.Severity, finding.RuleID, finding.Message)
for _, evidence := range finding.Evidence {
fmt.Fprintf(&b, " evidence: %s\n", evidence)
}
}
return b.String()
}

func runtimeFindingsMeetThreshold(findings []runtime.Finding, threshold string) bool {
minimum := severityRank(threshold)
if minimum == 0 {
return false
}
for _, finding := range findings {
if severityRank(finding.Severity) >= minimum {
return true
}
}
return false
}

func resolveConfigPath(root, requested string) (string, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
Expand Down
103 changes: 103 additions & 0 deletions docs/runtime-audit-mvp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Runtime-аудит Agent Control Plane: MVP

## Решение

Agent Control Plane следует расширять отдельным **runtime evidence layer**, а не превращать статический сканер в прокси или систему исполнения. Статический сканер отвечает на вопрос «что заявлено в коде», а runtime-аудит — на вопрос «что фактически происходило». Эти результаты должны связываться по стабильному идентификатору агента и объединяться только на этапе отчётности.

MVP должен принимать нормализованные события в формате JSON Lines, агрегировать фактические вызовы и сопоставлять их с результатом статического сканирования. На первом этапе система не должна исполнять инструменты, перехватывать секреты, менять политики или требовать конкретного observability-провайдера.

## Цели и ограничения

| Область | Входит в MVP | Не входит в MVP |
|---|---|---|
| Источники | Нормализованный JSONL, API Gateway JSON/JSONL, OpenTelemetry JSON export | Прямое подключение ко всем вендорам |
| Формат | JSONL с нормализованным событием | Произвольный парсинг логов каждого продукта |
| Аналитика | Число вызовов, успешность, уникальные цели, first/last seen | Поведенческая ML-детекция |
| Сопоставление | `agent_id`, затем устойчивое имя с явным предупреждением | Неоднозначное автоматическое связывание без evidence |
| Безопасность | Metadata-only, лимиты размера, отказ от payload/arguments | Сбор prompt, tool arguments и raw secrets |
| Выход | JSON-отчёт и runtime findings | Enforcement и runtime proxy |

## Нормализованное событие

Каждая строка входного потока представляет одно событие. Payload запроса и ответа намеренно отсутствуют. Идентификаторы и имена ограничиваются метаданными, необходимыми для аудита.

```json
{
"timestamp": "2026-09-12T12:00:00Z",
"request_id": "req-123",
"agent_id": "agent_customer_support",
"agent_name": "customer-support",
"environment": "production",
"operation": "tool_call",
"target": "crm.search_customers",
"provider": "internal-crm",
"action": "read",
"success": true
}
```

Обязательные поля: `timestamp`, `agent_id` или `agent_name`, `operation`, `target`, `success`. Значения `operation` и `action` являются свободными строками на этапе MVP, чтобы не блокировать интеграции. Нормализатор должен отклонять пустые идентификаторы и некорректные timestamps.

## Runtime-агрегат

Для каждого агента агрегируются следующие показатели:

| Показатель | Назначение |
|---|---|
| `event_count` | Общий объём наблюдений |
| `successful_events` и `failed_events` | Надёжность и ошибки интеграций |
| `unique_targets` | Фактический scope инструментов и API |
| `operations` | Разбивка по типам операций |
| `providers` | Фактические model/API providers |
| `first_seen` и `last_seen` | Окно наблюдения |
| `undeclared_targets` | Цели, отсутствующие в статическом inventory |
| `undeclared_providers` | Провайдеры, отсутствующие в декларации |

Агрегаты сортируются детерминированно. Это необходимо для стабильных diff-отчётов и baseline-механизма, уже используемого статическим сканером.

## Runtime findings

Первый набор правил должен быть небольшим и проверяемым:

| Правило | Условие | Начальная severity |
|---|---|---|
| `ACP-R001` | Фактическая цель отсутствует в статическом inventory агента | High |
| `ACP-R002` | Фактический provider отличается от заявленного | High |
| `ACP-R003` | Production-агент обращается к цели с write-действием, хотя заявлен как read-only | Critical |
| `ACP-R004` | Runtime-события невозможно надёжно сопоставить с агентом | Medium |
| `ACP-R005` | Наблюдаемые события старше заданного окна свежести | Note |

Правила должны создавать evidence только из безопасных полей: `request_id`, timestamp, operation и target. Никогда не следует помещать в finding prompt, аргументы инструмента, заголовки авторизации или тело ответа.

## План реализации

1. **Слой ingest.** Добавить потоковый JSONL reader с лимитом строки и лимитом общего числа событий.
2. **Агрегация.** Добавить детерминированный runtime report с first/last seen и уникальными целями.
3. **Сопоставление.** Сопоставить runtime agent с `scan.Report.Agents`; при отсутствии `agent_id` разрешать имя только при единственном совпадении.
4. **Findings.** Добавить первые runtime rules без enforcement.
5. **CLI.** Добавить отдельную команду `agentctl runtime-audit <events.jsonl> --inventory <report.json>` после стабилизации библиотечного API.
6. **Интеграции.** Реализовать адаптеры для OpenTelemetry и API gateway после утверждения нормализованной схемы.

## Критерии готовности MVP

MVP считается готовым, когда он обрабатывает поток минимум из 100 000 metadata-only событий с ограниченным потреблением памяти, не выводит запрещённые payload-поля, выдаёт одинаковый JSON для одинакового входа, корректно обрабатывает повреждённые строки с диагностикой и создаёт отдельные findings для undeclared target/provider.

## Принцип безопасности

Runtime-аудит должен быть **наблюдателем, а не исполнителем**. Он не запускает команды из событий, не обращается к URL из полей `target`, не читает содержимое tool arguments и не принимает решения о выдаче доступа. Enforcement — отдельный будущий компонент с самостоятельной моделью угроз и процессом согласования.

## Реализовано в текущем этапе

В репозитории реализован библиотечный слой `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`.

Адаптеры принимают только metadata-поля. OpenTelemetry spans преобразуются по атрибутам агента, инструмента, окружения, provider и HTTP-статуса. API Gateway записи поддерживают snake_case и camelCase идентификаторы, JSONL и JSON-массив. Payload запроса и ответа не читается и не переносится в нормализованное событие.

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

## References

[1]: https://opentelemetry.io/docs/specs/otel/ "OpenTelemetry Specification"

[2]: https://www.aicpa-cima.com/resources/download/2017-trust-services-criteria-tsp-section-100 "AICPA Trust Services Criteria"
Loading
Loading