-
Notifications
You must be signed in to change notification settings - Fork 0
feat: reconcile deployed 0.4.0 ghost into main — OTLP sink, MultiSink, rm-rf rule (+ preserve A4) #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: reconcile deployed 0.4.0 ghost into main — OTLP sink, MultiSink, rm-rf rule (+ preserve A4) #9
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| /** | ||
| * Fan-out MultiSink for the pi audit surface [LD-Y2 sink seam]. | ||
| * | ||
| * Implements the pi `AuditSink` interface and dispatches each write() to N | ||
| * children. Failure of any one child is non-blocking: catch → log → continue, | ||
| * mirroring the filesystem + OTLP sinks' own graceful-degradation contracts. | ||
| * This enables filesystem + OTel logging simultaneously without one breaking | ||
| * the other. | ||
| */ | ||
| import type { AuditSink } from '../pi/audit.ts'; | ||
|
|
||
| export class MultiSink implements AuditSink { | ||
| private readonly children: readonly AuditSink[]; | ||
|
|
||
| constructor(children: readonly AuditSink[]) { | ||
| this.children = children; | ||
| } | ||
|
|
||
| async write(entry: unknown): Promise<void> { | ||
| // Sequential fan-out so a throwing child cannot starve later children of | ||
| // the write (Promise.all would short-circuit scheduling on reject). | ||
| for (const child of this.children) { | ||
| try { | ||
| await child.write(entry); | ||
| } catch (err) { | ||
| console.error( | ||
| `[pi-opa-net] audit sink child failed, continuing: ${ | ||
| err instanceof Error ? err.message : String(err) | ||
| }`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| /** | ||
| * OpenTelemetry (OTLP/HTTP) audit sink [LD-Y2 sink seam]. | ||
| * | ||
| * Implements the pi `AuditSink` interface (`{ write }` from src/pi/audit.ts) | ||
| * and POSTs an OTLP Logs JSON document to a collector endpoint. OTLP-over-HTTP | ||
| * is the canonical transport for logs (see OTel spec). Network failure is | ||
| * non-fatal: write() catches and resolves, mirroring the filesystem sink's | ||
| * graceful-degradation contract. | ||
| * | ||
| * Keys never enter OPA, and secrets are redacted upstream (src/pi/audit.ts) | ||
| * before reaching this sink; this layer performs no redaction itself. | ||
| */ | ||
| import type { AuditSink } from '../pi/audit.ts'; | ||
|
|
||
| export interface OtlpAuditSinkOptions { | ||
| /** OTLP/HTTP logs endpoint, e.g. http://otel:4318/v1/logs. */ | ||
| readonly endpoint: string; | ||
| /** service.name resource attribute. Default 'pi-opa-net'. */ | ||
| readonly serviceName?: string; | ||
| /** Extra request headers (auth, tenant, ...). */ | ||
| readonly headers?: Record<string, string>; | ||
| } | ||
|
|
||
| /** Shape produced by writeAuditEntry (src/pi/audit.ts AuditEntry). */ | ||
| type AuditEntry = { | ||
| readonly decision_id?: string; | ||
| readonly decision?: string; | ||
| readonly source?: string; | ||
| readonly command?: string; | ||
| readonly rule_ids?: readonly string[]; | ||
| readonly evaluated_at?: string; | ||
| readonly [k: string]: unknown; | ||
| }; | ||
|
|
||
| /** OTLP any-value helper. */ | ||
| function stringValue(v: unknown): { stringValue: string } { | ||
| return { stringValue: typeof v === 'string' ? v : String(v ?? '') }; | ||
| } | ||
|
|
||
| /** | ||
| * OTLP/HTTP audit sink. POSTs each decision as an OTLP Logs logRecord. | ||
| */ | ||
| export class OtlpAuditSink implements AuditSink { | ||
| private readonly endpoint: string; | ||
| private readonly serviceName: string; | ||
| private readonly headers: Record<string, string>; | ||
|
|
||
| constructor(opts: OtlpAuditSinkOptions) { | ||
| this.endpoint = opts.endpoint; | ||
| this.serviceName = opts.serviceName ?? 'pi-opa-net'; | ||
| this.headers = { ...(opts.headers ?? {}) }; | ||
| } | ||
|
|
||
| async write(entry: unknown): Promise<void> { | ||
| const body = this.buildOtlpBody(entry as AuditEntry); | ||
| try { | ||
| await fetch(this.endpoint, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json', ...this.headers }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| } catch (err) { | ||
| // Audit export failure is non-fatal — log to stderr and continue. | ||
| console.error( | ||
| `[pi-opa-net] OTLP audit export failed, continuing without OTel: ${ | ||
| err instanceof Error ? err.message : String(err) | ||
| }`, | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+54
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== File map ==\n'
git ls-files 'src/audit/OtlpAuditSink.ts' 'src/pi/tool-call.ts' 'src/audit/*' | sed 's#^`#-` #'
printf '\n== OtlpAuditSink outline ==\n'
ast-grep outline src/audit/OtlpAuditSink.ts --view expanded || true
printf '\n== Relevant slices ==\n'
sed -n '1,220p' src/audit/OtlpAuditSink.ts | cat -n
printf '\n--- tool-call slice ---\n'
sed -n '120,190p' src/pi/tool-call.ts | cat -nRepository: buihongduc132/opa-net Length of output: 1919 🌐 Web query:
💡 Result: In Bun, Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== OtlpAuditSink outline ==\n'
ast-grep outline src/audit/OtlpAuditSink.ts --view expanded || true
printf '\n== OtlpAuditSink lines 1-220 ==\n'
sed -n '1,220p' src/audit/OtlpAuditSink.ts | cat -n
printf '\n== tool-call lines 120-190 ==\n'
sed -n '120,190p' src/pi/tool-call.ts | cat -nRepository: buihongduc132/opa-net Length of output: 1919 Add a bounded timeout and reject non-2xx OTLP responses in 🤖 Prompt for AI Agents |
||
|
|
||
| /** Build the OTLP Logs JSON document for a single audit entry. */ | ||
| private buildOtlpBody(entry: AuditEntry): Record<string, unknown> { | ||
| const decision = entry.decision ?? ''; | ||
| const severityText = decision === 'deny' ? 'ERROR' : 'INFO'; | ||
|
|
||
| // kvlistValue mirrors the filesystem audit line shape 1:1. | ||
| const kvValues = [ | ||
| { key: 'decision_id', value: stringValue(entry.decision_id) }, | ||
| { key: 'decision', value: stringValue(entry.decision) }, | ||
| { key: 'source', value: stringValue(entry.source) }, | ||
| { key: 'command', value: stringValue(entry.command) }, | ||
| { | ||
| key: 'rule_ids', | ||
| value: { | ||
| arrayValue: { | ||
| values: Array.isArray(entry.rule_ids) ? entry.rule_ids.map((r) => stringValue(r)) : [], | ||
| }, | ||
| }, | ||
| }, | ||
| { key: 'evaluated_at', value: stringValue(entry.evaluated_at) }, | ||
| ]; | ||
|
|
||
| return { | ||
| resourceLogs: [ | ||
| { | ||
| resource: { | ||
| attributes: [{ key: 'service.name', value: stringValue(this.serviceName) }], | ||
| }, | ||
| scopeLogs: [ | ||
| { | ||
| logRecords: [ | ||
| { | ||
| timeUnixNano: String(Date.now() * 1_000_000), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: sed -n '1,220p' src/audit/OtlpAuditSink.ts
printf '\n---\n'
rg -n "timeUnixNano|Date\.now\(\) \* 1_000_000|BigInt\(Date\.now\(\)\)" srcRepository: buihongduc132/opa-net Length of output: 1919 Use a BigInt nanosecond timestamp here
🤖 Prompt for AI Agents |
||
| severityText, | ||
| attributes: [{ key: 'decision', value: stringValue(decision) }], | ||
| body: { kvlistValue: { values: kvValues } }, | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| /** | ||
| * Config-driven audit sink factory [LD-Y2 sink seam]. | ||
| * | ||
| * Decides which sink(s) to wire based on environment: | ||
| * - PIOPANET_OTEL_ENABLED=1 + PIOPANET_OTEL_ENDPOINT → MultiSink(filesystem + OTLP) | ||
| * - PIOPANET_OTEL_ENABLED=1 + no endpoint → filesystem only (stderr warn) | ||
| * - otherwise → filesystem only (backward compat) | ||
| * | ||
| * This keeps OPA keys out of config (keys live in audit records) and preserves | ||
| * the existing filesystem-only behavior when OpenTelemetry is disabled. | ||
| */ | ||
| import { createFilesystemAuditSink } from '../pi/audit.ts'; | ||
| import type { AuditSink } from '../pi/audit.ts'; | ||
| import { MultiSink } from './MultiSink.ts'; | ||
| import { OtlpAuditSink } from './OtlpAuditSink.ts'; | ||
|
|
||
| export interface CreateAuditSinkOptions { | ||
| /** Working directory used by the filesystem sink. */ | ||
| readonly cwd: string; | ||
| /** Environment source. Defaults to process.env when omitted. */ | ||
| readonly env?: NodeJS.ProcessEnv; | ||
| } | ||
|
|
||
| const DEFAULT_SERVICE_NAME = 'pi-opa-net'; | ||
|
|
||
| /** | ||
| * Parse a 'k=v,k2=v2' header string into a Record. Whitespace tolerated. | ||
| */ | ||
| export function parseHeaders(raw: string | undefined): Record<string, string> { | ||
| const out: Record<string, string> = {}; | ||
| if (!raw) return out; | ||
| for (const part of raw.split(',')) { | ||
| const eq = part.indexOf('='); | ||
| if (eq <= 0) continue; // skip malformed / empty-key entries | ||
| const key = part.slice(0, eq).trim(); | ||
| const val = part.slice(eq + 1).trim(); | ||
| if (key.length > 0) out[key] = val; | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| /** | ||
| * Build the audit sink stack from environment. When OpenTelemetry is enabled | ||
| * AND an endpoint is configured, returns a MultiSink fanning out to both the | ||
| * filesystem sink and the OTLP sink. Otherwise returns the filesystem sink | ||
| * alone, preserving the pre-OTel behavior. | ||
| */ | ||
| export function createAuditSink(opts: CreateAuditSinkOptions): AuditSink { | ||
| const env = opts.env ?? process.env; | ||
| const fsSink = createFilesystemAuditSink(opts.cwd); | ||
|
|
||
| const otelEnabled = env.PIOPANET_OTEL_ENABLED === '1'; | ||
| const endpoint = env.PIOPANET_OTEL_ENDPOINT; | ||
| if (!otelEnabled || !endpoint) { | ||
| if (otelEnabled && !endpoint) { | ||
| console.error( | ||
| '[pi-opa-net] PIOPANET_OTEL_ENABLED=1 but PIOPANET_OTEL_ENDPOINT unset; falling back to filesystem audit only', | ||
| ); | ||
| } | ||
| return fsSink; | ||
| } | ||
|
|
||
| const serviceName = env.PIOPANET_OTEL_SERVICE_NAME ?? DEFAULT_SERVICE_NAME; | ||
| const headers = parseHeaders(env.PIOPANET_OTEL_HEADERS); | ||
| const otlpSink = new OtlpAuditSink({ endpoint, serviceName, headers }); | ||
| return new MultiSink([fsSink, otlpSink]); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Document the implemented
~/*dangerous target consistently.The Rego policy blocks
~/*, but all three user-facing/release descriptions omit it. Add~/*to each listed dangerous-target catalog so the documentation matchespolicy/safety.rego.README.md#L223-L223: add~/*to the blocked target list.skills/pi-opa-net/SKILL.md#L74-L74: add~/*to the blocked target list.CHANGELOG.md#L13-L13: add~/*to the 0.4.1 release-note target list.📍 Affects 3 files
README.md#L223-L223(this comment)skills/pi-opa-net/SKILL.md#L74-L74CHANGELOG.md#L13-L13🤖 Prompt for AI Agents