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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.4.1] - 2026-07-24

### Added

- **Reconciled deployed 0.4.0 ghost into repo main.** The deployed production copy (`~/.pi/agent/npm/node_modules/pi-opa-net/`) contained unpublished features (OTLP/HTTP audit sink, MultiSink fan-out, config-driven audit factory, `rm -rf` dangerous-target policy) that had never been committed to git. This release brings the repo source in sync with the deployed binary while preserving the A4 runtime self-check layer (which was inadvertently lost in the unpublished 0.4.0 ghost).
- **`block-rm-rf-dangerous-target` rule** — blocks `rm -rf` on dangerous targets (`/`, `~`, `.`, `..`, `*`, `/*`, `$HOME`, `/home`). Safe carve-outs preserved: `/tmp/<specific>`, `./<specific>`, named dirs. Rule uses both `args`-based matching AND `raw`-regex fallback (shell-quote expands globs/env-vars away from args).

### Fixed

- **A4 regression restored** — deployed 0.4.0 had silently dropped `markHookRegistered()` and `src/pi/runtime-self-check.ts`; repo main now retains the A4 prevention layer.

## [0.3.3] - 2026-07-24

### Added
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,23 @@ The default `open` matches the [`pi-safety-net`](https://www.npmjs.com/package/p
| `PI_OPA_HOSTNAME` | `os.hostname()` | metadata.hostname |
| `PI_OPA_SESSION_ID` | `""` | metadata.session_id |

### Audit sinks (pi extension)

The pi extension writes audit entries to a filesystem sink by default. To also forward to an OTLP/HTTP collector:

| Var | Default | Purpose |
|-----|---------|---------|
| `PIOPANET_OTEL_ENABLED` | unset | Set to `1` to enable OTLP forwarding |
| `PIOPANET_OTEL_ENDPOINT` | unset | Collector URL (required when enabled) |
| `PIOPANET_OTEL_SERVICE_NAME` | `pi-opa-net` | Service name in OTLP resource |
| `PIOPANET_OTEL_HEADERS` | unset | Extra headers as `k=v,k2=v2` (avoid secrets) |

When enabled + endpoint set: `MultiSink([filesystem, otlp])`. When enabled but no endpoint: filesystem only + stderr warn.

### Rules

- `block-rm-rf-dangerous-target` — blocks `rm -rf` on `/`, `~`, `.`, `..`, `*`, `/*`, `$HOME`, `/home`. Safe carve-outs: `/tmp/<specific>`, `./<specific>`, named dirs.

Copy link
Copy Markdown

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 matches policy/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-L74
  • CHANGELOG.md#L13-L13
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 223, Update the dangerous-target catalogs to include the
implemented ~/* pattern, preserving the existing wording and formatting: add ~/*
in README.md lines 223-223, skills/pi-opa-net/SKILL.md lines 74-74, and
CHANGELOG.md lines 13-13.


## Develop

```bash
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pi-opa-net",
"version": "0.3.3",
"version": "0.4.1",
"description": "OPA-backed bash command guard for the pi ecosystem — structured decision-output.v1 JSON, fail-open default, Claude Code hook protocol compatible. Agent-agnostic engine + CLI.",
"type": "module",
"main": "src/index.ts",
Expand Down
70 changes: 70 additions & 0 deletions policy/safety.rego
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,76 @@ deny[msg] if {
msg := "Removing symlink subdirs in beads/ skill is blocked (rule is misnamed 'allow')."
}

# block-rm-rf-dangerous-target — guard against `rm -rf` on broad/cwd/system paths.
# Parser caveat: shell-quote expands globs (`*`, `/*`) and env vars (`$HOME`)
# during parsing, so those tokens vanish from input.args but survive in input.raw.
# We therefore check BOTH args (exact targets) and raw (regex fallback).

# Recursive flag: -r | -R | --recursive | combined short cluster containing r/R
rm_has_recursive(args) if { has_any_arg(args, ["-r", "-R", "--recursive"]) }
rm_has_recursive(args) if {
some a in args
startswith(a, "-")
not startswith(a, "--")
count(a) > 2
contains(a, "r")
}
rm_has_recursive(args) if {
some a in args
startswith(a, "-")
not startswith(a, "--")
count(a) > 2
contains(a, "R")
}

# Force flag: -f | --force | combined short cluster containing f
rm_has_force(args) if { has_any_arg(args, ["-f", "--force"]) }
rm_has_force(args) if {
some a in args
startswith(a, "-")
not startswith(a, "--")
count(a) > 2
contains(a, "f")
}

# Non-flag target arguments
rm_targets(args) := [t | some t in args; not startswith(t, "-")]

# Dangerous targets visible in args (parser preserves literal ., .., /, ~, etc.)
rm_dangerous_arg_targets := ["/", "~", "$HOME", ".", "..", "/home", "/*", "~/*"]

rm_has_dangerous_arg_target(args) if {
some t in rm_targets(args)
t == rm_dangerous_arg_targets[_]
}

# Dangerous tokens that disappear from args due to shell expansion (globs, env vars).
# Matched as standalone words (whitespace or string boundary) in input.raw.
rm_raw_dangerous_token(raw) if { regex.match("(^|\\s)/\\*(\\s|$)", raw) }
rm_raw_dangerous_token(raw) if { regex.match("(^|\\s)~(/\\*)?(\\s|$)", raw) }
rm_raw_dangerous_token(raw) if { regex.match("(^|\\s)\\$HOME(\\s|$)", raw) }
rm_raw_dangerous_token(raw) if { regex.match("(^|\\s)/home(\\s|$)", raw) }
rm_raw_dangerous_token(raw) if { regex.match("(^|\\s)/(\\s|$)", raw) }
rm_raw_dangerous_token(raw) if { regex.match("(^|\\s)\\*(\\s|$)", raw) }

# Args-based deny: dangerous literal target present in args
deny[msg] if {
input.program == "rm"
rm_has_recursive(input.args)
rm_has_force(input.args)
rm_has_dangerous_arg_target(input.args)
msg := "rm -rf on dangerous targets (/, ~, ., .., *, /*, $HOME, /home) is blocked. Use specific paths like /tmp/dir or ./subdir."
}

# Raw-based deny: dangerous glob/env token present in raw (disappeared from args)
deny[msg] if {
input.program == "rm"
rm_has_recursive(input.args)
rm_has_force(input.args)
rm_raw_dangerous_token(input.raw)
msg := "rm -rf on dangerous targets (/, ~, ., .., *, /*, $HOME, /home) is blocked. Use specific paths like /tmp/dir or ./subdir."
}

# ──────────────────────────────────────────────────────────────────
# GROUP F — gh / glab repo lifecycle
# ──────────────────────────────────────────────────────────────────
Expand Down
17 changes: 17 additions & 0 deletions skills/pi-opa-net/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ The `source` field makes whichever mode fires **observable** per-decision.
2. Mirror it in `src/rules/catalog.ts` (same message string).
3. The catalog↔rego parity test enforces zero drift.

## Audit sinks (pi extension)

The pi extension writes audit entries to a filesystem sink by default. To also forward to an OTLP/HTTP collector:

| Var | Default | Purpose |
|-----|---------|---------|
| `PIOPANET_OTEL_ENABLED` | unset | Set to `1` to enable OTLP forwarding |
| `PIOPANET_OTEL_ENDPOINT` | unset | Collector URL (required when enabled) |
| `PIOPANET_OTEL_SERVICE_NAME` | `pi-opa-net` | Service name in OTLP resource |
| `PIOPANET_OTEL_HEADERS` | unset | Extra headers as `k=v,k2=v2` (avoid secrets) |

When enabled + endpoint set: `MultiSink([filesystem, otlp])`. When enabled but no endpoint: filesystem only + stderr warn.

## Rules

- `block-rm-rf-dangerous-target` — blocks `rm -rf` on `/`, `~`, `.`, `..`, `*`, `/*`, `$HOME`, `/home`. Safe carve-outs: `/tmp/<specific>`, `./<specific>`, named dirs.

## References

- Schema: [`schemas/decision-output.v1.json`](https://github.com/buihongduc132/pi-opa-net/blob/main/schemas/decision-output.v1.json)
Expand Down
34 changes: 34 additions & 0 deletions src/audit/MultiSink.ts
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)
}`,
);
}
}
}
}
116 changes: 116 additions & 0 deletions src/audit/OtlpAuditSink.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 -n

Repository: buihongduc132/opa-net

Length of output: 1919


🌐 Web query:

Bun fetch default timeout AbortSignal.timeout documentation

💡 Result:

In Bun, fetch() supports AbortSignal.timeout() as a standard way to manage request timeouts, but it also features a native timeout option in its RequestInit configuration [1][2][3]. Key details regarding timeout behavior in Bun: 1. Native Timeout Support: Beyond AbortSignal, Bun allows you to pass a timeout property directly in the fetch options object (e.g., fetch(url, { timeout: 5000 })) [3]. 2. Default Timeout Behavior: Historically, Bun implemented a default idle socket timeout to prevent requests from hanging indefinitely [2][4]. Recent updates have refined this, distinguishing between a whole-request deadline (timeout) and specific phases like connection or inactivity (connectTimeout, socketTimeout) [2]. 3. Disabling Timeouts: You can disable the default request timeout by setting { timeout: false } or { timeout: 0 } in the fetch options [2][4]. This is often used as an "escape hatch" for long-polling or Server-Sent Events (SSE) [2]. 4. Interaction with AbortSignal: AbortSignal.timeout(ms) is the standard Web API way to trigger an AbortError after a specific duration [1][5]. While you can use both native timeout and AbortSignal.timeout simultaneously, they serve as different mechanisms for managing request lifecycle [2]. For authoritative guidance, refer to the official Bun networking documentation, which explicitly recommends AbortSignal.timeout for standard use cases [1]. Note that Bun's internal implementation of these timeouts continues to evolve to better support diverse networking requirements [2].

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 -n

Repository: buihongduc132/opa-net

Length of output: 1919


Add a bounded timeout and reject non-2xx OTLP responses in src/audit/OtlpAuditSink.ts:54-70. write() is awaited on the deny/block path, so a slow collector can delay the response, and HTTP 4xx/5xx replies are currently treated as success because only thrown fetch failures are caught.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/OtlpAuditSink.ts` around lines 54 - 70, Update OtlpAuditSink.write
to bound the fetch duration with an AbortController-based timeout, and validate
the response after fetch so non-2xx statuses throw and enter the existing error
logging path. Preserve the non-fatal behavior by continuing to catch timeout,
network, and HTTP-status failures without propagating them.


/** 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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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\(\)\)" src

Repository: buihongduc132/opa-net

Length of output: 1919


Use a BigInt nanosecond timestamp here

Date.now() * 1_000_000 exceeds Number.MAX_SAFE_INTEGER, so the exported OTLP timestamp can be rounded before stringification. Use a BigInt-based nanosecond value instead, e.g. String(BigInt(Date.now()) * 1_000_000n).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/audit/OtlpAuditSink.ts` at line 104, Update the timeUnixNano assignment
in the OTLP audit export to compute nanoseconds with BigInt before
stringification, using BigInt(Date.now()) and a BigInt nanosecond multiplier to
avoid unsafe Number arithmetic.

severityText,
attributes: [{ key: 'decision', value: stringValue(decision) }],
body: { kvlistValue: { values: kvValues } },
},
],
},
],
},
],
};
}
}
67 changes: 67 additions & 0 deletions src/audit/sinkFactory.ts
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]);
}
5 changes: 3 additions & 2 deletions src/pi/tool-call.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { spawn } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createAuditSink } from '../audit/sinkFactory.ts';
import type { DecisionOutput } from '../output/DecisionBuilder.ts';
import { type AuditSink, createFilesystemAuditSink, writeAuditEntry } from './audit.ts';
import { type AuditSink, writeAuditEntry } from './audit.ts';

/** Fail-closed block reason — mirrors pi-safety-net's REASON_SAFETY_NET_FAILED_CLOSED. */
export const REASON_OPA_NET_FAILED_CLOSED =
Expand Down Expand Up @@ -151,7 +152,7 @@ export async function handlePiToolCall(
if (decision.decision === 'deny' && decision.action === 'block') {
const sessionId = ctx.sessionManager.getSessionFile();
if (sessionId) {
const auditSink = ctx.auditSink ?? createFilesystemAuditSink(cwd);
const auditSink = ctx.auditSink ?? createAuditSink({ cwd });
await writeAuditEntry({
sessionId,
decision,
Expand Down
Loading
Loading