@miller-tech/uap 1.106.0 → 1.107.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miller-tech/uap",
3
- "version": "1.106.0",
3
+ "version": "1.107.0",
4
4
  "description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  "install:web": "bash scripts/setup/install-web.sh",
30
30
  "install:desktop": "bash scripts/setup/install-desktop.sh",
31
31
  "install:cloakbrowser": "tsx scripts/setup/install-cloakbrowser.ts",
32
- "postinstall": "echo '\n Run: npx @miller-tech/uap init --interactive'",
32
+ "postinstall": "echo '\n\u2728 Run: npx @miller-tech/uap init --interactive'",
33
33
  "version:patch": "bash scripts/version-bump.sh patch",
34
34
  "version:minor": "bash scripts/version-bump.sh minor",
35
35
  "version:major": "bash scripts/version-bump.sh major",
@@ -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()
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """ship-loop-gate enforcer.
3
+
4
+ A task may not be marked `completed` until evidence exists for all four
5
+ ship-loop phases. Enforcement point: `TaskUpdate` operations that set
6
+ `status` to `completed` (and `TaskCreate` calls that try to land in
7
+ `completed` directly).
8
+
9
+ Required evidence keys (under `metadata.shipped`):
10
+ - merged : PR URL or merge commit SHA. The change is on `main`.
11
+ - deployed : deploy run URL / artifact ID. The change is in production.
12
+ - monitored : log/metric query window + result. The change isn't
13
+ producing new errors / regressions.
14
+ - verified : behavioural assertion against the deployed change
15
+ (URL probed, golden-path replayed, etc.). The change
16
+ produces the intended user-visible outcome.
17
+
18
+ Anything else (string content) is fine — the gate just checks each key
19
+ has a non-empty value, putting the burden on the engineer to write it
20
+ down. The values land in task metadata and become the audit trail.
21
+
22
+ Why this gate exists
23
+ --------------------
24
+ Multiple incidents in the last quarter where work was reported as
25
+ "done" once a PR merged, but the deploy regressed silently or the
26
+ behaviour was never verified. The mere act of having to write down
27
+ *proof of deploy + monitor + verify* eliminates the most common
28
+ class of "done" lies.
29
+
30
+ Bypass
31
+ ------
32
+ Set the `metadata.shipped.bypass_reason` to a non-empty justification
33
+ when a task legitimately cannot be verified end-to-end (docs-only PR,
34
+ research spike, internal refactor with no deployable surface). The
35
+ bypass still persists into task metadata so the audit trail captures
36
+ *why* the gate was waived. Do not use bypass to dodge real verification
37
+ on changes with a deployable surface.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ sys.path.insert(0, str(Path(__file__).parent))
45
+
46
+ from _common import emit, parse_cli # noqa: E402
47
+
48
+ REQUIRED_EVIDENCE_KEYS = ("merged", "deployed", "monitored", "verified")
49
+
50
+
51
+ def _example_payload() -> str:
52
+ return (
53
+ " metadata = {\n"
54
+ ' "shipped": {\n'
55
+ ' "merged": "PR #1234 merged at SHA abc1234",\n'
56
+ ' "deployed": "cd-frontend-multicloud run 25281634698 success",\n'
57
+ ' "monitored": "OO logs 14:00-14:15 UTC: 0 new errors on app.pay2u.com.au",\n'
58
+ ' "verified": "curl https://app.pay2u.com.au/feature returned 200 with expected payload"\n'
59
+ " }\n"
60
+ " }"
61
+ )
62
+
63
+
64
+ def main() -> None:
65
+ op, args = parse_cli()
66
+
67
+ # Only fire on attempts to mark a task `completed`.
68
+ if op not in {"TaskUpdate", "TaskCreate"}:
69
+ emit(True, "ship-loop-gate: not a task-status operation")
70
+
71
+ status = (args.get("status") or "").lower()
72
+ if status != "completed":
73
+ emit(True, "ship-loop-gate: not a completion update")
74
+
75
+ metadata = args.get("metadata") or {}
76
+ if not isinstance(metadata, dict):
77
+ emit(False, "ship-loop-gate: metadata must be an object containing `shipped` evidence")
78
+
79
+ shipped = metadata.get("shipped")
80
+ if not isinstance(shipped, dict):
81
+ emit(
82
+ False,
83
+ (
84
+ "ship-loop-gate: cannot mark task completed — no `metadata.shipped` "
85
+ "evidence block. Add proof of merge + deploy + monitor + verify before "
86
+ "claiming Done. Example:\n"
87
+ f"{_example_payload()}"
88
+ ),
89
+ )
90
+
91
+ bypass = (shipped.get("bypass_reason") or "").strip()
92
+ if bypass:
93
+ emit(True, f"ship-loop-gate: bypassed with reason: {bypass}")
94
+
95
+ missing = [k for k in REQUIRED_EVIDENCE_KEYS if not str(shipped.get(k) or "").strip()]
96
+ if missing:
97
+ emit(
98
+ False,
99
+ (
100
+ "ship-loop-gate: cannot mark task completed — missing evidence for: "
101
+ f"{', '.join(missing)}. Each key under metadata.shipped must be a "
102
+ "non-empty string describing what happened in that phase. Use "
103
+ "`metadata.shipped.bypass_reason` only when the task has no "
104
+ "deployable surface (docs / research / pure refactor). Example:\n"
105
+ f"{_example_payload()}"
106
+ ),
107
+ )
108
+
109
+ emit(True, "ship-loop-gate: all four phases (merged, deployed, monitored, verified) attested")
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
@@ -13,7 +13,8 @@ Allowed targets:
13
13
  * anything under the current working tree (UAP_WORKTREE_ROOT) or the main
14
14
  checkout (UAP_REPO_ROOT) — worktrees included;
15
15
  * relative paths (they resolve under the project root);
16
- * a scratch allow-list: /tmp, $TMPDIR, ~/.cache/uap, ~/.config/uap, plus any
16
+ * a scratch allow-list: /tmp, $TMPDIR, ~/.cache/uap, ~/.config/uap,
17
+ ~/.claude/projects (Claude Code auto-memory/session storage), plus any
17
18
  colon-separated prefixes in UAP_WORKDIR_ALLOW.
18
19
 
19
20
  Escape hatch: UAP_WORKDIR_SCOPE_OFF=1 allows everything (operator override).
@@ -58,7 +59,17 @@ def _allowed_roots() -> list[Path]:
58
59
 
59
60
  add(worktree_root())
60
61
  add(repo_root())
61
- for p in ("/tmp", os.environ.get("TMPDIR", "/tmp"), "~/.cache/uap", "~/.config/uap"):
62
+ # ~/.claude/projects is the Claude Code harness's own storage (auto-memory
63
+ # topic files, MEMORY.md index, session/transcript data). The harness
64
+ # instructs agents to persist memories there; blocking it silently breaks
65
+ # memory recording (observed on pay2u 2026-07-05).
66
+ for p in (
67
+ "/tmp",
68
+ os.environ.get("TMPDIR", "/tmp"),
69
+ "~/.cache/uap",
70
+ "~/.config/uap",
71
+ "~/.claude/projects",
72
+ ):
62
73
  add(_expand(p))
63
74
  for p in os.environ.get("UAP_WORKDIR_ALLOW", "").split(":"):
64
75
  if p.strip():
@@ -0,0 +1,35 @@
1
+ # adr-guard
2
+
3
+ **Category**: architecture
4
+ **Level**: RECOMMENDED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: adr, architecture, invariants, write-gate
7
+
8
+ ## Rule
9
+
10
+ Write/Edit tool calls MUST satisfy every machine-readable rule block declared
11
+ in the project's ADRs. An ADR under `docs/adr/` may embed a `<!-- uap-rules
12
+ ... -->` HTML comment declaring `forbid` / `require` regexes scoped by path
13
+ prefix, exclusion substring, and extension. A write whose content violates a
14
+ matching rule is **blocked** with the ADR's own message.
15
+
16
+ ## Why
17
+
18
+ Architectural and security invariants decided in an ADR decay unless they are
19
+ enforced where the regression would be written. Declaring the rule inside the
20
+ ADR itself keeps a single source of truth: accept the decision, and the gate
21
+ comes with it. The policy is inert in projects whose ADRs declare no rule
22
+ blocks.
23
+
24
+ ## Enforcement
25
+
26
+ Python enforcer `adr_guard.py` parses `uap-rules` blocks from every ADR under
27
+ `docs/adr/` (cached per mtime) and evaluates each Write/Edit target/content
28
+ against the matching rules. Rule fields: `id`, `scope.include/exclude/ext`,
29
+ `forbid` (regex must NOT match), `require` (regex MUST match), `message`.
30
+
31
+ ```rules
32
+ - title: "Writes must satisfy the machine-readable rule blocks embedded in ADRs"
33
+ keywords: [write, edit, adr, invariant, forbid, require]
34
+ antiPatterns: []
35
+ ```
@@ -0,0 +1,38 @@
1
+ # bearer-lockdown
2
+
3
+ **Category**: security
4
+ **Level**: REQUIRED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: auth, cookie-only, bearer, security, write-gate
7
+
8
+ ## Rule
9
+
10
+ Write/Edit tool calls MUST NOT reintroduce any cookie-only-frontend
11
+ (ADR-0007-style) invariant violation: frontend code setting
12
+ `Authorization: Bearer`, token keys in `localStorage`/`sessionStorage`,
13
+ `--skip-jwt-bearer-tokens=true` on oauth2-proxy, backend reading the client
14
+ `Authorization` header, `/auth/me` emitting a raw user id instead of an opaque
15
+ ref, or a committed RS256 JWT. Violating writes are **blocked** at tool-call
16
+ time.
17
+
18
+ ## Why
19
+
20
+ A cookie-only auth architecture is one careless write away from regression,
21
+ and CI-time scanning catches it only after the agent has already built on top
22
+ of the mistake. Gating at write time prevents the regression from ever
23
+ entering the tree. Mirrors the originating project's bearer-lockdown CI scan.
24
+
25
+ ## Enforcement
26
+
27
+ Python enforcer `bearer_lockdown.py` applies per-invariant regex checks scoped
28
+ by path (frontend trees, infra, backend). Path prefixes default to the
29
+ originating project's layout (`apps/web/`, `apps/cms/`, `apps/marketing/`,
30
+ `apps/api/`, `infra/`) — adjust the `path_include`/`path_exclude` tuples when
31
+ installing into a differently-shaped repo. Runtime-only invariants (e.g.
32
+ "Istio DENY policy alive") stay with cluster tooling, not this gate.
33
+
34
+ ```rules
35
+ - title: "No write may reintroduce a bearer-token/cookie-only-frontend regression"
36
+ keywords: [write, edit, authorization, bearer, localStorage, jwt, oauth2-proxy]
37
+ antiPatterns: ["Authorization: Bearer in frontend code", "token in localStorage"]
38
+ ```
@@ -0,0 +1,38 @@
1
+ # local-build-before-push
2
+
3
+ **Category**: quality
4
+ **Level**: RECOMMENDED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: build, push, ci, docker, compile-gate
7
+
8
+ ## Rule
9
+
10
+ `git push` / `gh pr create` / `gh pr merge` are **blocked** while the local
11
+ branch contains compiled-source changes (default scope: the project's C++ API
12
+ tree) that have not passed a local Docker builder-stage compile. The pass
13
+ marker (`.uap/local-build-pass.txt`, written by the project's local-build
14
+ script) must record the current HEAD SHA.
15
+
16
+ ## Why
17
+
18
+ The CI compile gate can take 10-50 minutes; the identical local Docker build
19
+ takes 2-5 minutes cached. Pushing unverified compiled-source changes burns CI
20
+ cycles on errors a local build catches immediately. The marker-ties-to-HEAD
21
+ design means "built something earlier" never satisfies the gate — only the
22
+ exact tree being pushed.
23
+
24
+ ## Enforcement
25
+
26
+ Python enforcer `local_build_before_push.py` intercepts push/PR Bash commands,
27
+ diffs the branch against the base, and blocks when watched paths (default
28
+ `apps/api/` sources/CMake/Dockerfiles — adjust per project) changed without a
29
+ matching pass marker. Bypass: `UAP_SKIP_LOCAL_BUILD=1` (session env — justify
30
+ in the commit message). NOTE: the enforcer diffs against the LOCAL base ref;
31
+ keep local main fast-forwarded or already-merged changes read as phantom
32
+ diffs.
33
+
34
+ ```rules
35
+ - title: "Compiled-source changes must pass a local Docker build before push/PR"
36
+ keywords: [git push, gh pr create, gh pr merge, docker, build, compile]
37
+ antiPatterns: ["push without local build verification"]
38
+ ```
@@ -0,0 +1,35 @@
1
+ # ship-loop-gate
2
+
3
+ **Category**: process
4
+ **Level**: REQUIRED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: ship-loop, task, completion, evidence, merge-deploy-monitor-verify
7
+
8
+ ## Rule
9
+
10
+ A task MUST NOT be marked `completed` without evidence for all four ship-loop
11
+ phases under `metadata.shipped`: `merged` (PR URL / merge SHA on main),
12
+ `deployed` (deploy run URL), `monitored` (log/metric window + result), and
13
+ `verified` (behavioural assertion against the deployed change). `TaskUpdate`
14
+ setting `status=completed` — or `TaskCreate` landing directly in `completed` —
15
+ without all four non-empty keys is **blocked**.
16
+
17
+ ## Why
18
+
19
+ "Merged" is not "done": deploys regress silently and features ship dark.
20
+ Requiring written evidence per phase turns the merge -> deploy -> monitor ->
21
+ verify loop from a convention into a contract, and the recorded values become
22
+ the audit trail. Companion to the `merge-deploy-monitor-verify` policy — this
23
+ is its enforcement point at task-completion time.
24
+
25
+ ## Enforcement
26
+
27
+ Python enforcer `ship_loop_gate.py` inspects TaskUpdate/TaskCreate arguments
28
+ and rejects completion without the four evidence keys, echoing an example of
29
+ the expected `metadata.shipped` shape in the denial message.
30
+
31
+ ```rules
32
+ - title: "Task completion requires merged/deployed/monitored/verified evidence"
33
+ keywords: [taskupdate, taskcreate, completed, shipped, evidence]
34
+ antiPatterns: ["marking a task completed with no ship evidence"]
35
+ ```
@@ -13,8 +13,11 @@ command whose create/move destination (`mkdir`, `touch`, `cp`, `mv`, `install`,
13
13
  `tee`, output redirection) — resolves OUTSIDE the working tree is **blocked**.
14
14
 
15
15
  In scope (allowed): the current working tree and main checkout (worktrees
16
- included), relative paths, and a scratch allow-list (`/tmp`, `$TMPDIR`,
17
- `~/.cache/uap`, `~/.config/uap`, plus `UAP_WORKDIR_ALLOW` prefixes).
16
+ included), relative paths, `/dev/*` device sinks (`2>/dev/null` is not a
17
+ write), and a scratch allow-list (`/tmp`, `$TMPDIR`, `~/.cache/uap`,
18
+ `~/.config/uap`, `~/.claude/projects` — the Claude Code harness's own
19
+ auto-memory/session storage, which agents are instructed to write to — plus
20
+ `UAP_WORKDIR_ALLOW` prefixes).
18
21
 
19
22
  ## Why
20
23
 
@@ -76,6 +76,16 @@ class TestWorkdirScopeEnforcer(unittest.TestCase):
76
76
  out, c = run("Write", {"file_path": "/tmp/uap-scratch/x"}, self.root)
77
77
  self._allow(out, c, "/tmp scratch")
78
78
 
79
+ def test_claude_memory_dir_allowed(self):
80
+ # ~/.claude/projects is the Claude Code harness's auto-memory/session
81
+ # storage; the harness instructs agents to write memories there.
82
+ out, c = run(
83
+ "Write",
84
+ {"file_path": str(Path.home() / ".claude/projects/-x-proj/memory/note.md")},
85
+ self.root,
86
+ )
87
+ self._allow(out, c, "claude memory dir")
88
+
79
89
  def test_worktree_path_allowed(self):
80
90
  out, c = run("Write", {"file_path": str(self.root / ".worktrees/001-x/f.ts")}, self.root)
81
91
  self._allow(out, c, "worktree path")