data-olympus 0.3.0__py3-none-any.whl
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.
- data_olympus/__init__.py +14 -0
- data_olympus/_bin/_kb_detect_workspace.sh +57 -0
- data_olympus/_bin/_kb_enforce.py +619 -0
- data_olympus/_bin/_kb_fallback.py +361 -0
- data_olympus/_bin/kb +957 -0
- data_olympus/_bin/kb-enforce-hook +337 -0
- data_olympus/_bin/opencode/data-olympus-gate.ts +102 -0
- data_olympus/audit_log.py +275 -0
- data_olympus/audit_trailers.py +42 -0
- data_olympus/auth.py +139 -0
- data_olympus/cli/__init__.py +1 -0
- data_olympus/cli/import_cmd.py +115 -0
- data_olympus/cli/indexgen.py +60 -0
- data_olympus/cli/main.py +151 -0
- data_olympus/cli/report_cmd.py +181 -0
- data_olympus/config.py +261 -0
- data_olympus/cooccurrence.py +393 -0
- data_olympus/dedup.py +57 -0
- data_olympus/durable.py +51 -0
- data_olympus/embeddings.py +317 -0
- data_olympus/enforce_policy.py +297 -0
- data_olympus/format/__init__.py +16 -0
- data_olympus/format/document.py +39 -0
- data_olympus/format/frontmatter.py +35 -0
- data_olympus/format/lint.py +92 -0
- data_olympus/format/validate.py +71 -0
- data_olympus/git_ops.py +397 -0
- data_olympus/health.py +114 -0
- data_olympus/importer/__init__.py +13 -0
- data_olympus/importer/adr.py +286 -0
- data_olympus/importer/flat.py +170 -0
- data_olympus/importer/model.py +106 -0
- data_olympus/importer/okf.py +192 -0
- data_olympus/importer/run.py +416 -0
- data_olympus/importer/stamp.py +227 -0
- data_olympus/index.py +1745 -0
- data_olympus/markdown_parse.py +103 -0
- data_olympus/models.py +480 -0
- data_olympus/onboarding.py +131 -0
- data_olympus/onboarding_inflight.py +137 -0
- data_olympus/onboarding_playbook.py +99 -0
- data_olympus/pending.py +533 -0
- data_olympus/principals.py +168 -0
- data_olympus/prompts.py +35 -0
- data_olympus/push_queue.py +261 -0
- data_olympus/query_expansion.py +200 -0
- data_olympus/rate_limit.py +81 -0
- data_olympus/refresh.py +329 -0
- data_olympus/report.py +133 -0
- data_olympus/rest_api.py +845 -0
- data_olympus/safe_id.py +36 -0
- data_olympus/search_gate.py +64 -0
- data_olympus/search_shortcut.py +146 -0
- data_olympus/server.py +1115 -0
- data_olympus/session_metrics.py +303 -0
- data_olympus/setup_wizard.py +751 -0
- data_olympus/thin_pointer.py +20 -0
- data_olympus/tools_audit.py +41 -0
- data_olympus/tools_enforce.py +198 -0
- data_olympus/tools_onboarding.py +585 -0
- data_olympus/tools_read.py +230 -0
- data_olympus/tools_write.py +878 -0
- data_olympus/trigram.py +126 -0
- data_olympus/viewer/__init__.py +1 -0
- data_olympus/viewer/generator.py +375 -0
- data_olympus/worktrees.py +147 -0
- data_olympus/write_gate.py +382 -0
- data_olympus-0.3.0.dist-info/METADATA +97 -0
- data_olympus-0.3.0.dist-info/RECORD +73 -0
- data_olympus-0.3.0.dist-info/WHEEL +4 -0
- data_olympus-0.3.0.dist-info/entry_points.txt +3 -0
- data_olympus-0.3.0.dist-info/licenses/LICENSE +202 -0
- data_olympus-0.3.0.dist-info/licenses/NOTICE +8 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# kb-enforce-hook - data-olympus enforcement hook dispatcher.
|
|
3
|
+
#
|
|
4
|
+
# Invoked by a coding agent's hooks. Reads the hook payload as JSON on stdin and
|
|
5
|
+
# talks to the data-olympus REST endpoints. Modes:
|
|
6
|
+
# session-start warm-up note (stdout -> context)
|
|
7
|
+
# user-prompt POST /consult, print governing rules (stdout -> context)
|
|
8
|
+
# pre-tool POST /gate/check, block (exit 2) on consult_required
|
|
9
|
+
# stop no-op
|
|
10
|
+
#
|
|
11
|
+
# Environment:
|
|
12
|
+
# KB_ENDPOINT REST endpoint (default http://localhost:8080)
|
|
13
|
+
# KB_ENFORCE_FAIL_MODE open|closed (default open) behaviour when unreachable
|
|
14
|
+
# KB_AUTH_TOKEN when set, sent as Authorization: Bearer on POSTs
|
|
15
|
+
set -uo pipefail
|
|
16
|
+
|
|
17
|
+
KB_ENDPOINT="${KB_ENDPOINT:-http://localhost:8080}"
|
|
18
|
+
KB_ENFORCE_FAIL_MODE="${KB_ENFORCE_FAIL_MODE:-open}"
|
|
19
|
+
KB_AUTH_TOKEN="${KB_AUTH_TOKEN:-}"
|
|
20
|
+
MODE="${1:-}"
|
|
21
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
22
|
+
|
|
23
|
+
# Optional --dialect after the mode. Default "claude" keeps the current
|
|
24
|
+
# (claude == codex) output contract byte-for-byte; "gemini" switches deny/inject
|
|
25
|
+
# to its JSON-on-stdout contract.
|
|
26
|
+
shift || true
|
|
27
|
+
DIALECT="claude"
|
|
28
|
+
# AGENT is reported as agent_identity to the consult audit so the per-agent
|
|
29
|
+
# compliance view is correct. The same dispatcher serves claude/codex/gemini, so
|
|
30
|
+
# a hardcoded identity would mis-attribute every non-claude consultation.
|
|
31
|
+
AGENT="unknown"
|
|
32
|
+
POSITIONAL=()
|
|
33
|
+
while [[ $# -gt 0 ]]; do
|
|
34
|
+
case "$1" in
|
|
35
|
+
--dialect) DIALECT="${2:-claude}"; shift; shift || true ;;
|
|
36
|
+
--agent) AGENT="${2:-unknown}"; shift; shift || true ;;
|
|
37
|
+
*) POSITIONAL+=("$1"); shift ;;
|
|
38
|
+
esac
|
|
39
|
+
done
|
|
40
|
+
|
|
41
|
+
# Read entire stdin payload. Skip for resolve-workspace: it is a stdin-free
|
|
42
|
+
# diagnostic (resolve and print a workspace label), so reading stdin would hang.
|
|
43
|
+
PAYLOAD=""
|
|
44
|
+
if [[ "$MODE" != "resolve-workspace" ]]; then
|
|
45
|
+
PAYLOAD="$(cat || true)"
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
json_field() {
|
|
49
|
+
# json_field <dotted.path> ; reads $PAYLOAD, prints value or empty.
|
|
50
|
+
# JSON null and missing keys BOTH emit an empty string (never the literal
|
|
51
|
+
# "None"): a null session_id must not become the ledger key "None".
|
|
52
|
+
python3 -c 'import json,sys
|
|
53
|
+
p=sys.argv[1].split(".")
|
|
54
|
+
try:
|
|
55
|
+
d=json.loads(sys.stdin.read() or "{}")
|
|
56
|
+
for k in p:
|
|
57
|
+
d=d.get(k, "") if isinstance(d, dict) else ""
|
|
58
|
+
if d is None:
|
|
59
|
+
print("")
|
|
60
|
+
else:
|
|
61
|
+
print(d if isinstance(d,str) else json.dumps(d))
|
|
62
|
+
except Exception:
|
|
63
|
+
print("")' "$1" <<<"$PAYLOAD"
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
json_field_from() {
|
|
67
|
+
# json_field_from <json-string> <key> ; null/missing -> empty string.
|
|
68
|
+
python3 -c 'import json,sys
|
|
69
|
+
try:
|
|
70
|
+
d=json.loads(sys.argv[1] or "{}")
|
|
71
|
+
v=d.get(sys.argv[2], "")
|
|
72
|
+
if v is None:
|
|
73
|
+
print("")
|
|
74
|
+
else:
|
|
75
|
+
print(v if isinstance(v,str) else json.dumps(v))
|
|
76
|
+
except Exception:
|
|
77
|
+
print("")' "$1" "$2"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
resolve_workspace() {
|
|
81
|
+
# Echo a stable workspace label for cwd. Preference order:
|
|
82
|
+
# 1. the MAIN git worktree basename, which is identical from the main
|
|
83
|
+
# checkout and any linked worktree (so one consult clears both the
|
|
84
|
+
# pre-tool and pre-commit gates, and matches `report`'s default);
|
|
85
|
+
# 2. the workspaces-root detector (KB_WORKSPACES_ROOT), for non-git dirs;
|
|
86
|
+
# 3. the raw cwd, as a last resort.
|
|
87
|
+
# Git resolution is tried FIRST (not the detector) so the two gates never
|
|
88
|
+
# disagree on a linked worktree, even when KB_WORKSPACES_ROOT resolves that
|
|
89
|
+
# worktree to its own basename. `git worktree list` reports the main worktree
|
|
90
|
+
# as its first entry and is correct for separate-git-dir layouts.
|
|
91
|
+
local cwd="$1"
|
|
92
|
+
local main_wt
|
|
93
|
+
# First NON-bare worktree record. The main worktree is listed first, but a bare
|
|
94
|
+
# repo's first record is the bare git dir (marked `bare`), not a real checkout;
|
|
95
|
+
# skip it so the key is a worktree basename, not `repo.git`.
|
|
96
|
+
main_wt="$(git -C "$cwd" worktree list --porcelain 2>/dev/null | awk '
|
|
97
|
+
/^worktree /{p=substr($0,10); b=0}
|
|
98
|
+
/^bare$/{b=1}
|
|
99
|
+
/^$/{ if(p!="" && b==0){print p; exit} p=""; b=0 }
|
|
100
|
+
END{ if(p!="" && b==0) print p }')"
|
|
101
|
+
if [[ -n "$main_wt" ]]; then
|
|
102
|
+
basename "$main_wt"; return 0
|
|
103
|
+
fi
|
|
104
|
+
if [[ -r "$HERE/_kb_detect_workspace.sh" ]]; then
|
|
105
|
+
# shellcheck disable=SC1091
|
|
106
|
+
. "$HERE/_kb_detect_workspace.sh"
|
|
107
|
+
if detect_workspace_and_component "$cwd" 2>/dev/null; then
|
|
108
|
+
echo "$WORKSPACE"; return 0
|
|
109
|
+
fi
|
|
110
|
+
fi
|
|
111
|
+
echo "$cwd"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
post_json() {
|
|
115
|
+
# post_json <url> <json-body>
|
|
116
|
+
# On a successful HTTP round-trip, sets globals RESP_BODY (response body) and
|
|
117
|
+
# RESP_CODE (numeric HTTP status) and returns 0. Returns non-zero ONLY when the
|
|
118
|
+
# curl CONNECTION itself fails (DNS/connect/timeout); callers must additionally
|
|
119
|
+
# check RESP_CODE for 2xx, since curl --silent exits 0 on 4xx/5xx.
|
|
120
|
+
#
|
|
121
|
+
# When KB_AUTH_TOKEN is set, pass the Authorization header via a curl config on
|
|
122
|
+
# a process-substitution FD so the token is NOT exposed in process arguments
|
|
123
|
+
# (visible via `ps`). Mirrors bin/kb's do_curl_post.
|
|
124
|
+
local url="$1"
|
|
125
|
+
local body="$2"
|
|
126
|
+
local tmpfile
|
|
127
|
+
tmpfile="$(mktemp)"
|
|
128
|
+
local auth_k_arg=()
|
|
129
|
+
if [[ -n "$KB_AUTH_TOKEN" ]]; then
|
|
130
|
+
auth_k_arg=(-K <(printf 'header = "Authorization: Bearer %s"\n' "$KB_AUTH_TOKEN"))
|
|
131
|
+
fi
|
|
132
|
+
# Timeouts: the pre-tool gate is on the agent's critical path (it blocks every
|
|
133
|
+
# governed edit), so it sets tight CURL_MAX_TIME/CURL_CONNECT_TIMEOUT to fail
|
|
134
|
+
# fast to the fail-mode switch rather than stalling the tool call. Other callers
|
|
135
|
+
# (user-prompt injection) keep the looser default.
|
|
136
|
+
local max_time="${CURL_MAX_TIME:-5}"
|
|
137
|
+
local connect_arg=()
|
|
138
|
+
if [[ -n "${CURL_CONNECT_TIMEOUT:-}" ]]; then
|
|
139
|
+
connect_arg=(--connect-timeout "$CURL_CONNECT_TIMEOUT")
|
|
140
|
+
fi
|
|
141
|
+
# Guard the array expansion: under `set -u`, "${arr[@]}" on an empty array is
|
|
142
|
+
# an unbound-variable error on bash 3.2 (macOS default).
|
|
143
|
+
if RESP_CODE=$(curl --silent --max-time "$max_time" \
|
|
144
|
+
${connect_arg[@]+"${connect_arg[@]}"} \
|
|
145
|
+
-H "Content-Type: application/json" \
|
|
146
|
+
${auth_k_arg[@]+"${auth_k_arg[@]}"} \
|
|
147
|
+
--data "$body" \
|
|
148
|
+
--output "$tmpfile" --write-out "%{http_code}" \
|
|
149
|
+
-X POST "$url" 2>/dev/null); then
|
|
150
|
+
RESP_BODY="$(cat "$tmpfile")"
|
|
151
|
+
rm -f "$tmpfile"
|
|
152
|
+
return 0
|
|
153
|
+
fi
|
|
154
|
+
rm -f "$tmpfile"
|
|
155
|
+
RESP_BODY=""
|
|
156
|
+
RESP_CODE=""
|
|
157
|
+
return 1
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
is_2xx() {
|
|
161
|
+
# is_2xx <http-code> ; true when the code is 200-299.
|
|
162
|
+
[[ "$1" =~ ^2[0-9][0-9]$ ]]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
emit_context() {
|
|
166
|
+
# emit_context <text> ; print injected context per dialect.
|
|
167
|
+
local text="$1"
|
|
168
|
+
if [[ "$DIALECT" == "gemini" ]]; then
|
|
169
|
+
python3 -c 'import json,sys; print(json.dumps({"hookSpecificOutput":{"additionalContext":sys.argv[1]}}))' "$text"
|
|
170
|
+
else
|
|
171
|
+
printf '%s\n' "$text"
|
|
172
|
+
fi
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
emit_deny() {
|
|
176
|
+
# emit_deny <reason> ; signal a blocked tool call per dialect, then exit.
|
|
177
|
+
local reason="$1"
|
|
178
|
+
if [[ "$DIALECT" == "gemini" ]]; then
|
|
179
|
+
python3 -c 'import json,sys; print(json.dumps({"decision":"deny","reason":sys.argv[1]}))' "$reason"
|
|
180
|
+
exit 0
|
|
181
|
+
fi
|
|
182
|
+
echo "$reason" >&2
|
|
183
|
+
exit 2
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
emit_fail_closed() {
|
|
187
|
+
# emit_fail_closed <reason> ; block due to gate-unavailable under fail-mode=closed.
|
|
188
|
+
emit_deny "$1"
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
record_degraded() {
|
|
192
|
+
# record_degraded <workspace> <session> <reason> ; best-effort, ignores failure.
|
|
193
|
+
# Records a gate_degraded audit event when the gate was REACHED but degraded.
|
|
194
|
+
# Never changes the gate's allow/block/fail decision.
|
|
195
|
+
local body
|
|
196
|
+
body="$(python3 -c 'import json,sys
|
|
197
|
+
print(json.dumps({"event_type":"gate_degraded","workspace":sys.argv[1],
|
|
198
|
+
"agent_identity":sys.argv[2],"source_session":sys.argv[3],"reason":sys.argv[4]}))' \
|
|
199
|
+
"$1" "$AGENT" "$2" "$3")"
|
|
200
|
+
post_json "$KB_ENDPOINT/api/v1/audit/event" "$body" >/dev/null 2>&1 || true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case "$MODE" in
|
|
204
|
+
session-start)
|
|
205
|
+
emit_context "[KB] data-olympus enforcement active (endpoint: $KB_ENDPOINT)."
|
|
206
|
+
exit 0
|
|
207
|
+
;;
|
|
208
|
+
user-prompt)
|
|
209
|
+
SESSION="$(json_field session_id)"
|
|
210
|
+
CWD="$(json_field cwd)"
|
|
211
|
+
PROMPT="$(json_field prompt)"
|
|
212
|
+
WS="$(resolve_workspace "$CWD")"
|
|
213
|
+
# trigger=prompt_hook: this consult is the installer's per-prompt auto-consult
|
|
214
|
+
# (UserPromptSubmit / Gemini BeforeAgent), NOT a deliberate agent kb_consult.
|
|
215
|
+
# It is recorded for audit/compliance and its rules are still injected, but it
|
|
216
|
+
# does NOT clear the gate: only an explicit kb_consult call does. Without this
|
|
217
|
+
# marker every user prompt would refresh the ledger and the gate would fire
|
|
218
|
+
# only during autonomous stretches longer than the TTL.
|
|
219
|
+
BODY="$(python3 -c 'import json,sys
|
|
220
|
+
print(json.dumps({"workspace":sys.argv[1],"intent":sys.argv[2],
|
|
221
|
+
"source_session":sys.argv[3],"agent_identity":sys.argv[4],"trigger":"prompt_hook"}))' "$WS" "$PROMPT" "$SESSION" "$AGENT")"
|
|
222
|
+
# Prompt-rule injection is best-effort and never blocks: any failure
|
|
223
|
+
# (connection error OR non-2xx) just means no injected rules this turn.
|
|
224
|
+
if ! post_json "$KB_ENDPOINT/api/v1/consult" "$BODY" || ! is_2xx "$RESP_CODE"; then
|
|
225
|
+
echo "[KB] warn: consult endpoint unreachable; proceeding without injected rules." >&2
|
|
226
|
+
exit 0
|
|
227
|
+
fi
|
|
228
|
+
GOV="$(json_field_from "$RESP_BODY" is_governed_decision)"
|
|
229
|
+
if [[ "$GOV" == "true" ]]; then
|
|
230
|
+
RULES="$(echo "$RESP_BODY" | python3 -c 'import json,sys
|
|
231
|
+
d=json.load(sys.stdin)
|
|
232
|
+
lines=["=== GOVERNING RULES (data-olympus) ==="]
|
|
233
|
+
for r in d.get("rules",[]):
|
|
234
|
+
lines.append(f"- {r.get('"'"'id'"'"')}: {r.get('"'"'title'"'"')}")
|
|
235
|
+
lines.append("=== consult these before deciding ===")
|
|
236
|
+
print("\n".join(lines))')"
|
|
237
|
+
emit_context "$RULES"
|
|
238
|
+
fi
|
|
239
|
+
exit 0
|
|
240
|
+
;;
|
|
241
|
+
pre-tool)
|
|
242
|
+
# Single parse of the hook payload: emit session_id, cwd, tool_name, file_path
|
|
243
|
+
# and the capped action_diff, each base64-encoded on its own line (base64 is
|
|
244
|
+
# newline/tab-safe, so a multi-line diff or command survives the read). This
|
|
245
|
+
# replaces four separate json_field spawns plus the ADIFF spawn with one
|
|
246
|
+
# python3 invocation on the pre-tool critical path.
|
|
247
|
+
# action_diff: the change content (Write.content, Edit.new_string,
|
|
248
|
+
# Bash.command), capped at 4000 chars so the gate body stays bounded, so the
|
|
249
|
+
# classifier can use diff signals and classify shell commands.
|
|
250
|
+
{
|
|
251
|
+
IFS= read -r SESSION_B64
|
|
252
|
+
IFS= read -r CWD_B64
|
|
253
|
+
IFS= read -r TOOL_B64
|
|
254
|
+
IFS= read -r FPATH_B64
|
|
255
|
+
IFS= read -r ADIFF_B64
|
|
256
|
+
} < <(printf '%s' "$PAYLOAD" | python3 -c 'import base64,json,sys
|
|
257
|
+
def enc(v):
|
|
258
|
+
return base64.b64encode((v or "").encode("utf-8","replace")).decode("ascii")
|
|
259
|
+
try:
|
|
260
|
+
d=json.loads(sys.stdin.read() or "{}")
|
|
261
|
+
if not isinstance(d,dict): d={}
|
|
262
|
+
except Exception:
|
|
263
|
+
d={}
|
|
264
|
+
ti=d.get("tool_input",{}) if isinstance(d.get("tool_input"),dict) else {}
|
|
265
|
+
diff=ti.get("content") or ti.get("new_string") or ti.get("command") or ""
|
|
266
|
+
if not isinstance(diff,str): diff=""
|
|
267
|
+
for v in (d.get("session_id"), d.get("cwd"), d.get("tool_name"),
|
|
268
|
+
ti.get("file_path"), diff[:4000]):
|
|
269
|
+
print(enc(v if isinstance(v,str) else ""))')
|
|
270
|
+
b64d() { printf '%s' "$1" | base64 --decode 2>/dev/null || true; }
|
|
271
|
+
SESSION="$(b64d "$SESSION_B64")"
|
|
272
|
+
CWD="$(b64d "$CWD_B64")"
|
|
273
|
+
TOOL="$(b64d "$TOOL_B64")"
|
|
274
|
+
FPATH="$(b64d "$FPATH_B64")"
|
|
275
|
+
ADIFF="$(b64d "$ADIFF_B64")"
|
|
276
|
+
WS="$(resolve_workspace "$CWD")"
|
|
277
|
+
BODY="$(python3 -c 'import json,sys
|
|
278
|
+
print(json.dumps({"workspace":sys.argv[1],"session_id":sys.argv[2],
|
|
279
|
+
"tool_name":sys.argv[3],"action_path":sys.argv[4],"action_diff":sys.argv[5]}))' "$WS" "$SESSION" "$TOOL" "$FPATH" "$ADIFF")"
|
|
280
|
+
# Gate is "unavailable" when the connection fails OR the HTTP status is not
|
|
281
|
+
# 2xx (curl --silent exits 0 on 4xx/5xx). Unavailable routes through the
|
|
282
|
+
# fail-mode switch; it must NEVER implicitly allow. REACHED distinguishes a
|
|
283
|
+
# full connection failure (cannot record) from a reachable-but-degraded gate.
|
|
284
|
+
GATE_AVAILABLE=1
|
|
285
|
+
REACHED=1
|
|
286
|
+
# Tight timeouts on the critical-path gate: fail fast to the fail-mode switch
|
|
287
|
+
# instead of stalling every governed edit for up to 5s on a slow/down server.
|
|
288
|
+
CURL_MAX_TIME=2 CURL_CONNECT_TIMEOUT=1
|
|
289
|
+
export CURL_MAX_TIME CURL_CONNECT_TIMEOUT
|
|
290
|
+
if ! post_json "$KB_ENDPOINT/api/v1/gate/check" "$BODY"; then
|
|
291
|
+
GATE_AVAILABLE=0; REACHED=0
|
|
292
|
+
elif ! is_2xx "$RESP_CODE"; then
|
|
293
|
+
GATE_AVAILABLE=0
|
|
294
|
+
fi
|
|
295
|
+
VERDICT=""
|
|
296
|
+
if [[ "$GATE_AVAILABLE" == "1" ]]; then
|
|
297
|
+
VERDICT="$(json_field_from "$RESP_BODY" verdict)"
|
|
298
|
+
fi
|
|
299
|
+
# A reachable 2xx with no parseable verdict is also "unavailable": do not
|
|
300
|
+
# silently allow on an unexpected/degraded response.
|
|
301
|
+
if [[ "$GATE_AVAILABLE" == "0" || ( "$VERDICT" != "allow" && "$VERDICT" != "consult_required" ) ]]; then
|
|
302
|
+
# Reachable-but-degraded (server up, non-2xx or unparseable) is recorded;
|
|
303
|
+
# a full connection failure cannot be recorded (phone is down).
|
|
304
|
+
if [[ "$REACHED" == "1" ]]; then
|
|
305
|
+
record_degraded "$WS" "$SESSION" "gate degraded (code=$RESP_CODE)"
|
|
306
|
+
fi
|
|
307
|
+
if [[ "$KB_ENFORCE_FAIL_MODE" == "closed" ]]; then
|
|
308
|
+
emit_fail_closed "[KB] gate unavailable and fail-mode=closed; blocking."
|
|
309
|
+
fi
|
|
310
|
+
echo "[KB] warn: gate endpoint unavailable; failing open (enforcement gap)." >&2
|
|
311
|
+
exit 0
|
|
312
|
+
fi
|
|
313
|
+
if [[ "$VERDICT" == "consult_required" ]]; then
|
|
314
|
+
# Actionable deny: include the exact workspace key AND the session id (the
|
|
315
|
+
# one value the agent cannot guess) plus a copy-pasteable kb_consult call.
|
|
316
|
+
# A null/empty session_id echoes as empty (json_field already emits "" for
|
|
317
|
+
# JSON null), never the literal "None".
|
|
318
|
+
emit_deny "[KB] BLOCKED: this is a governed change and no explicit consultation is on record. Call kb_consult(workspace='$WS', source_session='$SESSION', intent='<what you are doing>') then retry."
|
|
319
|
+
fi
|
|
320
|
+
# Only reached when 2xx AND verdict == allow.
|
|
321
|
+
exit 0
|
|
322
|
+
;;
|
|
323
|
+
stop)
|
|
324
|
+
exit 0
|
|
325
|
+
;;
|
|
326
|
+
resolve-workspace)
|
|
327
|
+
# Diagnostic: print the workspace label this hook resolves for a directory
|
|
328
|
+
# (default: $PWD). Same resolution the pre-tool gate applies, so operators
|
|
329
|
+
# can see exactly which key a consult must target.
|
|
330
|
+
resolve_workspace "${POSITIONAL[0]:-$PWD}"
|
|
331
|
+
exit 0
|
|
332
|
+
;;
|
|
333
|
+
*)
|
|
334
|
+
echo "kb-enforce-hook: unknown mode '$MODE' (want session-start|user-prompt|pre-tool|stop|resolve-workspace)" >&2
|
|
335
|
+
exit 64
|
|
336
|
+
;;
|
|
337
|
+
esac
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// data-olympus-enforce (managed) v1 -- installed by `kb enforce install --agent opencode`.
|
|
2
|
+
// Gates file-mutating tools through the data-olympus gate. Remove via
|
|
3
|
+
// `kb enforce uninstall --agent opencode`.
|
|
4
|
+
import { execFileSync } from "node:child_process"
|
|
5
|
+
import { basename } from "node:path"
|
|
6
|
+
import type { Plugin, Hooks } from "@opencode-ai/plugin"
|
|
7
|
+
|
|
8
|
+
const ENDPOINT = process.env.KB_ENDPOINT ?? "http://localhost:8080"
|
|
9
|
+
const FAIL_MODE = process.env.KB_ENFORCE_FAIL_MODE ?? "open"
|
|
10
|
+
const TOKEN = process.env.KB_AUTH_TOKEN ?? ""
|
|
11
|
+
// Gate mutating tools. "bash" is included because shell-driven writes surface as bash.
|
|
12
|
+
const GATED = new Set(["edit", "write", "patch", "multiedit", "bash"])
|
|
13
|
+
|
|
14
|
+
// Resolve the workspace key the SAME way bin/kb-enforce-hook's resolve_workspace
|
|
15
|
+
// does: the MAIN git worktree basename (worktree-invariant, so a consult recorded
|
|
16
|
+
// from the main checkout clears the gate from any linked worktree too), matching
|
|
17
|
+
// the basename key used by every other surface. Falls back to the basename of the
|
|
18
|
+
// worktree/directory path, then the raw path, so the key is never empty.
|
|
19
|
+
// Previously this sent the raw absolute `worktree ?? directory` path, which could
|
|
20
|
+
// never match consults recorded under the basename key everywhere else.
|
|
21
|
+
export function resolveWorkspace(dir: string): string {
|
|
22
|
+
try {
|
|
23
|
+
const out = execFileSync("git", ["-C", dir, "worktree", "list", "--porcelain"], {
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
26
|
+
})
|
|
27
|
+
// First NON-bare worktree record; the main worktree is listed first. Skip a
|
|
28
|
+
// leading bare record (a bare repo's first entry is the bare git dir).
|
|
29
|
+
let path: string | undefined
|
|
30
|
+
let bare = false
|
|
31
|
+
for (const line of out.split("\n")) {
|
|
32
|
+
if (line.startsWith("worktree ")) {
|
|
33
|
+
path = line.slice("worktree ".length)
|
|
34
|
+
bare = false
|
|
35
|
+
} else if (line === "bare") {
|
|
36
|
+
bare = true
|
|
37
|
+
} else if (line === "") {
|
|
38
|
+
if (path && !bare) return basename(path)
|
|
39
|
+
path = undefined
|
|
40
|
+
bare = false
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (path && !bare) return basename(path)
|
|
44
|
+
} catch {
|
|
45
|
+
// not a git repo / git unavailable -> fall through to basename of the path
|
|
46
|
+
}
|
|
47
|
+
return basename(dir) || dir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const DataOlympusGate: Plugin = async ({ directory, worktree }) => {
|
|
51
|
+
const workspace = resolveWorkspace(worktree ?? directory)
|
|
52
|
+
return {
|
|
53
|
+
"tool.execute.before": async (input, output) => {
|
|
54
|
+
if (!GATED.has(input.tool)) return
|
|
55
|
+
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
56
|
+
if (TOKEN) headers["authorization"] = `Bearer ${TOKEN}`
|
|
57
|
+
// action_diff: the change content, so the gate classifier sees a real signal
|
|
58
|
+
// for bash (the command) and patch (the diff) which carry no file path.
|
|
59
|
+
// Capped at 4000 chars to bound the request body (mirrors kb-enforce-hook).
|
|
60
|
+
const args = output.args ?? {}
|
|
61
|
+
const rawDiff =
|
|
62
|
+
(args.content ?? args.newString ?? args.new_string ?? args.command ?? args.patch ?? "") as string
|
|
63
|
+
const actionDiff = typeof rawDiff === "string" ? rawDiff.slice(0, 4000) : ""
|
|
64
|
+
let verdict: string | undefined
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`${ENDPOINT}/api/v1/gate/check`, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers,
|
|
69
|
+
body: JSON.stringify({
|
|
70
|
+
workspace,
|
|
71
|
+
session_id: input.sessionID,
|
|
72
|
+
tool_name: input.tool,
|
|
73
|
+
action_path: (args.filePath ?? args.path) ?? "",
|
|
74
|
+
action_diff: actionDiff,
|
|
75
|
+
}),
|
|
76
|
+
signal: AbortSignal.timeout(5000),
|
|
77
|
+
})
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
if (FAIL_MODE === "closed")
|
|
80
|
+
throw new Error(`data-olympus gate HTTP ${res.status}; blocking (fail-closed)`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
verdict = ((await res.json()) as { verdict?: string }).verdict
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof Error && err.message.startsWith("data-olympus gate")) throw err
|
|
86
|
+
if (FAIL_MODE === "closed")
|
|
87
|
+
throw new Error(`data-olympus gate unreachable; blocking (fail-closed)`)
|
|
88
|
+
return // fail open
|
|
89
|
+
}
|
|
90
|
+
if (verdict === "consult_required") {
|
|
91
|
+
// Actionable deny: echo the exact workspace key and session id (the one
|
|
92
|
+
// value the agent cannot guess) so the clearing call is copy-pasteable.
|
|
93
|
+
throw new Error(
|
|
94
|
+
`BLOCKED by data-olympus: '${input.tool}' is a governed change with no ` +
|
|
95
|
+
`explicit consultation on record. Call ` +
|
|
96
|
+
`kb_consult(workspace='${workspace}', source_session='${input.sessionID}', ` +
|
|
97
|
+
`intent='<what you are doing>') then retry.`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
} satisfies Hooks
|
|
102
|
+
}
|