pi-opa-net 0.1.0 → 0.2.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 +5 -2
- 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/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/CHANGELOG.md
CHANGED
|
@@ -5,8 +5,50 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2026-07-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Capability-based unlock-keys** (`src/unlock/`) — trusted agents present a per-rule salted HMAC key (long-lived `ll_<16hex>` or TTL `ttl.<exp>.<16hex>`); the TS-side post-eval filter demotes matching deny reasons. Schema stays v1 (additive). Policy file `safety.rego` is unchanged.
|
|
13
|
+
- **`unlock-key` CLI subcommand** — `pi-opa-net unlock-key <rule_id> [--ttl <sec>]` mints keys; `--list` enumerates catalog rule_ids. Refuses unknown rule_ids and the god-key `PIOPANET_UNLOCK_ALL`.
|
|
14
|
+
- **Three delivery channels** — `PIOPANET_UNLOCK_KEYS` env (comma-separated), `--unlock <key>` (repeatable), `--unlock-stdin` (requires positional command arg).
|
|
15
|
+
- **Salt seam** (`SaltResolver`) — deploy-local `~/.pi-opa-net/salt` with auto-gen (atomic `wx`, mode 0o600), env override (`PIOPANET_UNLOCK_SALT` literal, `PIOPANET_UNLOCK_SALT_FILE` path), warn on world-readable. Interface for future remote/keychain resolver.
|
|
16
|
+
- **Audit seam** (`AuditSink` + `NoOpSink`) — decision record is the sole audit surface in v1. Interface for future `FileAppendSink`/`WebhookSink`.
|
|
17
|
+
- **All-or-nothing multi-rule semantics** — allow ⟺ every `severity:block` reason has a matching valid key. Partial bypass forbidden; `metadata.unlock_blocked_count` records remaining blockers.
|
|
18
|
+
- **Three new schema sources** — `'opa-unlocked'` (legitimate bypass), `'fail-open-keyless'` (OPA down + keys present, auditable degradation), `'unlock-filter-error'` (filter crash falls back to un-filtered decision, never allows-by-accident).
|
|
19
|
+
- **Cache poisoning guard** — when unlock keys present, `cacheTtlMs` forced to 0 regardless of `PI_OPA_CACHE_TTL_MS`.
|
|
20
|
+
- **Decision-output.v1 schema (additive)** — `reasons[]` gains optional `bypassed`, `unlock_key_id` (first 8 hex only — full key NEVER logged), `unlock_key_type`, `unlock_expires_at`, `unlock_status`; `metadata` gains `unlock_count`, `unlock_blocked_count`, `unlock_agent`. `additionalProperties:false` preserved everywhere.
|
|
21
|
+
- **OpenSpec change** at `openspec/changes/rule-unlock-keys/` — proposal, design (D1–D11), spec (REQ-001..018 + 9 scenarios), tasks.
|
|
22
|
+
- **109 new tests** (304 total pass) covering key derivation, parser, verifier, filter, salt resolver, audit sink, decision builder integration, CLI, schema, and e2e flow. Typecheck + lint clean.
|
|
23
|
+
|
|
24
|
+
### Decisions (locked, immutable)
|
|
25
|
+
|
|
26
|
+
- **LD-L1**: per-rule granularity (one rule = one key, no per-command)
|
|
27
|
+
- **LD-L2**: no god-key (refuse `PIOPANET_UNLOCK_ALL`)
|
|
28
|
+
- **LD-L3**: two lifetimes (LL + TTL) via self-describing prefix
|
|
29
|
+
- **LD-L4**: delivery = ENV + `--unlock` + `--unlock-stdin`
|
|
30
|
+
- **LD-L6**: TS-side post-eval filter (keys never enter OPA input/trace)
|
|
31
|
+
- **LD-Y1/Y2**: deploy-local salt + NoOp audit (YAGNI seams for future)
|
|
32
|
+
- **LD-G1**: fail-open+keys → `source:fail-open-keyless`
|
|
33
|
+
- **LD-G2**: `--unlock-stdin` requires positional command
|
|
34
|
+
- **LD-G3**: `cacheTtlMs` forced 0 when keys present
|
|
35
|
+
- **LD-G6**: all-or-nothing multi-rule semantics
|
|
36
|
+
- **LD-G8**: filter crash → fall back to un-filtered decision
|
|
37
|
+
|
|
38
|
+
### Verifier-loop
|
|
39
|
+
|
|
40
|
+
- Pre-merge: jewilo 2/2 APPROVE, hash `072026-c604475a` (fullDigest `c604475a...`).
|
|
41
|
+
- Post-merge: jewilo-dev with rag-quick model 2/2 APPROVE, hash `072026-7291243b`.
|
|
42
|
+
|
|
8
43
|
## [Unreleased]
|
|
9
44
|
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- **Cupcake-compatible policy** (`.cupcake/policies/claude/cc_safety_net_parity.rego`) that ports all 42 active `cc-safety-net` user rules into OPA/Rego v1 with the Cupcake custom-policy structure: `# METADATA`, `package cupcake.policies.cc_safety_net_parity`, `import rego.v1`, and mandatory self-filtering. The aggregation entrypoint `data.cupcake.system.evaluate` is provided in `.cupcake/system/evaluate.rego`.
|
|
48
|
+
- **Rulebook fixture** at `tests/fixtures/user-rules.rulebook.json` and a new `tests/cupcake/cc_safety_net_parity.test.ts` suite that runs all 53 rulebook `tests[]` fixtures + 12 tmux/pkill/killall scenarios + self-filtering checks.
|
|
49
|
+
- **4 missing tmux/pkill/killall rules** to the pi-opa-net engine (`policy/safety.rego`, `src/rules/catalog.ts`) for full 42-rule parity with the active `cc-safety-net` rulebook.
|
|
50
|
+
- **DecisionBuilder disambiguation** for rules that share identical reason text (the tmux session-kill family): the registry now maps `(message, family)` to rule metadata when a raw deny carries a `family` hint, falling back to the previous message-only lookup.
|
|
51
|
+
- **Documentation**: `docs/cupcake-parity.md` describing the Cupcake policy and standalone `opa eval` usage; README updated to reflect the 42-rule catalog and the new policy file.
|
|
10
52
|
## [0.1.0] - 2026-07-01
|
|
11
53
|
|
|
12
54
|
### Added
|
package/README.md
CHANGED
|
@@ -20,11 +20,21 @@ Three limitations of today's asymmetric, agent-specific guard output that this f
|
|
|
20
20
|
|
|
21
21
|
## Status
|
|
22
22
|
|
|
23
|
-
- **Stable:** v0.
|
|
23
|
+
- **Stable:** v0.2.0 — schema v1.0 (additive), 42-rule catalog, capability-based unlock-keys, full TDD coverage (304 tests)
|
|
24
|
+
- Cupcake-compatible policy: `.cupcake/policies/claude/cc_safety_net_parity.rego` (all 42 active `cc-safety-net` rules in OPA/Rego v1)
|
|
25
|
+
- **Capability-based unlock-keys** (v0.2.0) — trusted agents present a per-rule salted HMAC key; TS-side post-eval filter demotes matching deny reasons.
|
|
24
26
|
- **Engine:** OPA 1.x (lazy-loaded on every dev box)
|
|
25
27
|
- **Scope:** bash command guarding only (see [`docs/locked-decisions.yaml`](docs/locked-decisions.yaml) LD3)
|
|
26
28
|
- **Pi extension:** the thin tool_call adapter lives in a separate future repo (`pi-opa-net-ext`, per OT5) — this package is the engine + library
|
|
27
29
|
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **OPA-backed decisions** — every command evaluated by an [OPA](https://www.openpolicyagent.org/)/Rego policy; 42-rule catalog (full `cc-safety-net` user-rule parity).
|
|
33
|
+
- **Symmetric structured output** — both allow AND deny emit the full `decision-output.v1` schema with `reasons[].rule_id` provenance, fail-mode observability, and parse-confidence surfacing.
|
|
34
|
+
- **Fail-open by default** — never bricks the shell; matches the `pi-safety-net` fork guarantee. `PI_OPA_FAIL_MODE=closed` for fail-closed.
|
|
35
|
+
- **Capability-based unlock-keys** (v0.2.0) — grant trusted agents a per-rule salted HMAC key (long-lived `ll_<16hex>` or TTL `ttl.<exp>.<16hex>`). TS-side post-eval filter demotes matching deny reasons. All-or-nothing multi-rule semantics; every bypass is auditable via `source:'opa-unlocked'`.
|
|
36
|
+
- **Claude Code hook compatible** — exit codes `0 = allow`, `2 = deny`; JSON on stdout.
|
|
37
|
+
- **Pluggable seams** — `SaltResolver` (deploy-local salt now, remote/keychain later) and `AuditSink` (decision-record only now, file/webhook later).
|
|
28
38
|
## Installation
|
|
29
39
|
|
|
30
40
|
### Prerequisites
|
|
@@ -48,6 +58,33 @@ bun add pi-opa-net
|
|
|
48
58
|
bunx pi-opa-net eval "git stash pop"
|
|
49
59
|
```
|
|
50
60
|
|
|
61
|
+
### For AI Agents (pi / OpenCode / Claude Code / Codex)
|
|
62
|
+
|
|
63
|
+
Add to your `settings.json`:
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
{
|
|
67
|
+
"packages": ["pi-opa-net"]
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or tell your agent:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Install and configure pi-opa-net by following:
|
|
75
|
+
https://raw.githubusercontent.com/buihongduc132/pi-opa-net/refs/heads/main/README.md
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### For pi (git-sourced)
|
|
79
|
+
|
|
80
|
+
In `settings.json`:
|
|
81
|
+
|
|
82
|
+
```jsonc
|
|
83
|
+
{
|
|
84
|
+
"packages": ["https://github.com/buihongduc132/pi-opa-net"]
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
51
88
|
## Usage
|
|
52
89
|
|
|
53
90
|
### CLI
|
|
@@ -179,12 +216,17 @@ bun run lint # biome
|
|
|
179
216
|
bun run smoke # one-shot CLI check
|
|
180
217
|
```
|
|
181
218
|
|
|
182
|
-
E2E tests run the live CLI against real OPA + the real policy, covering ≥40% of the
|
|
219
|
+
E2E tests run the live CLI against real OPA + the real policy, covering ≥40% of the 42-rule catalog.
|
|
220
|
+
|
|
221
|
+
## Cupcake-compatible policy
|
|
222
|
+
|
|
223
|
+
This repo also ships a Cupcake-format OPA/Rego policy at `.cupcake/policies/claude/cc_safety_net_parity.rego` that ports all 42 active `cc-safety-net` user rules. It can be evaluated directly by OPA or consumed as a Cupcake catalog overlay. See [`docs/cupcake-parity.md`](docs/cupcake-parity.md) for details, including the standalone `opa eval` examples and the input/output contract.
|
|
183
224
|
|
|
184
225
|
## Decisions & open threads
|
|
185
226
|
|
|
186
227
|
- [`docs/locked-decisions.yaml`](docs/locked-decisions.yaml) — LD1–LD5 (immutable inputs).
|
|
187
228
|
- [`docs/open-threads.yaml`](docs/open-threads.yaml) — OT1–OT5 resolved at implementation time.
|
|
229
|
+
- [`docs/cupcake-parity.md`](docs/cupcake-parity.md) — Cupcake-format policy documentation + standalone `opa eval` usage.
|
|
188
230
|
|
|
189
231
|
## Project health
|
|
190
232
|
|
|
@@ -198,6 +240,8 @@ E2E tests run the live CLI against real OPA + the real policy, covering ≥40% o
|
|
|
198
240
|
- [`pi-safety-net`](https://www.npmjs.com/package/pi-safety-net) — the fail-open fork of cc-safety-net (Path A: non-pi agents). pi-opa-net is Path B (OPA-backed, structured output).
|
|
199
241
|
- [`cc-safety-net`](https://www.npmjs.com/package/cc-safety-net) — upstream Claude Code safety net.
|
|
200
242
|
|
|
201
|
-
## License
|
|
202
|
-
|
|
203
243
|
MIT © [buihongduc132](https://github.com/buihongduc132)
|
|
244
|
+
|
|
245
|
+
## Repository
|
|
246
|
+
|
|
247
|
+
**GitHub**: [buihongduc132/pi-opa-net](https://github.com/buihongduc132/pi-opa-net)
|
package/bin/pi-opa-net.js
CHANGED
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
* Usage:
|
|
6
6
|
* pi-opa-net eval "<command>" # claude-code mode (default)
|
|
7
7
|
* pi-opa-net eval "<command>" --json # full schema on stdout always
|
|
8
|
+
* pi-opa-net eval "<command>" --unlock <key> # present unlock key
|
|
8
9
|
* echo "<command>" | pi-opa-net eval # read from stdin
|
|
10
|
+
* pi-opa-net unlock-key <rule_id> # mint long-lived key
|
|
11
|
+
* pi-opa-net unlock-key <rule_id> --ttl 3600 # mint TTL key
|
|
12
|
+
* pi-opa-net unlock-key --list # list unlockable rule_ids
|
|
9
13
|
*
|
|
10
14
|
* Exit codes: 0=allow, 2=deny (Claude Code hook protocol compatible).
|
|
11
15
|
*/
|
|
12
16
|
import { defaultPolicyPath, runCli } from '../src/cli/run.ts';
|
|
17
|
+
import { listUnlockableRules, mintUnlockKey } from '../src/cli/unlock-key.ts';
|
|
18
|
+
import { configFromEnv } from '../src/config/Config.ts';
|
|
13
19
|
|
|
14
20
|
function parseArgs(argv) {
|
|
15
21
|
const args = argv.slice(2); // drop node + script
|
|
@@ -17,6 +23,8 @@ function parseArgs(argv) {
|
|
|
17
23
|
let command;
|
|
18
24
|
let policyPath = defaultPolicyPath();
|
|
19
25
|
let sawAction = false;
|
|
26
|
+
const unlockKeys = [];
|
|
27
|
+
let unlockStdin = false;
|
|
20
28
|
|
|
21
29
|
for (let i = 0; i < args.length; i++) {
|
|
22
30
|
const a = args[i];
|
|
@@ -24,6 +32,10 @@ function parseArgs(argv) {
|
|
|
24
32
|
mode = 'json';
|
|
25
33
|
} else if (a === '--policy' || a === '-p') {
|
|
26
34
|
policyPath = args[++i];
|
|
35
|
+
} else if (a === '--unlock') {
|
|
36
|
+
unlockKeys.push(args[++i]);
|
|
37
|
+
} else if (a === '--unlock-stdin') {
|
|
38
|
+
unlockStdin = true;
|
|
27
39
|
} else if (a === '--help' || a === '-h') {
|
|
28
40
|
printHelp();
|
|
29
41
|
process.exit(0);
|
|
@@ -36,30 +48,99 @@ function parseArgs(argv) {
|
|
|
36
48
|
}
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
|
-
return { command, mode, policyPath };
|
|
51
|
+
return { command, mode, policyPath, unlockKeys, unlockStdin };
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
function printHelp() {
|
|
43
55
|
console.error(`pi-opa-net — OPA-backed bash command guard
|
|
44
56
|
|
|
45
57
|
Usage:
|
|
46
|
-
pi-opa-net eval "<command>" [--json] [--policy <path>]
|
|
58
|
+
pi-opa-net eval "<command>" [--json] [--policy <path>] [--unlock <key> ...]
|
|
47
59
|
echo "<command>" | pi-opa-net eval
|
|
60
|
+
pi-opa-net unlock-key <rule_id> [--ttl <sec>]
|
|
61
|
+
pi-opa-net unlock-key --list
|
|
48
62
|
|
|
49
63
|
Modes:
|
|
50
64
|
(default) claude-code: suppress stdout on allow, JSON on deny
|
|
51
65
|
--json always emit full decision-output.v1 schema on stdout
|
|
52
66
|
|
|
67
|
+
Unlock:
|
|
68
|
+
--unlock <key> Present an unlock key (repeatable)
|
|
69
|
+
--unlock-stdin Read a single key from stdin (requires positional command)
|
|
70
|
+
PIOPANET_UNLOCK_KEYS Comma-separated keys via env var
|
|
71
|
+
|
|
53
72
|
Exit codes: 0=allow, 2=deny`);
|
|
54
73
|
}
|
|
55
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Dispatch the unlock-key subcommand.
|
|
77
|
+
* Returns exit code; never throws (prints error to stderr).
|
|
78
|
+
*/
|
|
79
|
+
function runUnlockKey(subArgs) {
|
|
80
|
+
// Parse sub-args: --list, --ttl <sec>, or <rule_id>
|
|
81
|
+
let ruleId;
|
|
82
|
+
let ttlSec;
|
|
83
|
+
for (let i = 0; i < subArgs.length; i++) {
|
|
84
|
+
const a = subArgs[i];
|
|
85
|
+
if (a === '--list') {
|
|
86
|
+
for (const id of listUnlockableRules()) {
|
|
87
|
+
console.log(id);
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
if (a === '--ttl') {
|
|
92
|
+
ttlSec = Number.parseInt(subArgs[++i], 10);
|
|
93
|
+
} else if (!a.startsWith('-')) {
|
|
94
|
+
ruleId = a;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!ruleId) {
|
|
99
|
+
console.error('error: unlock-key requires a rule_id argument (or --list)');
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const config = configFromEnv(defaultPolicyPath());
|
|
105
|
+
const key = mintUnlockKey({
|
|
106
|
+
ruleId,
|
|
107
|
+
saltPath: config.unlockSaltPath,
|
|
108
|
+
ttlSec,
|
|
109
|
+
});
|
|
110
|
+
console.log(key);
|
|
111
|
+
return 0;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.error(`error: ${e.message}`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
56
118
|
async function main() {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
119
|
+
const rawArgs = process.argv.slice(2);
|
|
120
|
+
|
|
121
|
+
// Dispatch unlock-key subcommand.
|
|
122
|
+
if (rawArgs[0] === 'unlock-key') {
|
|
123
|
+
const exitCode = runUnlockKey(rawArgs.slice(1));
|
|
124
|
+
process.exit(exitCode);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { command, mode, policyPath, unlockKeys, unlockStdin } = parseArgs(process.argv);
|
|
128
|
+
try {
|
|
129
|
+
const { stdout, exitCode } = await runCli({
|
|
130
|
+
command,
|
|
131
|
+
mode,
|
|
132
|
+
policyPath,
|
|
133
|
+
unlockKeys,
|
|
134
|
+
unlockStdin,
|
|
135
|
+
});
|
|
136
|
+
if (stdout) {
|
|
137
|
+
console.log(stdout);
|
|
138
|
+
}
|
|
139
|
+
process.exit(exitCode);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error(`error: ${e.message}`);
|
|
142
|
+
process.exit(1);
|
|
61
143
|
}
|
|
62
|
-
process.exit(exitCode);
|
|
63
144
|
}
|
|
64
145
|
|
|
65
146
|
void main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-opa-net",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"keywords": [
|
|
32
32
|
"pi-package",
|
|
33
|
+
"pi-extension",
|
|
33
34
|
"pi",
|
|
34
35
|
"pi-coding-agent",
|
|
35
36
|
"pi-library",
|
|
@@ -39,7 +40,9 @@
|
|
|
39
40
|
"safety-net",
|
|
40
41
|
"claude-code",
|
|
41
42
|
"policy",
|
|
42
|
-
"decision-output"
|
|
43
|
+
"decision-output",
|
|
44
|
+
"capability-based",
|
|
45
|
+
"unlock-keys"
|
|
43
46
|
],
|
|
44
47
|
"pi": {
|
|
45
48
|
"skills": ["./skills"]
|
package/policy/safety.rego
CHANGED
|
@@ -356,6 +356,42 @@ deny[msg] if {
|
|
|
356
356
|
msg := "Public GitLab repository creation is blocked by default."
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
# ──────────────────────────────────────────────────────────────────
|
|
360
|
+
# GROUP G — tmux / pkill / killall session protection
|
|
361
|
+
# (cc-safety-net parity: block-tmux-kill-server, block-tmux-kill-session,
|
|
362
|
+
# block-pkill-tmux-wezterm, block-killall-tmux-wezterm)
|
|
363
|
+
#
|
|
364
|
+
# The pi-opa-net parser treats tmux/pkill/killall as non-subcommand programs,
|
|
365
|
+
# so the kill verb lands in input.args. Messages are copied verbatim from the
|
|
366
|
+
# canonical rulebook reason field.
|
|
367
|
+
# ──────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
session_kill_targets := ["tmux", "wezterm", "wezterm-mux-server"]
|
|
370
|
+
|
|
371
|
+
deny[msg] if {
|
|
372
|
+
input.program == "tmux"
|
|
373
|
+
has_any_arg(input.args, ["kill-server"])
|
|
374
|
+
msg := "Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically — hand the exact command back to the user and let them run it themselves."
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
deny[msg] if {
|
|
378
|
+
input.program == "tmux"
|
|
379
|
+
has_any_arg(input.args, ["kill-session"])
|
|
380
|
+
msg := "Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically — hand the exact command back to the user and let them run it themselves."
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
deny[msg] if {
|
|
384
|
+
input.program == "pkill"
|
|
385
|
+
has_any_arg(input.args, session_kill_targets)
|
|
386
|
+
msg := "Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically — hand the exact command back to the user and let them run it themselves."
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
deny[msg] if {
|
|
390
|
+
input.program == "killall"
|
|
391
|
+
has_any_arg(input.args, session_kill_targets)
|
|
392
|
+
msg := "Killing the tmux/wezterm server destroys ALL sessions, panes, and in-flight work across every client. Do NOT run this automatically — hand the exact command back to the user and let them run it themselves."
|
|
393
|
+
}
|
|
394
|
+
|
|
359
395
|
# ──────────────────────────────────────────────────────────────────
|
|
360
396
|
# USAGE
|
|
361
397
|
# ──────────────────────────────────────────────────────────────────
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
|
|
30
30
|
"source": {
|
|
31
31
|
"type": "string",
|
|
32
|
-
"enum": ["opa", "fail-open", "fail-closed", "cached"],
|
|
33
|
-
"description": "Where the decision came from. opa=live OPA eval; fail-open=OPA unreachable, defaulted allow (see [OT2]); fail-closed=OPA unreachable, defaulted deny; cached=replay of prior decision for identical input within TTL."
|
|
32
|
+
"enum": ["opa", "fail-open", "fail-closed", "cached", "opa-unlocked", "fail-open-keyless", "unlock-filter-error"],
|
|
33
|
+
"description": "Where the decision came from. opa=live OPA eval; fail-open=OPA unreachable, defaulted allow (see [OT2]); fail-closed=OPA unreachable, defaulted deny; cached=replay of prior decision for identical input within TTL; opa-unlocked=OPA denied but all blocking rules bypassed via valid unlock keys; fail-open-keyless=OPA unreachable AND unlock keys present (degradation, not legitimate bypass); unlock-filter-error=unlock filter crashed, fell back to engine verdict."
|
|
34
34
|
},
|
|
35
35
|
|
|
36
36
|
"reasons": {
|
|
@@ -95,13 +95,37 @@
|
|
|
95
95
|
},
|
|
96
96
|
"family": {
|
|
97
97
|
"type": "string",
|
|
98
|
-
"enum": ["git", "docker", "rm", "gcloud", "bq", "gh", "glab", "bd", "builtin", "custom"],
|
|
98
|
+
"enum": ["git", "docker", "rm", "gcloud", "bq", "gh", "glab", "bd", "builtin", "custom", "tmux", "pkill", "killall"],
|
|
99
99
|
"description": "Rule family for grouping/filtering. Matches the 6 families in the rego translation (turn6)."
|
|
100
100
|
},
|
|
101
101
|
"severity": {
|
|
102
102
|
"type": "string",
|
|
103
103
|
"enum": ["block", "warn", "info"],
|
|
104
104
|
"description": "Reserved for v2 tiered outcomes. Today always 'block'. Lets future prompt_user/log_only rules coexist with hard blocks."
|
|
105
|
+
},
|
|
106
|
+
"bypassed": {
|
|
107
|
+
"type": "boolean",
|
|
108
|
+
"description": "True when this deny reason was bypassed by a valid unlock key."
|
|
109
|
+
},
|
|
110
|
+
"unlock_key_id": {
|
|
111
|
+
"type": "string",
|
|
112
|
+
"pattern": "^[a-f0-9]{8}$",
|
|
113
|
+
"description": "First 8 hex of the unlock key mac (truncated — full key MUST NEVER appear)."
|
|
114
|
+
},
|
|
115
|
+
"unlock_key_type": {
|
|
116
|
+
"type": "string",
|
|
117
|
+
"enum": ["ll", "ttl"],
|
|
118
|
+
"description": "Type of unlock key used to bypass this reason."
|
|
119
|
+
},
|
|
120
|
+
"unlock_expires_at": {
|
|
121
|
+
"type": "string",
|
|
122
|
+
"format": "date-time",
|
|
123
|
+
"description": "ISO 8601 expiry timestamp (TTL keys only)."
|
|
124
|
+
},
|
|
125
|
+
"unlock_status": {
|
|
126
|
+
"type": "string",
|
|
127
|
+
"enum": ["valid", "expired"],
|
|
128
|
+
"description": "Status of the unlock key attempt for this reason."
|
|
105
129
|
}
|
|
106
130
|
}
|
|
107
131
|
},
|
|
@@ -166,6 +190,20 @@
|
|
|
166
190
|
"session_id": {
|
|
167
191
|
"type": "string",
|
|
168
192
|
"description": "Calling agent's session ID if available (pi session, claude session, etc.). Empty string if not provided."
|
|
193
|
+
},
|
|
194
|
+
"unlock_count": {
|
|
195
|
+
"type": "integer",
|
|
196
|
+
"minimum": 0,
|
|
197
|
+
"description": "Number of deny reasons bypassed by valid unlock keys."
|
|
198
|
+
},
|
|
199
|
+
"unlock_blocked_count": {
|
|
200
|
+
"type": "integer",
|
|
201
|
+
"minimum": 0,
|
|
202
|
+
"description": "Number of deny reasons still blocking after unlock attempt (all-or-nothing semantics)."
|
|
203
|
+
},
|
|
204
|
+
"unlock_agent": {
|
|
205
|
+
"type": "string",
|
|
206
|
+
"description": "Agent identifier that presented the unlock keys."
|
|
169
207
|
}
|
|
170
208
|
}
|
|
171
209
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit sink seam [D9 / LD-Y2].
|
|
3
|
+
*
|
|
4
|
+
* The decision record (stdout JSON) IS the audit surface in v1. The default
|
|
5
|
+
* NoOpSink does nothing — no file, no network, no throw. Future sinks
|
|
6
|
+
* (FileAppendSink, WebhookSink) swap in via config with zero pipeline changes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Record passed to AuditSink.onUnlock for each unlock event. */
|
|
10
|
+
export interface UnlockRecord {
|
|
11
|
+
readonly ruleId: string;
|
|
12
|
+
readonly unlockKeyId: string;
|
|
13
|
+
readonly keyType: 'll' | 'ttl';
|
|
14
|
+
readonly timestamp: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AuditSink {
|
|
18
|
+
onUnlock(record: UnlockRecord): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** No-op default — side-effect-free. */
|
|
22
|
+
export class NoOpSink implements AuditSink {
|
|
23
|
+
onUnlock(_record: UnlockRecord): void {
|
|
24
|
+
// Intentionally empty — the decision record is the sole audit surface.
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/cli/run.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { configFromEnv } from '../config/Config.ts';
|
|
3
3
|
import { OpaCliEngine, probeOpaVersion } from '../engine/index.ts';
|
|
4
|
-
import { DecisionBuilder } from '../output/DecisionBuilder.ts';
|
|
4
|
+
import { DecisionBuilder, type DecisionOutput } from '../output/DecisionBuilder.ts';
|
|
5
5
|
import { OutputFormatter, validateDecision } from '../output/OutputFormatter.ts';
|
|
6
6
|
import { CommandParserCoordinator } from '../parser/index.ts';
|
|
7
7
|
import { RULES, RuleRegistry } from '../rules/index.ts';
|
|
8
|
+
import { SaltResolver } from '../unlock/SaltResolver.ts';
|
|
9
|
+
import { UnlockFilter } from '../unlock/UnlockFilter.ts';
|
|
10
|
+
import type { UnlockResult } from '../unlock/types.ts';
|
|
8
11
|
|
|
9
12
|
export interface CliOptions {
|
|
10
13
|
/** Command string to evaluate. If omitted, read from stdin. */
|
|
@@ -13,6 +16,12 @@ export interface CliOptions {
|
|
|
13
16
|
readonly mode: 'json' | 'claude-code';
|
|
14
17
|
/** Path to the .rego policy. */
|
|
15
18
|
readonly policyPath: string;
|
|
19
|
+
/** Unlock keys from --unlock flags (repeatable). */
|
|
20
|
+
readonly unlockKeys?: readonly string[];
|
|
21
|
+
/** Read a single unlock key from stdin (requires positional command). */
|
|
22
|
+
readonly unlockStdin?: boolean;
|
|
23
|
+
/** Injected stdin content (for testing — bypasses fd 0 read). */
|
|
24
|
+
readonly stdin?: string;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
export interface CliResult {
|
|
@@ -27,12 +36,32 @@ export interface CliResult {
|
|
|
27
36
|
* is unit-testable. The bin wrapper calls process.exit with the returned code.
|
|
28
37
|
*/
|
|
29
38
|
export async function runCli(opts: CliOptions): Promise<CliResult> {
|
|
39
|
+
// LD-G2: --unlock-stdin requires a positional command argument.
|
|
40
|
+
if (opts.unlockStdin && (!opts.command || opts.command.length === 0)) {
|
|
41
|
+
throw new Error('--unlock-stdin requires a positional command argument');
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
const raw = resolveRaw(opts);
|
|
31
45
|
if (raw === '') {
|
|
32
46
|
return { stdout: '', exitCode: 0 };
|
|
33
47
|
}
|
|
34
48
|
|
|
49
|
+
// Read unlock key from stdin if --unlock-stdin [LD-L4].
|
|
50
|
+
let stdinKey: string | undefined;
|
|
51
|
+
if (opts.unlockStdin) {
|
|
52
|
+
stdinKey = readStdinKey(opts);
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
const config = configFromEnv(opts.policyPath);
|
|
56
|
+
|
|
57
|
+
// Collect unlock keys from all channels: ENV + --unlock + --unlock-stdin.
|
|
58
|
+
const unlockKeys = dedupe([
|
|
59
|
+
...(config.unlockKeys ?? []),
|
|
60
|
+
...(opts.unlockKeys ?? []),
|
|
61
|
+
...(stdinKey ? [stdinKey] : []),
|
|
62
|
+
]);
|
|
63
|
+
const hasKeys = unlockKeys.length > 0;
|
|
64
|
+
|
|
36
65
|
const parser = new CommandParserCoordinator();
|
|
37
66
|
const parsed = parser.parse(raw);
|
|
38
67
|
|
|
@@ -45,7 +74,42 @@ export async function runCli(opts: CliOptions): Promise<CliResult> {
|
|
|
45
74
|
registry: new RuleRegistry(RULES),
|
|
46
75
|
digest: engine.rulebookDigest(),
|
|
47
76
|
});
|
|
48
|
-
|
|
77
|
+
|
|
78
|
+
let output: DecisionOutput;
|
|
79
|
+
|
|
80
|
+
if (engineDecision.source === 'fail-open' && hasKeys) {
|
|
81
|
+
// LD-G1: OPA down + keys present → fail-open-keyless (NOT opa-unlocked).
|
|
82
|
+
output = builder.build(parsed, engineDecision);
|
|
83
|
+
output = { ...output, source: 'fail-open-keyless' };
|
|
84
|
+
} else if (!hasKeys || engineDecision.decision === 'allow') {
|
|
85
|
+
// No keys or engine said allow → no unlock filtering.
|
|
86
|
+
output = builder.build(parsed, engineDecision);
|
|
87
|
+
} else {
|
|
88
|
+
// Engine said deny + keys present → run unlock filter [D1].
|
|
89
|
+
let unlockResult: UnlockResult | undefined;
|
|
90
|
+
let filterCrashed = false;
|
|
91
|
+
|
|
92
|
+
const salt = new SaltResolver({ saltPath: config.unlockSaltPath ?? '' }).resolve();
|
|
93
|
+
try {
|
|
94
|
+
unlockResult = UnlockFilter.filter(
|
|
95
|
+
engineDecision.reasons,
|
|
96
|
+
unlockKeys,
|
|
97
|
+
salt,
|
|
98
|
+
Date.now(),
|
|
99
|
+
new RuleRegistry(RULES),
|
|
100
|
+
);
|
|
101
|
+
} catch {
|
|
102
|
+
// LD-G8: filter crash → fall back to un-filtered engine decision.
|
|
103
|
+
filterCrashed = true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (filterCrashed) {
|
|
107
|
+
output = builder.build(parsed, engineDecision);
|
|
108
|
+
output = { ...output, source: 'unlock-filter-error' };
|
|
109
|
+
} else {
|
|
110
|
+
output = builder.build(parsed, engineDecision, { unlockResult });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
49
113
|
|
|
50
114
|
// Hard internal gate: the record MUST validate against the schema before emit.
|
|
51
115
|
validateDecision(output);
|
|
@@ -68,6 +132,32 @@ function resolveRaw(opts: CliOptions): string {
|
|
|
68
132
|
}
|
|
69
133
|
}
|
|
70
134
|
|
|
135
|
+
/** Read a single unlock key from stdin (or injected opts.stdin for testing). */
|
|
136
|
+
function readStdinKey(opts: CliOptions): string {
|
|
137
|
+
if (opts.stdin !== undefined) {
|
|
138
|
+
return opts.stdin.trim();
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const fs = require('node:fs') as typeof import('node:fs');
|
|
142
|
+
return fs.readFileSync(0, 'utf8').trim();
|
|
143
|
+
} catch {
|
|
144
|
+
return '';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** De-duplicate a string array while preserving order. */
|
|
149
|
+
function dedupe(arr: readonly string[]): string[] {
|
|
150
|
+
const seen = new Set<string>();
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
for (const s of arr) {
|
|
153
|
+
if (!seen.has(s)) {
|
|
154
|
+
seen.add(s);
|
|
155
|
+
out.push(s);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
71
161
|
/** Resolve the default policy path relative to package root. */
|
|
72
162
|
export function defaultPolicyPath(): string {
|
|
73
163
|
// import.meta.dir is available under Bun; fall back to cwd-relative for Node.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { RULES } from '../rules/index.ts';
|
|
2
|
+
import { KeyDerivation } from '../unlock/KeyDerivation.ts';
|
|
3
|
+
import { SaltResolver } from '../unlock/SaltResolver.ts';
|
|
4
|
+
|
|
5
|
+
/** Options for minting an unlock key. */
|
|
6
|
+
export interface MintUnlockKeyOptions {
|
|
7
|
+
/** Rule ID to mint a key for. Must be in the catalog. */
|
|
8
|
+
readonly ruleId: string;
|
|
9
|
+
/** Path to the salt file (auto-generates if missing). */
|
|
10
|
+
readonly saltPath?: string;
|
|
11
|
+
/** Direct salt buffer (bypasses SaltResolver — for testing). */
|
|
12
|
+
readonly salt?: Buffer;
|
|
13
|
+
/** TTL in seconds. Omit for a long-lived key. */
|
|
14
|
+
readonly ttlSec?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mint an unlock key for a catalog-registered rule_id [D3].
|
|
19
|
+
*
|
|
20
|
+
* - Long-lived: `ll_<16hex>` where mac = HMAC(salt, rule_id).
|
|
21
|
+
* - TTL: `ttl.<exp>.<16hex>` where exp = floor(now/1000) + ttlSec,
|
|
22
|
+
* mac = HMAC(salt, rule_id + '.' + str(exp)).
|
|
23
|
+
*
|
|
24
|
+
* @throws if ruleId is not in the catalog.
|
|
25
|
+
*/
|
|
26
|
+
export function mintUnlockKey(opts: MintUnlockKeyOptions): string {
|
|
27
|
+
if (!isKnownRule(opts.ruleId)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`unknown rule_id '${opts.ruleId}' — not in catalog. Run 'unlock-key --list' for valid IDs.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const salt = opts.salt ?? new SaltResolver({ saltPath: opts.saltPath }).resolve();
|
|
34
|
+
|
|
35
|
+
if (opts.ttlSec !== undefined && opts.ttlSec > 0) {
|
|
36
|
+
const exp = Math.floor(Date.now() / 1000) + opts.ttlSec;
|
|
37
|
+
const mac = KeyDerivation.derive(salt, `${opts.ruleId}.${exp}`);
|
|
38
|
+
return `ttl.${exp}.${mac}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const mac = KeyDerivation.derive(salt, opts.ruleId);
|
|
42
|
+
return `ll_${mac}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Enumerate catalog-registered rule_ids (excludes gcloud/bq sprintf rules). */
|
|
46
|
+
export function listUnlockableRules(): readonly string[] {
|
|
47
|
+
return RULES.map((r) => r.ruleId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isKnownRule(ruleId: string): boolean {
|
|
51
|
+
return RULES.some((r) => r.ruleId === ruleId);
|
|
52
|
+
}
|