@miller-tech/uap 1.106.0 → 1.108.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/dist/.tsbuildinfo +1 -1
- package/dist/dashboard/data-service.d.ts +4 -0
- package/dist/dashboard/data-service.d.ts.map +1 -1
- package/dist/dashboard/data-service.js +4 -0
- package/dist/dashboard/data-service.js.map +1 -1
- package/dist/dashboard/orchestration-tree.d.ts +45 -0
- package/dist/dashboard/orchestration-tree.d.ts.map +1 -0
- package/dist/dashboard/orchestration-tree.js +110 -0
- package/dist/dashboard/orchestration-tree.js.map +1 -0
- package/dist/dashboard/savings.d.ts +31 -0
- package/dist/dashboard/savings.d.ts.map +1 -0
- package/dist/dashboard/savings.js +106 -0
- package/dist/dashboard/savings.js.map +1 -0
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/adr_guard.py +189 -0
- package/src/policies/enforcers/bearer_lockdown.py +298 -0
- package/src/policies/enforcers/local_build_before_push.py +125 -0
- package/src/policies/enforcers/ship_loop_gate.py +113 -0
- package/src/policies/enforcers/workdir_scope.py +13 -2
- package/src/policies/schemas/policies/adr-guard.md +35 -0
- package/src/policies/schemas/policies/bearer-lockdown.md +38 -0
- package/src/policies/schemas/policies/local-build-before-push.md +38 -0
- package/src/policies/schemas/policies/ship-loop-gate.md +35 -0
- package/src/policies/schemas/policies/workdir-scope.md +5 -2
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/tests/test_workdir_scope_enforcer.py +10 -0
- package/web/dashboard.html +74 -0
|
Binary file
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""adr-guard enforcer — generic ADR-driven tool-call gate.
|
|
3
|
+
|
|
4
|
+
Reads machine-readable rule blocks from every ADR under `docs/adr/` and
|
|
5
|
+
enforces them on Write/Edit tool calls. This lets us express architectural
|
|
6
|
+
and security invariants once — in the ADR itself — and have them enforced
|
|
7
|
+
automatically by UAP agents.
|
|
8
|
+
|
|
9
|
+
Rule block format (embedded inside an ADR as an HTML comment):
|
|
10
|
+
|
|
11
|
+
<!-- uap-rules
|
|
12
|
+
- id: ADR-0007-FE-NO-BEARER
|
|
13
|
+
scope:
|
|
14
|
+
include: ["apps/web/", "apps/cms/", "apps/marketing/"]
|
|
15
|
+
exclude: ["/tests/", ".test.", ".spec.", "/node_modules/"]
|
|
16
|
+
ext: [".js", ".ts", ".jsx", ".tsx"]
|
|
17
|
+
forbid: 'Authorization.*Bearer'
|
|
18
|
+
message: "FE must not set Authorization: Bearer. See ADR-0007."
|
|
19
|
+
- id: ADR-0006-PIPELINE-ONLY
|
|
20
|
+
scope:
|
|
21
|
+
include: ["infra/"]
|
|
22
|
+
exclude: ["infra/archive/"]
|
|
23
|
+
require: '^#\\s*managed-by:\\s*terraform'
|
|
24
|
+
message: "Infra files must declare terraform ownership. See ADR-0006."
|
|
25
|
+
-->
|
|
26
|
+
|
|
27
|
+
Fields:
|
|
28
|
+
id — unique rule ID (prefix with ADR number)
|
|
29
|
+
scope.* — path filters (include prefixes, exclude substrings, ext list)
|
|
30
|
+
forbid — regex that MUST NOT match the written content
|
|
31
|
+
require — regex that MUST match (at least one hit) when writing into scope
|
|
32
|
+
message — explanation shown when blocked
|
|
33
|
+
on_tools — optional list of tool names (default: Write, Edit, MultiEdit)
|
|
34
|
+
|
|
35
|
+
The parser caches rules by ADR-file mtime so rebuilds are cheap.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import sys
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
46
|
+
from _common import emit, parse_cli, repo_root # noqa: E402
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
import yaml # type: ignore
|
|
50
|
+
except ImportError: # pragma: no cover
|
|
51
|
+
yaml = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
RULE_BLOCK_RE = re.compile(
|
|
55
|
+
r"<!--\s*uap-rules\s*\n(.*?)\n\s*-->", re.DOTALL | re.IGNORECASE
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_adr_rules(adr_dir: Path) -> list[dict[str, Any]]:
|
|
60
|
+
"""Extract all uap-rules blocks from ADR files."""
|
|
61
|
+
if not adr_dir.is_dir() or yaml is None:
|
|
62
|
+
return []
|
|
63
|
+
rules: list[dict[str, Any]] = []
|
|
64
|
+
for f in sorted(adr_dir.glob("*.md")):
|
|
65
|
+
try:
|
|
66
|
+
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
67
|
+
except OSError:
|
|
68
|
+
continue
|
|
69
|
+
for m in RULE_BLOCK_RE.finditer(text):
|
|
70
|
+
body = m.group(1)
|
|
71
|
+
try:
|
|
72
|
+
parsed = yaml.safe_load(body)
|
|
73
|
+
except yaml.YAMLError:
|
|
74
|
+
continue
|
|
75
|
+
if not isinstance(parsed, list):
|
|
76
|
+
continue
|
|
77
|
+
for r in parsed:
|
|
78
|
+
if not isinstance(r, dict):
|
|
79
|
+
continue
|
|
80
|
+
r["_adr"] = f.name
|
|
81
|
+
rules.append(r)
|
|
82
|
+
return rules
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_write_target(op: str, args: dict[str, Any]) -> tuple[str, str] | None:
|
|
86
|
+
tool = op.lower()
|
|
87
|
+
if tool == "write":
|
|
88
|
+
return args.get("file_path") or "", args.get("content") or ""
|
|
89
|
+
if tool == "edit":
|
|
90
|
+
return args.get("file_path") or "", args.get("new_string") or ""
|
|
91
|
+
if tool == "multiedit":
|
|
92
|
+
fp = args.get("file_path") or ""
|
|
93
|
+
edits = args.get("edits") or []
|
|
94
|
+
content = "\n".join(
|
|
95
|
+
e.get("new_string", "") for e in edits if isinstance(e, dict)
|
|
96
|
+
)
|
|
97
|
+
return fp, content
|
|
98
|
+
if tool == "notebookedit":
|
|
99
|
+
return args.get("notebook_path") or "", args.get("new_source") or ""
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _path_matches_scope(path: str, scope: dict[str, Any] | None) -> bool:
|
|
104
|
+
if not scope:
|
|
105
|
+
return True
|
|
106
|
+
includes = scope.get("include") or [""]
|
|
107
|
+
excludes = scope.get("exclude") or []
|
|
108
|
+
exts = scope.get("ext")
|
|
109
|
+
if not any(inc == "" or inc in path for inc in includes):
|
|
110
|
+
return False
|
|
111
|
+
for exc in excludes:
|
|
112
|
+
if exc and exc in path:
|
|
113
|
+
return False
|
|
114
|
+
if exts:
|
|
115
|
+
if not any(path.endswith(e) for e in exts):
|
|
116
|
+
return False
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _normalize_path(file_path: str) -> str:
|
|
121
|
+
norm = file_path.replace("\\", "/")
|
|
122
|
+
for top in (
|
|
123
|
+
"apps/", "infra/", "services/", "docs/", "scripts/", ".github/",
|
|
124
|
+
".semgrep/", ".policy-tools/", ".claude/", "observability/",
|
|
125
|
+
):
|
|
126
|
+
idx = norm.find(top)
|
|
127
|
+
if idx >= 0:
|
|
128
|
+
return norm[idx:]
|
|
129
|
+
return norm
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def main() -> None:
|
|
133
|
+
op, args = parse_cli()
|
|
134
|
+
target = _extract_write_target(op, args)
|
|
135
|
+
if target is None:
|
|
136
|
+
emit(True, "not a write/edit operation")
|
|
137
|
+
file_path, content = target
|
|
138
|
+
if not file_path:
|
|
139
|
+
emit(True, "no file path in args")
|
|
140
|
+
|
|
141
|
+
root = repo_root()
|
|
142
|
+
rules = _parse_adr_rules(root / "docs" / "adr")
|
|
143
|
+
if not rules:
|
|
144
|
+
emit(True, "no ADR rules active")
|
|
145
|
+
|
|
146
|
+
norm = _normalize_path(file_path)
|
|
147
|
+
tool = op if op else ""
|
|
148
|
+
|
|
149
|
+
violations: list[str] = []
|
|
150
|
+
for rule in rules:
|
|
151
|
+
on_tools = rule.get("on_tools") or ["Write", "Edit", "MultiEdit"]
|
|
152
|
+
if tool and tool not in on_tools:
|
|
153
|
+
continue
|
|
154
|
+
if not _path_matches_scope(norm, rule.get("scope")):
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
forbid = rule.get("forbid")
|
|
158
|
+
if forbid:
|
|
159
|
+
try:
|
|
160
|
+
if re.search(forbid, content):
|
|
161
|
+
msg = rule.get("message") or f"ADR rule {rule.get('id','?')}"
|
|
162
|
+
violations.append(
|
|
163
|
+
f"[{rule.get('id','adr-rule')} from {rule.get('_adr')}] {msg}"
|
|
164
|
+
)
|
|
165
|
+
except re.error:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
require = rule.get("require")
|
|
169
|
+
if require:
|
|
170
|
+
try:
|
|
171
|
+
if not re.search(require, content):
|
|
172
|
+
msg = rule.get("message") or f"ADR rule {rule.get('id','?')}"
|
|
173
|
+
violations.append(
|
|
174
|
+
f"[{rule.get('id','adr-rule')} from {rule.get('_adr')}] (required pattern missing) {msg}"
|
|
175
|
+
)
|
|
176
|
+
except re.error:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
if violations:
|
|
180
|
+
emit(
|
|
181
|
+
False,
|
|
182
|
+
"adr-guard: " + " | ".join(violations),
|
|
183
|
+
violations=violations,
|
|
184
|
+
)
|
|
185
|
+
emit(True, "no ADR rule violations in write")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
main()
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""bearer-lockdown enforcer — UAP gate for ADR-0007 cookie-only frontend.
|
|
3
|
+
|
|
4
|
+
Blocks Write/Edit tool calls that would reintroduce any of the seven
|
|
5
|
+
ADR-0007 invariants. Mirrors scripts/bearer-lockdown-scan.sh but runs at
|
|
6
|
+
tool-call time so agents are prevented from writing the regression in
|
|
7
|
+
the first place, not merely caught at CI time.
|
|
8
|
+
|
|
9
|
+
Invariants enforced (details in docs/adr/ADR-0007-cookie-only-frontend.md):
|
|
10
|
+
FE-NO-BEARER Frontend code must not set Authorization: Bearer.
|
|
11
|
+
FE-NO-STORAGE No token keys in localStorage / sessionStorage.
|
|
12
|
+
PROXY-NO-BYPASS No --skip-jwt-bearer-tokens=true on oauth2-proxy.
|
|
13
|
+
BACKEND-NO-CLIENT-AUTH Backend must not read client Authorization header.
|
|
14
|
+
AUTH-ME-OPAQUE /auth/me must emit user_ref, not raw user_id.
|
|
15
|
+
NO-COMMITTED-JWT No RS256 JWT (>100 chars) anywhere.
|
|
16
|
+
|
|
17
|
+
The ISTIO-DENY-ALIVE invariant is runtime-only (Kyverno + alerts) since
|
|
18
|
+
tool-level detection would require reasoning about the whole cluster state.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
28
|
+
from _common import emit, parse_cli # noqa: E402
|
|
29
|
+
|
|
30
|
+
# --------------------------------------------------------------------------
|
|
31
|
+
# Rule definitions. Each rule has:
|
|
32
|
+
# - id: short code (matches scanner/semgrep IDs)
|
|
33
|
+
# - path_include: list of glob prefixes the file must match
|
|
34
|
+
# - path_exclude: list of glob substrings that veto the rule
|
|
35
|
+
# - pattern: compiled regex matched against the written content
|
|
36
|
+
# - message: explanation shown to the agent when blocked
|
|
37
|
+
# --------------------------------------------------------------------------
|
|
38
|
+
RULES: list[dict[str, Any]] = [
|
|
39
|
+
{
|
|
40
|
+
"id": "BL-001",
|
|
41
|
+
"invariant": "FE-NO-BEARER",
|
|
42
|
+
"path_include": ("apps/web/", "apps/cms/", "apps/marketing/"),
|
|
43
|
+
"path_exclude": (
|
|
44
|
+
"/tests/",
|
|
45
|
+
"/test/",
|
|
46
|
+
".test.",
|
|
47
|
+
".spec.",
|
|
48
|
+
"/node_modules/",
|
|
49
|
+
"/dist/",
|
|
50
|
+
),
|
|
51
|
+
"ext": (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"),
|
|
52
|
+
# Match Authorization followed by Bearer on the same line, with
|
|
53
|
+
# optional quoting/colon/equals between them. False positives on
|
|
54
|
+
# JS comments in scoped paths are rare and easy to rephrase — the
|
|
55
|
+
# scanner + semgrep catch the same, so redundancy is fine.
|
|
56
|
+
"pattern": re.compile(
|
|
57
|
+
r"Authorization[\"']?\s*[:=]\s*[\"'`]?Bearer\b",
|
|
58
|
+
),
|
|
59
|
+
"message": (
|
|
60
|
+
"ADR-0007 FE-NO-BEARER: frontend code must not set "
|
|
61
|
+
"`Authorization: Bearer`. Auth rides on the httpOnly "
|
|
62
|
+
"`_oauth2_proxy` cookie only. See "
|
|
63
|
+
"docs/adr/ADR-0007-cookie-only-frontend.md."
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"id": "BL-002",
|
|
68
|
+
"invariant": "FE-NO-STORAGE",
|
|
69
|
+
"path_include": ("apps/web/", "apps/cms/", "apps/marketing/"),
|
|
70
|
+
"path_exclude": (
|
|
71
|
+
"/tests/",
|
|
72
|
+
"/test/",
|
|
73
|
+
".test.",
|
|
74
|
+
".spec.",
|
|
75
|
+
"/node_modules/",
|
|
76
|
+
),
|
|
77
|
+
"ext": (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"),
|
|
78
|
+
"pattern": re.compile(
|
|
79
|
+
r"(local|session)Storage\.setItem\s*\(\s*[\"'](access_token|refresh_token|id_token|auth_token|authToken|bearer_token)[\"']",
|
|
80
|
+
),
|
|
81
|
+
"message": (
|
|
82
|
+
"ADR-0007 FE-NO-STORAGE: token-like keys must not be written to "
|
|
83
|
+
"localStorage/sessionStorage (XSS-reachable). Auth is cookie-only. "
|
|
84
|
+
"See docs/adr/ADR-0007-cookie-only-frontend.md."
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"id": "BL-003",
|
|
89
|
+
"invariant": "PROXY-NO-BYPASS",
|
|
90
|
+
"path_include": ("infra/",),
|
|
91
|
+
"path_exclude": (
|
|
92
|
+
"infra/archive/",
|
|
93
|
+
"infra/k8s/archive/",
|
|
94
|
+
# Policy files that reference the forbidden flag to enforce it.
|
|
95
|
+
"infra/policies/rego/bearer_lockdown.rego",
|
|
96
|
+
"infra/k8s/kyverno/",
|
|
97
|
+
"infra/k8s/istio/authz-policy-deny-client-bearer.yaml",
|
|
98
|
+
),
|
|
99
|
+
"ext": None, # any extension
|
|
100
|
+
"pattern": re.compile(r"--skip-jwt-bearer-tokens=true"),
|
|
101
|
+
"message": (
|
|
102
|
+
"ADR-0007 PROXY-NO-BYPASS: oauth2-proxy must not carry "
|
|
103
|
+
"`--skip-jwt-bearer-tokens=true`. It lets a client-held Bearer "
|
|
104
|
+
"substitute for the session cookie. See "
|
|
105
|
+
"docs/adr/ADR-0007-cookie-only-frontend.md."
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"id": "BL-006",
|
|
110
|
+
"invariant": "BACKEND-NO-CLIENT-AUTH",
|
|
111
|
+
"path_include": ("apps/api/",),
|
|
112
|
+
"path_exclude": ("apps/api/tests/",),
|
|
113
|
+
"ext": (".hpp", ".cpp", ".h"),
|
|
114
|
+
"pattern": re.compile(
|
|
115
|
+
r'get_header_value\s*\(\s*"authorization"\s*\)',
|
|
116
|
+
),
|
|
117
|
+
"message": (
|
|
118
|
+
"ADR-0007 BACKEND-NO-CLIENT-AUTH: the backend must not read the "
|
|
119
|
+
"client `Authorization` header. JWT is resolved from proxy-set "
|
|
120
|
+
"headers (`X-Forwarded-Access-Token` or "
|
|
121
|
+
"`x-auth-request-access-token`) only. See "
|
|
122
|
+
"docs/adr/ADR-0007-cookie-only-frontend.md."
|
|
123
|
+
),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "BL-007",
|
|
127
|
+
"invariant": "AUTH-ME-OPAQUE",
|
|
128
|
+
"path_include": ("apps/api/src/handlers/auth_handler.cpp",),
|
|
129
|
+
"path_exclude": (),
|
|
130
|
+
"ext": None,
|
|
131
|
+
"pattern": re.compile(r'response\s*\[\s*"user_id"\s*\]\s*='),
|
|
132
|
+
"message": (
|
|
133
|
+
"ADR-0007 AUTH-ME-OPAQUE: /auth/me must return `user_ref` "
|
|
134
|
+
"(opaque HMAC of user_id), not the raw Zitadel `user_id`. See "
|
|
135
|
+
"apps/api/include/pay2u/utils/token.hpp::deriveUserRef and "
|
|
136
|
+
"docs/adr/ADR-0007-cookie-only-frontend.md."
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"id": "BL-004",
|
|
141
|
+
"invariant": "NO-COMMITTED-JWT",
|
|
142
|
+
"path_include": ("",), # any path
|
|
143
|
+
"path_exclude": (
|
|
144
|
+
"scripts/bearer-lockdown-scan.sh",
|
|
145
|
+
".gitleaks.toml",
|
|
146
|
+
".semgrep/",
|
|
147
|
+
"docs/adr/",
|
|
148
|
+
".policy-tools/bearer_lockdown.py",
|
|
149
|
+
),
|
|
150
|
+
"ext": None,
|
|
151
|
+
# Require >=100 chars after header to skip truncated doc examples.
|
|
152
|
+
"pattern": re.compile(r"eyJhbGciOiJSUzI1NiI[A-Za-z0-9_-]{100,}"),
|
|
153
|
+
"message": (
|
|
154
|
+
"ADR-0007 NO-COMMITTED-JWT: RS256 JWT detected in content. "
|
|
155
|
+
"Tokens must never be committed to the repository — they are "
|
|
156
|
+
"real credentials with long TTLs. See "
|
|
157
|
+
"docs/adr/ADR-0007-cookie-only-frontend.md."
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
# Frontend must not READ the upstream-bound auth headers from any
|
|
162
|
+
# Response. They are stripped at the gateway egress, but blocking
|
|
163
|
+
# the read at write-time prevents code that would silently break
|
|
164
|
+
# once the strip lands (or worse, ship before it lands).
|
|
165
|
+
"id": "BL-008",
|
|
166
|
+
"invariant": "XAUTH-FE-NO-READ",
|
|
167
|
+
"path_include": ("apps/web/", "apps/cms/", "apps/marketing/"),
|
|
168
|
+
"path_exclude": (
|
|
169
|
+
"/tests/",
|
|
170
|
+
"/test/",
|
|
171
|
+
".test.",
|
|
172
|
+
".spec.",
|
|
173
|
+
"/node_modules/",
|
|
174
|
+
"/dist/",
|
|
175
|
+
),
|
|
176
|
+
"ext": (".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"),
|
|
177
|
+
# Match any reference to one of the forbidden header names in
|
|
178
|
+
# string-literal form. Case-insensitive — HTTP header names are.
|
|
179
|
+
"pattern": re.compile(
|
|
180
|
+
r"""['"](?i:x-auth-request-(?:email|preferred-username|user|access-token|groups|jti)|x-forwarded-access-token)['"]""",
|
|
181
|
+
),
|
|
182
|
+
"message": (
|
|
183
|
+
"ADR-0007 XAUTH-FE-NO-READ: frontend code must not reference "
|
|
184
|
+
"upstream-bound auth headers (`x-auth-request-*`, "
|
|
185
|
+
"`x-forwarded-access-token`). They are stripped at the "
|
|
186
|
+
"gateway egress and exist only between oauth2-proxy and the "
|
|
187
|
+
"backend API. See "
|
|
188
|
+
"infra/k8s/istio/envoyfilter-strip-auth-egress-headers.yaml "
|
|
189
|
+
"and docs/adr/ADR-0007-cookie-only-frontend.md."
|
|
190
|
+
),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
# Backend must not EMIT (set as response header) any of the
|
|
194
|
+
# forbidden auth-bound names. Defence-in-depth against a future
|
|
195
|
+
# handler accidentally echoing an upstream header back to the caller.
|
|
196
|
+
"id": "BL-009",
|
|
197
|
+
"invariant": "XAUTH-BE-NO-EMIT",
|
|
198
|
+
"path_include": ("apps/api/",),
|
|
199
|
+
"path_exclude": ("apps/api/tests/",),
|
|
200
|
+
"ext": (".hpp", ".cpp", ".h"),
|
|
201
|
+
# Match `set_header("authorization", ...)`,
|
|
202
|
+
# `set_header("x-auth-request-...", ...)`, etc. Case-insensitive.
|
|
203
|
+
"pattern": re.compile(
|
|
204
|
+
r"""set_header\s*\(\s*['"](?i:authorization|x-auth-request-(?:email|preferred-username|user|access-token|groups|jti)|x-forwarded-access-token|x-access-token|x-id-token)['"]""",
|
|
205
|
+
),
|
|
206
|
+
"message": (
|
|
207
|
+
"ADR-0007 XAUTH-BE-NO-EMIT: backend handlers must not write "
|
|
208
|
+
"auth-bound headers (`Authorization`, `x-auth-request-*`, "
|
|
209
|
+
"`x-forwarded-access-token`, `x-access-token`, `x-id-token`) "
|
|
210
|
+
"into responses. The gateway strips them as a safety net but "
|
|
211
|
+
"this rule prevents the source from ever being introduced. "
|
|
212
|
+
"See docs/adr/ADR-0007-cookie-only-frontend.md."
|
|
213
|
+
),
|
|
214
|
+
},
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _extract_write_target(op: str, args: dict[str, Any]) -> tuple[str, str] | None:
|
|
219
|
+
"""Return (file_path, content) if this call writes/edits a file, else None."""
|
|
220
|
+
tool = op.lower()
|
|
221
|
+
if tool == "write":
|
|
222
|
+
fp = args.get("file_path") or ""
|
|
223
|
+
content = args.get("content") or ""
|
|
224
|
+
return fp, content
|
|
225
|
+
if tool == "edit":
|
|
226
|
+
fp = args.get("file_path") or ""
|
|
227
|
+
# For Edit, only the new_string is the newly-introduced content.
|
|
228
|
+
new = args.get("new_string") or ""
|
|
229
|
+
return fp, new
|
|
230
|
+
if tool == "multiedit":
|
|
231
|
+
fp = args.get("file_path") or ""
|
|
232
|
+
edits = args.get("edits") or []
|
|
233
|
+
content = "\n".join(e.get("new_string", "") for e in edits if isinstance(e, dict))
|
|
234
|
+
return fp, content
|
|
235
|
+
if tool == "notebookedit":
|
|
236
|
+
fp = args.get("notebook_path") or ""
|
|
237
|
+
content = args.get("new_source") or ""
|
|
238
|
+
return fp, content
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _path_matches(path: str, includes: tuple[str, ...], excludes: tuple[str, ...]) -> bool:
|
|
243
|
+
"""True if path is under any include prefix and not under any exclude."""
|
|
244
|
+
# "" in includes means match any path.
|
|
245
|
+
if not any(inc == "" or inc in path for inc in includes):
|
|
246
|
+
return False
|
|
247
|
+
for exc in excludes:
|
|
248
|
+
if exc and exc in path:
|
|
249
|
+
return False
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _ext_ok(path: str, exts: tuple[str, ...] | None) -> bool:
|
|
254
|
+
if exts is None:
|
|
255
|
+
return True
|
|
256
|
+
return any(path.endswith(e) for e in exts)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main() -> None:
|
|
260
|
+
op, args = parse_cli()
|
|
261
|
+
target = _extract_write_target(op, args)
|
|
262
|
+
if target is None:
|
|
263
|
+
emit(True, "not a write/edit operation")
|
|
264
|
+
file_path, content = target
|
|
265
|
+
if not file_path:
|
|
266
|
+
emit(True, "no file path in args")
|
|
267
|
+
|
|
268
|
+
# Normalize: strip leading ./ or absolute prefix to repo-relative form.
|
|
269
|
+
norm = file_path.replace("\\", "/")
|
|
270
|
+
# Drop a leading absolute path portion up to the first known top-level dir.
|
|
271
|
+
for top in ("apps/", "infra/", "services/", "docs/", "scripts/", ".github/",
|
|
272
|
+
".semgrep/", ".policy-tools/", ".claude/", "observability/"):
|
|
273
|
+
idx = norm.find(top)
|
|
274
|
+
if idx >= 0:
|
|
275
|
+
norm = norm[idx:]
|
|
276
|
+
break
|
|
277
|
+
|
|
278
|
+
violations: list[str] = []
|
|
279
|
+
for rule in RULES:
|
|
280
|
+
if not _path_matches(norm, rule["path_include"], rule["path_exclude"]):
|
|
281
|
+
continue
|
|
282
|
+
if not _ext_ok(norm, rule["ext"]):
|
|
283
|
+
continue
|
|
284
|
+
if rule["pattern"].search(content):
|
|
285
|
+
violations.append(f"[{rule['id']}] {rule['message']}")
|
|
286
|
+
|
|
287
|
+
if violations:
|
|
288
|
+
emit(
|
|
289
|
+
False,
|
|
290
|
+
"bearer-lockdown: " + " | ".join(violations),
|
|
291
|
+
violations=[v for v in violations],
|
|
292
|
+
adr="docs/adr/ADR-0007-cookie-only-frontend.md",
|
|
293
|
+
)
|
|
294
|
+
emit(True, "no bearer-lockdown violations in write")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
main()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""local-build-before-push enforcer.
|
|
3
|
+
|
|
4
|
+
Blocks `git push` / `gh pr create` / `gh pr merge` when the local branch
|
|
5
|
+
contains C++ API source changes that have not been verified by a local
|
|
6
|
+
Docker `--target builder` compile. The policy exists because the project's
|
|
7
|
+
"compilation check via Docker" CI workflow takes ~50 min from scratch and
|
|
8
|
+
~10-20 min cached, while running the same Dockerfile locally takes ~2-5 min
|
|
9
|
+
cached. The pentest-remediation saga (2026-05-20) burned several CI cycles
|
|
10
|
+
on a jwt-cpp v0.7.0 API mismatch and a sequence of wrong-audience IaC pins
|
|
11
|
+
that local builds would have caught immediately.
|
|
12
|
+
|
|
13
|
+
The pass marker is `.uap/local-build-pass.txt`, written by
|
|
14
|
+
`scripts/uap-local-build.sh` after a successful Docker builder-stage build.
|
|
15
|
+
The marker contains the verified HEAD SHA; this enforcer allows the push
|
|
16
|
+
only when the current HEAD matches.
|
|
17
|
+
|
|
18
|
+
Bypass: set `UAP_SKIP_LOCAL_BUILD=1` (the override should be justified in
|
|
19
|
+
the commit message — there are legitimate cases like remote-only secrets).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
28
|
+
from _common import emit, parse_cli, repo_root, run # noqa: E402
|
|
29
|
+
|
|
30
|
+
MARKER = ".uap/local-build-pass.txt"
|
|
31
|
+
|
|
32
|
+
# A push that only touches files outside these prefixes does not need the
|
|
33
|
+
# local C++ compile gate. Adjust if more code paths gain expensive builds.
|
|
34
|
+
BUILDABLE_PREFIXES = (
|
|
35
|
+
"apps/api/src/",
|
|
36
|
+
"apps/api/include/",
|
|
37
|
+
"apps/api/CMakeLists.txt",
|
|
38
|
+
"apps/api/Dockerfile",
|
|
39
|
+
"apps/api/Dockerfile.optimized",
|
|
40
|
+
"apps/api/Dockerfile.test",
|
|
41
|
+
"apps/api/Dockerfile.ubuntu",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Bash command substrings that trigger the gate. Kept narrow to avoid
|
|
45
|
+
# false-positives on unrelated git commands.
|
|
46
|
+
PUSH_TRIGGERS = (
|
|
47
|
+
"git push",
|
|
48
|
+
"gh pr create",
|
|
49
|
+
"gh pr merge",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _wants_gate(cmd_lower: str) -> bool:
|
|
54
|
+
return any(trigger in cmd_lower for trigger in PUSH_TRIGGERS)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _changed_files(root: Path) -> list[str]:
|
|
58
|
+
"""Files changed on the current branch vs origin/main."""
|
|
59
|
+
# Make sure we have an origin/main ref to compare against; if not,
|
|
60
|
+
# fall back to comparing against HEAD~10 or HEAD itself.
|
|
61
|
+
rc, _, _ = run(["git", "show-ref", "--verify", "--quiet",
|
|
62
|
+
"refs/remotes/origin/main"], cwd=root)
|
|
63
|
+
base = "origin/main" if rc == 0 else "HEAD~10"
|
|
64
|
+
rc, out, _ = run(["git", "diff", "--name-only", f"{base}..HEAD"], cwd=root)
|
|
65
|
+
if rc != 0:
|
|
66
|
+
return []
|
|
67
|
+
return [line for line in out.splitlines() if line]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
op, args = parse_cli()
|
|
72
|
+
cmd = (args.get("command") or args.get("cmd") or "").strip()
|
|
73
|
+
if not cmd or op.lower() != "bash":
|
|
74
|
+
emit(True, "not a Bash command")
|
|
75
|
+
|
|
76
|
+
cmd_lower = cmd.lower()
|
|
77
|
+
if not _wants_gate(cmd_lower):
|
|
78
|
+
emit(True, "not a push/PR-create/PR-merge command")
|
|
79
|
+
|
|
80
|
+
if os.environ.get("UAP_SKIP_LOCAL_BUILD") == "1":
|
|
81
|
+
emit(True, "UAP_SKIP_LOCAL_BUILD=1 override (justify in commit msg)")
|
|
82
|
+
|
|
83
|
+
root = repo_root()
|
|
84
|
+
|
|
85
|
+
changed = _changed_files(root)
|
|
86
|
+
needs_build = any(
|
|
87
|
+
f.startswith(p) for f in changed for p in BUILDABLE_PREFIXES
|
|
88
|
+
)
|
|
89
|
+
if not needs_build:
|
|
90
|
+
emit(True, "no buildable C++ API source changes — gate not required")
|
|
91
|
+
|
|
92
|
+
rc, head_sha, _ = run(["git", "rev-parse", "HEAD"], cwd=root)
|
|
93
|
+
head_sha = head_sha.strip()
|
|
94
|
+
if not head_sha:
|
|
95
|
+
emit(True, "could not resolve HEAD (allowing)")
|
|
96
|
+
|
|
97
|
+
marker_path = root / MARKER
|
|
98
|
+
if marker_path.exists():
|
|
99
|
+
try:
|
|
100
|
+
marker_text = marker_path.read_text()
|
|
101
|
+
except OSError:
|
|
102
|
+
marker_text = ""
|
|
103
|
+
if head_sha in marker_text:
|
|
104
|
+
emit(True, f"local-build-pass marker matches HEAD={head_sha[:10]}")
|
|
105
|
+
|
|
106
|
+
short_changed = ", ".join(sorted({
|
|
107
|
+
next((p for p in BUILDABLE_PREFIXES if f.startswith(p)), f)
|
|
108
|
+
for f in changed
|
|
109
|
+
}))[:200]
|
|
110
|
+
|
|
111
|
+
emit(
|
|
112
|
+
False,
|
|
113
|
+
"local-build-before-push: apps/api/ source changed "
|
|
114
|
+
f"({short_changed}) but no local-build-pass marker found for "
|
|
115
|
+
f"HEAD={head_sha[:10]}. Run the same Docker compile gate that CI "
|
|
116
|
+
"uses locally:\n"
|
|
117
|
+
" bash scripts/uap-local-build.sh\n"
|
|
118
|
+
"Cached build takes ~2-5 min; CI takes ~10-50 min. If local build "
|
|
119
|
+
"is genuinely not feasible (e.g. cluster-only secret), set "
|
|
120
|
+
"UAP_SKIP_LOCAL_BUILD=1 and justify in the commit message.",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
main()
|