@miller-tech/uap 1.211.0 → 1.211.1
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/docs/reference/CONFIGURATION.md +2 -0
- package/package.json +3 -3
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +7 -0
- package/src/policies/enforcers/schema_diff_gate.py +20 -12
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +94 -7
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_gate_failclosed_schema_diff.py +410 -0
- package/tools/agents/tests/test_schema_diff_inline.py +59 -3
|
@@ -83,6 +83,8 @@ Top level: `version`, `project`, `memory`, `worktree`, `costOptimization`,
|
|
|
83
83
|
| `UAP_DELIVER_SANDBOX` | Deliver sandbox root path | — |
|
|
84
84
|
| `UAP_ACTIVE_RUNS_DIR` | Registry of live deliver runs, used for cross-root (nested-project) single-flight | `~/.uap/active-runs` |
|
|
85
85
|
| `UAP_DELIVER_NO_LOCK` | `1` disables the per-project lock **and** the overlapping-root check; the run also becomes invisible to other runs' checks | enabled |
|
|
86
|
+
| `UAP_SCHEMA_DIFF_INLINE` | Set by the schema-diff gate on its own checker subprocess so a nested gate skips the inline check instead of recursing. Setting it yourself only drops the gate to its marker fallback — it does not disable it — and the **inline** form is refused, like every switch in this group. | set by the gate |
|
|
87
|
+
| `UAP_ENFORCER_TIMEOUT` | Seconds the policy hook allows each enforcer. Must exceed the schema enforcer's own worst case (`INLINE_TIMEOUT` × 2 sources) or a slow healthy check is killed, which on a commit is a refusal. | `30` |
|
|
86
88
|
| `UAP_HALO_TRACE` | `1` enables HALO tracing | off |
|
|
87
89
|
| `UAP_HALO_TRACE_PATH` | HALO trace output file | `.uap/halo/traces.jsonl` |
|
|
88
90
|
| `UAP_HALO_PROJECT_ID` | HALO project id | `uap` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.211.
|
|
3
|
+
"version": "1.211.1",
|
|
4
4
|
"description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node dist/bin/cli.js",
|
|
22
22
|
"test": "vitest",
|
|
23
23
|
"test:ci": "vitest run",
|
|
24
|
-
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs tools.agents.tests.test_cycle_break_wait_poll",
|
|
24
|
+
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_live_deliver_kill tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_schema_diff_inline tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_gate_evidence tools.agents.tests.test_repeat_call_guard tools.agents.tests.test_gate_integrity tools.agents.tests.test_gate_failclosed_indirection tools.agents.tests.test_gate_failclosed_schema_diff tools.agents.tests.test_expert_review_pr_scope tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_write_complexity tools.agents.tests.test_fastpath_config tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_non_stream_resp_log tools.agents.tests.test_delivery_enforcement_worktree tools.agents.tests.test_output_token_ceilings tools.agents.tests.test_tool_narrowing_core tools.agents.tests.test_models_context_window tools.agents.tests.test_thinking_template_kwargs tools.agents.tests.test_cycle_break_wait_poll",
|
|
25
25
|
"test:coverage": "vitest --coverage",
|
|
26
26
|
"bench": "vitest --config vitest.bench.config.ts",
|
|
27
27
|
"lint": "eslint src --ext .ts",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"install:web": "bash scripts/setup/install-web.sh",
|
|
32
32
|
"install:desktop": "bash scripts/setup/install-desktop.sh",
|
|
33
33
|
"install:cloakbrowser": "tsx scripts/setup/install-cloakbrowser.ts",
|
|
34
|
-
"postinstall": "echo '\n
|
|
34
|
+
"postinstall": "echo '\n✨ Run: npx @miller-tech/uap init --interactive'",
|
|
35
35
|
"version:patch": "bash scripts/version-bump.sh patch",
|
|
36
36
|
"version:minor": "bash scripts/version-bump.sh minor",
|
|
37
37
|
"version:major": "bash scripts/version-bump.sh major",
|
|
Binary file
|
|
@@ -93,6 +93,13 @@ BYPASS_PATTERNS = (
|
|
|
93
93
|
# acceptance criteria. An inline assignment would let the agent waive its
|
|
94
94
|
# own consistency requirement, so refuse it here.
|
|
95
95
|
re.compile(r"UAP_ORACLE_CONSISTENCY\s*=\s*['\"]?0", re.I),
|
|
96
|
+
# schema-diff's re-entry guard. It exists so a nested gate skips its own
|
|
97
|
+
# inline checker run rather than recursing, and it no longer allows
|
|
98
|
+
# anything on its own -- but it still WEAKENS the gate to its fallback
|
|
99
|
+
# layer, and every other switch of that shape is listed here. An inline
|
|
100
|
+
# assignment is written by the constrained party into its own command
|
|
101
|
+
# line, which is an off switch rather than an override.
|
|
102
|
+
re.compile(r"UAP_SCHEMA_DIFF_INLINE\s*=\s*['\"]?1", re.I),
|
|
96
103
|
)
|
|
97
104
|
|
|
98
105
|
# The gateless-root override in its CLI-flag spelling. Anchored on both sides
|
|
@@ -205,8 +205,9 @@ def schema_diff_ok(root: Path) -> list | None:
|
|
|
205
205
|
freshness window is applied here, per candidate.
|
|
206
206
|
|
|
207
207
|
Every miss must return None, not False: main() consumes this value, and a
|
|
208
|
-
stray bool crashed the enforcer
|
|
209
|
-
|
|
208
|
+
stray bool crashed the enforcer. On a commit or push the hook now turns
|
|
209
|
+
that crash into a refusal rather than an ALLOW, so the cost is a blocked
|
|
210
|
+
commit instead of a silent bypass -- still a bug, and still worth the type.
|
|
210
211
|
"""
|
|
211
212
|
db = root / "agents" / "data" / "memory" / "short_term.db"
|
|
212
213
|
if not db.exists():
|
|
@@ -268,10 +269,15 @@ def schema_diff_ok(root: Path) -> list | None:
|
|
|
268
269
|
|
|
269
270
|
INLINE_GUARD = "UAP_SCHEMA_DIFF_INLINE"
|
|
270
271
|
# Comfortably inside policy-tools' 30s enforcer budget. At 60s a slow checker
|
|
271
|
-
# meant the ENFORCER was killed first,
|
|
272
|
-
# the
|
|
273
|
-
#
|
|
274
|
-
|
|
272
|
+
# meant the ENFORCER was killed first, which on the TS path is still an ALLOW
|
|
273
|
+
# and on the shell-hook path is now a hard block on the commit -- a bypass or a
|
|
274
|
+
# deadlock depending which caller you are under, and neither is the intended
|
|
275
|
+
# fallback. 400 watched files measured at 6.3s for one source. The layers nest
|
|
276
|
+
# innermost-shortest: this x 2 sources (20s) < the hook's per-enforcer bound
|
|
277
|
+
# (30s, UAP_ENFORCER_TIMEOUT) < the harness hook budget. Raising this without
|
|
278
|
+
# raising those turns a slow check into a killed enforcer, which on a commit is
|
|
279
|
+
# now a refusal.
|
|
280
|
+
INLINE_TIMEOUT = 10.0
|
|
275
281
|
# Verdict shapes this gate knows how to read (SCHEMA_DIFF_CONTRACT in the CLI).
|
|
276
282
|
KNOWN_CONTRACTS = (1,)
|
|
277
283
|
# Passed to the checker. Everything else -- API keys, tokens, proxy secrets --
|
|
@@ -690,12 +696,14 @@ def main() -> None:
|
|
|
690
696
|
|
|
691
697
|
|
|
692
698
|
if __name__ == "__main__":
|
|
693
|
-
# A crash must not read as consent. The
|
|
694
|
-
#
|
|
695
|
-
#
|
|
696
|
-
#
|
|
697
|
-
# TypeErrors on a malformed verdict.
|
|
698
|
-
# the backstop
|
|
699
|
+
# A crash must not read as consent. The hook maps a non-zero exit or
|
|
700
|
+
# unparseable output to ALLOW for every enforcer except self-protect and,
|
|
701
|
+
# since the fail-closed net was widened, this one on a commit or push
|
|
702
|
+
# (.claude/hooks/uap-policy-gate.sh). Two reachable crashes were found by
|
|
703
|
+
# review, both TypeErrors on a malformed verdict. Emitting a refusal here
|
|
704
|
+
# is still the right backstop: it keeps the outcome correct on the paths
|
|
705
|
+
# the hook does NOT cover, and produces a reasoned message rather than a
|
|
706
|
+
# bare block on the ones it does.
|
|
699
707
|
try:
|
|
700
708
|
main()
|
|
701
709
|
except SystemExit:
|
|
Binary file
|
|
@@ -172,7 +172,8 @@ bypass = re.search(
|
|
|
172
172
|
r"|UAP_SELF_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_NO_WORKTREE\s*=\s*[\x27\"]?1"
|
|
173
173
|
r"|UAP_WORKDIR_SCOPE_OFF\s*=\s*[\x27\"]?1|UAP_USER_VALIDATION\s*=\s*[\x27\"]?0"
|
|
174
174
|
r"|UAP_DELIVER_NO_LOCK\s*=\s*[\x27\"]?1|UAP_NO_REVIEW\s*=\s*[\x27\"]?1"
|
|
175
|
-
r"|UAP_INFRA_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_ALLOW_GATELESS_ROOT\s*=\s*[\x27\"]?1"
|
|
175
|
+
r"|UAP_INFRA_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_ALLOW_GATELESS_ROOT\s*=\s*[\x27\"]?1"
|
|
176
|
+
r"|UAP_SCHEMA_DIFF_INLINE\s*=\s*[\x27\"]?1|UAP_ORACLE_CONSISTENCY\s*=\s*[\x27\"]?0",
|
|
176
177
|
cmd, re.I)
|
|
177
178
|
print("1" if (hit or bypass) else "0")
|
|
178
179
|
' 2>/dev/null || echo 1)"
|
|
@@ -239,8 +240,32 @@ sys.exit(0 if (is_test or trivial) else 1)
|
|
|
239
240
|
esac
|
|
240
241
|
fi
|
|
241
242
|
|
|
243
|
+
# $1 = short reason, $2 = the enforcer that could not run.
|
|
244
|
+
# The message has to name the RIGHT enforcer and an override that actually
|
|
245
|
+
# works. Routing schema_diff_gate through the self-protect wording told the
|
|
246
|
+
# operator the wrong thing had failed and pointed at UAP_SELF_PROTECT_OFF=1,
|
|
247
|
+
# which cleared SEC_SENSITIVE and not COMMIT_OP. A refusal that describes the
|
|
248
|
+
# wrong thing and offers no alternative is how a loop survives a guard.
|
|
242
249
|
fail_closed() {
|
|
243
|
-
|
|
250
|
+
local why="${1:-machinery unavailable}"
|
|
251
|
+
local who="${2:-enforcement_self_protect}"
|
|
252
|
+
# Record before exiting. This is the most serious verdict the gate can
|
|
253
|
+
# reach and it was the one that left no trace: measured, policy_executions
|
|
254
|
+
# was unchanged across a fail-closed block while ordinary blocks recorded,
|
|
255
|
+
# so the compliance view showed zero blocks for exactly this failure mode.
|
|
256
|
+
# Guarded with declare -F because the earliest call sites (policies.db
|
|
257
|
+
# missing, no sqlite3) fire before record_execution is defined -- and in
|
|
258
|
+
# those states there is nothing to write to anyway.
|
|
259
|
+
declare -F record_execution >/dev/null \
|
|
260
|
+
&& record_execution 0 "$who" "FAIL-CLOSED: $why"
|
|
261
|
+
case "$who" in
|
|
262
|
+
schema_diff_gate)
|
|
263
|
+
echo "[UAP policy gate] FAIL-CLOSED: this commit touches watched schema paths but the schema-diff enforcer could not run (${why}). Blocked so a broken/absent gate can't become a bypass. Restore it with: uap policy verify --repair (or uap policy install schema-diff-gate if the manifest is gone; npm run build if dist/ is missing). Operator override: UAP_SELF_PROTECT_OFF=1 in the environment." >&2
|
|
264
|
+
;;
|
|
265
|
+
*)
|
|
266
|
+
echo "[UAP policy gate] FAIL-CLOSED: this operation touches the enforcement control surface but the self-protect enforcer could not run (${why}). Blocked so a broken/absent gate can't become a bypass. (Operator override: UAP_SELF_PROTECT_OFF=1.)" >&2
|
|
267
|
+
;;
|
|
268
|
+
esac
|
|
244
269
|
exit 2
|
|
245
270
|
}
|
|
246
271
|
|
|
@@ -330,26 +355,88 @@ fi
|
|
|
330
355
|
# Did the self-protect enforcer actually run and make a decision this call?
|
|
331
356
|
sec_enforcer_ran=0
|
|
332
357
|
|
|
358
|
+
# Is this a commit or a push? schema_diff_gate is the only control standing
|
|
359
|
+
# between a breaking schema change and history, and like self-protect its
|
|
360
|
+
# failure must not be read as consent -- the loop below otherwise maps an
|
|
361
|
+
# errored or missing enforcer to ALLOW for everything except self-protect.
|
|
362
|
+
# Scoped to commit/push so a broken schema enforcer cannot block every shell
|
|
363
|
+
# command in the session; it mirrors the enforcer's own activation test.
|
|
364
|
+
COMMIT_OP="$(printf '%s' "$ARGS" | python3 -c '
|
|
365
|
+
import json, re, sys
|
|
366
|
+
try: a = json.loads(sys.stdin.read() or "{}")
|
|
367
|
+
except Exception: a = {}
|
|
368
|
+
cmd = str(a.get("command") or "")
|
|
369
|
+
# Quoted text is prose, not an invocation. While the enforcer was broken,
|
|
370
|
+
# echo "next step: git commit" and grep -rn "git push" docs/ were hard blocks.
|
|
371
|
+
# The enforcer over-matches the same way, but when IT over-matches it runs,
|
|
372
|
+
# finds no watched paths and allows -- so the fail-closed net has to be
|
|
373
|
+
# STRICTER than the thing it protects, not looser.
|
|
374
|
+
bare = re.sub(r"\x27[^\x27]*\x27|\"[^\"]*\"", " ", cmd).lower()
|
|
375
|
+
inv = re.search(r"(?:^|[;&|]|\bthen\b|\bdo\b)\s*git\b[^;&|]*\b(?:commit|push)\b", bare)
|
|
376
|
+
# --help and --dry-run store nothing, so refusing them is pure friction. The
|
|
377
|
+
# match itself ends at the verb, so the flags are searched in the rest of the
|
|
378
|
+
# command SEGMENT -- searching inv.group(0) found nothing and blocked them.
|
|
379
|
+
seg = re.split(r"[;&|]", bare[inv.start():])[0] if inv else ""
|
|
380
|
+
inert = inv and re.search(r"--help|--dry-run|(?:^|\s)-h(?:\s|$)", seg)
|
|
381
|
+
print("1" if (inv and not inert) else "0")
|
|
382
|
+
' 2>/dev/null || echo 1)"
|
|
383
|
+
|
|
384
|
+
# The operator hatch clears the whole net. It already zeroed SEC_SENSITIVE
|
|
385
|
+
# further up; leaving COMMIT_OP armed made the override named in the refusal a
|
|
386
|
+
# no-op for exactly the case that prints it -- so a stale .policy-tools copy
|
|
387
|
+
# refused every commit and push in the session with no working remedy.
|
|
388
|
+
[[ "${UAP_SELF_PROTECT_OFF:-}" == "1" ]] && COMMIT_OP=0
|
|
389
|
+
|
|
390
|
+
# Which enforcers must fail CLOSED, and when. Called from `if`, never as the
|
|
391
|
+
# left side of `&&`: this script runs under `set -e`, and `if` suspends it for
|
|
392
|
+
# the whole condition.
|
|
393
|
+
must_fail_closed() {
|
|
394
|
+
case "$1" in
|
|
395
|
+
enforcement_self_protect) [[ "$SEC_SENSITIVE" == "1" ]] ;;
|
|
396
|
+
schema_diff_gate) [[ "$COMMIT_OP" == "1" ]] ;;
|
|
397
|
+
*) false ;;
|
|
398
|
+
esac
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
# Bound every enforcer. Without this a hung one stalled the hook until the
|
|
402
|
+
# harness killed the whole process -- and a killed HOOK is not a fail-closed,
|
|
403
|
+
# it is an unbounded stall with the outcome decided elsewhere. On timeout the
|
|
404
|
+
# output is empty, which the parser below already reads as allowed=2, so the
|
|
405
|
+
# existing net decides what that means per enforcer.
|
|
406
|
+
#
|
|
407
|
+
# The layers must nest, innermost shortest: the schema enforcer's own inline
|
|
408
|
+
# checker (10s x up to 2 sources) < this (30s) < the harness hook budget.
|
|
409
|
+
# gtimeout is the macOS spelling; with neither present the call is unbounded,
|
|
410
|
+
# which is the behaviour that shipped.
|
|
411
|
+
TIMEOUT_BIN="$(command -v timeout || command -v gtimeout || true)"
|
|
412
|
+
ENFORCER_TIMEOUT="${UAP_ENFORCER_TIMEOUT:-30}"
|
|
413
|
+
|
|
333
414
|
# Iterate active policies with attached executable tools
|
|
334
415
|
while IFS='|' read -r pid pname tool; do
|
|
335
416
|
[[ -z "$pid" ]] && continue
|
|
336
417
|
enforcer="$MAIN_ROOT/.policy-tools/${pid}_${tool}.py"
|
|
337
418
|
if [[ ! -f "$enforcer" ]]; then
|
|
338
|
-
# A missing
|
|
339
|
-
|
|
419
|
+
# A missing fail-closed enforcer on an operation it guards = fail closed.
|
|
420
|
+
if must_fail_closed "$tool"; then fail_closed "enforcer file missing" "$tool"; fi
|
|
340
421
|
continue
|
|
341
422
|
fi
|
|
342
|
-
|
|
423
|
+
if [[ -n "$TIMEOUT_BIN" ]]; then
|
|
424
|
+
out="$("$TIMEOUT_BIN" "${ENFORCER_TIMEOUT}s" python3 "$enforcer" --operation "$TOOL" --args "$ARGS" 2>/dev/null || true)"
|
|
425
|
+
else
|
|
426
|
+
out="$(python3 "$enforcer" --operation "$TOOL" --args "$ARGS" 2>/dev/null || true)"
|
|
427
|
+
fi
|
|
343
428
|
allowed="$(printf '%s' "$out" | python3 -c 'import json,sys;
|
|
344
429
|
try: d=json.loads(sys.stdin.read()); print("1" if d.get("allowed",True) else "0")
|
|
345
430
|
except: print("2")' 2>/dev/null || echo 2)"
|
|
346
431
|
# allowed=2 => enforcer errored / emitted unparseable output. For a sensitive
|
|
347
432
|
# op via the self-protect enforcer, that error must NOT default to allow.
|
|
433
|
+
if [[ "$allowed" == "2" ]] && must_fail_closed "$tool"; then
|
|
434
|
+
fail_closed "enforcer errored" "$tool"
|
|
435
|
+
fi
|
|
348
436
|
if [[ "$tool" == "enforcement_self_protect" ]]; then
|
|
349
|
-
[[ "$SEC_SENSITIVE" == "1" && "$allowed" == "2" ]] && fail_closed "enforcer errored"
|
|
350
437
|
sec_enforcer_ran=1
|
|
351
438
|
fi
|
|
352
|
-
# For
|
|
439
|
+
# For every other enforcer, an error still fails open (unchanged behavior).
|
|
353
440
|
[[ "$allowed" == "2" ]] && allowed=1
|
|
354
441
|
if [[ "$allowed" == "0" ]]; then
|
|
355
442
|
# R1: consume the enforcer's route:deliver signal (log intent, opt-in
|
|
Binary file
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""The schema-diff gate's failure must not read as consent.
|
|
2
|
+
|
|
3
|
+
Two residuals left over from the inline-check pivot (PR #733), both in files
|
|
4
|
+
the agent that wrote that change could not edit:
|
|
5
|
+
|
|
6
|
+
1. `UAP_SCHEMA_DIFF_INLINE=1` was the only gate switch of its shape absent
|
|
7
|
+
from self-protect's BYPASS_PATTERNS. It no longer allows anything on its
|
|
8
|
+
own -- it skips the inline checker and lands in the fallback -- but it
|
|
9
|
+
still WEAKENS the control, and an inline assignment is written by the
|
|
10
|
+
constrained party into its own command line. Every comparable switch
|
|
11
|
+
(UAP_SELF_PROTECT_OFF, UAP_NO_REVIEW, UAP_DELIVER_NO_LOCK, ...) is
|
|
12
|
+
refused inline for exactly that reason.
|
|
13
|
+
|
|
14
|
+
2. uap-policy-gate.sh maps an errored or missing enforcer to ALLOW for
|
|
15
|
+
everything except enforcement_self_protect. schema_diff_gate is the only
|
|
16
|
+
thing standing between a breaking schema change and history, so a crash
|
|
17
|
+
in it was a silent bypass. It now fails closed -- but only on a commit or
|
|
18
|
+
push, so a broken schema enforcer cannot block every shell command in a
|
|
19
|
+
session.
|
|
20
|
+
|
|
21
|
+
The drift test at the bottom is the one that will actually catch a regression:
|
|
22
|
+
the hook has NINE copies in this repo, and `uap worktree create` seeds new
|
|
23
|
+
worktrees from templates/hooks/, so patching only .claude/hooks/ silently
|
|
24
|
+
reverts the fix on the next worktree.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import sqlite3
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
import unittest
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
REPO = Path(__file__).resolve().parents[3]
|
|
40
|
+
ENFORCERS = REPO / "src" / "policies" / "enforcers"
|
|
41
|
+
SELF_PROTECT = ENFORCERS / "enforcement_self_protect.py"
|
|
42
|
+
PRIMARY_HOOK = REPO / ".claude" / "hooks" / "uap-policy-gate.sh"
|
|
43
|
+
HOOK_NAME = "uap-policy-gate.sh"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def hook_copies() -> list[Path]:
|
|
47
|
+
"""Every copy of the hook in this checkout.
|
|
48
|
+
|
|
49
|
+
Exclusions are matched on the path RELATIVE to REPO. Matching the absolute
|
|
50
|
+
path excluded the entire tree whenever REPO was itself a worktree, since
|
|
51
|
+
every path under .worktrees/240-x/ contains ".worktrees" -- so this
|
|
52
|
+
returned [] exactly where it runs, and the two sweeps below passed by
|
|
53
|
+
iterating nothing. Caught by running it for real; a scratch copy in /tmp
|
|
54
|
+
could not reproduce it.
|
|
55
|
+
"""
|
|
56
|
+
tracked = subprocess.run(
|
|
57
|
+
["git", "ls-files", "--full-name", "*" + HOOK_NAME],
|
|
58
|
+
cwd=REPO, capture_output=True, text=True,
|
|
59
|
+
).stdout.split()
|
|
60
|
+
# TRACKED copies only. An rglob also swept `uap hooks install` output --
|
|
61
|
+
# .uap/omp/ and .codex|.cursor|.forge|.opencode/hooks/ are all gitignored --
|
|
62
|
+
# so on any machine whose installed hooks predate a change, the drift
|
|
63
|
+
# assertions failed on files no PR can commit. What matters here is that
|
|
64
|
+
# every copy the repo SHIPS stays in step, templates/hooks/ above all.
|
|
65
|
+
return sorted(REPO / t for t in tracked if (REPO / t).is_file())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_self_protect(command: str, root: Path) -> tuple[bool, str]:
|
|
69
|
+
env = {
|
|
70
|
+
"UAP_REPO_ROOT": str(root),
|
|
71
|
+
"UAP_WORKTREE_ROOT": str(root),
|
|
72
|
+
"PYTHONPATH": str(ENFORCERS),
|
|
73
|
+
"PATH": "/usr/bin:/bin",
|
|
74
|
+
}
|
|
75
|
+
r = subprocess.run(
|
|
76
|
+
[sys.executable, str(SELF_PROTECT), "--operation", "Bash",
|
|
77
|
+
"--args", json.dumps({"command": command})],
|
|
78
|
+
cwd=root, env=env, capture_output=True, text=True, timeout=60,
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
payload = json.loads(r.stdout or "{}")
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return False, f"unparseable: {r.stdout[:120]}"
|
|
84
|
+
return payload.get("allowed", True), payload.get("reason", "")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class InlineGuardIsABypassPattern(unittest.TestCase):
|
|
88
|
+
def test_setting_it_inline_is_refused(self):
|
|
89
|
+
allowed, reason = run_self_protect(
|
|
90
|
+
"UAP_SCHEMA_DIFF_INLINE=1 git commit -m x", REPO
|
|
91
|
+
)
|
|
92
|
+
self.assertFalse(allowed, f"an inline off-switch must be refused: {reason}")
|
|
93
|
+
|
|
94
|
+
def test_a_comparable_switch_behaves_the_same(self):
|
|
95
|
+
"""Anchors the assertion above to existing, agreed behaviour."""
|
|
96
|
+
allowed, _ = run_self_protect("UAP_NO_REVIEW=1 git commit -m x", REPO)
|
|
97
|
+
self.assertFalse(allowed)
|
|
98
|
+
|
|
99
|
+
def test_merely_naming_the_variable_is_not_refused(self):
|
|
100
|
+
"""Docs, commit messages and greps must keep working.
|
|
101
|
+
|
|
102
|
+
The pattern requires an assignment to 1, not the bare name -- the same
|
|
103
|
+
distinction that made GATELESS_FLAG_RE scan `scannable_command`
|
|
104
|
+
instead of raw text after bare flag names started refusing honest work.
|
|
105
|
+
"""
|
|
106
|
+
for cmd in (
|
|
107
|
+
"echo the UAP_SCHEMA_DIFF_INLINE guard is documented",
|
|
108
|
+
"grep -rn UAP_SCHEMA_DIFF_INLINE src/",
|
|
109
|
+
):
|
|
110
|
+
with self.subTest(cmd=cmd):
|
|
111
|
+
allowed, reason = run_self_protect(cmd, REPO)
|
|
112
|
+
self.assertTrue(allowed, f"{cmd} must stay allowed: {reason}")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class HookFailsClosedOnCommits(unittest.TestCase):
|
|
116
|
+
"""The hook's own logic, extracted and executed rather than eyeballed."""
|
|
117
|
+
|
|
118
|
+
def commit_op(self, command: str) -> str:
|
|
119
|
+
gate = PRIMARY_HOOK.read_text()
|
|
120
|
+
start = 'COMMIT_OP="$(printf \'%s\' "$ARGS" | python3 -c \''
|
|
121
|
+
end = "' 2>/dev/null || echo 1)\""
|
|
122
|
+
i = gate.index(start) + len(start)
|
|
123
|
+
code = gate[i:gate.index(end, i)]
|
|
124
|
+
p = subprocess.run(
|
|
125
|
+
[sys.executable, "-c", code],
|
|
126
|
+
input=json.dumps({"command": command}),
|
|
127
|
+
capture_output=True, text=True, timeout=30,
|
|
128
|
+
env={"PATH": "/usr/bin:/bin"},
|
|
129
|
+
)
|
|
130
|
+
return p.stdout.strip()
|
|
131
|
+
|
|
132
|
+
def must_fail_closed(self, tool: str, sec: str, commit: str) -> bool:
|
|
133
|
+
gate = PRIMARY_HOOK.read_text()
|
|
134
|
+
start = gate.index("must_fail_closed() {")
|
|
135
|
+
fn = gate[start:gate.index("\n}\n", start) + 3]
|
|
136
|
+
script = (
|
|
137
|
+
"set -euo pipefail\n"
|
|
138
|
+
f'SEC_SENSITIVE="{sec}"\nCOMMIT_OP="{commit}"\n'
|
|
139
|
+
+ fn
|
|
140
|
+
+ f'\nif must_fail_closed "{tool}"; then echo CLOSED; else echo open; fi\n'
|
|
141
|
+
)
|
|
142
|
+
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=30)
|
|
143
|
+
self.assertEqual(p.returncode, 0, f"set -e tripped: {p.stderr[:200]}")
|
|
144
|
+
return p.stdout.strip() == "CLOSED"
|
|
145
|
+
|
|
146
|
+
def test_commit_and_push_are_recognised(self):
|
|
147
|
+
for cmd in ("git commit -m x", "git push origin master", "git commit -am x && echo ok"):
|
|
148
|
+
with self.subTest(cmd=cmd):
|
|
149
|
+
self.assertEqual(self.commit_op(cmd), "1")
|
|
150
|
+
|
|
151
|
+
def test_ordinary_commands_are_not(self):
|
|
152
|
+
# Scoped deliberately: a broken schema enforcer must not turn every
|
|
153
|
+
# shell command in the session into a hard block.
|
|
154
|
+
for cmd in ("ls -la", "npm test", "cat README.md"):
|
|
155
|
+
with self.subTest(cmd=cmd):
|
|
156
|
+
self.assertEqual(self.commit_op(cmd), "0")
|
|
157
|
+
|
|
158
|
+
def test_the_schema_gate_fails_closed_on_a_commit(self):
|
|
159
|
+
self.assertTrue(self.must_fail_closed("schema_diff_gate", "0", "1"))
|
|
160
|
+
|
|
161
|
+
def test_the_schema_gate_does_not_fail_closed_otherwise(self):
|
|
162
|
+
self.assertFalse(self.must_fail_closed("schema_diff_gate", "0", "0"))
|
|
163
|
+
|
|
164
|
+
def test_self_protect_keeps_its_existing_condition(self):
|
|
165
|
+
self.assertTrue(self.must_fail_closed("enforcement_self_protect", "1", "0"))
|
|
166
|
+
self.assertFalse(self.must_fail_closed("enforcement_self_protect", "0", "0"))
|
|
167
|
+
|
|
168
|
+
def test_an_unrelated_enforcer_still_fails_open(self):
|
|
169
|
+
"""Widening this to every enforcer would be a session-wide deadlock."""
|
|
170
|
+
self.assertFalse(self.must_fail_closed("worktree_required", "1", "1"))
|
|
171
|
+
|
|
172
|
+
def test_the_hook_parses(self):
|
|
173
|
+
for copy in hook_copies():
|
|
174
|
+
with self.subTest(copy=str(copy.relative_to(REPO))):
|
|
175
|
+
p = subprocess.run(["bash", "-n", str(copy)], capture_output=True, text=True)
|
|
176
|
+
self.assertEqual(p.returncode, 0, p.stderr[:200])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class HookCopiesDoNotDrift(unittest.TestCase):
|
|
180
|
+
"""Nine copies, and the one that matters most is the one nobody edits.
|
|
181
|
+
|
|
182
|
+
`uap worktree create` seeds a new worktree from templates/hooks/, so a fix
|
|
183
|
+
applied only to .claude/hooks/ is reverted the next time anyone starts a
|
|
184
|
+
branch -- a documented failure in this repo. Any change to the gate has to
|
|
185
|
+
land in all of them.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def test_the_sweep_actually_finds_the_copies(self):
|
|
189
|
+
"""Guards the two sweeps below against passing on an empty list.
|
|
190
|
+
|
|
191
|
+
hook_copies() returned [] in a worktree because its exclusions matched
|
|
192
|
+
the absolute path, and both drift tests went green while checking
|
|
193
|
+
nothing. An assertion about a collection is worthless without an
|
|
194
|
+
assertion that the collection is non-empty.
|
|
195
|
+
"""
|
|
196
|
+
found = hook_copies()
|
|
197
|
+
self.assertGreaterEqual(
|
|
198
|
+
len(found), 2,
|
|
199
|
+
f"expected several hook copies under {REPO}, found {[str(p) for p in found]}",
|
|
200
|
+
)
|
|
201
|
+
self.assertIn(PRIMARY_HOOK, found)
|
|
202
|
+
|
|
203
|
+
def test_every_copy_is_identical_to_the_primary(self):
|
|
204
|
+
primary = PRIMARY_HOOK.read_text()
|
|
205
|
+
for copy in hook_copies():
|
|
206
|
+
with self.subTest(copy=str(copy.relative_to(REPO))):
|
|
207
|
+
self.assertEqual(
|
|
208
|
+
copy.read_text(), primary,
|
|
209
|
+
f"{copy.relative_to(REPO)} has drifted from .claude/hooks/",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def test_the_template_copy_carries_the_fail_closed_logic(self):
|
|
213
|
+
template = REPO / "templates" / "hooks" / HOOK_NAME
|
|
214
|
+
self.assertTrue(template.is_file(), "templates/hooks copy is missing")
|
|
215
|
+
self.assertIn(
|
|
216
|
+
"must_fail_closed", template.read_text(),
|
|
217
|
+
"new worktrees would be seeded with a gate that fails open",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
class FailClosedIsAuditable(unittest.TestCase):
|
|
221
|
+
"""The most serious verdict the gate can reach was the one leaving no trace.
|
|
222
|
+
|
|
223
|
+
fail_closed() exits, and it exited before record_execution ever ran --
|
|
224
|
+
measured, policy_executions was unchanged across a fail-closed block while
|
|
225
|
+
ordinary blocks recorded. The compliance view therefore showed zero blocks
|
|
226
|
+
for exactly the failure mode most worth seeing.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def fail_closed_fn(self) -> str:
|
|
230
|
+
gate = PRIMARY_HOOK.read_text()
|
|
231
|
+
start = gate.index("fail_closed() {")
|
|
232
|
+
return gate[start:gate.index("\n}\n", start) + 3]
|
|
233
|
+
|
|
234
|
+
def test_it_records_before_exiting(self):
|
|
235
|
+
script = (
|
|
236
|
+
"set -euo pipefail\n"
|
|
237
|
+
'record_execution() { echo "RECORDED allowed=$1 policy=$2 reason=$3"; }\n'
|
|
238
|
+
+ self.fail_closed_fn()
|
|
239
|
+
+ '\nfail_closed "enforcer errored" "schema_diff_gate" || true\n'
|
|
240
|
+
)
|
|
241
|
+
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=30)
|
|
242
|
+
self.assertIn("RECORDED allowed=0", p.stdout)
|
|
243
|
+
self.assertIn("policy=schema_diff_gate", p.stdout, "the row must name the enforcer")
|
|
244
|
+
self.assertIn("FAIL-CLOSED", p.stdout)
|
|
245
|
+
|
|
246
|
+
def test_it_still_works_before_record_execution_exists(self):
|
|
247
|
+
"""The earliest call sites fire before that function is defined.
|
|
248
|
+
|
|
249
|
+
`policies.db not found` and `sqlite3 not on PATH` both call fail_closed
|
|
250
|
+
from above record_execution's definition -- and in those states there is
|
|
251
|
+
nothing to write to anyway. An unguarded call would turn the refusal
|
|
252
|
+
into a bash error.
|
|
253
|
+
"""
|
|
254
|
+
script = "set -euo pipefail\n" + self.fail_closed_fn() + '\nfail_closed "policies.db not found" || true\n'
|
|
255
|
+
p = subprocess.run(["bash", "-c", script], capture_output=True, text=True, timeout=30)
|
|
256
|
+
self.assertIn("FAIL-CLOSED", p.stderr)
|
|
257
|
+
self.assertNotIn("command not found", p.stderr)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class EnforcersAreBounded(unittest.TestCase):
|
|
261
|
+
"""A hung enforcer stalled the hook until the harness killed the process.
|
|
262
|
+
|
|
263
|
+
A killed HOOK is not a fail-closed -- it is an unbounded stall whose outcome
|
|
264
|
+
is decided somewhere else entirely. Measured at 20.2s with a sleeping
|
|
265
|
+
enforcer before this.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def test_the_invocation_is_wrapped(self):
|
|
269
|
+
gate = PRIMARY_HOOK.read_text()
|
|
270
|
+
self.assertIn("TIMEOUT_BIN", gate)
|
|
271
|
+
self.assertIn('"${ENFORCER_TIMEOUT}s" python3 "$enforcer"', gate)
|
|
272
|
+
|
|
273
|
+
def test_it_degrades_to_the_shipped_behaviour_without_a_timeout_binary(self):
|
|
274
|
+
"""macOS spells it gtimeout; with neither, unbounded is what shipped."""
|
|
275
|
+
gate = PRIMARY_HOOK.read_text()
|
|
276
|
+
self.assertIn("command -v timeout || command -v gtimeout || true", gate)
|
|
277
|
+
self.assertIn('if [[ -n "$TIMEOUT_BIN" ]]; then', gate)
|
|
278
|
+
|
|
279
|
+
def test_the_timeout_layers_nest_innermost_shortest(self):
|
|
280
|
+
"""The arithmetic that makes the bound meaningful rather than decorative.
|
|
281
|
+
|
|
282
|
+
The schema enforcer may run its checker twice (index and worktree). If
|
|
283
|
+
that worst case can exceed the hook's per-enforcer bound, a slow but
|
|
284
|
+
healthy check is killed and -- on a commit -- refused. Previously
|
|
285
|
+
2 x 15s met the 30s budget exactly, with nothing left over.
|
|
286
|
+
"""
|
|
287
|
+
gate = PRIMARY_HOOK.read_text()
|
|
288
|
+
enforcer = (REPO / "src" / "policies" / "enforcers" / "schema_diff_gate.py").read_text()
|
|
289
|
+
hook_bound = int(
|
|
290
|
+
re.search(r'ENFORCER_TIMEOUT="\$\{UAP_ENFORCER_TIMEOUT:-(\d+)\}"', gate).group(1)
|
|
291
|
+
)
|
|
292
|
+
inline = float(re.search(r"INLINE_TIMEOUT = ([\d.]+)", enforcer).group(1))
|
|
293
|
+
self.assertLess(
|
|
294
|
+
inline * 2, hook_bound,
|
|
295
|
+
"the enforcer's worst case must fit inside the hook's bound, or a "
|
|
296
|
+
"slow healthy check becomes a refusal",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class GateCallSitesAreWired(unittest.TestCase):
|
|
301
|
+
"""Drives the REAL hook script.
|
|
302
|
+
|
|
303
|
+
Everything above extracts a bash fragment and runs it, which cannot notice
|
|
304
|
+
if the fragment is never CALLED. Delete `must_fail_closed` from either call
|
|
305
|
+
site, or move the COMMIT_OP assignment below the loop, and every other test
|
|
306
|
+
here still passes. This one fails.
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
PID = "22222222-2222-2222-2222-222222222222"
|
|
310
|
+
|
|
311
|
+
def setUp(self):
|
|
312
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="gate-e2e-")
|
|
313
|
+
self.sb = Path(self._tmp.name)
|
|
314
|
+
for d in (".policy-tools", "agents/data/memory", ".claude/hooks"):
|
|
315
|
+
(self.sb / d).mkdir(parents=True, exist_ok=True)
|
|
316
|
+
subprocess.run(["git", "init", "-q"], cwd=self.sb, capture_output=True)
|
|
317
|
+
|
|
318
|
+
helper = (
|
|
319
|
+
"import json, sys\n"
|
|
320
|
+
"def parse_cli():\n"
|
|
321
|
+
" a = sys.argv\n"
|
|
322
|
+
" op = a[a.index('--operation') + 1] if '--operation' in a else ''\n"
|
|
323
|
+
" ar = json.loads(a[a.index('--args') + 1]) if '--args' in a else {}\n"
|
|
324
|
+
" return op, ar\n"
|
|
325
|
+
"def emit(allowed, reason):\n"
|
|
326
|
+
" print(json.dumps({'allowed': bool(allowed), 'reason': reason}))\n"
|
|
327
|
+
" sys.exit(0)\n"
|
|
328
|
+
)
|
|
329
|
+
(self.sb / ".policy-tools/_common.py").write_text(helper)
|
|
330
|
+
|
|
331
|
+
db = sqlite3.connect(self.sb / "agents/data/memory/policies.db")
|
|
332
|
+
db.execute("CREATE TABLE policies (id TEXT, name TEXT, category TEXT, level TEXT,"
|
|
333
|
+
" rawMarkdown TEXT, convertedFormat TEXT, executableTools TEXT, tags TEXT,"
|
|
334
|
+
" createdAt TEXT, updatedAt TEXT, version INT, isActive INT, priority INT,"
|
|
335
|
+
" enforcementStage TEXT)")
|
|
336
|
+
db.execute("CREATE TABLE executable_tools (id TEXT, policyId TEXT, toolName TEXT,"
|
|
337
|
+
" code TEXT, language TEXT, createdAt TEXT)")
|
|
338
|
+
db.execute("CREATE TABLE policy_executions (id TEXT)")
|
|
339
|
+
db.execute("INSERT INTO policies VALUES (?,?,?,?,?,?,?,?,?,?,1,1,1,'pre-exec')",
|
|
340
|
+
(self.PID, "Schema Diff Gate", "quality", "REQUIRED",
|
|
341
|
+
"# Schema Diff Gate", "", "", "", "", ""))
|
|
342
|
+
db.execute("INSERT INTO executable_tools VALUES (?,?,?,?,?,?)",
|
|
343
|
+
("t1", self.PID, "schema_diff_gate", "", "python", ""))
|
|
344
|
+
db.commit()
|
|
345
|
+
db.close()
|
|
346
|
+
|
|
347
|
+
def tearDown(self):
|
|
348
|
+
self._tmp.cleanup()
|
|
349
|
+
|
|
350
|
+
def install_enforcer(self, body: str | None) -> None:
|
|
351
|
+
p = self.sb / f".policy-tools/{self.PID}_schema_diff_gate.py"
|
|
352
|
+
if body is None:
|
|
353
|
+
if p.exists():
|
|
354
|
+
p.unlink()
|
|
355
|
+
return
|
|
356
|
+
p.write_text(body)
|
|
357
|
+
|
|
358
|
+
def gate(self, command: str, env_extra: dict | None = None) -> int:
|
|
359
|
+
dst = self.sb / ".claude/hooks/uap-policy-gate.sh"
|
|
360
|
+
dst.write_text(PRIMARY_HOOK.read_text())
|
|
361
|
+
dst.chmod(0o755)
|
|
362
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
363
|
+
env.update(env_extra or {})
|
|
364
|
+
payload = json.dumps({"tool_name": "Bash", "cwd": str(self.sb),
|
|
365
|
+
"tool_input": {"command": command}})
|
|
366
|
+
p = subprocess.run(["bash", str(dst)], input=payload, capture_output=True,
|
|
367
|
+
text=True, cwd=self.sb, env=env, timeout=180)
|
|
368
|
+
return p.returncode
|
|
369
|
+
|
|
370
|
+
BROKEN = "raise SystemExit('boom')\n"
|
|
371
|
+
HEALTHY = (
|
|
372
|
+
"import json\n"
|
|
373
|
+
"print(json.dumps({'allowed': True, 'reason': 'ok'}))\n"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def test_a_broken_enforcer_refuses_a_commit(self):
|
|
377
|
+
self.install_enforcer(self.BROKEN)
|
|
378
|
+
self.assertEqual(self.gate("git commit -m x"), 2)
|
|
379
|
+
|
|
380
|
+
def test_a_missing_enforcer_refuses_a_commit(self):
|
|
381
|
+
self.install_enforcer(None)
|
|
382
|
+
self.assertEqual(self.gate("git commit -m x"), 2)
|
|
383
|
+
|
|
384
|
+
def test_a_broken_enforcer_does_not_block_ordinary_work(self):
|
|
385
|
+
"""The scoping that keeps this from being a session-wide deadlock."""
|
|
386
|
+
self.install_enforcer(self.BROKEN)
|
|
387
|
+
for command in ("ls -la", "npm test", "git status", "git commit --help"):
|
|
388
|
+
with self.subTest(command=command):
|
|
389
|
+
self.assertEqual(self.gate(command), 0, command)
|
|
390
|
+
|
|
391
|
+
def test_the_operator_hatch_actually_clears_it(self):
|
|
392
|
+
"""The refusal names this override, so it has to work.
|
|
393
|
+
|
|
394
|
+
It cleared SEC_SENSITIVE only, so for a schema-gate fail-closed it did
|
|
395
|
+
nothing -- every commit refused, with the message pointing at a switch
|
|
396
|
+
that had no effect on the branch printing it.
|
|
397
|
+
"""
|
|
398
|
+
self.install_enforcer(self.BROKEN)
|
|
399
|
+
self.assertEqual(self.gate("git commit -m x"), 2)
|
|
400
|
+
self.assertEqual(
|
|
401
|
+
self.gate("git commit -m x", {"UAP_SELF_PROTECT_OFF": "1"}), 0,
|
|
402
|
+
"the advertised override must clear the schema-gate branch too",
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def test_a_healthy_enforcer_is_unaffected(self):
|
|
406
|
+
self.install_enforcer(self.HEALTHY)
|
|
407
|
+
self.assertEqual(self.gate("git commit -m x"), 0)
|
|
408
|
+
|
|
409
|
+
if __name__ == "__main__":
|
|
410
|
+
unittest.main()
|
|
@@ -23,6 +23,7 @@ import json
|
|
|
23
23
|
import os
|
|
24
24
|
import shutil
|
|
25
25
|
import subprocess
|
|
26
|
+
import sys
|
|
26
27
|
import tempfile
|
|
27
28
|
import unittest
|
|
28
29
|
from pathlib import Path
|
|
@@ -35,13 +36,36 @@ BASE_SQL = "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);\n"
|
|
|
35
36
|
BREAKING_SQL = "CREATE TABLE t (id INTEGER PRIMARY KEY);\n"
|
|
36
37
|
|
|
37
38
|
|
|
39
|
+
def _git_only_path() -> str:
|
|
40
|
+
"""A PATH with git and nothing else, so `cli_argv` can find no checker.
|
|
41
|
+
|
|
42
|
+
A test that means "no checker is available" has to SAY so. Without this it
|
|
43
|
+
inherits the ambient PATH, finds the globally installed `uap`, and the
|
|
44
|
+
inline layer answers -- which is a different test with a different
|
|
45
|
+
expectation. It passed only while that binary predated --json, and inverted
|
|
46
|
+
the day it was updated. Exactly the coupling the marker suite was pinned
|
|
47
|
+
against in test_schema_diff_gate.py; this file needed the same treatment.
|
|
48
|
+
"""
|
|
49
|
+
d = Path(tempfile.mkdtemp(prefix="schema-inline-gitonly-"))
|
|
50
|
+
found = shutil.which("git")
|
|
51
|
+
if found:
|
|
52
|
+
(d / "git").symlink_to(found)
|
|
53
|
+
return str(d)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_GIT_ONLY_PATH = _git_only_path()
|
|
57
|
+
|
|
58
|
+
|
|
38
59
|
def run_gate(op: str, args: dict, root: Path, env_extra: dict | None = None):
|
|
39
60
|
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
40
61
|
env["UAP_REPO_ROOT"] = str(root)
|
|
41
62
|
env["UAP_WORKTREE_ROOT"] = str(root)
|
|
42
63
|
env.update(env_extra or {})
|
|
43
64
|
r = subprocess.run(
|
|
44
|
-
|
|
65
|
+
# Absolute interpreter, not "python3": a test may pin PATH to exclude
|
|
66
|
+
# the checker, and resolving the interpreter through that same PATH
|
|
67
|
+
# fails before the enforcer ever runs.
|
|
68
|
+
[sys.executable, str(ENFORCER), "--operation", op, "--args", json.dumps(args)],
|
|
45
69
|
cwd=root,
|
|
46
70
|
env=env,
|
|
47
71
|
capture_output=True,
|
|
@@ -212,7 +236,11 @@ class InlineSchemaDiffTest(unittest.TestCase):
|
|
|
212
236
|
self.assertNotEqual(reason, "", "the gate must answer, not die silently")
|
|
213
237
|
|
|
214
238
|
def test_a_missing_checker_leaves_the_shipped_behaviour_untouched(self):
|
|
215
|
-
|
|
239
|
+
# No stub, and a PATH with no `uap` on it, so there is genuinely no
|
|
240
|
+
# checker to run. Without the PATH pin this found the installed CLI and
|
|
241
|
+
# asserted against the inline layer's message instead -- green for the
|
|
242
|
+
# wrong reason until that binary learned --json, then red.
|
|
243
|
+
allowed, reason = self.commit(PATH=_GIT_ONLY_PATH)
|
|
216
244
|
self.assertFalse(allowed, f"missing checker must not open the gate: {reason}")
|
|
217
245
|
self.assertIn("uap schema-diff", reason)
|
|
218
246
|
|
|
@@ -342,12 +370,24 @@ class CheckerProvenanceTest(unittest.TestCase):
|
|
|
342
370
|
subprocess.run(["git", "add", "-A"], cwd=self.root, check=True)
|
|
343
371
|
bin_dir = self.root / "dist" / "bin"
|
|
344
372
|
bin_dir.mkdir(parents=True)
|
|
345
|
-
# A rubber stamp: three lines, and it attests to anything.
|
|
373
|
+
# A rubber stamp: three lines, and it attests to anything. It also
|
|
374
|
+
# leaves a footprint, because "the gate blocked" does not by itself
|
|
375
|
+
# prove this file went unread -- the installed CLI would also block
|
|
376
|
+
# here, on the real breaking change. The footprint is the only thing
|
|
377
|
+
# that distinguishes "ignored the foreign build" from "ran something
|
|
378
|
+
# else that happened to agree".
|
|
346
379
|
(bin_dir / "cli.js").write_text(
|
|
380
|
+
"require('fs').writeFileSync('rubber-stamp-was-run', '1');\n"
|
|
347
381
|
"console.log(JSON.stringify({contract:1,ran:true,base:'HEAD',files:"
|
|
348
382
|
"[{path:'" + WATCHED + "',sha:'x',analysed:true,breaking:[]}]}));\n"
|
|
349
383
|
)
|
|
350
384
|
|
|
385
|
+
def assertStampUnread(self):
|
|
386
|
+
self.assertFalse(
|
|
387
|
+
(self.root / "rubber-stamp-was-run").exists(),
|
|
388
|
+
"the gate executed a checker out of the tree it was gating",
|
|
389
|
+
)
|
|
390
|
+
|
|
351
391
|
def tearDown(self):
|
|
352
392
|
self._tmp.cleanup()
|
|
353
393
|
|
|
@@ -362,12 +402,28 @@ class CheckerProvenanceTest(unittest.TestCase):
|
|
|
362
402
|
"""
|
|
363
403
|
(self.root / "package.json").write_text('{"name":"totally-normal-app"}\n')
|
|
364
404
|
allowed, reason = run_gate("Bash", {"command": "git commit -m x"}, self.root)
|
|
405
|
+
self.assertStampUnread()
|
|
365
406
|
self.assertFalse(allowed, f"a foreign dist/ must not be the oracle: {reason}")
|
|
366
407
|
|
|
367
408
|
def test_no_package_json_is_also_not_the_uap_checkout(self):
|
|
368
409
|
allowed, reason = run_gate("Bash", {"command": "git commit -m x"}, self.root)
|
|
410
|
+
self.assertStampUnread()
|
|
369
411
|
self.assertFalse(allowed, f"unidentified tree must not be trusted: {reason}")
|
|
370
412
|
|
|
413
|
+
def test_the_stamp_would_fire_if_the_build_were_trusted(self):
|
|
414
|
+
"""Proves the two assertions above can fail.
|
|
415
|
+
|
|
416
|
+
A footprint check is worthless if the stub could never run in the
|
|
417
|
+
first place. Claiming to be the UAP checkout is exactly the condition
|
|
418
|
+
that makes the local build trusted, so here it MUST be executed.
|
|
419
|
+
"""
|
|
420
|
+
(self.root / "package.json").write_text('{"name":"@miller-tech/uap"}\n')
|
|
421
|
+
run_gate("Bash", {"command": "git commit -m x"}, self.root)
|
|
422
|
+
self.assertTrue(
|
|
423
|
+
(self.root / "rubber-stamp-was-run").exists(),
|
|
424
|
+
"the local build should be trusted in the UAP checkout itself",
|
|
425
|
+
)
|
|
426
|
+
|
|
371
427
|
|
|
372
428
|
if __name__ == "__main__":
|
|
373
429
|
unittest.main()
|