pi-opa-net 0.1.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 +37 -0
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/pi-opa-net.js +65 -0
- package/package.json +71 -0
- package/policy/safety.rego +374 -0
- package/schemas/decision-output.v1.json +298 -0
- package/skills/pi-opa-net/SKILL.md +63 -0
- package/src/cli/run.ts +76 -0
- package/src/config/Config.ts +78 -0
- package/src/engine/OpaCliEngine.ts +166 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/types.ts +35 -0
- package/src/index.ts +32 -0
- package/src/output/DecisionBuilder.ts +163 -0
- package/src/output/OutputFormatter.ts +43 -0
- package/src/output/index.ts +9 -0
- package/src/parser/CommandParser.ts +32 -0
- package/src/parser/RegexFallbackParser.ts +31 -0
- package/src/parser/ShellQuoteParser.ts +72 -0
- package/src/parser/index.ts +4 -0
- package/src/parser/types.ts +27 -0
- package/src/rules/RuleRegistry.ts +65 -0
- package/src/rules/catalog.ts +221 -0
- package/src/rules/index.ts +3 -0
- package/src/util/digest.ts +18 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# Example: cc-safety-net rules → OPA/Rego translation
|
|
2
|
+
# ----------------------------------------------------------------
|
|
3
|
+
# STATUS: explore artifact (NOT a deployed implementation).
|
|
4
|
+
# Purpose: demonstrate how each of the 38 current rules looks in OPA,
|
|
5
|
+
# as evidence supporting locked decision [LD1] (engine = OPA).
|
|
6
|
+
#
|
|
7
|
+
# Scope: [LD3] bash command guarding ONLY. No other OPA logic here.
|
|
8
|
+
#
|
|
9
|
+
# Two-halves framing (from turn3):
|
|
10
|
+
# 1. PARSE "git stash list" → {program, subcommand, args[]}
|
|
11
|
+
# (the half OPA does NOT solve — open thread [OT1])
|
|
12
|
+
# 2. DECIDE {program, subcommand, args[]} → allow/deny
|
|
13
|
+
# (the half this .rego implements)
|
|
14
|
+
#
|
|
15
|
+
# This file assumes the parse half produced a normalized struct:
|
|
16
|
+
# input = {
|
|
17
|
+
# program: "git" | "docker" | "rm" | ... (string, lowercase)
|
|
18
|
+
# subcommand: "commit" | "stash" | "" (string; "" if none)
|
|
19
|
+
# args: ["-am", "--hard", ...] (array of strings)
|
|
20
|
+
# raw: "git stash list" (original string, for regex fallback)
|
|
21
|
+
# }
|
|
22
|
+
#
|
|
23
|
+
# Fail-mode: `default allow := true` = fail-OPEN. Matches pi-safety-net
|
|
24
|
+
# fork's behavior. Fail-mode when OPA itself is down is [OT2] (open).
|
|
25
|
+
|
|
26
|
+
package safety
|
|
27
|
+
|
|
28
|
+
import rego.v1
|
|
29
|
+
|
|
30
|
+
# ──────────────────────────────────────────────────────────────────
|
|
31
|
+
# DEFAULT — fail-open base
|
|
32
|
+
# ──────────────────────────────────────────────────────────────────
|
|
33
|
+
default allow := true
|
|
34
|
+
|
|
35
|
+
# Any deny reason ⇒ block
|
|
36
|
+
allow := false if {
|
|
37
|
+
count(deny) > 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# ──────────────────────────────────────────────────────────────────
|
|
41
|
+
# HELPERS — arg matching
|
|
42
|
+
# ──────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
# True if any arg token exactly matches one of `tokens`
|
|
45
|
+
has_any_arg(args, tokens) if {
|
|
46
|
+
some t in tokens
|
|
47
|
+
args[_] == t
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# True if any arg starts with one of `prefixes` (e.g. "--project-name=")
|
|
51
|
+
has_arg_prefix(args, prefixes) if {
|
|
52
|
+
some p in prefixes
|
|
53
|
+
some a in args
|
|
54
|
+
startswith(a, p)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# ──────────────────────────────────────────────────────────────────
|
|
58
|
+
# GROUP A — git subcommand + blocked arg tokens
|
|
59
|
+
# (rule family: command + subcommand + block_args[])
|
|
60
|
+
# ──────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
deny[msg] if {
|
|
63
|
+
input.program == "git"
|
|
64
|
+
input.subcommand == "commit"
|
|
65
|
+
has_any_arg(input.args, ["-am", "-a"])
|
|
66
|
+
msg := "git commit -am stages ALL tracked modifications indiscriminately. Use explicit paths."
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
deny[msg] if {
|
|
70
|
+
input.program == "git"
|
|
71
|
+
input.subcommand == "commit"
|
|
72
|
+
has_any_arg(input.args, ["--no-verify", "-n"])
|
|
73
|
+
msg := "ALWAYS run pre-commit hooks. Bypassing hooks risks shipping broken changes."
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
deny[msg] if {
|
|
77
|
+
input.program == "git"
|
|
78
|
+
input.subcommand == "stash"
|
|
79
|
+
has_any_arg(input.args, ["push", "pop", "drop", "clear", "store", "create", "save"])
|
|
80
|
+
msg := "Do not mutate stashes in shared work. Others may be relying on them."
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# BARE-DEFAULT (resolved [OT3]): `git stash` with no operation arg ≡ push.
|
|
84
|
+
# cc-safety-net could not express this (no token to match). OPA solves it —
|
|
85
|
+
# stash subcommand with zero args (list/show/branch carve-outs carry args).
|
|
86
|
+
deny[msg] if {
|
|
87
|
+
input.program == "git"
|
|
88
|
+
input.subcommand == "stash"
|
|
89
|
+
count(input.args) == 0
|
|
90
|
+
msg := "Bare `git stash` defaults to push. Use `git stash list/show` explicitly."
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
deny[msg] if {
|
|
94
|
+
input.program == "git"
|
|
95
|
+
input.subcommand == "reset"
|
|
96
|
+
has_any_arg(input.args, ["--hard"])
|
|
97
|
+
msg := "Hard reset discards local work and can remove others' uncommitted changes."
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
deny[msg] if {
|
|
101
|
+
input.program == "git"
|
|
102
|
+
input.subcommand == "reset"
|
|
103
|
+
has_any_arg(input.args, ["--mixed"])
|
|
104
|
+
msg := "Mixed reset rewrites index state and can disrupt shared work."
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
deny[msg] if {
|
|
108
|
+
input.program == "git"
|
|
109
|
+
input.subcommand == "reset"
|
|
110
|
+
has_any_arg(input.args, ["--merge", "--keep"])
|
|
111
|
+
msg := "Reset modes can unexpectedly alter local changes in shared work."
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
deny[msg] if {
|
|
115
|
+
input.program == "git"
|
|
116
|
+
input.subcommand == "clean"
|
|
117
|
+
has_any_arg(input.args, ["-f", "-fd", "-fdx", "-xdf", "--force", "-x", "-d"])
|
|
118
|
+
msg := "git clean can permanently remove untracked files from the working tree."
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
deny[msg] if {
|
|
122
|
+
input.program == "git"
|
|
123
|
+
input.subcommand == "checkout"
|
|
124
|
+
has_any_arg(input.args, ["--"])
|
|
125
|
+
msg := "checkout -- discards local file changes and may destroy others' work."
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
deny[msg] if {
|
|
129
|
+
input.program == "git"
|
|
130
|
+
input.subcommand == "checkout"
|
|
131
|
+
has_any_arg(input.args, ["-B"])
|
|
132
|
+
msg := "git checkout -B force-resets branch refs and can trash shared branches."
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
deny[msg] if {
|
|
136
|
+
input.program == "git"
|
|
137
|
+
input.subcommand == "restore"
|
|
138
|
+
has_any_arg(input.args, ["--worktree", "--source=HEAD"])
|
|
139
|
+
msg := "git restore can discard tracked modifications in shared work."
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
deny[msg] if {
|
|
143
|
+
input.program == "git"
|
|
144
|
+
input.subcommand == "add"
|
|
145
|
+
has_any_arg(input.args, ["-A", "--all", "-a"])
|
|
146
|
+
msg := "git add -A / -a stages ALL changed files indiscriminately. Use explicit paths."
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
deny[msg] if {
|
|
150
|
+
input.program == "git"
|
|
151
|
+
input.subcommand == "add"
|
|
152
|
+
has_any_arg(input.args, ["."])
|
|
153
|
+
msg := "git add . stages ALL files in the current directory indiscriminately."
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
deny[msg] if {
|
|
157
|
+
input.program == "git"
|
|
158
|
+
input.subcommand == "switch"
|
|
159
|
+
has_any_arg(input.args, ["-C"])
|
|
160
|
+
msg := "git switch -C force-resets branch refs and can rewrite shared history."
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
deny[msg] if {
|
|
164
|
+
input.program == "git"
|
|
165
|
+
input.subcommand == "branch"
|
|
166
|
+
has_any_arg(input.args, ["-f", "-M", "-C"])
|
|
167
|
+
msg := "Forced branch moves or renames can rewrite refs and disrupt shared work."
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Rebase — block the subcommand entirely (redundant in OPA: just check subcommand)
|
|
171
|
+
deny[msg] if {
|
|
172
|
+
input.program == "git"
|
|
173
|
+
input.subcommand == "rebase"
|
|
174
|
+
msg := "Rebase rewrites commit history and is blocked in this environment."
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Rebase lifecycle verbs — but `rebase` itself is already blocked above,
|
|
178
|
+
# so these are belt-and-suspenders (covers `git rebase --continue` etc.)
|
|
179
|
+
deny[msg] if {
|
|
180
|
+
input.program == "git"
|
|
181
|
+
input.subcommand == "rebase"
|
|
182
|
+
has_any_arg(input.args, ["--continue", "--skip", "--abort"])
|
|
183
|
+
msg := "git rebase --continue/--skip/--abort should be run only with explicit approval."
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# ──────────────────────────────────────────────────────────────────
|
|
187
|
+
# GROUP B — docker subcommands blocked entirely
|
|
188
|
+
# (rule family: command + subcommand == subcommand; block_args redundant)
|
|
189
|
+
# ──────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
docker_blocked_subcommands := {
|
|
192
|
+
"stop": "Direct container stop is blocked to protect services managed by Nomad.",
|
|
193
|
+
"kill": "Direct container kill is blocked. Abrupt termination risks data loss.",
|
|
194
|
+
"rm": "Direct container removal is blocked. Re-deploying via Nomad is safer.",
|
|
195
|
+
"restart": "NEVER restart containers directly. This bypasses scheduling safety.",
|
|
196
|
+
"exec": "Direct exec into containers is blocked for security.",
|
|
197
|
+
"update": "Direct resource updates are blocked. Use Nomad job specification.",
|
|
198
|
+
"rename": "Container renaming is blocked to prevent breaking service discovery.",
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
deny[msg] if {
|
|
202
|
+
input.program == "docker"
|
|
203
|
+
input.subcommand in object.keys(docker_blocked_subcommands)
|
|
204
|
+
msg := docker_blocked_subcommands[input.subcommand]
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
deny[msg] if {
|
|
208
|
+
input.program == "docker"
|
|
209
|
+
input.subcommand == "volume"
|
|
210
|
+
has_any_arg(input.args, ["rm", "prune"])
|
|
211
|
+
msg := "Direct volume removal is strictly blocked to prevent data loss."
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
deny[msg] if {
|
|
215
|
+
input.program == "docker"
|
|
216
|
+
input.subcommand == "volume"
|
|
217
|
+
has_any_arg(input.args, ["create"])
|
|
218
|
+
msg := "Manual volume creation is blocked to maintain infra-as-code parity."
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# ──────────────────────────────────────────────────────────────────
|
|
222
|
+
# GROUP C — docker compose with project-name / target filters
|
|
223
|
+
# (the carve-out family — block ONLY litellm/omniroute, not other projects)
|
|
224
|
+
# ──────────────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
litellm_projects := ["--project-name=litellm", "--project-name=litellm-local", "--project-name=omniroute"]
|
|
227
|
+
litellm_targets := ["--target=litellm", "--target=litellm-local", "--target=omniroute"]
|
|
228
|
+
|
|
229
|
+
deny[msg] if {
|
|
230
|
+
input.program == "docker"
|
|
231
|
+
input.subcommand == "compose"
|
|
232
|
+
has_any_arg(input.args, ["down"])
|
|
233
|
+
has_arg_prefix(input.args, litellm_projects)
|
|
234
|
+
msg := "NEVER bring down litellm/litellm-local/omniroute via docker compose."
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
deny[msg] if {
|
|
238
|
+
input.program == "docker"
|
|
239
|
+
input.subcommand == "compose"
|
|
240
|
+
has_any_arg(input.args, ["rm"])
|
|
241
|
+
has_arg_prefix(input.args, litellm_projects)
|
|
242
|
+
msg := "NEVER remove litellm/litellm-local/omniroute containers via docker compose."
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
deny[msg] if {
|
|
246
|
+
input.program == "docker"
|
|
247
|
+
input.subcommand == "compose"
|
|
248
|
+
has_arg_prefix(input.args, litellm_targets)
|
|
249
|
+
msg := "NEVER stop litellm/litellm-local/omniroute via docker compose --target."
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# ──────────────────────────────────────────────────────────────────
|
|
253
|
+
# GROUP D — command-level token blocks (no subcommand)
|
|
254
|
+
# ──────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
deny[msg] if {
|
|
257
|
+
input.program == "bd"
|
|
258
|
+
has_any_arg(input.args, ["--notes"])
|
|
259
|
+
msg := "Use --append-notes instead to preserve existing notes."
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
# gcloud — mutation verbs
|
|
263
|
+
gcloud_blocked_verbs := [
|
|
264
|
+
"create", "delete", "update", "replace", "patch", "deploy",
|
|
265
|
+
"undelete", "restore", "restore-backup", "clone",
|
|
266
|
+
"import", "export", "execute", "failover", "switchover",
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
deny[msg] if {
|
|
270
|
+
input.program == "gcloud"
|
|
271
|
+
# verb appears anywhere in args (gcloud nests: compute instances delete)
|
|
272
|
+
some v in gcloud_blocked_verbs
|
|
273
|
+
has_any_arg(input.args, [v])
|
|
274
|
+
msg := sprintf("Mutation-capable gcloud operation '%s' is blocked by default.", [v])
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# bq — mutation commands
|
|
278
|
+
bq_blocked_verbs := [
|
|
279
|
+
"mk", "rm", "update", "load", "insert", "truncate",
|
|
280
|
+
"set-iam-policy", "add-iam-policy-binding", "remove-iam-policy-binding",
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
deny[msg] if {
|
|
284
|
+
input.program == "bq"
|
|
285
|
+
some v in bq_blocked_verbs
|
|
286
|
+
has_any_arg(input.args, [v])
|
|
287
|
+
msg := sprintf("BigQuery mutation command '%s' is blocked by default.", [v])
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
# ──────────────────────────────────────────────────────────────────
|
|
291
|
+
# GROUP E — `rm` rules (the misnamed "allow-*" family)
|
|
292
|
+
# ──────────────────────────────────────────────────────────────────
|
|
293
|
+
#
|
|
294
|
+
# IMPORTANT (turn1 insight): the rules named `allow-rm-bd-sub-skills`
|
|
295
|
+
# and `allow-rm-beads-subdirs` are MISNAMED. In cc-safety-net they
|
|
296
|
+
# actually BLOCK those exact tokens (there is no carve-out primitive).
|
|
297
|
+
#
|
|
298
|
+
# In OPA we can express them two ways. The faithful translation
|
|
299
|
+
# (matches current behavior — blocks the named paths):
|
|
300
|
+
#
|
|
301
|
+
rm_bd_blocked := [
|
|
302
|
+
"bd-workflow", "bd-planning", "bd-troubleshoot", "bd-config",
|
|
303
|
+
"bd-workflow-init", "bd-formula-workflow", "bd-worktree", "bd-as-doc",
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
deny[msg] if {
|
|
307
|
+
input.program == "rm"
|
|
308
|
+
has_any_arg(input.args, rm_bd_blocked)
|
|
309
|
+
msg := "Removing deprecated bd sub-skill directories is blocked (rule is misnamed 'allow')."
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
rm_beads_blocked := ["adr", "references", "resources"]
|
|
313
|
+
|
|
314
|
+
deny[msg] if {
|
|
315
|
+
input.program == "rm"
|
|
316
|
+
has_any_arg(input.args, rm_beads_blocked)
|
|
317
|
+
msg := "Removing symlink subdirs in beads/ skill is blocked (rule is misnamed 'allow')."
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
# ──────────────────────────────────────────────────────────────────
|
|
321
|
+
# GROUP F — gh / glab repo lifecycle
|
|
322
|
+
# ──────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
deny[msg] if {
|
|
325
|
+
input.program == "gh"
|
|
326
|
+
input.subcommand == "repo"
|
|
327
|
+
has_any_arg(input.args, ["delete", "archive"])
|
|
328
|
+
msg := "Destructive GitHub repository lifecycle actions are blocked by default."
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
deny[msg] if {
|
|
332
|
+
input.program == "gh"
|
|
333
|
+
input.subcommand == "repo"
|
|
334
|
+
has_any_arg(input.args, ["--public"])
|
|
335
|
+
msg := "Public GitHub repository creation is blocked by default."
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
deny[msg] if {
|
|
339
|
+
input.program == "gh"
|
|
340
|
+
input.subcommand == "repo"
|
|
341
|
+
has_any_arg(input.args, ["--visibility"])
|
|
342
|
+
msg := "GitHub repository visibility changes are blocked by default."
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
deny[msg] if {
|
|
346
|
+
input.program == "glab"
|
|
347
|
+
input.subcommand == "repo"
|
|
348
|
+
has_any_arg(input.args, ["delete", "archive"])
|
|
349
|
+
msg := "Destructive GitLab repository lifecycle actions are blocked by default."
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
deny[msg] if {
|
|
353
|
+
input.program == "glab"
|
|
354
|
+
input.subcommand == "repo"
|
|
355
|
+
has_any_arg(input.args, ["--public"])
|
|
356
|
+
msg := "Public GitLab repository creation is blocked by default."
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# ──────────────────────────────────────────────────────────────────
|
|
360
|
+
# USAGE
|
|
361
|
+
# ──────────────────────────────────────────────────────────────────
|
|
362
|
+
# After your parser normalizes a raw command into the input struct:
|
|
363
|
+
#
|
|
364
|
+
# opa eval -d safety.rego -i input.json 'data.safety.allow'
|
|
365
|
+
#
|
|
366
|
+
# input.json example:
|
|
367
|
+
# {"program":"git","subcommand":"stash","args":["list"],"raw":"git stash list"}
|
|
368
|
+
# → true (allowed — list is carve-out)
|
|
369
|
+
#
|
|
370
|
+
# {"program":"git","subcommand":"stash","args":["pop"],"raw":"git stash pop"}
|
|
371
|
+
# → false (denied)
|
|
372
|
+
#
|
|
373
|
+
# {"program":"git","subcommand":"","args":[],"raw":"git stash"}
|
|
374
|
+
# → false (denied — bare-default handled natively; [OT3] resolved in OPA)
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://buihongduc132/pi-safety-net/schemas/decision-output.v1.json",
|
|
4
|
+
"title": "Bash Guard Decision Output (--json mode, v1)",
|
|
5
|
+
"description": "Programmatic output schema for the OPA-backed bash guard. INPUT stays OPA/Rego; THIS schema governs the OUTPUT wrapper consumed by pi extensions, scripts, and other agents. Symmetric: both allow and deny emit this shape. Exit codes map 0=allow, 2=deny (backward-compat with Claude Code hook protocol).",
|
|
6
|
+
|
|
7
|
+
"type": "object",
|
|
8
|
+
"required": ["schema_version", "decision", "action", "source", "input", "evaluated_at", "decision_id"],
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
|
|
11
|
+
"properties": {
|
|
12
|
+
"schema_version": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"const": "1.0",
|
|
15
|
+
"description": "Pinned schema version. Bump on breaking change. Consumers MUST validate this before reading other fields."
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
"decision": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"enum": ["allow", "deny"],
|
|
21
|
+
"description": "Primary verdict. Maps to exit code: allow→0, deny→2."
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
"action": {
|
|
25
|
+
"type": "string",
|
|
26
|
+
"enum": ["allow", "block", "prompt_user", "log_only"],
|
|
27
|
+
"description": "Caller guidance. allow=proceed silently; block=stop with message; prompt_user=ask human (future ask-mode, like pi-control's 5 outcomes); log_only=proceed but record. Today only allow/block are emitted; prompt_user/log_only reserved for v2."
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
"source": {
|
|
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."
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
"reasons": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"description": "Every fired deny rule contributes one entry. Empty array on allow. NOT collapsed to a single string — preserves rule provenance for audit. Order = rule evaluation order (deterministic).",
|
|
39
|
+
"items": { "$ref": "#/$defs/Reason" }
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
"input": {
|
|
43
|
+
"$ref": "#/$defs/EvaluatedInput",
|
|
44
|
+
"description": "Echo of the normalized command the parser produced (the decide-half input). Lets consumer verify what was actually evaluated vs what they sent."
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
"summary": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"description": "Human-readable one-liner for TUI/logging. e.g. 'BLOCKED: git stash pop (rule: block-git-stash-mutations)'. Optional on allow (may be empty string)."
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
"suggestions": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": { "type": "string" },
|
|
55
|
+
"description": "Safe alternatives the user could run instead. e.g. ['git stash list', 'git stash show']. Empty array if none. Powering future 'did you mean?' UX."
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
"metadata": {
|
|
59
|
+
"$ref": "#/$defs/DecisionMetadata",
|
|
60
|
+
"description": "Provenance for audit: which rulebook, which OPA version, which policy digest decided this. Enables after-the-fact replay and drift detection."
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
"evaluated_at": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"format": "date-time",
|
|
66
|
+
"description": "ISO-8601 UTC timestamp of decision. Millisecond precision."
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
"decision_id": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"format": "uuid",
|
|
72
|
+
"description": "Unique ID per decision. Carry through to audit log, hook response, user-facing message — enables end-to-end tracing."
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
"duration_ms": {
|
|
76
|
+
"type": "number",
|
|
77
|
+
"minimum": 0,
|
|
78
|
+
"description": "Wall-clock time from input received to decision emitted. For perf monitoring / OPA cold-start detection (relevant to lazy-load topology [LD2])."
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
"$defs": {
|
|
83
|
+
"Reason": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"required": ["rule_id", "message"],
|
|
86
|
+
"additionalProperties": false,
|
|
87
|
+
"properties": {
|
|
88
|
+
"rule_id": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"description": "Stable rule identifier. Matches the cc-safety-net rule `name` field (e.g. 'block-git-stash-mutations') OR a synthesized ID for built-in semantic rules (e.g. 'builtin:git-stash-drop'). Lets audits trace decision→rule→source line."
|
|
91
|
+
},
|
|
92
|
+
"message": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "Human-readable explanation. Same text as the rule's `reason` field. NOT redacted of secrets here — redaction happens at display layer."
|
|
95
|
+
},
|
|
96
|
+
"family": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"enum": ["git", "docker", "rm", "gcloud", "bq", "gh", "glab", "bd", "builtin", "custom"],
|
|
99
|
+
"description": "Rule family for grouping/filtering. Matches the 6 families in the rego translation (turn6)."
|
|
100
|
+
},
|
|
101
|
+
"severity": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"enum": ["block", "warn", "info"],
|
|
104
|
+
"description": "Reserved for v2 tiered outcomes. Today always 'block'. Lets future prompt_user/log_only rules coexist with hard blocks."
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
"EvaluatedInput": {
|
|
110
|
+
"type": "object",
|
|
111
|
+
"required": ["raw"],
|
|
112
|
+
"additionalProperties": false,
|
|
113
|
+
"properties": {
|
|
114
|
+
"raw": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"description": "Original command string as received. May be redacted of secrets at display layer but stored verbatim in JSON for audit (consumer is trusted)."
|
|
117
|
+
},
|
|
118
|
+
"program": {
|
|
119
|
+
"type": "string",
|
|
120
|
+
"description": "Normalized program name (lowercase). Empty string if parser could not extract."
|
|
121
|
+
},
|
|
122
|
+
"subcommand": {
|
|
123
|
+
"type": "string",
|
|
124
|
+
"description": "Normalized subcommand. Empty string if none (the bare-default case [OT3] — `git stash` with no sub)."
|
|
125
|
+
},
|
|
126
|
+
"args": {
|
|
127
|
+
"type": "array",
|
|
128
|
+
"items": { "type": "string" },
|
|
129
|
+
"description": "Normalized arg tokens. Empty array if none."
|
|
130
|
+
},
|
|
131
|
+
"parse_confidence": {
|
|
132
|
+
"type": "string",
|
|
133
|
+
"enum": ["full", "partial", "regex-only", "failed"],
|
|
134
|
+
"description": "Parser fidelity. full=bash-parser AST; partial=AST with gaps; regex-only=string match fallback ([OT1] common-case path); failed=could not parse, fell back to raw-string matching only."
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
"DecisionMetadata": {
|
|
140
|
+
"type": "object",
|
|
141
|
+
"required": ["engine", "rulebook_digest"],
|
|
142
|
+
"additionalProperties": false,
|
|
143
|
+
"properties": {
|
|
144
|
+
"engine": {
|
|
145
|
+
"type": "string",
|
|
146
|
+
"const": "opa",
|
|
147
|
+
"description": "Decision engine identity. Pinned to 'opa' per [LD1]."
|
|
148
|
+
},
|
|
149
|
+
"opa_version": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "OPA binary version string (e.g. '1.18.1'). For reproducibility."
|
|
152
|
+
},
|
|
153
|
+
"rulebook_digest": {
|
|
154
|
+
"type": "string",
|
|
155
|
+
"pattern": "^[a-f0-9]{12}$",
|
|
156
|
+
"description": "SHA-256 prefix (12 hex) of the active rego policy bundle. Matches the cc-safety-net rule.lock digest format. Lets audits detect 'decision made under different rules than current'."
|
|
157
|
+
},
|
|
158
|
+
"policy_path": {
|
|
159
|
+
"type": "string",
|
|
160
|
+
"description": "Filesystem path to the .rego bundle evaluated. For debugging 'which policy fired'."
|
|
161
|
+
},
|
|
162
|
+
"hostname": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"description": "Dev box hostname (per [LD2] — every dev box runs its own OPA). For correlating decisions across the fleet."
|
|
165
|
+
},
|
|
166
|
+
"session_id": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"description": "Calling agent's session ID if available (pi session, claude session, etc.). Empty string if not provided."
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
"examples": [
|
|
175
|
+
{
|
|
176
|
+
"schema_version": "1.0",
|
|
177
|
+
"decision": "deny",
|
|
178
|
+
"action": "block",
|
|
179
|
+
"source": "opa",
|
|
180
|
+
"reasons": [
|
|
181
|
+
{
|
|
182
|
+
"rule_id": "block-git-stash-mutations",
|
|
183
|
+
"message": "Do not mutate stashes in shared work. Others may be relying on them.",
|
|
184
|
+
"family": "git",
|
|
185
|
+
"severity": "block"
|
|
186
|
+
}
|
|
187
|
+
],
|
|
188
|
+
"input": {
|
|
189
|
+
"raw": "git stash pop",
|
|
190
|
+
"program": "git",
|
|
191
|
+
"subcommand": "stash",
|
|
192
|
+
"args": ["pop"],
|
|
193
|
+
"parse_confidence": "full"
|
|
194
|
+
},
|
|
195
|
+
"summary": "BLOCKED: git stash pop (rule: block-git-stash-mutations)",
|
|
196
|
+
"suggestions": ["git stash list", "git stash show"],
|
|
197
|
+
"metadata": {
|
|
198
|
+
"engine": "opa",
|
|
199
|
+
"opa_version": "1.18.1",
|
|
200
|
+
"rulebook_digest": "dee3746bf7b5",
|
|
201
|
+
"policy_path": "/home/agent/.pi/opa/safety.rego",
|
|
202
|
+
"hostname": "dev-box",
|
|
203
|
+
"session_id": "ses_abc123"
|
|
204
|
+
},
|
|
205
|
+
"evaluated_at": "2026-07-01T14:23:45.123Z",
|
|
206
|
+
"decision_id": "7f3a9c2e-1b4d-4e8f-9a2c-5d6e7f8a9b01",
|
|
207
|
+
"duration_ms": 4.2
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
"schema_version": "1.0",
|
|
211
|
+
"decision": "allow",
|
|
212
|
+
"action": "allow",
|
|
213
|
+
"source": "opa",
|
|
214
|
+
"reasons": [],
|
|
215
|
+
"input": {
|
|
216
|
+
"raw": "git stash list",
|
|
217
|
+
"program": "git",
|
|
218
|
+
"subcommand": "stash",
|
|
219
|
+
"args": ["list"],
|
|
220
|
+
"parse_confidence": "full"
|
|
221
|
+
},
|
|
222
|
+
"summary": "",
|
|
223
|
+
"suggestions": [],
|
|
224
|
+
"metadata": {
|
|
225
|
+
"engine": "opa",
|
|
226
|
+
"opa_version": "1.18.1",
|
|
227
|
+
"rulebook_digest": "dee3746bf7b5",
|
|
228
|
+
"policy_path": "/home/agent/.pi/opa/safety.rego",
|
|
229
|
+
"hostname": "dev-box",
|
|
230
|
+
"session_id": "ses_abc123"
|
|
231
|
+
},
|
|
232
|
+
"evaluated_at": "2026-07-01T14:23:46.456Z",
|
|
233
|
+
"decision_id": "f58c8a4b-f478-456e-b762-6fb710a3380f",
|
|
234
|
+
"duration_ms": 3.8
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
"schema_version": "1.0",
|
|
238
|
+
"decision": "deny",
|
|
239
|
+
"action": "block",
|
|
240
|
+
"source": "opa",
|
|
241
|
+
"reasons": [
|
|
242
|
+
{
|
|
243
|
+
"rule_id": "builtin:bare-stash-default",
|
|
244
|
+
"message": "Bare `git stash` defaults to push. Use `git stash list/show` explicitly.",
|
|
245
|
+
"family": "builtin",
|
|
246
|
+
"severity": "block"
|
|
247
|
+
}
|
|
248
|
+
],
|
|
249
|
+
"input": {
|
|
250
|
+
"raw": "git stash",
|
|
251
|
+
"program": "git",
|
|
252
|
+
"subcommand": "",
|
|
253
|
+
"args": [],
|
|
254
|
+
"parse_confidence": "full"
|
|
255
|
+
},
|
|
256
|
+
"summary": "BLOCKED: bare `git stash` (rule: builtin:bare-stash-default)",
|
|
257
|
+
"suggestions": ["git stash list", "git stash show", "git stash branch <name>"],
|
|
258
|
+
"metadata": {
|
|
259
|
+
"engine": "opa",
|
|
260
|
+
"opa_version": "1.18.1",
|
|
261
|
+
"rulebook_digest": "dee3746bf7b5",
|
|
262
|
+
"policy_path": "/home/agent/.pi/opa/safety.rego",
|
|
263
|
+
"hostname": "dev-box",
|
|
264
|
+
"session_id": "ses_abc123"
|
|
265
|
+
},
|
|
266
|
+
"evaluated_at": "2026-07-01T14:23:47.789Z",
|
|
267
|
+
"decision_id": "38708bfc-dd9b-454e-9248-d071e3354fde",
|
|
268
|
+
"duration_ms": 4.0
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"schema_version": "1.0",
|
|
272
|
+
"decision": "allow",
|
|
273
|
+
"action": "allow",
|
|
274
|
+
"source": "fail-open",
|
|
275
|
+
"reasons": [],
|
|
276
|
+
"input": {
|
|
277
|
+
"raw": "git push origin main",
|
|
278
|
+
"program": "git",
|
|
279
|
+
"subcommand": "push",
|
|
280
|
+
"args": ["origin", "main"],
|
|
281
|
+
"parse_confidence": "regex-only"
|
|
282
|
+
},
|
|
283
|
+
"summary": "ALLOWED (fail-open: OPA unreachable for 250ms)",
|
|
284
|
+
"suggestions": [],
|
|
285
|
+
"metadata": {
|
|
286
|
+
"engine": "opa",
|
|
287
|
+
"opa_version": "",
|
|
288
|
+
"rulebook_digest": "dee3746bf7b5",
|
|
289
|
+
"policy_path": "/home/agent/.pi/opa/safety.rego",
|
|
290
|
+
"hostname": "dev-box",
|
|
291
|
+
"session_id": "ses_abc123"
|
|
292
|
+
},
|
|
293
|
+
"evaluated_at": "2026-07-01T14:24:01.001Z",
|
|
294
|
+
"decision_id": "a407558c-0d12-4bed-a40b-4bd0546c3c1a",
|
|
295
|
+
"duration_ms": 250.0
|
|
296
|
+
}
|
|
297
|
+
]
|
|
298
|
+
}
|