pi-opa-net 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +48 -4
- package/bin/pi-opa-net.js +88 -7
- package/package.json +8 -3
- package/policy/safety.rego +36 -0
- package/schemas/decision-output.v1.json +41 -3
- package/src/audit/AuditSink.ts +26 -0
- package/src/cli/run.ts +92 -2
- package/src/cli/unlock-key.ts +52 -0
- package/src/config/Config.ts +32 -1
- package/src/output/DecisionBuilder.ts +90 -19
- package/src/pi/audit.ts +81 -0
- package/src/pi/index.ts +33 -0
- package/src/pi/tool-call.ts +193 -0
- package/src/rules/RuleRegistry.ts +50 -7
- package/src/rules/catalog.ts +39 -3
- package/src/unlock/KeyDerivation.ts +28 -0
- package/src/unlock/KeyParser.ts +33 -0
- package/src/unlock/KeyVerifier.ts +74 -0
- package/src/unlock/SaltResolver.ts +98 -0
- package/src/unlock/UnlockFilter.ts +107 -0
- package/src/unlock/types.ts +58 -0
package/src/config/Config.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface EngineConfig {
|
|
|
23
23
|
readonly hostname?: string;
|
|
24
24
|
/** Calling session ID for metadata (pi/claude session). Empty if none. */
|
|
25
25
|
readonly sessionId?: string;
|
|
26
|
+
/** Unlock keys from PIOPANET_UNLOCK_KEYS / --unlock (comma-separated env). */
|
|
27
|
+
readonly unlockKeys?: readonly string[];
|
|
28
|
+
/** Path to the deploy-local salt file (or PIOPANET_UNLOCK_SALT override). */
|
|
29
|
+
readonly unlockSaltPath?: string;
|
|
30
|
+
/** Agent ID for unlock audit metadata (PIOPANET_AGENT_ID). */
|
|
31
|
+
readonly unlockAgentId?: string;
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
const ENV = process.env;
|
|
@@ -63,9 +69,25 @@ function readdirSafe(path: string): string[] {
|
|
|
63
69
|
export function configFromEnv(policyPath: string): EngineConfig {
|
|
64
70
|
const failMode: FailMode = (ENV.PI_OPA_FAIL_MODE as FailMode) === 'closed' ? 'closed' : 'open';
|
|
65
71
|
const timeoutMs = ENV.PI_OPA_TIMEOUT_MS ? Number.parseInt(ENV.PI_OPA_TIMEOUT_MS, 10) : 250;
|
|
66
|
-
const
|
|
72
|
+
const baseCacheTtlMs = ENV.PI_OPA_CACHE_TTL_MS
|
|
67
73
|
? Number.parseInt(ENV.PI_OPA_CACHE_TTL_MS, 10)
|
|
68
74
|
: DEFAULT_CACHE_TTL_MS;
|
|
75
|
+
|
|
76
|
+
// Unlock keys: PIOPANET_UNLOCK_KEYS (comma-separated, trimmed).
|
|
77
|
+
const unlockKeys = (ENV.PIOPANET_UNLOCK_KEYS ?? '')
|
|
78
|
+
.split(',')
|
|
79
|
+
.map((k) => k.trim())
|
|
80
|
+
.filter((k) => k.length > 0);
|
|
81
|
+
|
|
82
|
+
// LD-G3: force cacheTtlMs=0 when unlock keys are present (cache poisoning guard).
|
|
83
|
+
const cacheTtlMs = unlockKeys.length > 0 ? 0 : baseCacheTtlMs;
|
|
84
|
+
|
|
85
|
+
// Salt path: PIOPANET_UNLOCK_SALT or PIOPANET_UNLOCK_SALT_FILE → default ~/.pi-opa-net/salt.
|
|
86
|
+
const unlockSaltPath =
|
|
87
|
+
ENV.PIOPANET_UNLOCK_SALT ?? ENV.PIOPANET_UNLOCK_SALT_FILE ?? defaultSaltPath();
|
|
88
|
+
|
|
89
|
+
const unlockAgentId = ENV.PIOPANET_AGENT_ID;
|
|
90
|
+
|
|
69
91
|
return {
|
|
70
92
|
opaBinary: resolveOpaBinary(),
|
|
71
93
|
policyPath,
|
|
@@ -74,5 +96,14 @@ export function configFromEnv(policyPath: string): EngineConfig {
|
|
|
74
96
|
cacheTtlMs,
|
|
75
97
|
hostname: ENV.PI_OPA_HOSTNAME,
|
|
76
98
|
sessionId: ENV.PI_OPA_SESSION_ID,
|
|
99
|
+
unlockKeys,
|
|
100
|
+
unlockSaltPath,
|
|
101
|
+
unlockAgentId,
|
|
77
102
|
};
|
|
78
103
|
}
|
|
104
|
+
|
|
105
|
+
/** Default salt file path: ~/.pi-opa-net/salt. */
|
|
106
|
+
function defaultSaltPath(): string {
|
|
107
|
+
const home = ENV.HOME ?? process.env.HOME ?? '';
|
|
108
|
+
return `${home}/.pi-opa-net/salt`;
|
|
109
|
+
}
|
|
@@ -4,13 +4,21 @@ import type { EngineConfig } from '../config/Config.ts';
|
|
|
4
4
|
import type { EngineDecision, RawDeny } from '../engine/types.ts';
|
|
5
5
|
import type { ParsedCommand } from '../parser/types.ts';
|
|
6
6
|
import { type RuleMeta, type RuleRegistry, inferFamilyFromProgram } from '../rules/index.ts';
|
|
7
|
+
import type { UnlockResult } from '../unlock/types.ts';
|
|
7
8
|
|
|
8
9
|
/** The schema-compliant decision output (decision-output.v1). */
|
|
9
10
|
export interface DecisionOutput {
|
|
10
11
|
readonly schema_version: '1.0';
|
|
11
12
|
readonly decision: 'allow' | 'deny';
|
|
12
13
|
readonly action: 'allow' | 'block' | 'prompt_user' | 'log_only';
|
|
13
|
-
readonly source:
|
|
14
|
+
readonly source:
|
|
15
|
+
| 'opa'
|
|
16
|
+
| 'fail-open'
|
|
17
|
+
| 'fail-closed'
|
|
18
|
+
| 'cached'
|
|
19
|
+
| 'opa-unlocked'
|
|
20
|
+
| 'fail-open-keyless'
|
|
21
|
+
| 'unlock-filter-error';
|
|
14
22
|
readonly reasons: readonly Reason[];
|
|
15
23
|
readonly input: EvaluatedInput;
|
|
16
24
|
readonly summary: string;
|
|
@@ -26,6 +34,11 @@ export interface Reason {
|
|
|
26
34
|
readonly message: string;
|
|
27
35
|
readonly family: string;
|
|
28
36
|
readonly severity: 'block' | 'warn' | 'info';
|
|
37
|
+
readonly bypassed?: boolean;
|
|
38
|
+
readonly unlock_key_id?: string;
|
|
39
|
+
readonly unlock_key_type?: 'll' | 'ttl';
|
|
40
|
+
readonly unlock_expires_at?: string;
|
|
41
|
+
readonly unlock_status?: 'valid' | 'expired';
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
export interface EvaluatedInput {
|
|
@@ -43,6 +56,9 @@ export interface DecisionMetadata {
|
|
|
43
56
|
readonly policy_path: string;
|
|
44
57
|
readonly hostname: string;
|
|
45
58
|
readonly session_id: string;
|
|
59
|
+
readonly unlock_count?: number;
|
|
60
|
+
readonly unlock_blocked_count?: number;
|
|
61
|
+
readonly unlock_agent?: string;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
export interface DecisionBuilderDeps {
|
|
@@ -70,15 +86,49 @@ export class DecisionBuilder {
|
|
|
70
86
|
this.deps = deps;
|
|
71
87
|
}
|
|
72
88
|
|
|
73
|
-
build(
|
|
74
|
-
|
|
89
|
+
build(
|
|
90
|
+
parsed: ParsedCommand,
|
|
91
|
+
engine: EngineDecision,
|
|
92
|
+
opts?: { unlockResult?: UnlockResult },
|
|
93
|
+
): DecisionOutput {
|
|
94
|
+
const unlockResult = opts?.unlockResult;
|
|
95
|
+
const reasons =
|
|
96
|
+
engine.decision === 'deny' ? this.buildReasons(engine.reasons, parsed, unlockResult) : [];
|
|
75
97
|
const suggestions = this.collectSuggestions(reasons);
|
|
76
|
-
|
|
98
|
+
|
|
99
|
+
// Determine source and decision based on unlock result.
|
|
100
|
+
let source: DecisionOutput['source'] = engine.source;
|
|
101
|
+
let decision = engine.decision;
|
|
102
|
+
if (unlockResult && unlockResult.bypassedCount > 0 && unlockResult.blockedCount === 0) {
|
|
103
|
+
source = 'opa-unlocked';
|
|
104
|
+
decision = 'allow';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const action = decision === 'allow' ? 'allow' : 'block';
|
|
108
|
+
|
|
109
|
+
// Build metadata with optional unlock fields.
|
|
110
|
+
const metadata: DecisionMetadata = {
|
|
111
|
+
engine: 'opa',
|
|
112
|
+
opa_version: engine.opaVersion,
|
|
113
|
+
rulebook_digest: this.deps.digest,
|
|
114
|
+
policy_path: this.deps.config.policyPath,
|
|
115
|
+
hostname: this.deps.config.hostname ?? osHostname(),
|
|
116
|
+
session_id: this.deps.config.sessionId ?? '',
|
|
117
|
+
};
|
|
118
|
+
if (unlockResult) {
|
|
119
|
+
const meta = metadata as unknown as Record<string, unknown>;
|
|
120
|
+
meta.unlock_count = unlockResult.bypassedCount;
|
|
121
|
+
meta.unlock_blocked_count = unlockResult.blockedCount;
|
|
122
|
+
if (this.deps.config.unlockAgentId) {
|
|
123
|
+
meta.unlock_agent = this.deps.config.unlockAgentId;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
77
127
|
return {
|
|
78
128
|
schema_version: '1.0',
|
|
79
|
-
decision
|
|
129
|
+
decision,
|
|
80
130
|
action,
|
|
81
|
-
source
|
|
131
|
+
source,
|
|
82
132
|
reasons,
|
|
83
133
|
input: {
|
|
84
134
|
raw: parsed.raw,
|
|
@@ -89,29 +139,50 @@ export class DecisionBuilder {
|
|
|
89
139
|
},
|
|
90
140
|
summary: this.summary(engine, parsed, reasons),
|
|
91
141
|
suggestions,
|
|
92
|
-
metadata
|
|
93
|
-
engine: 'opa',
|
|
94
|
-
opa_version: engine.opaVersion,
|
|
95
|
-
rulebook_digest: this.deps.digest,
|
|
96
|
-
policy_path: this.deps.config.policyPath,
|
|
97
|
-
hostname: this.deps.config.hostname ?? osHostname(),
|
|
98
|
-
session_id: this.deps.config.sessionId ?? '',
|
|
99
|
-
},
|
|
142
|
+
metadata,
|
|
100
143
|
evaluated_at: (this.deps.now ?? (() => new Date()))().toISOString(),
|
|
101
144
|
decision_id: (this.deps.uuid ?? randomUUID)(),
|
|
102
145
|
duration_ms: engine.durationMs,
|
|
103
146
|
};
|
|
104
147
|
}
|
|
105
148
|
|
|
106
|
-
private buildReasons(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
149
|
+
private buildReasons(
|
|
150
|
+
raw: readonly RawDeny[],
|
|
151
|
+
parsed: ParsedCommand,
|
|
152
|
+
unlockResult?: UnlockResult,
|
|
153
|
+
): Reason[] {
|
|
154
|
+
return raw.map((d, i) => {
|
|
155
|
+
const familyHint = inferFamilyFromProgram(parsed.program);
|
|
156
|
+
const meta = this.deps.registry.lookup(d, familyHint, parsed.args);
|
|
157
|
+
const reason: Record<string, unknown> = {
|
|
110
158
|
rule_id: meta.ruleId,
|
|
111
159
|
message: meta.message,
|
|
112
160
|
family: this.resolveFamily(meta, parsed),
|
|
113
|
-
severity: 'block'
|
|
161
|
+
severity: 'block',
|
|
114
162
|
};
|
|
163
|
+
|
|
164
|
+
// Merge unlock info if available.
|
|
165
|
+
if (unlockResult && i < unlockResult.reasons.length) {
|
|
166
|
+
const info = unlockResult.reasons[i];
|
|
167
|
+
if (info.bypassed) {
|
|
168
|
+
reason.bypassed = true;
|
|
169
|
+
reason.severity = 'info'; // Demote bypassed reason severity.
|
|
170
|
+
}
|
|
171
|
+
const keyId = info.unlockKeyId ?? info.unlock_key_id;
|
|
172
|
+
if (keyId) reason.unlock_key_id = keyId;
|
|
173
|
+
const keyType = info.keyType ?? info.unlock_key_type;
|
|
174
|
+
if (keyType) reason.unlock_key_type = keyType;
|
|
175
|
+
const exp = info.expiresAt ?? info.unlock_expires_at;
|
|
176
|
+
if (typeof exp === 'number') {
|
|
177
|
+
reason.unlock_expires_at = new Date(exp * 1000).toISOString();
|
|
178
|
+
} else if (typeof exp === 'string') {
|
|
179
|
+
reason.unlock_expires_at = exp;
|
|
180
|
+
}
|
|
181
|
+
const status = info.unlockStatus ?? info.unlock_status;
|
|
182
|
+
if (status) reason.unlock_status = status;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return reason as unknown as Reason;
|
|
115
186
|
});
|
|
116
187
|
}
|
|
117
188
|
|
package/src/pi/audit.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { DecisionOutput } from '../output/DecisionBuilder.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Redact common secret patterns from a command string before audit.
|
|
7
|
+
* - Bearer tokens / Authorization headers
|
|
8
|
+
* - API keys (sk-... long hex/alnum)
|
|
9
|
+
*/
|
|
10
|
+
export function redactSecrets(text: string): string {
|
|
11
|
+
let out = text;
|
|
12
|
+
// Authorization: Bearer <token>
|
|
13
|
+
out = out.replace(/(Bearer\s+)([A-Za-z0-9._\-]+)/gi, '$1[REDACTED]');
|
|
14
|
+
// Generic sk-<token> (Stripe/Anthropic/etc API keys)
|
|
15
|
+
out = out.replace(/\bsk-[A-Za-z0-9_\-]{6,}/g, 'sk-[REDACTED]');
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Audit sink bridge — writes one JSONL line per decision.
|
|
21
|
+
*
|
|
22
|
+
* Test/unit injectable: tests pass an `auditSink` with a captured `write`.
|
|
23
|
+
* Production wires this to a real filesystem sink (JSONL append).
|
|
24
|
+
*/
|
|
25
|
+
export interface AuditSink {
|
|
26
|
+
write: (entry: unknown) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface WriteAuditEntryInput {
|
|
30
|
+
sessionId: string | undefined;
|
|
31
|
+
decision: Pick<
|
|
32
|
+
DecisionOutput,
|
|
33
|
+
'decision' | 'source' | 'reasons' | 'input' | 'evaluated_at' | 'decision_id'
|
|
34
|
+
>;
|
|
35
|
+
auditSink: AuditSink;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface AuditEntry {
|
|
39
|
+
decision_id: string;
|
|
40
|
+
decision: string;
|
|
41
|
+
source: string;
|
|
42
|
+
command: string;
|
|
43
|
+
rule_ids: string[];
|
|
44
|
+
evaluated_at: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function writeAuditEntry(input: WriteAuditEntryInput): Promise<void> {
|
|
48
|
+
if (!input.sessionId) return;
|
|
49
|
+
|
|
50
|
+
const entry: AuditEntry = {
|
|
51
|
+
decision_id: input.decision.decision_id,
|
|
52
|
+
decision: input.decision.decision,
|
|
53
|
+
source: input.decision.source,
|
|
54
|
+
command: redactSecrets(input.decision.input.raw),
|
|
55
|
+
rule_ids: input.decision.reasons.map((r) => r.rule_id),
|
|
56
|
+
evaluated_at: input.decision.evaluated_at,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
await input.auditSink.write(entry);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Default filesystem audit sink — appends JSONL to `${cwd}/.pi-opa-net/audit/${decision_id}.jsonl`.
|
|
64
|
+
* Production wires this when ctx.auditSink is not injected.
|
|
65
|
+
*/
|
|
66
|
+
export function createFilesystemAuditSink(cwd: string): AuditSink {
|
|
67
|
+
const auditDir = resolve(cwd, '.pi-opa-net', 'audit');
|
|
68
|
+
return {
|
|
69
|
+
write: async (entry: unknown) => {
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(auditDir, { recursive: true });
|
|
72
|
+
const decisionId = (entry as { decision_id?: string }).decision_id ?? 'unknown';
|
|
73
|
+
const logPath = resolve(auditDir, `${decisionId}.jsonl`);
|
|
74
|
+
appendFileSync(logPath, `${JSON.stringify(entry)}\n`);
|
|
75
|
+
} catch {
|
|
76
|
+
// Audit write failure is non-fatal — log to stderr and continue.
|
|
77
|
+
console.error('[pi-opa-net] audit write failed, continuing without audit');
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/pi/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { registerToolCallEvent } from './tool-call.ts';
|
|
4
|
+
|
|
5
|
+
type PiExtensionApi = Parameters<typeof registerToolCallEvent>[0];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auto-discover the rule/policy config shipped alongside the pi agent.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors pi-safety-net/src/pi/index.ts: derives agent dir from PI_CODING_AGENT_DIR
|
|
11
|
+
* or PI_SESSION_FILE (sessions/ parent), checks for a `<agentDir>/pi-opa-net/rules/`
|
|
12
|
+
* candidate, and sets PIOPANET_HOME so the engine picks it up zero-config.
|
|
13
|
+
*/
|
|
14
|
+
function autoDiscoverOpaNetHome(): void {
|
|
15
|
+
if (process.env.PIOPANET_HOME) return;
|
|
16
|
+
const agentDir =
|
|
17
|
+
process.env.PI_CODING_AGENT_DIR ||
|
|
18
|
+
(process.env.PI_SESSION_FILE && dirname(dirname(process.env.PI_SESSION_FILE)));
|
|
19
|
+
if (!agentDir) return;
|
|
20
|
+
const candidate = join(agentDir, 'pi-opa-net');
|
|
21
|
+
if (existsSync(join(candidate, 'rules'))) {
|
|
22
|
+
process.env.PIOPANET_HOME = candidate;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function piOpaNetExtension(pi: PiExtensionApi): void {
|
|
27
|
+
autoDiscoverOpaNetHome();
|
|
28
|
+
registerToolCallEvent(pi);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Re-export for direct unit-test access.
|
|
32
|
+
export { handlePiToolCall, registerToolCallEvent } from './tool-call.ts';
|
|
33
|
+
export type { PiToolCallContext, PiToolCallResult } from './tool-call.ts';
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { DecisionOutput } from '../output/DecisionBuilder.ts';
|
|
5
|
+
import { type AuditSink, createFilesystemAuditSink, writeAuditEntry } from './audit.ts';
|
|
6
|
+
|
|
7
|
+
/** Fail-closed block reason — mirrors pi-safety-net's REASON_SAFETY_NET_FAILED_CLOSED. */
|
|
8
|
+
export const REASON_OPA_NET_FAILED_CLOSED =
|
|
9
|
+
'OPA-Net fail-closed: command evaluation failed unexpectedly.';
|
|
10
|
+
|
|
11
|
+
/** Reason prefix for malformed shell tool input. */
|
|
12
|
+
const REASON_MALFORMED_SHELL_INPUT =
|
|
13
|
+
'OPA-Net fail-closed: shell tool call input missing required command field.';
|
|
14
|
+
|
|
15
|
+
type PiApi = {
|
|
16
|
+
on: (
|
|
17
|
+
event: 'tool_call',
|
|
18
|
+
handler: (event: unknown, ctx: PiToolCallContext) => Promise<PiToolCallResult>,
|
|
19
|
+
) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type EvalOpts = { cwd?: string; env?: NodeJS.ProcessEnv };
|
|
23
|
+
|
|
24
|
+
export type PiToolCallContext = {
|
|
25
|
+
cwd: string;
|
|
26
|
+
sessionManager: {
|
|
27
|
+
getSessionFile: () => string | undefined;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Inject-able eval function. Unit tests pass a stub. Production wires this
|
|
31
|
+
* to the real `bin/pi-opa-net.js eval` subprocess.
|
|
32
|
+
*/
|
|
33
|
+
opaNetEvalCommand?: (command: string, opts?: EvalOpts) => Promise<DecisionOutput>;
|
|
34
|
+
/** Optional audit sink for test capture. Production wires filesystem sink. */
|
|
35
|
+
auditSink?: AuditSink;
|
|
36
|
+
/** Reserved for future config passthrough. */
|
|
37
|
+
opaNetConfigOptions?: Record<string, unknown>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type PiToolCallResult = { block: true; reason: string } | undefined;
|
|
41
|
+
|
|
42
|
+
type PiToolCallEvent = {
|
|
43
|
+
type?: string;
|
|
44
|
+
toolName?: string;
|
|
45
|
+
input?: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type PiShellToolAdapter = {
|
|
49
|
+
commandField: string;
|
|
50
|
+
cwdField?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const PI_SHELL_TOOL_ADAPTERS: Partial<Record<string, PiShellToolAdapter>> = {
|
|
54
|
+
bash: { commandField: 'command' },
|
|
55
|
+
Shell: { commandField: 'command', cwdField: 'working_directory' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
type PiShellToolCall = { command: string; cwd: string } | { malformed: true };
|
|
59
|
+
|
|
60
|
+
export function registerToolCallEvent(pi: PiApi): void {
|
|
61
|
+
pi.on('tool_call', handlePiToolCall);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isStrictMode(): boolean {
|
|
65
|
+
return process.env.PIOPANET_STRICT === '1';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Default subprocess eval — spawns `bin/pi-opa-net.js eval "<cmd>" --json`
|
|
70
|
+
* and parses stdout as DecisionOutput. This is the production bridge:
|
|
71
|
+
* without this, the handler has no way to evaluate commands.
|
|
72
|
+
*
|
|
73
|
+
* Mirrors pi-safety-net's `?? analyzeCommand` default.
|
|
74
|
+
*/
|
|
75
|
+
async function defaultEvalCommand(command: string, opts?: EvalOpts): Promise<DecisionOutput> {
|
|
76
|
+
const binPath = resolve(fileURLToPath(import.meta.url), '../../../bin/pi-opa-net.js');
|
|
77
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
78
|
+
|
|
79
|
+
return new Promise((accept, reject) => {
|
|
80
|
+
const child = spawn('bun', [binPath, 'eval', command, '--json'], {
|
|
81
|
+
cwd,
|
|
82
|
+
env: opts?.env ?? process.env,
|
|
83
|
+
});
|
|
84
|
+
let stdout = '';
|
|
85
|
+
let stderr = '';
|
|
86
|
+
child.stdout.on('data', (d: Buffer) => {
|
|
87
|
+
stdout += d.toString();
|
|
88
|
+
});
|
|
89
|
+
child.stderr.on('data', (d: Buffer) => {
|
|
90
|
+
stderr += d.toString();
|
|
91
|
+
});
|
|
92
|
+
child.on('error', reject);
|
|
93
|
+
child.on('close', (code: number | null) => {
|
|
94
|
+
if (code !== 0 && code !== 2) {
|
|
95
|
+
// Exit code 0 = allow, 2 = deny. Anything else = subprocess error.
|
|
96
|
+
reject(new Error(`pi-opa-net eval exited ${code}: ${stderr}`));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const decision = JSON.parse(stdout.trim()) as DecisionOutput;
|
|
101
|
+
accept(decision);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
reject(
|
|
104
|
+
new Error(`pi-opa-net eval non-JSON stdout: ${stdout.slice(0, 200)}\nstderr: ${stderr}`),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @internal — exported for test coverage */
|
|
112
|
+
export async function handlePiToolCall(
|
|
113
|
+
event: unknown,
|
|
114
|
+
ctx: PiToolCallContext,
|
|
115
|
+
): Promise<PiToolCallResult> {
|
|
116
|
+
const shellToolCall = getPiShellToolCall(event, ctx);
|
|
117
|
+
if (!shellToolCall) return undefined;
|
|
118
|
+
|
|
119
|
+
if ('malformed' in shellToolCall) {
|
|
120
|
+
return blockPiToolCall(REASON_MALFORMED_SHELL_INPUT);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const { command, cwd } = shellToolCall;
|
|
124
|
+
|
|
125
|
+
// Use injected eval OR default subprocess eval.
|
|
126
|
+
const evalCommand = ctx.opaNetEvalCommand ?? defaultEvalCommand;
|
|
127
|
+
|
|
128
|
+
let decision: DecisionOutput;
|
|
129
|
+
try {
|
|
130
|
+
decision = await evalCommand(command, { cwd });
|
|
131
|
+
} catch {
|
|
132
|
+
// Fork fail-open default: do not brick the agent on eval errors.
|
|
133
|
+
if (isStrictMode()) {
|
|
134
|
+
return blockPiToolCall(REASON_OPA_NET_FAILED_CLOSED, command);
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Allow + log_only: proceed (with audit).
|
|
140
|
+
// opa-unlocked: engine already filtered — plugin trusts it.
|
|
141
|
+
// fail-open / cached: degraded paths — proceed silently.
|
|
142
|
+
if (
|
|
143
|
+
decision.decision === 'allow' ||
|
|
144
|
+
decision.action === 'allow' ||
|
|
145
|
+
decision.action === 'log_only'
|
|
146
|
+
) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Deny + block: translate + audit + block.
|
|
151
|
+
if (decision.decision === 'deny' && decision.action === 'block') {
|
|
152
|
+
const sessionId = ctx.sessionManager.getSessionFile();
|
|
153
|
+
if (sessionId) {
|
|
154
|
+
const auditSink = ctx.auditSink ?? createFilesystemAuditSink(cwd);
|
|
155
|
+
await writeAuditEntry({
|
|
156
|
+
sessionId,
|
|
157
|
+
decision,
|
|
158
|
+
auditSink,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return blockPiToolCall(formatBlockReason(decision), command);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// prompt_user (reserved v2) — treat as allow for now.
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function getPiShellToolCall(event: unknown, ctx: PiToolCallContext): PiShellToolCall | undefined {
|
|
169
|
+
if (!event || typeof event !== 'object') return undefined;
|
|
170
|
+
const toolCall = event as PiToolCallEvent;
|
|
171
|
+
if (typeof toolCall.toolName !== 'string') return undefined;
|
|
172
|
+
|
|
173
|
+
const adapter = PI_SHELL_TOOL_ADAPTERS[toolCall.toolName];
|
|
174
|
+
if (!adapter) return undefined;
|
|
175
|
+
if (!toolCall.input || typeof toolCall.input !== 'object') return { malformed: true };
|
|
176
|
+
|
|
177
|
+
const command = toolCall.input[adapter.commandField];
|
|
178
|
+
if (typeof command !== 'string') return { malformed: true };
|
|
179
|
+
|
|
180
|
+
const cwdInput = adapter.cwdField ? toolCall.input[adapter.cwdField] : undefined;
|
|
181
|
+
const cwd = typeof cwdInput === 'string' ? resolve(ctx.cwd, cwdInput) : ctx.cwd;
|
|
182
|
+
return { command, cwd };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function formatBlockReason(decision: DecisionOutput): string {
|
|
186
|
+
const reasons = decision.reasons.map((r) => `${r.rule_id}: ${r.message}`).join('; ');
|
|
187
|
+
const header = decision.summary || 'BLOCKED by OPA-Net';
|
|
188
|
+
return `${header}\nReason: ${reasons}\nRule family: ${decision.reasons[0]?.family ?? 'unknown'}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function blockPiToolCall(reason: string, _command?: string): PiToolCallResult {
|
|
192
|
+
return { block: true, reason };
|
|
193
|
+
}
|
|
@@ -10,7 +10,10 @@ export type RuleFamily =
|
|
|
10
10
|
| 'glab'
|
|
11
11
|
| 'bd'
|
|
12
12
|
| 'builtin'
|
|
13
|
-
| 'custom'
|
|
13
|
+
| 'custom'
|
|
14
|
+
| 'tmux'
|
|
15
|
+
| 'pkill'
|
|
16
|
+
| 'killall';
|
|
14
17
|
|
|
15
18
|
export interface RuleMeta {
|
|
16
19
|
readonly ruleId: string;
|
|
@@ -18,6 +21,15 @@ export interface RuleMeta {
|
|
|
18
21
|
readonly message: string;
|
|
19
22
|
/** Safe alternatives for the "did you mean?" UX. Optional. */
|
|
20
23
|
readonly suggestions?: readonly string[];
|
|
24
|
+
/**
|
|
25
|
+
* Disambiguation tokens used ONLY when multiple registered rules share an
|
|
26
|
+
* identical message AND family (collision). If any of these tokens appears
|
|
27
|
+
* in the parsed command's args, this candidate wins. Only needed for rules
|
|
28
|
+
* whose reason text is byte-identical to a sibling rule's (e.g.
|
|
29
|
+
* block-tmux-kill-server vs block-tmux-kill-session, which share the same
|
|
30
|
+
* verbatim reason and family 'tmux').
|
|
31
|
+
*/
|
|
32
|
+
readonly matchArgs?: readonly string[];
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
/**
|
|
@@ -28,20 +40,51 @@ export interface RuleMeta {
|
|
|
28
40
|
* so audits trace decision → rule → source line. Messages not in the registry
|
|
29
41
|
* fall back to a synthesized `custom:<hash>` id with family=custom.
|
|
30
42
|
*
|
|
43
|
+
* Multiple rules MAY share an identical message (e.g. the four
|
|
44
|
+
* tmux/pkill/killall session-kill rules all carry the same verbatim reason
|
|
45
|
+
* text). Such collisions are stored as a list and resolved by family hint
|
|
46
|
+
* (inferred from the parsed program) when one is supplied to lookup().
|
|
47
|
+
*
|
|
31
48
|
* Keeping this in TS (not rego) is intentional: rego is the policy, this is the
|
|
32
49
|
* provenance metadata layer. DRY — one canonical list, consumed by the builder.
|
|
33
50
|
*/
|
|
34
51
|
export class RuleRegistry {
|
|
35
|
-
private readonly byMessage: Map<string, RuleMeta>;
|
|
52
|
+
private readonly byMessage: Map<string, RuleMeta[]>;
|
|
36
53
|
|
|
37
54
|
constructor(rules: readonly RuleMeta[]) {
|
|
38
|
-
this.byMessage = new Map
|
|
55
|
+
this.byMessage = new Map<string, RuleMeta[]>();
|
|
56
|
+
for (const r of rules) {
|
|
57
|
+
const arr = this.byMessage.get(r.message) ?? [];
|
|
58
|
+
arr.push(r);
|
|
59
|
+
this.byMessage.set(r.message, arr);
|
|
60
|
+
}
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Look up metadata for a deny message; synthesizes a custom entry if unknown.
|
|
65
|
+
*
|
|
66
|
+
* Collision resolution order when several registered rules share the same
|
|
67
|
+
* message:
|
|
68
|
+
* 1. `familyHint` (inferred from the parsed program) narrows to that family.
|
|
69
|
+
* 2. `parsedArgs` narrows further to a candidate whose `matchArgs` intersects
|
|
70
|
+
* the parsed args (for rules that share message AND family, e.g. the two
|
|
71
|
+
* tmux kill-* rules).
|
|
72
|
+
* 3. Otherwise the first remaining candidate is returned.
|
|
73
|
+
*/
|
|
74
|
+
lookup(deny: RawDeny, familyHint?: string, parsedArgs?: readonly string[]): RuleMeta {
|
|
75
|
+
const candidates = this.byMessage.get(deny.message);
|
|
76
|
+
if (candidates && candidates.length > 0) {
|
|
77
|
+
let pool = candidates;
|
|
78
|
+
if (familyHint) {
|
|
79
|
+
const byFamily = pool.filter((c) => c.family === familyHint);
|
|
80
|
+
if (byFamily.length > 0) pool = byFamily;
|
|
81
|
+
}
|
|
82
|
+
if (parsedArgs && pool.length > 1) {
|
|
83
|
+
const byArgs = pool.filter((c) => c.matchArgs?.some((t) => parsedArgs.includes(t)));
|
|
84
|
+
if (byArgs.length > 0) pool = byArgs;
|
|
85
|
+
}
|
|
86
|
+
return pool[0];
|
|
87
|
+
}
|
|
45
88
|
return {
|
|
46
89
|
ruleId: `custom:${hashMessage(deny.message)}`,
|
|
47
90
|
family: 'custom',
|
package/src/rules/catalog.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RuleMeta } from './RuleRegistry.ts';
|
|
1
|
+
import type { RuleFamily, RuleMeta } from './RuleRegistry.ts';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Canonical rule catalog — mirrors policy/safety.rego message-for-message.
|
|
@@ -206,15 +206,51 @@ export const RULES: readonly RuleMeta[] = [
|
|
|
206
206
|
family: 'glab',
|
|
207
207
|
message: 'Public GitLab repository creation is blocked by default.',
|
|
208
208
|
},
|
|
209
|
+
// ── GROUP G: tmux / pkill / killall session protection (cc-safety-net parity) ──
|
|
210
|
+
{
|
|
211
|
+
ruleId: 'block-tmux-kill-server',
|
|
212
|
+
family: 'tmux',
|
|
213
|
+
message:
|
|
214
|
+
'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
|
|
215
|
+
matchArgs: ['kill-server'],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
ruleId: 'block-tmux-kill-session',
|
|
219
|
+
family: 'tmux',
|
|
220
|
+
message:
|
|
221
|
+
'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
|
|
222
|
+
matchArgs: ['kill-session'],
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
ruleId: 'block-pkill-tmux-wezterm',
|
|
226
|
+
family: 'pkill',
|
|
227
|
+
message:
|
|
228
|
+
'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
ruleId: 'block-killall-tmux-wezterm',
|
|
232
|
+
family: 'killall',
|
|
233
|
+
message:
|
|
234
|
+
'Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically \u2014 hand the exact command back to the user and let them run it themselves.',
|
|
235
|
+
},
|
|
209
236
|
];
|
|
210
237
|
|
|
211
|
-
/** gcloud/bq produce sprintf messages — family inferred from program.
|
|
212
|
-
|
|
238
|
+
/** gcloud/bq produce sprintf messages — family inferred from program.
|
|
239
|
+
* tmux/pkill/killall rules share identical reason text (the four session-kill
|
|
240
|
+
* rules), so their family is also inferred from the program to disambiguate
|
|
241
|
+
* the message-keyed registry. */
|
|
242
|
+
export function inferFamilyFromProgram(program: string): RuleFamily {
|
|
213
243
|
switch (program) {
|
|
214
244
|
case 'gcloud':
|
|
215
245
|
return 'gcloud';
|
|
216
246
|
case 'bq':
|
|
217
247
|
return 'bq';
|
|
248
|
+
case 'tmux':
|
|
249
|
+
return 'tmux';
|
|
250
|
+
case 'pkill':
|
|
251
|
+
return 'pkill';
|
|
252
|
+
case 'killall':
|
|
253
|
+
return 'killall';
|
|
218
254
|
default:
|
|
219
255
|
return 'custom';
|
|
220
256
|
}
|