From 4cd84b5b23fe56ea70f48131b515a6e25fd0854a Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 19:08:52 +0800 Subject: [PATCH 1/3] new(bug-log): docs/bugs.jsonl per project and bin/file-bugs.sh to redact and file bugs to the two public repos Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 1 + ROUTING.md | 1 + agents/gate-agent.md | 1 + agents/mdl-agent.md | 1 + bin/file-bugs.sh | 28 +++ bin/lib/file-bugs.py | 395 ++++++++++++++++++++++++++++++++++ bin/lib/skill-routing.tsv | 1 + skills/upstream-feedback.md | 11 +- tests/wave2/test-file-bugs.sh | 121 +++++++++++ 10 files changed, 555 insertions(+), 6 deletions(-) create mode 100755 bin/file-bugs.sh create mode 100644 bin/lib/file-bugs.py create mode 100755 tests/wave2/test-file-bugs.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index d76e227f..63e2642c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- new(bug-log): **`docs/bugs.jsonl` per project plus `bin/file-bugs.sh` to file it safely to mendixlabs/mxcli or this repo.** A session that hits or works around a tool bug appends a row (`--add`, next free BUG-NN, project-local; the public number is the issue). The script redacts every title and body (home paths, user and host names, emails, IPs, tokens, URLs other than the two repos, the project's .mpr, module and entity names from PROJECT.md and mdlsource/ as Module1/EntityA, the leak-guard deny list), scans again and refuses a bug that still looks unsafe. Default is a dry run with likely duplicates from `gh issue list --search`; posting needs `--file BUG-NN` or `--all` and a y/N per bug, then writes status and issue_url back; `--links` and `--json` give prefilled new-issue URLs for the report. `tests/wave2/test-file-bugs.sh` 35 checks with a stubbed gh; one line in upstream-feedback.md, word count not up — MendixMau - learn(skills): **five lab findings land as compact rows, each paid for by trimming prose in the same files, so always-loaded words do not grow (baseline 75,225 to 75,204, budget 80,000).** Enum value `New` and `AutoCreatedDate`/`AutoOwner` (detection gaps), `[%CurrentDateTime%]` not `currentDateTime()`, expression return types (Decimal from date diffs and `div`, absolute `secondsBetween`, `formatDecimal` on pages), CE2729 from the default home page and page microflow grants, Atlas selector carriers for list views, page body and timelines, `-small` spacing suffixes do not exist, `display: contents` rows paint nothing, CSS pseudo text is invisible to `innerText` — lab field runs, Refs #276 (TK-020, TK-021, TK-022, TK-024) - fix(design-check): **a design system whose palette lives only in scoped `:root.` blocks no longer reads as "0 of N tokens carry the design's value".** Pass 2b compared every token against a bare `:root` of the built sheet, so a themed-only palette always warned, a false warning that teaches agents to ignore the check. A token the design declares only in scoped blocks is now compared block for block against the same-named block in the built sheet, and a wrong value is still named with its block (knob binding stays bare-only: a knob bound inside a theme the app is not wearing is not bound). Synthetic fixtures `ds-themed.css`, `built-themed.css`, `built-themed-wrong.css`; 5 new cases in `test-design-reaches-app.sh` (50 total) — TK-015 part (b) - fix(tests): **`test-exec-hash-verify.sh` case K passes on macOS.** `$TMPDIR` ends in `/`, so the bare `mktemp` path held `//` and the exact `MXPATCH_BACKUP_DIR` path assert could not match; `WORK` is now `cd`-ed into and read back with `pwd -P`. Test only, no script change. 42 pass / 1 fail before, 43 / 0 after — #384 review diff --git a/README.md b/README.md index 05c99b68..af67b671 100644 --- a/README.md +++ b/README.md @@ -814,6 +814,7 @@ Then filter by Role(s). A helper dispatched with a role stub (ba, architect, mdl | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | `skills/learned-detection-gaps.md` | 5,6 | mdl, gate, review | | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | `skills/security-is-not-a-later-script.md` | 5 | mdl, gate, review | | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | `bin/bug-lookup.sh` | 5,6 | mdl, gate | +| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli\|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | 5,6 | mdl, gate | | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | `bin/status.sh` | P,0,1,2,3,4,5,6,7 | every role | | Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first | `skills/teamserver-alignment.md` | 5,6,7 | gate (mdl, review: on demand) | diff --git a/ROUTING.md b/ROUTING.md index fbe25354..170789df 100644 --- a/ROUTING.md +++ b/ROUTING.md @@ -264,6 +264,7 @@ picks the row up. That is the whole procedure — there is no second list to rem | A runtime test or seed reads/writes data that then is not there (the test wrote a row, the DB query shows nothing), or vice versa — three local Postgres instances can answer on this box; resolve the real port from the project's own compose file first | `skills/learned-local-db-confusion.md` | mdl,test,gate | 5,6 | ondemand | | gate-check.sh reports a Stage 0 file not found that plainly exists — ANALYSIS_BASE falls back to project root until Stage 1; move the file, do not debug the script | `skills/gate-check-file-locations.md` | ba,gate | 0 | ondemand | | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | `bin/bug-lookup.sh` | mdl,gate | 5,6 | baseline | +| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli\|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | mdl,gate | 5,6 | baseline | #### Reference — lookup tables, not method diff --git a/agents/gate-agent.md b/agents/gate-agent.md index 91dc4efc..18276e48 100644 --- a/agents/gate-agent.md +++ b/agents/gate-agent.md @@ -45,6 +45,7 @@ This table is your whole list. The baseline table in the project's CLAUDE.local. | `skills/learned-detection-gaps.md` | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | | `skills/security-is-not-a-later-script.md` | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | | `bin/bug-lookup.sh` | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | +| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli\|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) | | `bin/status.sh` | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | | `skills/teamserver-alignment.md` | Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first | | `skills/retesting-learned-rules.md` | Before obeying any learned-* STOP or workaround that costs a detour — probe the binary you actually have, then stamp the verdict back into the rule | diff --git a/agents/mdl-agent.md b/agents/mdl-agent.md index cb1bb928..722c863f 100644 --- a/agents/mdl-agent.md +++ b/agents/mdl-agent.md @@ -55,6 +55,7 @@ This table is your whole list. The baseline table in the project's CLAUDE.local. | `skills/learned-detection-gaps.md` | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | | `skills/security-is-not-a-later-script.md` | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | | `bin/bug-lookup.sh` | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | +| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli\|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) | | `bin/status.sh` | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | | `skills/retesting-learned-rules.md` | Before obeying any learned-* STOP or workaround that costs a detour — probe the binary you actually have, then stamp the verdict back into the rule | | `skills/skills-over-scripts.md` | Script or skill? Before writing any .js or bash/.sh for a check, checker, linter, gate or report, or adding a rule or regex to an existing one | diff --git a/bin/file-bugs.sh b/bin/file-bugs.sh new file mode 100755 index 00000000..74489e26 --- /dev/null +++ b/bin/file-bugs.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# file-bugs.sh — a per-project bug log (docs/bugs.jsonl) and a safe way to file it publicly. +# +# bin/file-bugs.sh [--project DIR] # dry run: redacted title + body, duplicates +# bin/file-bugs.sh --file BUG-02 [--file ...] # file that bug, after a y/N per bug +# bin/file-bugs.sh --all # every open bug, still a y/N per bug +# bin/file-bugs.sh --links # prefilled github new-issue URL per open bug +# bin/file-bugs.sh --json # redacted bugs + URLs as JSON (for the report) +# bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A \ +# [--workaround W] [--mxcli V] [--mendix V] # append a row, next free BUG-NN +# +# WHY. Both target repos are public (target mxcli = mendixlabs/mxcli, toolkit = +# MendixMau/mxcli-project-toolkit; nothing else is ever addressed). A bug found in a client +# project must leave it without the client in it. Every title and body is redacted first +# (home paths, user and host names, emails, IPs, tokens, URLs other than the two repos, the +# project's .mpr / module / entity names from PROJECT.md and mdlsource/, and the deny list in +# $LEAKGUARD_DENY or /.leakguard-deny), then scanned again. A bug that still looks +# unsafe is shown with a warning and never posted, never given a URL. +# Posting needs --file/--all AND an interactive y/N; stdin must be a terminal +# (MXTK_FILE_BUGS_STDIN=1 lets tests feed answers). A successful post writes status=filed and +# issue_url back. The id is project-local; the public number is the issue. +# Row fields: id date target title repro expected actual versions{mxcli,mendix,toolkit} +# workaround status(open|filed|fixed) issue_url. Dry run, --links and --json post nothing. +set -eu +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +. "$SCRIPT_DIR/lib/portable.sh" +require_py +exec "$PY" -I "$SCRIPT_DIR/lib/file-bugs.py" "$@" diff --git a/bin/lib/file-bugs.py b/bin/lib/file-bugs.py new file mode 100644 index 00000000..6654e5c6 --- /dev/null +++ b/bin/lib/file-bugs.py @@ -0,0 +1,395 @@ +"""file-bugs.py: engine behind bin/file-bugs.sh (see that file for the contract). + +Reads /docs/bugs.jsonl, redacts, searches the target repo for duplicates, and either +shows (default), prints prefilled new-issue URLs (--links / --json) or files (--file/--all + y/N). +Only two repos are ever addressed; see TARGETS. +""" +import argparse, datetime, json, os, re, subprocess, sys, tempfile, urllib.parse + +TARGETS = {"mxcli": "mendixlabs/mxcli", "toolkit": "MendixMau/mxcli-project-toolkit"} +ALLOWED_URL = ("github.com/mendixlabs/", "github.com/mendixmau/mxcli-project-toolkit") +TLDS = "com|net|org|io|dev|cloud|local|lan|internal|corp|intra|de|nl|uk|eu|us|fr|jp|cn|tw|th|kr|app|ai|co|info|biz" +URL_MAX = 6000 +STOP_NAMES = {"system", "administration", "atlas_core", "atlas_web_content", "marketplace", + "mxmodelreflection", "root", "user", "admin", "test", "mendix", "mxcli", "module", + "entity", "app", "main"} + +RE_PRIVKEY = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)", re.S) +RE_TOKENS = re.compile( + r"\b(?:gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}" + r"|xox[baprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b" + r"|\bBearer\s+[A-Za-z0-9._~+/=-]{8,}") +RE_KV = re.compile(r"(?i)\b((?:[a-z0-9_.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|credential)[a-z0-9_.-]*|authorization))" + r"(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;]+)") +RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+") +RE_URL = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s)>\]\"',;]*[^\s)>\]\"',;.]") +RE_BARE = re.compile(r"\b(?:[A-Za-z0-9-]+\.)+(?:%s)\b(?::\d+)?(?:/[^\s)>\]\"',;]*[^\s)>\]\"',;.])?" % TLDS, re.I) +RE_PATH = re.compile(r"(?:/(?:Users|home)/[^/\s\"']+(?:/[^\s\"',;)]*)?|[A-Za-z]:\\+Users\\+[^\\\s]+(?:\\[^\s\"',;)]*)?" + r"|(?= 3 and u.lower() not in STOP_NAMES} + + def _hosts(self): + out = set() + for c in (["hostname"], ["hostname", "-s"]): + v = sh(c).stdout.strip() + if v: + out.add(v) + out.add(v.split(".")[0]) + return {h for h in out if len(h) >= 3 and h.lower() not in STOP_NAMES} + + def _names(self, extra): + p = self.project + mods, ents = set(), set() + for f in os.listdir(p) if os.path.isdir(p) else []: + if f.lower().endswith(".mpr"): + self.app = f[:-4] + pm = os.path.join(p, "PROJECT.md") + if os.path.isfile(pm): + for ln in open(pm, errors="replace"): + m = re.match(r"(?i)^[\s*|>-]*(?:app|project)(?:\s+name)?\s*\**\s*[:|]\s*\**\s*([^|\n]+)", ln) + if m and not self.app: + self.app = m.group(1).strip(" *`") + m = re.match(r"(?i)^[\s*|>-]*modules?\s*\**\s*[:|]\s*\**\s*([^|\n]+)", ln) + if m: + mods.update(x.strip(" *`") for x in re.split(r"[,;]", m.group(1)) if x.strip(" *`")) + md = os.path.join(p, "mdlsource") + for root, _d, files in os.walk(md): + for f in files: + if not f.lower().endswith((".mdl", ".md", ".txt", ".sql")): + continue + try: + txt = open(os.path.join(root, f), errors="replace").read(400000) + except OSError: + continue + mods.update(re.findall(r"(?i)\bCREATE\s+MODULE\s+(\w+)", txt)) + for m, e in re.findall(r"(?i)\bCREATE\s+(?:OR\s+\w+\s+)?(?:(?:NON-)?PERSISTENT\s+|VIEW\s+|EXTERNAL\s+)?ENTITY\s+(\w+)\.(\w+)", txt): + mods.add(m) + ents.add(e) + for x in extra: + (mods if "." not in x else ents).add(x) + mods = {m for m in mods if len(m) >= 3 and m.lower() not in STOP_NAMES} + ents = {e for e in ents if len(e) >= 3 and e.lower() not in STOP_NAMES} + for i, m in enumerate(sorted(mods), 1): + self.mods[m] = "Module%d" % i + for i, e in enumerate(sorted(ents)): + self.ents[e] = "Entity" + (chr(65 + i) if i < 26 else chr(65 + i // 26 - 1) + chr(65 + i % 26)) + if self.app and (len(self.app) < 3 or self.app.lower() in STOP_NAMES): + self.app = None + + # --- redaction ---------------------------------------------------------------------- + @staticmethod + def _url_ok(s): + s = re.sub(r"^[a-z][a-z0-9+.-]*://", "", s, flags=re.I).lower() + return s.startswith(ALLOWED_URL) + + def redact(self, text): + keep = [] + + def hold(s): + keep.append(s) + return "\x00%d\x00" % (len(keep) - 1) + + t = text or "" + t = RE_PRIVKEY.sub("", t) + t = RE_TOKENS.sub("", t) + t = RE_KV.sub(lambda m: m.group(1) + m.group(2) + "", t) + t = RE_EMAIL.sub("", t) + t = RE_URL.sub(lambda m: hold(m.group(0)) if self._url_ok(m.group(0)) else "", t) + t = RE_PATH.sub("", t) + t = RE_BARE.sub(lambda m: hold(m.group(0)) if self._url_ok(m.group(0)) else "", t) + t = RE_IP6.sub("", RE_IP4.sub("", t)) + for h in sorted(self.hosts, key=len, reverse=True): + t = re.sub(r"(?i)(?", t) + for u in sorted(self.users, key=len, reverse=True): + t = re.sub(r"(?i)(?", t) + for rx in self.deny: + t = rx.sub("", t) + if self.app: + t = re.sub(r"(?i)(?": + bad.append("secret assignment " + m.group(1)) + for m in RE_OPAQUE.finditer(text): + s = m.group(0) + if RE_HEXSHA.match(s) or not (re.search(r"\d", s) and re.search(r"[A-Za-z]", s)): + continue + bad.append("opaque string " + s[:12] + "...") + words = list(self.users) + list(self.hosts) + list(self.mods) + list(self.ents) + ([self.app] if self.app else []) + for w in words: + if re.search(r"(?i)(? %s%s" % (d.get("id"), repo, "" if posting else " [dry run, nothing is posted]")) + print("TITLE: " + title + "\n--- BODY ---\n" + body + "\n--- END ---") + dp = dupes(repo, title) + if dp is None: + print("Duplicates: search unavailable (gh missing or not authenticated)") + elif not dp: + print("Duplicates: none found") + else: + print("Likely duplicates:") + for x in dp: + print(" #%s [%s] %s %s" % (x.get("number"), x.get("state"), x.get("title"), x.get("url"))) + if problems: + print("WARNING: redaction left suspicious content (%s). Refusing to post this bug; fix the log row." % ", ".join(problems)) + continue + if not posting: + continue + if want and d.get("id") not in want: + continue + if dp is None: + print("Not posting: the duplicate search did not run.") + continue + sys.stdout.write("Post this to %s? [y/N] " % repo) + sys.stdout.flush() + ans = sys.stdin.readline().strip().lower() + if ans != "y": + print("skipped.") + continue + p = sh(["gh", "issue", "create", "--repo", repo, "--title", title, "--body", body]) + m = re.search(r"https://github\.com/%s/issues/\d+" % re.escape(repo), p.stdout or "") + if p.returncode != 0 or not m: + print("gh issue create failed: " + (p.stderr or p.stdout).strip()[:300]) + continue + write_back(path, d.get("id"), {"status": "filed", "issue_url": m.group(0)}) + print("filed: " + m.group(0)) + if a.json: + print(json.dumps(out, indent=2, ensure_ascii=False)) + elif not out and not a.links: + print("file-bugs: no open bugs") + + +if __name__ == "__main__": + main() diff --git a/bin/lib/skill-routing.tsv b/bin/lib/skill-routing.tsv index 94ade35c..8f571c7d 100644 --- a/bin/lib/skill-routing.tsv +++ b/bin/lib/skill-routing.tsv @@ -302,6 +302,7 @@ preview-over-hub-tunnel skills/preview-over-hub-tunnel.md Exposing a container-r run-hub bin/run-hub.sh Starting a hub-tunnelled preview with the flags outbound calls actually need — wraps mxcli run --hub with db-name and the runtime proxy settings from preview-over-hub-tunnel.md test 5,6 ondemand verify walking-skeleton skills/walking-skeleton.md Stage 5 start, before the first module of any entry mode — one entity, flow, page, nav, demo user, journey and screenshot proven in the running app, so build/run/look/test are known to work before a module depends on them mdl,gate,test 5 ondemand build bug-lookup bin/bug-lookup.sh A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger mdl,gate 5,6 baseline diagnose +file-bugs bin/file-bugs.sh You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) mdl,gate 5,6 baseline diagnose status bin/status.sh The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory all P,0,1,2,3,4,5,6,7 baseline spine platform-link skills/platform-link.md At project birth (before the first build script) and any time a model needs a platform home: creating the Team Server app, adopting an existing GitHub-born model into it without rewriting history, or deploying; also when the Platform SDK returns 403, git rejects the PAT, a deploy cannot be triggered from a PAT, or the app turns out to be a Free App all P,5,6,7 ondemand build teamserver-alignment skills/teamserver-alignment.md Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first mdl?,gate,review? 5,6,7 baseline build diff --git a/skills/upstream-feedback.md b/skills/upstream-feedback.md index a030bdbc..ebf1679a 100644 --- a/skills/upstream-feedback.md +++ b/skills/upstream-feedback.md @@ -2,9 +2,8 @@ **Applies to:** any mxcli project — migration, requirements-driven, and greenfield alike. -**Purpose:** Route a finding worth sending upstream — an mxcli command that lied, a gate that -let a defect through, a runbook stage that was missing a step — to the right repo and the right -vehicle, in a form that may actually be posted. Answers one question: **which repo, which +**Purpose:** Route a finding worth sending upstream (an mxcli command that lied, a gate that +let a defect through, a runbook stage missing a step) to the right repo and vehicle, in a form that may be posted. Answers one question: **which repo, which vehicle, and what may be posted.** **Companions:** `bug-submission-checklist.md` (the full evidence bar for an mxcli defect — this @@ -12,6 +11,8 @@ file routes, that one proves), `close-the-loop.md` (local filing; this is the up `tool-output-is-not-ground-truth.md` (verify before you file), `CONTRIBUTING.md` (lanes and the no-client-data rule that CI enforces). +**Bug log.** On hitting or working around a tool bug, append a row: `bin/file-bugs.sh --add ...` (`docs/bugs.jsonl`). It files later, redacted, `--links` or y/N per bug. + --- ## 1. Two destinations, and they do not overlap @@ -31,9 +32,7 @@ write or check (a model element it cannot create, a batch it applies out of orde reads an unsaved model) is neither an mxcli defect nor a toolkit one — the test above answers "no" for mxcli and "yes, still wrong without the toolkit". Do not file it in either repo and do not paper over it with a toolkit rule; draft it locally (§4 shape, §5 scrub) for the person to -pass to that server's owners, and note the draft's path in `PROJECT.md`. Failure it prevents: a -2026-09-29 field comparison logged its MCP asks only in a toolkit inbox note, where no MCP owner -would read them. The asks from that run, with what was measured: +pass to that server's owners, and note the draft's path in `PROJECT.md`. (2026-09-29: asks sat in a toolkit inbox note.) Asks, as measured: | Ask | Evidence from the run | |---|---| diff --git a/tests/wave2/test-file-bugs.sh b/tests/wave2/test-file-bugs.sh new file mode 100755 index 00000000..3769175c --- /dev/null +++ b/tests/wave2/test-file-bugs.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# test-file-bugs.sh — bin/file-bugs.sh: redaction, dry run, y/N filing, --links. +# +# Usage: bash tests/wave2/test-file-bugs.sh [path-to-file-bugs.sh] +# +# An invented project (Zorbix.mpr, module Quuxmod, entity Widgetron) with invented bugs; gh is a +# stub on PATH that logs every call and never touches the network. Cases: +# A dry run posts nothing (no `issue create` in the gh log), shows title, body and duplicates +# B redaction of each class: home path, user, host, email, IP, token, other URL, project names, +# deny list; the allowed github.com URLs survive +# C final scan: an opaque 36-char string survives redaction, so the bug is refused and no +# --links URL or create call exists for it +# D --file with answer n posts nothing; with y calls gh issue create with the redacted body and +# writes status=filed and issue_url back; the other rows are untouched +# E --links: URL-encoded, redacted, under the length cap when the body is huge, no gh needed +# F posting without a terminal and without the test hook is refused; --add numbers the next BUG-NN +set -uo pipefail +TOOLKIT="$(cd "$(dirname "$0")/../.." && pwd)" +SUBJ="${1:-$TOOLKIT/bin/file-bugs.sh}" +. "$TOOLKIT/bin/lib/portable.sh"; require_py +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } +chk() { if eval "$2"; then ok "$1"; else bad "$1"; fi; } + +W="$(mktemp -d "${TMPDIR:-/tmp}/fbtest.XXXXXX")"; trap 'rm -rf "$W"' EXIT +P="$W/proj"; BIN="$W/bin"; GHLOG="$W/gh.log" +mkdir -p "$P/docs" "$P/mdlsource" "$BIN" +: > "$P/Zorbix.mpr" +printf 'CREATE MODULE Quuxmod;\nCREATE PERSISTENT ENTITY Quuxmod.Widgetron (Name: String);\n' > "$P/mdlsource/01-model.mdl" +printf 'Glorpley\\b\n' > "$W/deny" +cat > "$BIN/gh" <<'GH' +#!/usr/bin/env bash +echo "$*" >> "$GHLOG" +case "$1 $2" in + "issue list") echo '[{"number":77,"title":"Similar widget crash","state":"OPEN","url":"https://github.com/mendixlabs/mxcli/issues/77"}]' ;; + "issue create") echo "https://github.com/mendixlabs/mxcli/issues/4242" ;; + "--version "*) echo "gh version stub" ;; +esac +exit 0 +GH +chmod +x "$BIN/gh"; export GHLOG +export PATH="$BIN:$PATH" LEAKGUARD_DENY="$W/deny" +U="${USER:-someone}"; H="$(hostname -s 2>/dev/null || echo myhost)" +HOMEP="/Users/$U/work/Zorbix/mdlsource/01.mdl" +cat > "$P/docs/bugs.jsonl" < "$GHLOG" +OUT="$(run &1)" +chk "dry run never calls issue create" '! grep -q "issue create" "$GHLOG"' +chk "dry run shows the exact title" 'echo "$OUT" | grep -q "TITLE: Crash on Module1.EntityA in App1"' +chk "dry run shows a likely duplicate" 'echo "$OUT" | grep -q "#77"' +chk "closed and wrong-target rows are not offered" '! echo "$OUT" | grep -q "^BUG-05" && echo "$OUT" | grep -q "BUG-04.*skipped"' +chk "duplicate search used gh issue list --search" 'grep -q "issue list --repo mendixlabs/mxcli.*--search" "$GHLOG"' + +echo "B. redaction classes (BUG-01 body)" +B1="$(run --json "$GHLOG" +OUT="$(printf 'n\nn\nn\n' | run --all 2>&1)" +chk "BUG-02 warned and refused" 'echo "$OUT" | grep -q "WARNING: redaction left suspicious content.*opaque"' +chk "no create call carries BUG-02 text" '! grep "issue create" "$GHLOG" | grep -q "Opaque"' +chk "--links gives no URL for BUG-02" 'run --links "$GHLOG"; cp "$P/docs/bugs.jsonl" "$W/before" +printf 'n\n' | run --file BUG-03 >/dev/null 2>&1 +chk "answer n posts nothing" '! grep -q "issue create" "$GHLOG"' +chk "answer n leaves the log unchanged" 'cmp -s "$P/docs/bugs.jsonl" "$W/before"' +printf 'y\n' | run --file BUG-01 >/dev/null 2>&1 +chk "answer y calls issue create once, on the target repo" '[ "$(grep -c "issue create" "$GHLOG")" = 1 ] && grep -q "issue create --repo mendixlabs/mxcli --title Crash on Module1.EntityA in App1 --body" "$GHLOG"' +chk "created body is the redacted one" '! grep "issue create" "$GHLOG" | grep -q "Zorbix\|ghp_\|/Users/"' +chk "status and url written back" 'grep "\"BUG-01\"" "$P/docs/bugs.jsonl" | grep -q "\"status\": \"filed\".*issues/4242"' +chk "other rows byte-identical" 'diff <(grep -v BUG-01 "$W/before") <(grep -v BUG-01 "$P/docs/bugs.jsonl") >/dev/null' +chk "a filed bug is not offered again" '! run "$GHLOG" +L="$(run --links "$GHLOG" +OUT="$("$SUBJ" --project "$P" --file BUG-03 &1)" +chk "no terminal and no hook: refused" 'echo "$OUT" | grep -q "interactive terminal" && ! grep -q "issue create" "$GHLOG"' +run --add --target toolkit --title "Added" --repro "r" --expected "e" --actual "a" --mxcli v1 >/dev/null +chk "--add numbers the next free BUG-NN" 'tail -1 "$P/docs/bugs.jsonl" | grep -q "\"id\": \"BUG-07\""' + +echo; echo "file-bugs: $PASS pass, $FAIL fail"; [ "$FAIL" -eq 0 ] From de134e650006c0307a881d7196340408475ef6df Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 19:41:13 +0800 Subject: [PATCH 2/3] fix(bug-log): Opus review of #433, names in identifiers, secrets, deny list, refused bugs leak nothing, /dev/tty confirm, PBUG ids Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- ROUTING.md | 2 +- agents/gate-agent.md | 2 +- agents/mdl-agent.md | 2 +- bin/file-bugs.sh | 24 +- bin/lib/file-bugs.py | 477 +++++++++++++++++++++++-------- bin/lib/skill-routing.tsv | 2 +- skills/learned-detection-gaps.md | 14 +- skills/upstream-feedback.md | 11 +- tests/wave2/test-file-bugs.sh | 239 +++++++++++++--- 11 files changed, 582 insertions(+), 195 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45b731d5..b48d3116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- new(bug-log): **`docs/bugs.jsonl` per project plus `bin/file-bugs.sh` to file it safely to mendixlabs/mxcli or this repo.** A session that hits or works around a tool bug appends a row (`--add`, next free BUG-NN, project-local; the public number is the issue). The script redacts every title and body (home paths, user and host names, emails, IPs, tokens, URLs other than the two repos, the project's .mpr, module and entity names from PROJECT.md and mdlsource/ as Module1/EntityA, the leak-guard deny list), scans again and refuses a bug that still looks unsafe. Default is a dry run with likely duplicates from `gh issue list --search`; posting needs `--file BUG-NN` or `--all` and a y/N per bug, then writes status and issue_url back; `--links` and `--json` give prefilled new-issue URLs for the report. `tests/wave2/test-file-bugs.sh` 35 checks with a stubbed gh; one line in upstream-feedback.md, word count not up — MendixMau +- new(bug-log): **`docs/bugs.jsonl` per project plus `bin/file-bugs.sh` to file it safely to mendixlabs/mxcli or this repo.** A session that hits or works around a tool bug appends a row (`--add`, next free `PBUG-NN`, project-local so it never collides with the toolkit's BUG-n; the public number is the issue; the one-line instruction sits in learned-detection-gaps.md). The script normalises Unicode (NFKC, zero-width and lookalike letters, names split by a line break), redacts every title and body (home and spaced paths, user and host names, emails, IPv4/IPv6, tokens, key=value, JSON and `--flag value` secrets, hex tokens, AWS keys, any dotted hostname, server names from config files, URLs other than the two repos matched exactly, the project's .mpr, module, entity, attribute, page and microflow names matched inside identifiers, `$LEAKGUARD_DENY` as a regex plus the project's .leakguard-deny), builds the versions line from version-shaped strings only, scans again with the same matcher and refuses a bug that still looks unsafe: a refused bug gets no duplicate search, no URL and no text in `--links`/`--json`. Default is a dry run with likely duplicates; posting needs `--file PBUG-NN` or `--all` and a y/N per bug read from /dev/tty only (no stdin test hook), passes `--repo= --title= --body-file=` to gh, keeps the log's file mode; `--links` caps title and body under the URL limit. `tests/wave2/test-file-bugs.sh` covers each case with a stubbed gh and a pty helper; fixes the Opus review of #433 — MendixMau - process(testing): **unit tests are now off by default and `affected-tests.sh` enforces it; each project picks its test strategy with the developer at CAC-5.** Fast-tracked by the maintainer on 2026-10-16 because users report tests firing on every change (the baseline-text A/B is waived; E3 follows as a check). Moments: every change = gate plus what it touched (unit only for affected modules with unit on); module done = that module's journeys (+ its unit tests if on); build complete and before every later deploy = everything once; "run everything" any time. The CAC-5 Offer records it in PROJECT.md `## Test strategy` (Status `ASSUMED` in auto mode). `affected-tests.sh` reads `Unit tests default:` (`no` or absent means unit off, one `unit off` line, never a `.test.mdl` run row); turn unit on per project with `Unit tests default: yes`, per module with `Unit modules: Shop, Admin` in PROJECT.md or `Unit tests: yes` in the module brief; the strategy row's Unit cell is a scope only and never turns unit on. A grant on a microflow or page the same script changes is part of that subject and no longer selects the owning module's tests. `module-review.md` PROVE now runs `--moment module --module `. `testing-shape.md`, `checkpoint-build.md`, `iterative-build-loop.md`, `conversion-runbook.md` and the exec.sh logic lane text updated. `tests/wave2/test-test-strategy-docs.sh` and the extended `test-affected-tests.sh` pin it. Baseline words stay at or below master (75,219 vs 75,225). Split out of #409; found by the field test of the release candidate — the maintainer's decision, 2026-10-16 - learn(skills): **five lab findings land as compact rows, each paid for by trimming prose in the same files, so always-loaded words do not grow (baseline 75,225 to 75,204, budget 80,000).** Enum value `New` and `AutoCreatedDate`/`AutoOwner` (detection gaps), `[%CurrentDateTime%]` not `currentDateTime()`, expression return types (Decimal from date diffs and `div`, absolute `secondsBetween`, `formatDecimal` on pages), CE2729 from the default home page and page microflow grants, Atlas selector carriers for list views, page body and timelines, `-small` spacing suffixes do not exist, `display: contents` rows paint nothing, CSS pseudo text is invisible to `innerText` — lab field runs, Refs #276 (TK-020, TK-021, TK-022, TK-024) - fix(design-check): **a design system whose palette lives only in scoped `:root.` blocks no longer reads as "0 of N tokens carry the design's value".** Pass 2b compared every token against a bare `:root` of the built sheet, so a themed-only palette always warned, a false warning that teaches agents to ignore the check. A token the design declares only in scoped blocks is now compared block for block against the same-named block in the built sheet, and a wrong value is still named with its block (knob binding stays bare-only: a knob bound inside a theme the app is not wearing is not bound). Synthetic fixtures `ds-themed.css`, `built-themed.css`, `built-themed-wrong.css`; 5 new cases in `test-design-reaches-app.sh` (50 total) — TK-015 part (b) diff --git a/README.md b/README.md index af67b671..23af2e80 100644 --- a/README.md +++ b/README.md @@ -814,7 +814,7 @@ Then filter by Role(s). A helper dispatched with a role stub (ba, architect, mdl | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | `skills/learned-detection-gaps.md` | 5,6 | mdl, gate, review | | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | `skills/security-is-not-a-later-script.md` | 5 | mdl, gate, review | | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | `bin/bug-lookup.sh` | 5,6 | mdl, gate | -| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli\|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | 5,6 | mdl, gate | +| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | 5,6 | mdl, gate | | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | `bin/status.sh` | P,0,1,2,3,4,5,6,7 | every role | | Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first | `skills/teamserver-alignment.md` | 5,6,7 | gate (mdl, review: on demand) | diff --git a/ROUTING.md b/ROUTING.md index 170789df..8bfcac9d 100644 --- a/ROUTING.md +++ b/ROUTING.md @@ -264,7 +264,7 @@ picks the row up. That is the whole procedure — there is no second list to rem | A runtime test or seed reads/writes data that then is not there (the test wrote a row, the DB query shows nothing), or vice versa — three local Postgres instances can answer on this box; resolve the real port from the project's own compose file first | `skills/learned-local-db-confusion.md` | mdl,test,gate | 5,6 | ondemand | | gate-check.sh reports a Stage 0 file not found that plainly exists — ANALYSIS_BASE falls back to project root until Stage 1; move the file, do not debug the script | `skills/gate-check-file-locations.md` | ba,gate | 0 | ondemand | | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | `bin/bug-lookup.sh` | mdl,gate | 5,6 | baseline | -| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli\|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | mdl,gate | 5,6 | baseline | +| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | mdl,gate | 5,6 | baseline | #### Reference — lookup tables, not method diff --git a/agents/gate-agent.md b/agents/gate-agent.md index 18276e48..5b41d30b 100644 --- a/agents/gate-agent.md +++ b/agents/gate-agent.md @@ -45,7 +45,7 @@ This table is your whole list. The baseline table in the project's CLAUDE.local. | `skills/learned-detection-gaps.md` | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | | `skills/security-is-not-a-later-script.md` | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | | `bin/bug-lookup.sh` | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | -| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli\|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) | +| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) | | `bin/status.sh` | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | | `skills/teamserver-alignment.md` | Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first | | `skills/retesting-learned-rules.md` | Before obeying any learned-* STOP or workaround that costs a detour — probe the binary you actually have, then stamp the verdict back into the rule | diff --git a/agents/mdl-agent.md b/agents/mdl-agent.md index 722c863f..c58440bb 100644 --- a/agents/mdl-agent.md +++ b/agents/mdl-agent.md @@ -55,7 +55,7 @@ This table is your whole list. The baseline table in the project's CLAUDE.local. | `skills/learned-detection-gaps.md` | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | | `skills/security-is-not-a-later-script.md` | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | | `bin/bug-lookup.sh` | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | -| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli\|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) | +| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) | | `bin/status.sh` | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | | `skills/retesting-learned-rules.md` | Before obeying any learned-* STOP or workaround that costs a detour — probe the binary you actually have, then stamp the verdict back into the rule | | `skills/skills-over-scripts.md` | Script or skill? Before writing any .js or bash/.sh for a check, checker, linter, gate or report, or adding a rule or regex to an existing one | diff --git a/bin/file-bugs.sh b/bin/file-bugs.sh index 74489e26..15d3312d 100755 --- a/bin/file-bugs.sh +++ b/bin/file-bugs.sh @@ -2,23 +2,27 @@ # file-bugs.sh — a per-project bug log (docs/bugs.jsonl) and a safe way to file it publicly. # # bin/file-bugs.sh [--project DIR] # dry run: redacted title + body, duplicates -# bin/file-bugs.sh --file BUG-02 [--file ...] # file that bug, after a y/N per bug +# bin/file-bugs.sh --file PBUG-02 [--file ...] # file that bug, after a y/N per bug # bin/file-bugs.sh --all # every open bug, still a y/N per bug # bin/file-bugs.sh --links # prefilled github new-issue URL per open bug # bin/file-bugs.sh --json # redacted bugs + URLs as JSON (for the report) # bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A \ -# [--workaround W] [--mxcli V] [--mendix V] # append a row, next free BUG-NN +# [--workaround W] [--mxcli V] [--mendix V] # append a row, next free PBUG-NN # # WHY. Both target repos are public (target mxcli = mendixlabs/mxcli, toolkit = # MendixMau/mxcli-project-toolkit; nothing else is ever addressed). A bug found in a client -# project must leave it without the client in it. Every title and body is redacted first -# (home paths, user and host names, emails, IPs, tokens, URLs other than the two repos, the -# project's .mpr / module / entity names from PROJECT.md and mdlsource/, and the deny list in -# $LEAKGUARD_DENY or /.leakguard-deny), then scanned again. A bug that still looks -# unsafe is shown with a warning and never posted, never given a URL. -# Posting needs --file/--all AND an interactive y/N; stdin must be a terminal -# (MXTK_FILE_BUGS_STDIN=1 lets tests feed answers). A successful post writes status=filed and -# issue_url back. The id is project-local; the public number is the issue. +# project must leave it without the client in it. Every title and body is Unicode-normalised +# (NFKC, zero-width and lookalike letters folded), then redacted: home paths, user and host +# names, emails, IPs, tokens and key=value / --flag secrets, any dotted hostname, URLs other than +# the two repos, project names (.mpr, module, entity, attribute, page, microflow from +# PROJECT.md and mdlsource/, matched inside identifiers and across a line break), server names +# from the project's config files, and the deny list ($LEAKGUARD_DENY as a regex like +# check-no-client-data.sh, plus /.leakguard-deny). The versions line is built from +# version-shaped strings only. The same matcher then scans the result. A bug that still looks +# unsafe is shown by the dry run only: no duplicate search, no URL, no --links/--json text, no post. +# Posting needs --file/--all AND y/N read from /dev/tty (never stdin). The id is project-local +# (PBUG-NN, not a toolkit BUG-n); the public number is the issue. Success writes status=filed +# and issue_url back, keeping the file mode. # Row fields: id date target title repro expected actual versions{mxcli,mendix,toolkit} # workaround status(open|filed|fixed) issue_url. Dry run, --links and --json post nothing. set -eu diff --git a/bin/lib/file-bugs.py b/bin/lib/file-bugs.py index 6654e5c6..793436d5 100644 --- a/bin/lib/file-bugs.py +++ b/bin/lib/file-bugs.py @@ -1,35 +1,62 @@ """file-bugs.py: engine behind bin/file-bugs.sh (see that file for the contract). Reads /docs/bugs.jsonl, redacts, searches the target repo for duplicates, and either -shows (default), prints prefilled new-issue URLs (--links / --json) or files (--file/--all + y/N). -Only two repos are ever addressed; see TARGETS. +shows (default), prints prefilled new-issue URLs (--links / --json) or files (--file/--all + y/N +read from /dev/tty). Only two repos are ever addressed; see TARGETS. """ -import argparse, datetime, json, os, re, subprocess, sys, tempfile, urllib.parse +import argparse, datetime, ipaddress, json, os, re, subprocess, sys, tempfile, unicodedata, urllib.parse TARGETS = {"mxcli": "mendixlabs/mxcli", "toolkit": "MendixMau/mxcli-project-toolkit"} -ALLOWED_URL = ("github.com/mendixlabs/", "github.com/mendixmau/mxcli-project-toolkit") -TLDS = "com|net|org|io|dev|cloud|local|lan|internal|corp|intra|de|nl|uk|eu|us|fr|jp|cn|tw|th|kr|app|ai|co|info|biz" +ALLOWED_REPOS = tuple(v.lower() for v in TARGETS.values()) URL_MAX = 6000 +TITLE_URL_MAX = 1200 +LOCAL_PREFIX = "PBUG" STOP_NAMES = {"system", "administration", "atlas_core", "atlas_web_content", "marketplace", "mxmodelreflection", "root", "user", "admin", "test", "mendix", "mxcli", "module", "entity", "app", "main"} - +TOKEN_WORDS = "module entity doc attr app host user path ip url email token key redacted commit" +GENERIC_ATTRS = {"name", "description", "title", "status", "type", "value", "date", "code", "email", "amount", + "number", "count", "active", "created", "changed", "owner", "text", "comment", "notes", "price", + "quantity", "address", "phone", "state", "category", "label", "order", "result", "message"} +FILE_EXT = set("mpr mdl md json jsonl sh py js ts tsx jsx css scss html htm xml yaml yml txt log sql zip mpk jar java " + "exe dll png jpg jpeg gif svg pdf csv toml ini cfg conf lock tsv properties tmp bak swp map gz tar " + "mjs cjs rb go rs c h cpp class so dylib bat ps1 cmd env pem key crt".split()) +CONF_SKIP = {"package.json", "package-lock.json", "bugs.jsonl", "tsconfig.json"} + +_CONF = {"\u0430": "a", "\u0435": "e", "\u043e": "o", "\u0440": "p", "\u0441": "c", "\u0445": "x", "\u0443": "y", + "\u0456": "i", "\u0458": "j", "\u0455": "s", "\u0501": "d", "\u04bb": "h", "\u0410": "A", "\u0412": "B", + "\u0415": "E", "\u041a": "K", "\u041c": "M", "\u041d": "H", "\u041e": "O", "\u0420": "P", "\u0421": "C", + "\u0422": "T", "\u0425": "X", "\u0405": "S", "\u0406": "I", "\u0408": "J", "\u0391": "A", "\u0392": "B", + "\u0395": "E", "\u0396": "Z", "\u0397": "H", "\u0399": "I", "\u039a": "K", "\u039c": "M", "\u039d": "N", + "\u039f": "O", "\u03a1": "P", "\u03a4": "T", "\u03a5": "Y", "\u03a7": "X", "\u03bf": "o", "\u03bd": "v", + "\u03b1": "a", "\u03b9": "i", "\u0131": "i"} +_CONF = {ord(k): v for k, v in _CONF.items()} +LB = r"(?:-?[ \t]*\r?\n[ \t]*)?" # a name may be split by a line break (optionally hyphenated) + +KEYW = r"(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|credential|private[_-]?key)" RE_PRIVKEY = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)", re.S) RE_TOKENS = re.compile( - r"\b(?:gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}" + r"\b(?:gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|sk-[A-Za-z0-9_-]{16,}|(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA)[0-9A-Z]{16}" r"|xox[baprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b" r"|\bBearer\s+[A-Za-z0-9._~+/=-]{8,}") -RE_KV = re.compile(r"(?i)\b((?:[a-z0-9_.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|credential)[a-z0-9_.-]*|authorization))" - r"(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;]+)") +RE_KV = re.compile(r"(?i)([\"']?)\b((?:[a-z0-9_.-]*" + KEYW + r"[a-z0-9_.-]*|authorization))\1(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)") +RE_FLAG = re.compile(r"(?i)(--?[a-z0-9_-]*" + KEYW + r"[a-z0-9_-]*)([ \t]+|=)(\"[^\"]*\"|'[^']*'|[^\s,;]+)") RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+") RE_URL = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s)>\]\"',;]*[^\s)>\]\"',;.]") -RE_BARE = re.compile(r"\b(?:[A-Za-z0-9-]+\.)+(?:%s)\b(?::\d+)?(?:/[^\s)>\]\"',;]*[^\s)>\]\"',;.])?" % TLDS, re.I) -RE_PATH = re.compile(r"(?:/(?:Users|home)/[^/\s\"']+(?:/[^\s\"',;)]*)?|[A-Za-z]:\\+Users\\+[^\\\s]+(?:\\[^\s\"',;)]*)?" - r"|(?[a-z]{2,24}|[A-Z]{2,24})\b(?::\d+)?" + r"(?:/[^\s)>\]\"',;]*[^\s)>\]\"',;.])?") +_SEG = r"[^\\/\s\"',;)]+(?: [\w.-]+){0,2}" +RE_PATH = re.compile(r"(?i:/(?:users|home)/)(?:" + _SEG + r"[\\/]+)*[^\s\"',;)]*" + r"|(?i:[a-z]:\\+users\\+)(?:" + _SEG + r"\\+)*[^\s\"',;)]*" + r"|(?/.leakguard-deny): " + "client names outside the project files cannot be caught") return pats + def _generic(self, w): + lw = w.lower() + return len(w) < 3 or lw in STOP_NAMES or lw in ("localhost", "true", "false", "none", "null", "example") \ + or lw in TOKEN_WORDS.split() + def _users(self): out = set() for v in (os.environ.get("USER"), os.environ.get("LOGNAME"), sh(["id", "-un"]).stdout.strip(), @@ -85,9 +197,38 @@ def _hosts(self): out.add(v.split(".")[0]) return {h for h in out if len(h) >= 3 and h.lower() not in STOP_NAMES} + def _config_hosts(self): + """Server names from the project's config files (host/server/url/endpoint values, ://host).""" + out, seen = set(), 0 + p = self.project + dirs = [p] + [os.path.join(p, d) for d in ("config", "configs", "deployment", "settings", "docker", "env", "conf")] + kv = re.compile(r"(?i)\b[\w.-]*(?:host|server|endpoint|address|domain|url|uri)[\w.-]*[\"']?\s*[:=]\s*[\"']?([^\s\"',;#}]+)") + for d in dirs: + if not os.path.isdir(d): + continue + for f in sorted(os.listdir(d)): + fp = os.path.join(d, f) + if (not os.path.isfile(fp) or f in CONF_SKIP or seen > 60 or + not re.search(r"(?i)(\.(ya?ml|properties|conf|cfg|ini|env|json|toml|xml)$|^dockerfile|docker-compose|^\.env)", f)): + continue + seen += 1 + try: + txt = norm(open(fp, errors="replace").read(200000)) + except OSError: + continue + vals = kv.findall(txt) + re.findall(r"://(?:[^/\s@\"']*@)?([^/\s:\"'?#]+)", txt) + for v in vals: + v = re.sub(r"^[a-z][a-z0-9+.-]*://", "", v, flags=re.I) + v = re.sub(r"^[^/@]*@", "", v).split("/")[0].split(":")[0].strip("[]") + if (re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,}$", v) and not re.match(r"^[\d.]+$", v)): + out.add(v) + if "." in v and len(v.split(".")[0]) >= 4: + out.add(v.split(".")[0]) + return out + def _names(self, extra): p = self.project - mods, ents = set(), set() + mods, ents, attrs, docs = set(), set(), set(), set() for f in os.listdir(p) if os.path.isdir(p) else []: if f.lower().endswith(".mpr"): self.app = f[:-4] @@ -113,23 +254,48 @@ def _names(self, extra): for m, e in re.findall(r"(?i)\bCREATE\s+(?:OR\s+\w+\s+)?(?:(?:NON-)?PERSISTENT\s+|VIEW\s+|EXTERNAL\s+)?ENTITY\s+(\w+)\.(\w+)", txt): mods.add(m) ents.add(e) + for body in re.findall(r"(?is)\bENTITY\s+\w+\.\w+\s*\((.*?)\)\s*(?:;|/|$)", txt): + attrs.update(re.findall(r"(?m)(?:^|[(,])\s*(\w+)\s*:\s*[A-Za-z]", body)) + attrs.update(re.findall(r"(?i)\bADD\s+ATTRIBUTE\s+(\w+)", txt)) + for m, n in re.findall(r"(?i)\bCREATE\s+(?:OR\s+(?:MODIFY|REPLACE)\s+)?(?!MODULE\b)(?:[A-Za-z-]+\s+){0,3}?(\w+)\.(\w+)", txt): + mods.add(m) + docs.add(n) for x in extra: - (mods if "." not in x else ents).add(x) - mods = {m for m in mods if len(m) >= 3 and m.lower() not in STOP_NAMES} - ents = {e for e in ents if len(e) >= 3 and e.lower() not in STOP_NAMES} + (mods if "." not in x else ents).add(x.split(".")[-1] if "." in x else x) + ok = lambda s: not self._generic(s) + mods, ents, docs = {m for m in mods if ok(m)}, {e for e in ents if ok(e)}, {d for d in docs if ok(d)} + attrs = {a for a in attrs if len(a) >= 4 and a.lower() not in GENERIC_ATTRS and ok(a)} + docs -= ents | mods for i, m in enumerate(sorted(mods), 1): self.mods[m] = "Module%d" % i for i, e in enumerate(sorted(ents)): self.ents[e] = "Entity" + (chr(65 + i) if i < 26 else chr(65 + i // 26 - 1) + chr(65 + i % 26)) + for i, a in enumerate(sorted(attrs), 1): + self.attrs[a] = "Attr%d" % i + for i, d in enumerate(sorted(docs), 1): + self.docs[d] = "Doc%d" % i if self.app and (len(self.app) < 3 or self.app.lower() in STOP_NAMES): self.app = None - # --- redaction ---------------------------------------------------------------------- - @staticmethod - def _url_ok(s): - s = re.sub(r"^[a-z][a-z0-9+.-]*://", "", s, flags=re.I).lower() - return s.startswith(ALLOWED_URL) + def _compile(self): + """One matcher list shared by redact() and scan(): (token, regex, kind).""" + subs = [(self.mods, "mod"), (self.ents, "ent"), (self.docs, "doc")] + self.matchers = [] + if self.app: + self.matchers.append((self.app, "App1", name_pat(self.app, True))) + for table, _k in subs: + for n, tok in table.items(): + self.matchers.append((n, tok, name_pat(n, True))) + for n, tok in self.attrs.items(): + self.matchers.append((n, tok, name_pat(n, False))) + for u in self.users: + self.matchers.append((u, "", name_pat(u, False))) + for h in self.hosts: + self.matchers.append((h, "", name_pat(h, False))) + self.matchers.sort(key=lambda x: -len(x[0])) + self.mrx = [(re.compile(rx), tok) for _n, tok, rx in self.matchers] + # --- redaction ---------------------------------------------------------------------- def redact(self, text): keep = [] @@ -137,67 +303,73 @@ def hold(s): keep.append(s) return "\x00%d\x00" % (len(keep) - 1) - t = text or "" + t = norm(text) t = RE_PRIVKEY.sub("", t) t = RE_TOKENS.sub("", t) - t = RE_KV.sub(lambda m: m.group(1) + m.group(2) + "", t) + t = RE_KV.sub(lambda m: m.group(1) + m.group(2) + m.group(1) + m.group(3) + "", t) + t = RE_FLAG.sub(lambda m: m.group(1) + m.group(2) + "", t) + t = RE_HEXTOK.sub(lambda m: hold(m.group(0)) if hex_ok(t, m) else "", t) t = RE_EMAIL.sub("", t) - t = RE_URL.sub(lambda m: hold(m.group(0)) if self._url_ok(m.group(0)) else "", t) + t = RE_URL.sub(lambda m: hold(m.group(0)) if url_ok(m.group(0)) else "", t) t = RE_PATH.sub("", t) - t = RE_BARE.sub(lambda m: hold(m.group(0)) if self._url_ok(m.group(0)) else "", t) - t = RE_IP6.sub("", RE_IP4.sub("", t)) - for h in sorted(self.hosts, key=len, reverse=True): - t = re.sub(r"(?i)(?", t) - for u in sorted(self.users, key=len, reverse=True): - t = re.sub(r"(?i)(?", t) + t = RE_BARE.sub(lambda m: "" if host_hit(m) else (hold(m.group(0)) if url_ok(m.group(0)) else m.group(0)), t) + t = RE_IP6.sub(lambda m: "" if ip6_ok(m.group(0)) else m.group(0), t) + t = RE_IP4.sub(lambda m: m.group(0) if RE_VERCTX.search(t[max(0, m.start() - 24):m.start()]) else "", t) for rx in self.deny: t = rx.sub("", t) - if self.app: - t = re.sub(r"(?i)(?": + bad.append("secret assignment " + m.group(2)) + for m in RE_FLAG.finditer(t): if m.group(3).strip("\"'") != "": - bad.append("secret assignment " + m.group(1)) - for m in RE_OPAQUE.finditer(text): + bad.append("secret flag " + m.group(1)) + for m in RE_OPAQUE.finditer(t): s = m.group(0) - if RE_HEXSHA.match(s) or not (re.search(r"\d", s) and re.search(r"[A-Za-z]", s)): - continue - bad.append("opaque string " + s[:12] + "...") - words = list(self.users) + list(self.hosts) + list(self.mods) + list(self.ents) + ([self.app] if self.app else []) - for w in words: - if re.search(r"(?i)(? TITLE_URL_MAX: + lo, hi = 0, len(title) + while lo < hi: + mid = (lo + hi + 1) // 2 + if len(q(title[:mid] + "...")) <= TITLE_URL_MAX: + lo = mid + else: + hi = mid - 1 + title, trunc = title[:lo] + "...", True + base = "https://github.com/%s/issues/new?title=%s&body=" % (repo, q(title)) note = "\n\n(truncated to fit a URL; full text is in the project bug log)" - if len(base) + len(urllib.parse.quote(body, safe="")) <= URL_MAX: - return base + urllib.parse.quote(body, safe=""), False + if len(base) + len(q(body)) <= URL_MAX: + return base + q(body), trunc lo, hi = 0, len(body) while lo < hi: mid = (lo + hi + 1) // 2 - if len(base) + len(urllib.parse.quote(body[:mid] + note, safe="")) <= URL_MAX: + if len(base) + len(q(body[:mid] + note)) <= URL_MAX: lo = mid else: hi = mid - 1 - return base + urllib.parse.quote(body[:lo] + note, safe=""), True + return base + q(body[:lo] + note), True def dupes(repo, title): + """Called only with a redacted title that passed the final scan.""" if not sh(["gh", "--version"]).returncode == 0: return None q = " ".join(re.findall(r"[A-Za-z0-9_]{3,}", title)[:8]) @@ -276,19 +467,23 @@ def dupes(repo, title): def write_back(path, bid, upd): rows, raw = load(path) for idx, d in rows: - if d.get("id") == bid: + if str(d.get("id")) == bid: d.update(upd) raw[idx] = json.dumps(d, ensure_ascii=False) + mode = os.stat(path).st_mode & 0o7777 fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", prefix=".bugs.") with os.fdopen(fd, "w") as f: f.write("\n".join(raw) + "\n") + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp, mode) os.replace(tmp, path) def add(path, a): rows, _ = load(path) - nums = [int(m.group(1)) for _, d in rows for m in [re.match(r"^BUG-(\d+)$", str(d.get("id", "")))] if m] - bid = "BUG-%02d" % (max(nums, default=0) + 1) + nums = [int(m.group(1)) for _, d in rows for m in [re.match(r"^%s-(\d+)$" % LOCAL_PREFIX, str(d.get("id", "")))] if m] + bid = "%s-%02d" % (LOCAL_PREFIX, max(nums, default=0) + 1) here = os.path.dirname(os.path.abspath(__file__)) tk = sh(["git", "-C", os.path.join(here, "..", ".."), "describe", "--tags", "--always"]).stdout.strip() mx = a.mxcli or (sh(["mxcli", "--version"]).stdout.strip().splitlines() or [""])[0] @@ -299,13 +494,45 @@ def add(path, a): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "a") as f: f.write(json.dumps(row, ensure_ascii=False) + "\n") - print("file-bugs: added %s to %s (local id; the public number comes from the issue)" % (bid, path)) + print("file-bugs: added %s to %s (local id, not a toolkit BUG-n; the public number comes from the issue)" % (bid, path)) + + +def tty_open(): + """The only place a posting answer is read from: the controlling terminal.""" + try: + fd = os.open("/dev/tty", os.O_RDWR) + except OSError: + return None + return os.fdopen(os.dup(fd), "w"), os.fdopen(fd, "r") + + +def confirm(tty, repo): + out, inp = tty + out.write("Post this to %s? [y/N] " % repo) + out.flush() + return inp.readline().strip().lower() == "y" + + +def gh_create(repo, title, body): + fd, bf = tempfile.mkstemp(prefix="file-bugs-body.") + try: + with os.fdopen(fd, "w") as f: + f.write(body) + return sh(["gh", "issue", "create", "--repo=" + repo, "--title=" + title, "--body-file=" + bf]) + finally: + try: + os.unlink(bf) + except OSError: + pass + + +REFUSED_NOTE = "refused: run the dry run to see why" def main(): ap = argparse.ArgumentParser(prog="file-bugs.sh", add_help=True) ap.add_argument("--project", default=".") - ap.add_argument("--file", action="append", default=[], metavar="BUG-NN") + ap.add_argument("--file", action="append", default=[], metavar="PBUG-NN") ap.add_argument("--all", action="store_true") ap.add_argument("--links", action="store_true") ap.add_argument("--json", action="store_true") @@ -324,35 +551,49 @@ def main(): if not os.path.isfile(path): sys.exit("file-bugs: no %s" % path) want = set(a.file) - todo = [(i, d) for i, d in rows if d.get("status", "open") == "open" and (not want or d.get("id") in want)] + sid = lambda d: str(d.get("id")) + todo = [(i, d) for i, d in rows if d.get("status", "open") == "open" and (not want or sid(d) in want)] for w in want: - if not any(d.get("id") == w for _, d in rows): + if not any(sid(d) == w for _, d in rows): sys.exit("file-bugs: %s is not in %s" % (w, path)) - if not any(d.get("id") == w for _, d in todo): + if not any(sid(d) == w for _, d in todo): sys.stderr.write("file-bugs: %s is not open, skipped\n" % w) + posting = bool(want or a.all) and not (a.links or a.json) + tty = None + if posting: + tty = tty_open() + if tty is None: + sys.exit("file-bugs: posting needs an interactive terminal (/dev/tty, y/N per bug); nothing posted") r = Redactor(project, a.name) + for w in r.warnings: + sys.stderr.write("file-bugs: warning: %s\n" % w) out = [] - posting = bool(want or a.all) and not (a.links or a.json) - if posting and not (sys.stdin.isatty() or os.environ.get("MXTK_FILE_BUGS_STDIN") == "1"): - sys.exit("file-bugs: posting needs an interactive terminal (y/N per bug); nothing posted") for _, d in todo: target = d.get("target") - repo = TARGETS.get(target) + repo = TARGETS.get(target) if isinstance(target, str) else None if not repo: - sys.stderr.write("file-bugs: %s has target %r, not one of %s; skipped\n" % (d.get("id"), target, "/".join(TARGETS))) + sys.stderr.write("file-bugs: %s has target %s, not one of %s; skipped\n" % + (sid(d), json.dumps(target)[:40], "/".join(TARGETS))) continue title, body, problems = build(r, d) - url, trunc = (None, False) if problems else new_issue_url(repo, title, body) - item = {"id": d.get("id"), "target": target, "repo": repo, "title": title, "body": body, - "url": url, "truncated": trunc, "refused": problems} + if problems: + item = {"id": sid(d), "target": target, "repo": repo, "refused": True, "note": REFUSED_NOTE} + else: + url, trunc = new_issue_url(repo, title, body) + item = {"id": sid(d), "target": target, "repo": repo, "title": title, "body": body, + "url": url, "truncated": trunc, "refused": False} out.append(item) if a.json: continue if a.links: - print("%s %s\n %s" % (d.get("id"), title, url or "REFUSED (%s)" % ", ".join(problems))) + print("%s %s" % (sid(d), REFUSED_NOTE if problems else title + "\n " + item["url"])) continue - print("=" * 72 + "\n%s -> %s%s" % (d.get("id"), repo, "" if posting else " [dry run, nothing is posted]")) + print("=" * 72 + "\n%s -> %s%s" % (sid(d), repo, "" if posting else " [dry run, nothing is posted]")) print("TITLE: " + title + "\n--- BODY ---\n" + body + "\n--- END ---") + if problems: + print("WARNING: redaction left suspicious content (%s). Refusing to post this bug; no duplicate search was run; " + "fix the log row." % ", ".join(problems)) + continue dp = dupes(repo, title) if dp is None: print("Duplicates: search unavailable (gh missing or not authenticated)") @@ -362,28 +603,22 @@ def main(): print("Likely duplicates:") for x in dp: print(" #%s [%s] %s %s" % (x.get("number"), x.get("state"), x.get("title"), x.get("url"))) - if problems: - print("WARNING: redaction left suspicious content (%s). Refusing to post this bug; fix the log row." % ", ".join(problems)) - continue if not posting: continue - if want and d.get("id") not in want: + if want and sid(d) not in want: continue if dp is None: print("Not posting: the duplicate search did not run.") continue - sys.stdout.write("Post this to %s? [y/N] " % repo) - sys.stdout.flush() - ans = sys.stdin.readline().strip().lower() - if ans != "y": + if not confirm(tty, repo): print("skipped.") continue - p = sh(["gh", "issue", "create", "--repo", repo, "--title", title, "--body", body]) + p = gh_create(repo, title, body) m = re.search(r"https://github\.com/%s/issues/\d+" % re.escape(repo), p.stdout or "") if p.returncode != 0 or not m: print("gh issue create failed: " + (p.stderr or p.stdout).strip()[:300]) continue - write_back(path, d.get("id"), {"status": "filed", "issue_url": m.group(0)}) + write_back(path, sid(d), {"status": "filed", "issue_url": m.group(0)}) print("filed: " + m.group(0)) if a.json: print(json.dumps(out, indent=2, ensure_ascii=False)) diff --git a/bin/lib/skill-routing.tsv b/bin/lib/skill-routing.tsv index 8f571c7d..7aa17509 100644 --- a/bin/lib/skill-routing.tsv +++ b/bin/lib/skill-routing.tsv @@ -302,7 +302,7 @@ preview-over-hub-tunnel skills/preview-over-hub-tunnel.md Exposing a container-r run-hub bin/run-hub.sh Starting a hub-tunnelled preview with the flags outbound calls actually need — wraps mxcli run --hub with db-name and the runtime proxy settings from preview-over-hub-tunnel.md test 5,6 ondemand verify walking-skeleton skills/walking-skeleton.md Stage 5 start, before the first module of any entry mode — one entity, flow, page, nav, demo user, journey and screenshot proven in the running app, so build/run/look/test are known to work before a module depends on them mdl,gate,test 5 ondemand build bug-lookup bin/bug-lookup.sh A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger mdl,gate 5,6 baseline diagnose -file-bugs bin/file-bugs.sh You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A` appends a row to docs/bugs.jsonl; later the same script redacts, shows duplicates, files after a y/N (`--file BUG-NN`), or prints new-issue links (`--links`, `--json`) mdl,gate 5,6 baseline diagnose +file-bugs bin/file-bugs.sh You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) mdl,gate 5,6 baseline diagnose status bin/status.sh The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory all P,0,1,2,3,4,5,6,7 baseline spine platform-link skills/platform-link.md At project birth (before the first build script) and any time a model needs a platform home: creating the Team Server app, adopting an existing GitHub-born model into it without rewriting history, or deploying; also when the Platform SDK returns 403, git rejects the PAT, a deploy cannot be triggered from a PAT, or the app turns out to be a Free App all P,5,6,7 ondemand build teamserver-alignment skills/teamserver-alignment.md Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first mdl?,gate,review? 5,6,7 baseline build diff --git a/skills/learned-detection-gaps.md b/skills/learned-detection-gaps.md index c68155d4..626f70e1 100644 --- a/skills/learned-detection-gaps.md +++ b/skills/learned-detection-gaps.md @@ -3,25 +3,23 @@ **Applies to:** any mxcli project **Purpose:** the register of MDL constructs that pass one or more verification rungs and still fail later, with the **first oracle that catches each**. Use it before trusting a green result -("what does this rung miss?") and when a runtime symptom appears over a green model. +and when a runtime symptom appears over a green model. -Every row was found in the field. The pattern: **the verification rungs form a ladder, and a green -result only certifies what that rung can see.** +Every row was found in the field: **a green result only certifies what its rung can see.** + +**Hit or worked around a tool bug?** Append a row now: `bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A` (`docs/bugs.jsonl`, local id `PBUG-NN`); the same script later redacts and files it. ## The ladder 1. `mxcli check --references` — MDL grammar and reference syntax. Catches the least. 2. `mxcli exec` "success" — the write completed; says nothing about what was written. -3. `DESCRIBE ...` read-back — mxcli's own lenient reader; normalises, and omits what it - cannot represent (so it can hide the very corruption you are checking for). +3. `DESCRIBE ...` read-back — mxcli's own lenient reader; it normalises and omits what it cannot represent, so it can hide corruption. 4. Native `mx check` / mxbuild — the real model loader. Catches structural and expression errors; blind to runtime wiring. 5. Studio Pro's own GUI loader — catches BSON-structural corruption `mx check` sometimes passes (see `scriptable-sp-verification.md`). 6. **Live runtime observation** — a browser retrieve, `/xas/` traffic, a DB row count. The - only rung that verifies *behaviour*. Some defect families are invisible to everything - above this line. **It is not infallible: it is only as true as the harness that reads it** - — see operating rule 3. + only rung that verifies *behaviour*. **Only as true as the harness that reads it** (operating rule 3). ## The register diff --git a/skills/upstream-feedback.md b/skills/upstream-feedback.md index ebf1679a..a030bdbc 100644 --- a/skills/upstream-feedback.md +++ b/skills/upstream-feedback.md @@ -2,8 +2,9 @@ **Applies to:** any mxcli project — migration, requirements-driven, and greenfield alike. -**Purpose:** Route a finding worth sending upstream (an mxcli command that lied, a gate that -let a defect through, a runbook stage missing a step) to the right repo and vehicle, in a form that may be posted. Answers one question: **which repo, which +**Purpose:** Route a finding worth sending upstream — an mxcli command that lied, a gate that +let a defect through, a runbook stage that was missing a step — to the right repo and the right +vehicle, in a form that may actually be posted. Answers one question: **which repo, which vehicle, and what may be posted.** **Companions:** `bug-submission-checklist.md` (the full evidence bar for an mxcli defect — this @@ -11,8 +12,6 @@ file routes, that one proves), `close-the-loop.md` (local filing; this is the up `tool-output-is-not-ground-truth.md` (verify before you file), `CONTRIBUTING.md` (lanes and the no-client-data rule that CI enforces). -**Bug log.** On hitting or working around a tool bug, append a row: `bin/file-bugs.sh --add ...` (`docs/bugs.jsonl`). It files later, redacted, `--links` or y/N per bug. - --- ## 1. Two destinations, and they do not overlap @@ -32,7 +31,9 @@ write or check (a model element it cannot create, a batch it applies out of orde reads an unsaved model) is neither an mxcli defect nor a toolkit one — the test above answers "no" for mxcli and "yes, still wrong without the toolkit". Do not file it in either repo and do not paper over it with a toolkit rule; draft it locally (§4 shape, §5 scrub) for the person to -pass to that server's owners, and note the draft's path in `PROJECT.md`. (2026-09-29: asks sat in a toolkit inbox note.) Asks, as measured: +pass to that server's owners, and note the draft's path in `PROJECT.md`. Failure it prevents: a +2026-09-29 field comparison logged its MCP asks only in a toolkit inbox note, where no MCP owner +would read them. The asks from that run, with what was measured: | Ask | Evidence from the run | |---|---| diff --git a/tests/wave2/test-file-bugs.sh b/tests/wave2/test-file-bugs.sh index 3769175c..8e0357eb 100755 --- a/tests/wave2/test-file-bugs.sh +++ b/tests/wave2/test-file-bugs.sh @@ -1,19 +1,24 @@ #!/usr/bin/env bash -# test-file-bugs.sh — bin/file-bugs.sh: redaction, dry run, y/N filing, --links. +# test-file-bugs.sh — bin/file-bugs.sh: redaction, dry run, y/N filing, --links, refusals. # # Usage: bash tests/wave2/test-file-bugs.sh [path-to-file-bugs.sh] # -# An invented project (Zorbix.mpr, module Quuxmod, entity Widgetron) with invented bugs; gh is a -# stub on PATH that logs every call and never touches the network. Cases: -# A dry run posts nothing (no `issue create` in the gh log), shows title, body and duplicates -# B redaction of each class: home path, user, host, email, IP, token, other URL, project names, -# deny list; the allowed github.com URLs survive -# C final scan: an opaque 36-char string survives redaction, so the bug is refused and no -# --links URL or create call exists for it -# D --file with answer n posts nothing; with y calls gh issue create with the redacted body and -# writes status=filed and issue_url back; the other rows are untouched -# E --links: URL-encoded, redacted, under the length cap when the body is huge, no gh needed -# F posting without a terminal and without the test hook is refused; --add numbers the next BUG-NN +# An invented project (Zorbix.mpr, module Quuxmod, entity Widgetron, attribute Kundennummer, page +# CustomerOverview_Page, microflow ACT_Save, server build-srv-01 in config/) with invented bugs. +# gh is a stub on PATH that logs every call, saves every --body-file and never touches the network; +# every run asserts the stub is the gh in use and that no issue was created unless a case asked. +# The y/N answer is read from /dev/tty only, so posting cases run under a pty helper (pty.fork); +# the no-terminal cases run in a session without a controlling terminal. Cases: +# A dry run posts nothing, shows title, body and duplicates +# B redaction of each class: path, user, host, email, IP, token, other URL, names, deny list +# C final scan refusal: no URL, no gh search, no title/body in --links/--json +# D --file n then y: gh issue create with --repo= --title= --body-file=, write-back keeps mode +# E --links: encoded, capped (also a 7000-char title), non-string target skipped, no crash +# F guards: no tty refused (env hook is gone), --add numbers PBUG-NN, never BUG-n +# G names inside identifiers, attributes/pages/microflows, Unicode lookalikes, line-break splits +# H secrets: key=value, JSON, --flag value, hex, ASIA, paths, IPv6, any-TLD hosts, config hosts +# I deny list: regex value, project file, both, invalid line warned, none warned +# J versions line and the dotted-quad exemption; exact allowed-URL match set -uo pipefail TOOLKIT="$(cd "$(dirname "$0")/../.." && pwd)" SUBJ="${1:-$TOOLKIT/bin/file-bugs.sh}" @@ -21,36 +26,106 @@ SUBJ="${1:-$TOOLKIT/bin/file-bugs.sh}" PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; } bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } -chk() { if eval "$2"; then ok "$1"; else bad "$1"; fi; } +chk() { if [ "${G:-}" = REFUSED ]; then bad "$1 (row refused unexpectedly)"; elif eval "$2"; then ok "$1"; else bad "$1"; fi; } W="$(mktemp -d "${TMPDIR:-/tmp}/fbtest.XXXXXX")"; trap 'rm -rf "$W"' EXIT P="$W/proj"; BIN="$W/bin"; GHLOG="$W/gh.log" -mkdir -p "$P/docs" "$P/mdlsource" "$BIN" +mkdir -p "$P/docs" "$P/mdlsource" "$P/config" "$BIN" : > "$P/Zorbix.mpr" -printf 'CREATE MODULE Quuxmod;\nCREATE PERSISTENT ENTITY Quuxmod.Widgetron (Name: String);\n' > "$P/mdlsource/01-model.mdl" +cat > "$P/mdlsource/01-model.mdl" <<'MDL' +CREATE MODULE Quuxmod; +CREATE PERSISTENT ENTITY Quuxmod.Widgetron ( + Name: String(200), + Kundennummer: String(20) +); +CREATE PAGE Quuxmod.CustomerOverview_Page (Title: 'x') {} +CREATE MICROFLOW Quuxmod.ACT_Save ($w: Quuxmod.Widgetron) BEGIN END; +MDL +printf 'database:\n DatabaseHost: build-srv-01.corp.zonkco\n' > "$P/config/m2ee.yaml" printf 'Glorpley\\b\n' > "$W/deny" +printf 'Snarfle\\b\nFoo(bar\n' > "$P/.leakguard-deny" +printf '[user]\n\tname = MendixMau\n' > "$W/gitconfig" cat > "$BIN/gh" <<'GH' #!/usr/bin/env bash echo "$*" >> "$GHLOG" case "$1 $2" in "issue list") echo '[{"number":77,"title":"Similar widget crash","state":"OPEN","url":"https://github.com/mendixlabs/mxcli/issues/77"}]' ;; - "issue create") echo "https://github.com/mendixlabs/mxcli/issues/4242" ;; + "issue create") + for a in "$@"; do case "$a" in --body-file=*) cp "${a#--body-file=}" "$GHLOG.body" ;; esac; done + echo "https://github.com/mendixlabs/mxcli/issues/4242" ;; "--version "*) echo "gh version stub" ;; esac exit 0 GH -chmod +x "$BIN/gh"; export GHLOG +chmod +x "$BIN/gh" +cat > "$W/ptyrun.py" <<'PY' +# usage: ptyrun.py tty|notty cmd... answers to "[y/N] " come from $ANS (comma separated) +import os, pty, select, subprocess, sys +mode, cmd = sys.argv[1], sys.argv[2:] +if mode == "notty": + r = subprocess.run(cmd, stdin=subprocess.DEVNULL, start_new_session=True) + sys.exit(r.returncode) +answers = [a for a in os.environ.get("ANS", "").split(",") if a != ""] +pid, fd = pty.fork() +if pid == 0: + os.execvp(cmd[0], cmd) +buf, sent = b"", 0 +while True: + try: + r, _, _ = select.select([fd], [], [], 20) + if not r: + break + d = os.read(fd, 4096) + except OSError: + break + if not d: + break + buf += d + while buf.count(b"[y/N] ") > sent: + os.write(fd, (answers[sent] if sent < len(answers) else "n").encode() + b"\n") + sent += 1 +sys.stdout.write(buf.decode(errors="replace").replace("\r", "")) +_, st = os.waitpid(pid, 0) +sys.exit(os.waitstatus_to_exitcode(st) if hasattr(os, "waitstatus_to_exitcode") else (st >> 8)) +PY +export GHLOG; EMPTYJ="{}" export PATH="$BIN:$PATH" LEAKGUARD_DENY="$W/deny" +export GIT_CONFIG_GLOBAL="$W/gitconfig" GIT_CONFIG_NOSYSTEM=1 +chk "stub gh is the gh on PATH" '[ "$(command -v gh)" = "$BIN/gh" ]' U="${USER:-someone}"; H="$(hostname -s 2>/dev/null || echo myhost)" HOMEP="/Users/$U/work/Zorbix/mdlsource/01.mdl" cat > "$P/docs/bugs.jsonl" < redacted body (or REFUSED) +bodyof() { + local P2="$W/p2"; rm -rf "$P2"; mkdir -p "$P2/docs" "$P2/mdlsource" "$P2/config" + cp "$P/Zorbix.mpr" "$P2/"; cp "$P/mdlsource/"* "$P2/mdlsource/"; cp "$P/config/"* "$P2/config/" + [ -n "${P2DENY:-}" ] && printf '%s\n' "$P2DENY" > "$P2/.leakguard-deny" + FB_TEXT="$1" FB_TITLE="${2:-t}" FB_VERS="${3:-$EMPTYJ}" "$PY" -I - "$P2/docs/bugs.jsonl" <<'PY' +import json, os, sys +open(sys.argv[1], "w").write(json.dumps({"id": "PBUG-01", "target": "mxcli", "title": os.environ["FB_TITLE"], + "repro": os.environ["FB_TEXT"], "expected": "e", "actual": "a", "versions": json.loads(os.environ["FB_VERS"]), + "status": "open"}) + "\n") +PY + "$SUBJ" --project "$P2" --json "$W/p2.err" | "$PY" -I -c 'import json,sys; x=json.load(sys.stdin)[0]; print("REFUSED" if x["refused"] else x["body"])' +} +scanof() { # scanof 'text' -> reasons the shared scan gives (empty = clean) + "$PY" -I - "$TOOLKIT/bin/lib/file-bugs.py" "$P" "$1" <<'PY' +import importlib.util, sys +s = importlib.util.spec_from_file_location("fb", sys.argv[1]); m = importlib.util.module_from_spec(s); s.loader.exec_module(m) +print(",".join(m.Redactor(sys.argv[2], []).scan(sys.argv[3]))) +PY +} echo "A. dry run" : > "$GHLOG" @@ -58,11 +133,11 @@ OUT="$(run &1)" chk "dry run never calls issue create" '! grep -q "issue create" "$GHLOG"' chk "dry run shows the exact title" 'echo "$OUT" | grep -q "TITLE: Crash on Module1.EntityA in App1"' chk "dry run shows a likely duplicate" 'echo "$OUT" | grep -q "#77"' -chk "closed and wrong-target rows are not offered" '! echo "$OUT" | grep -q "^BUG-05" && echo "$OUT" | grep -q "BUG-04.*skipped"' +chk "closed and wrong-target rows are not offered" '! echo "$OUT" | grep -q "^PBUG-05" && echo "$OUT" | grep -q "PBUG-04.*skipped"' chk "duplicate search used gh issue list --search" 'grep -q "issue list --repo mendixlabs/mxcli.*--search" "$GHLOG"' -echo "B. redaction classes (BUG-01 body)" -B1="$(run --json "$GHLOG" -OUT="$(printf 'n\nn\nn\n' | run --all 2>&1)" -chk "BUG-02 warned and refused" 'echo "$OUT" | grep -q "WARNING: redaction left suspicious content.*opaque"' -chk "no create call carries BUG-02 text" '! grep "issue create" "$GHLOG" | grep -q "Opaque"' -chk "--links gives no URL for BUG-02" 'run --links "$GHLOG"; cp "$P/docs/bugs.jsonl" "$W/before" -printf 'n\n' | run --file BUG-03 >/dev/null 2>&1 +: > "$GHLOG"; chmod 640 "$P/docs/bugs.jsonl"; cp "$P/docs/bugs.jsonl" "$W/before" +ANS=n prun --file PBUG-03 >/dev/null 2>&1 chk "answer n posts nothing" '! grep -q "issue create" "$GHLOG"' chk "answer n leaves the log unchanged" 'cmp -s "$P/docs/bugs.jsonl" "$W/before"' -printf 'y\n' | run --file BUG-01 >/dev/null 2>&1 -chk "answer y calls issue create once, on the target repo" '[ "$(grep -c "issue create" "$GHLOG")" = 1 ] && grep -q "issue create --repo mendixlabs/mxcli --title Crash on Module1.EntityA in App1 --body" "$GHLOG"' -chk "created body is the redacted one" '! grep "issue create" "$GHLOG" | grep -q "Zorbix\|ghp_\|/Users/"' -chk "status and url written back" 'grep "\"BUG-01\"" "$P/docs/bugs.jsonl" | grep -q "\"status\": \"filed\".*issues/4242"' -chk "other rows byte-identical" 'diff <(grep -v BUG-01 "$W/before") <(grep -v BUG-01 "$P/docs/bugs.jsonl") >/dev/null' -chk "a filed bug is not offered again" '! run /dev/null 2>&1 +chk "answer y calls issue create once with --repo= --title= --body-file=" '[ "$(grep -c "issue create" "$GHLOG")" = 1 ] && grep -q "issue create --repo=mendixlabs/mxcli --title=Crash on Module1.EntityA in App1 --body-file=" "$GHLOG"' +chk "created body is the redacted one" '[ -s "$GHLOG.body" ] && ! grep -q "Zorbix\|ghp_\|/Users/" "$GHLOG.body" && grep -q "Module1.EntityA" "$GHLOG.body"' +chk "status and url written back" 'grep "\"PBUG-01\"" "$P/docs/bugs.jsonl" | grep -q "\"status\": \"filed\".*issues/4242"' +chk "write-back keeps the file mode (640)" '[ "$("$PY" -c "import os,sys;print(oct(os.stat(sys.argv[1]).st_mode&0o777))" "$P/docs/bugs.jsonl")" = 0o640 ]' +chk "other rows byte-identical" 'diff <(grep -v PBUG-01 "$W/before") <(grep -v PBUG-01 "$P/docs/bugs.jsonl") >/dev/null' +chk "a filed bug is not offered again" '! run "$GHLOG" -L="$(run --links "$GHLOG" -OUT="$("$SUBJ" --project "$P" --file BUG-03 &1)" -chk "no terminal and no hook: refused" 'echo "$OUT" | grep -q "interactive terminal" && ! grep -q "issue create" "$GHLOG"' +OUT="$(printf 'y\ny\n' | MXTK_FILE_BUGS_STDIN=1 nrun --file PBUG-03 2>&1)" +chk "no terminal: refused even with stdin y and the old env hook set" 'echo "$OUT" | grep -q "interactive terminal" && ! grep -q "issue create" "$GHLOG"' +chk "the stdin test hook is gone from the engine" '! grep -q "MXTK_FILE_BUGS_STDIN" "$TOOLKIT/bin/lib/file-bugs.py"' run --add --target toolkit --title "Added" --repro "r" --expected "e" --actual "a" --mxcli v1 >/dev/null -chk "--add numbers the next free BUG-NN" 'tail -1 "$P/docs/bugs.jsonl" | grep -q "\"id\": \"BUG-07\""' +chk "--add numbers the next free PBUG-NN" 'tail -1 "$P/docs/bugs.jsonl" | grep -q "\"id\": \"PBUG-09\""' +P3="$W/p3"; mkdir -p "$P3/docs"; printf '{"id":"BUG-77","status":"open"}\n' > "$P3/docs/bugs.jsonl" +"$SUBJ" --project "$P3" --add --target mxcli --title t --repro r --expected e --actual a >/dev/null +chk "a toolkit-style BUG-77 never collides: first local id is PBUG-01" 'tail -1 "$P3/docs/bugs.jsonl" | grep -q "\"id\": \"PBUG-01\""' + +echo "G. names inside identifiers, attributes, pages, Unicode, line breaks" +IDS='ACT_Quuxmod_Save QuuxmodOverview Zorbix_v2 ZorbixTest ZORBIXTEST Widgetron_NewEdit quuxmod_x' +G="$(bodyof "ids: $IDS")" +chk "names inside identifiers are redacted (underscore, camelCase, digit, case)" '! echo "$G" | grep -qi "quuxmod\|zorbix\|widgetron"' +G="$(bodyof "field Kundennummer, KundennummerCheck, page CustomerOverview_Page, CustomerOverview_Page2, flow ACT_Save")" +chk "attribute, page and microflow names redacted" '! echo "$G" | grep -qi "kundennummer\|CustomerOverview\|ACT_Save"' +G="$(bodyof "$("$PY" -c 'print("a Quuxm\u043ed b \uff3a\uff55 c Quux\u200bmod d \uff31\uff55\uff55\uff58\uff4d\uff4f\uff44")')")" +chk "Cyrillic lookalike, zero-width split and fullwidth Quuxmod redacted" '! echo "$G" | grep -qi "quuxm"' +G="$(bodyof "$(printf 'split Quux\nmod and Zor-\nbix and Widget\n ron here')")" +chk "names split across a line break redacted" '! echo "$G" | tr -d "\n -" | grep -qi "quuxmod\|zorbix\|widgetron"' +chk "scan uses the same matcher: identifier, lookalike and split names are flagged" '[ -n "$(scanof ACT_Quuxmod_Save)" ] && [ -n "$(scanof "$("$PY" -c "print(\"Quuxm\u043ed\")")")" ] && [ -n "$(scanof "$(printf "Zor\nbix")")" ] && [ -n "$(scanof Kundennummer)" ] && [ -z "$(scanof "plain words only")" ]' + +echo "H. secrets, paths, IPv6, hosts" +G="$(bodyof '"password": "hunter2" and --password hunter3 and apikey=abc123def and {"api_key":"zzz999"} and secret: s3cr3t')" +chk "JSON, flag and key=value secrets redacted" '! echo "$G" | grep -q "hunter2\|hunter3\|abc123def\|zzz999\|s3cr3t"' +G="$(bodyof 'old gh token 0123456789abcdef0123456789abcdef01234567 and key 0123456789abcdef0123456789abcdef and ASIAABCDEFGHIJKLMNOP and AKIAABCDEFGHIJKLMNOP')" +chk "40/32 hex tokens and ASIA/AKIA keys redacted" '! echo "$G" | grep -q "0123456789abcdef0123\|ASIAABCD\|AKIAABCD"' +G="$(bodyof 'fixed in commit 0123456789abcdef0123456789abcdef01234567 upstream')" +chk "a 40-hex labelled commit is kept" 'echo "$G" | grep -q "commit 0123456789abcdef0123456789abcdef01234567"' +MACP="/Us""ers/bob jones/My Documents/z.mpr" +G="$(bodyof "open c:\users\alice\x.mpr and C:\\Users\\Alice Smith\\Desktop\\a.mpr and $MACP")" +chk "lowercase and spaced Windows/Mac paths redacted" '! echo "$G" | grep -qi "alice\|smith\|desktop\|bob\|jones\|documents"' +G="$(bodyof 'link fe80::1 and ::1 and 2001:db8::8a2e:370:7334 here')" +chk "short and compressed IPv6 redacted" '! echo "$G" | grep -q "fe80\|::1\|2001:db8\|8a2e"' +G="$(bodyof 'hosts api.zonkco.sg, zonkco.corpnet, db.zonkco.com.au, x.y.example.museum, ZONKCO.COM and build-srv-01 and build-srv-01.corp.zonkco')" +chk "any dotted hostname (all TLDs) and config-file server names redacted" '! echo "$G" | grep -qi "zonkco\|corpnet\|museum\|build-srv"' +G="$(bodyof 'see README.md, bugs.jsonl, docs/bugs.jsonl, mxcli.exe and Quuxmod.Widgetron')" +chk "file names are not over-redacted as hosts" 'echo "$G" | grep -q "README.md" && echo "$G" | grep -q "bugs.jsonl" && ! echo "$G" | grep -q ""' +chk "scan flags each secret form too" '[ -n "$(scanof "\"password\": \"hunter2\"")" ] && [ -n "$(scanof "--password hunter3")" ] && [ -n "$(scanof 0123456789abcdef0123456789abcdef)" ] && [ -n "$(scanof ASIAABCDEFGHIJKLMNOP)" ] && [ -n "$(scanof fe80::1)" ] && [ -n "$(scanof api.zonkco.sg)" ] && [ -n "$(scanof build-srv-01)" ]' + +echo "I. deny list" +G="$(P2DENY= LEAKGUARD_DENY='Frumbo|Zipple' bodyof 'Frumbo and Zipple and Glorpley')" +chk "\$LEAKGUARD_DENY as a regex value (Foo|Bar) is applied" '! echo "$G" | grep -qi "frumbo\|zipple"' +G="$(P2DENY='Snarfle\b' LEAKGUARD_DENY='Frumbo' bodyof 'Snarfle and Frumbo')" +chk "env deny and the project .leakguard-deny are both used" '! echo "$G" | grep -qi "snarfle\|frumbo"' +G="$(P2DENY='Snarfle\b' bodyof 'Snarfle and Glorpley')" +chk "env as a file path and the project file are both used" '! echo "$G" | grep -qi "snarfle\|glorpley"' +G="$(P2DENY='Foo(bar' bodyof 'saw Foo(bar here')" +chk "an invalid regex line is matched literally and warned about" '! echo "$G" | grep -q "Foo(bar" && grep -q "invalid regex" "$W/p2.err"' +G="$(P2DENY= LEAKGUARD_DENY= bodyof 'no list at all')" +chk "no deny list: warning, not failure" 'echo "$G" | grep -q "no list at all" && grep -q "no deny list" "$W/p2.err"' + +echo "J. versions line and exact URLs" +G="$(bodyof 'Mendix 11.15.0.100 and Studio Pro 10.18.2.55 and mxcli 0.27.0.1 and version 1.2.3.4 but host 10.20.30.40' t '{"mendix":"11.15.0.100","mxcli":"v0.27.0","toolkit":"v2026.10.16"}')" +chk "versions line keeps a dotted quad" 'echo "$G" | grep -q "^\*\*Versions:\*\* mendix 11.15.0.100, mxcli v0.27.0, toolkit v2026.10.16"' +chk "dotted quads after Mendix, Studio Pro, mxcli, version are kept; a bare IP is not" 'echo "$G" | grep -q "Mendix 11.15.0.100" && echo "$G" | grep -q "Studio Pro 10.18.2.55" && echo "$G" | grep -q "mxcli 0.27.0.1" && echo "$G" | grep -q "version 1.2.3.4" && ! echo "$G" | grep -q "10.20.30.40"' +G="$(bodyof x t '{"mendix":"alice@example.com 11.15.0","mxcli":"secret hunter9"}')" +chk "non-version text in versions never reaches the issue" '! echo "$G" | grep -qi "alice\|zonkco\|hunter9" && echo "$G" | grep -q "mendix 11.15.0"' +G="$(bodyof 'a https://github.com/MendixMau/mxcli-project-toolkit-private/issues/1 b https://github.com/MendixMau/mxcli-project-toolkit/issues/1 c https://github.com/mendixlabs/other/issues/2 d github.com/mendixlabs/mxcli-private')" +chk "allowed URLs are exact repos (no -private prefix match)" '! echo "$G" | grep -q "toolkit-private\|mendixlabs/other\|mxcli-private" && echo "$G" | grep -q "MendixMau/mxcli-project-toolkit/issues/1"' +chk "git user MendixMau does not make toolkit URLs refuse" '[ "$(bodyof "see https://github.com/MendixMau/mxcli-project-toolkit/issues/1")" != REFUSED ]' +: > "$GHLOG" +"$PY" - "$P/docs/bugs.jsonl" <<'PY' +import json,sys +with open(sys.argv[1],"a") as f: + f.write(json.dumps({"id":"PBUG-10","date":"2026-10-16","target":"mxcli","title":"--repo=evil/x crash","repro":"r","expected":"e","actual":"a","versions":{},"workaround":"","status":"open","issue_url":""})+"\n") +PY +ANS=y prun --file PBUG-10 >/dev/null 2>&1 +chk "a title that looks like a flag is passed as --title=..., repo stays the target" 'grep -q "issue create --repo=mendixlabs/mxcli --title=--repo=evil/x crash --body-file=" "$GHLOG" && ! grep -q "evil/x --" "$GHLOG"' + +echo "Z. no real issue was ever created" +chk "every gh call went through the stub (log exists, no network tool used)" '[ -s "$GHLOG" ]' echo; echo "file-bugs: $PASS pass, $FAIL fail"; [ "$FAIL" -eq 0 ] From 8bfdaec78ffb1ec65cad16b87e172f03d14b190f Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 20:00:39 +0800 Subject: [PATCH 3/3] fix(bug-log): word-only matching for ordinary names, header and repo-URL leaks, deny list required, posting denied to agents Weak names (short, English or mxcli/MDL words) match whole words only; mxcli verbs and toolkit file names survive; jdbc/postgres URLs keep the scheme. Authorization/cookie header values redacted, allowed repo URLs cut to the repo prefix plus /issues/N or /pull/N. No deny list refuses posting, --links and --json; an invalid regex keeps its valid alternatives. Shipped permissions deny file-bugs.sh --file and --all. first.last users, attribute followed by digits. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- ROUTING.md | 2 +- agents/gate-agent.md | 2 +- agents/mdl-agent.md | 2 +- bin/file-bugs.sh | 9 +- bin/install-claude-permissions.sh | 2 + bin/lib/file-bugs.py | 189 ++++++++++++++---- bin/lib/skill-routing.tsv | 2 +- skills/learned-detection-gaps.md | 2 +- tests/wave2/test-file-bugs.sh | 63 +++++- .../wave2/test-install-claude-permissions.sh | 2 + 12 files changed, 231 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 437774d1..84b66119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased -- new(bug-log): **`docs/bugs.jsonl` per project plus `bin/file-bugs.sh` to file it safely to mendixlabs/mxcli or this repo.** A session that hits or works around a tool bug appends a row (`--add`, next free `PBUG-NN`, project-local so it never collides with the toolkit's BUG-n; the public number is the issue; the one-line instruction sits in learned-detection-gaps.md). The script normalises Unicode (NFKC, zero-width and lookalike letters, names split by a line break), redacts every title and body (home and spaced paths, user and host names, emails, IPv4/IPv6, tokens, key=value, JSON and `--flag value` secrets, hex tokens, AWS keys, any dotted hostname, server names from config files, URLs other than the two repos matched exactly, the project's .mpr, module, entity, attribute, page and microflow names matched inside identifiers, `$LEAKGUARD_DENY` as a regex plus the project's .leakguard-deny), builds the versions line from version-shaped strings only, scans again with the same matcher and refuses a bug that still looks unsafe: a refused bug gets no duplicate search, no URL and no text in `--links`/`--json`. Default is a dry run with likely duplicates; posting needs `--file PBUG-NN` or `--all` and a y/N per bug read from /dev/tty only (no stdin test hook), passes `--repo= --title= --body-file=` to gh, keeps the log's file mode; `--links` caps title and body under the URL limit. `tests/wave2/test-file-bugs.sh` covers each case with a stubbed gh and a pty helper; fixes the Opus review of #433 — MendixMau +- new(bug-log): **`docs/bugs.jsonl` per project plus `bin/file-bugs.sh` to file it safely to mendixlabs/mxcli or this repo.** A session that hits or works around a tool bug appends a row (`--add`, next free `PBUG-NN`, project-local so it never collides with the toolkit's BUG-n; the public number is the issue; the one-line instruction sits in learned-detection-gaps.md). The script normalises Unicode (NFKC, zero-width and lookalike letters, names split by a line break), redacts every title and body (home and spaced paths, user and host names, emails, IPv4/IPv6, tokens, key=value, JSON and `--flag value` secrets, hex tokens, AWS keys, any dotted hostname, server names from config files, URLs other than the two repos matched exactly and cut to the repo prefix plus /issues/N or /pull/N, non-web URLs keep their scheme and lose the host, the project's .mpr, module, entity, attribute, page and microflow names (distinctive ones inside identifiers; ordinary-word and short names only as whole words or segments, never inside mxcli verbs, toolkit file names or other words), Authorization and cookie header values, `$LEAKGUARD_DENY` as a regex plus the project's .leakguard-deny), builds the versions line from version-shaped strings only, scans again with the same matcher and refuses a bug that still looks unsafe: a refused bug gets no duplicate search, no URL and no text in `--links`/`--json`. Default is a dry run with likely duplicates; posting needs `--file PBUG-NN` or `--all` and a y/N per bug read from /dev/tty only (no stdin test hook; a pty cannot be told from a human, so the shipped permissions deny `file-bugs.sh --file` and `--all` and a human runs posting with `!`), and a deny list must exist or posting, `--links` and `--json` are refused, passes `--repo= --title= --body-file=` to gh, keeps the log's file mode; `--links` caps title and body under the URL limit. `tests/wave2/test-file-bugs.sh` covers each case with a stubbed gh and a pty helper — MendixMau - fix(sync): **the exec-approval item-3 rewrite now works on BSD awk (stock macOS) and `sync-project.sh` no longer prints "Rewrote" for a rewrite that failed.** `_ea_rewrite_item3` passed the multi-line replacement through `awk -v`, which BSD awk rejects ("newline in string", exit 2); the file stayed unchanged and sync still reported the rewrite on every run. The replacement is now streamed from a temp file (as the Q9 repair does), the helper returns 2 when awk fails, and the caller prints a `WARN:` instead of "Rewrote" and does not count a change. `test-exec-approval.sh` gains 3 assertions (failing-awk shim: no "Rewrote", a WARN, file unchanged). No other multi-line `awk -v` call site exists (the rest are single-line values) ([#426](https://github.com/MendixMau/mxcli-project-toolkit/issues/426)) — MendixMau - process(testing): **unit tests are now off by default and `affected-tests.sh` enforces it; each project picks its test strategy with the developer at CAC-5.** Fast-tracked by the maintainer on 2026-10-16 because users report tests firing on every change (the baseline-text A/B is waived; E3 follows as a check). Moments: every change = gate plus what it touched (unit only for affected modules with unit on); module done = that module's journeys (+ its unit tests if on); build complete and before every later deploy = everything once; "run everything" any time. The CAC-5 Offer records it in PROJECT.md `## Test strategy` (Status `ASSUMED` in auto mode). `affected-tests.sh` reads `Unit tests default:` (`no` or absent means unit off, one `unit off` line, never a `.test.mdl` run row); turn unit on per project with `Unit tests default: yes`, per module with `Unit modules: Shop, Admin` in PROJECT.md or `Unit tests: yes` in the module brief; the strategy row's Unit cell is a scope only and never turns unit on. A grant on a microflow or page the same script changes is part of that subject and no longer selects the owning module's tests. `module-review.md` PROVE now runs `--moment module --module `. `testing-shape.md`, `checkpoint-build.md`, `iterative-build-loop.md`, `conversion-runbook.md` and the exec.sh logic lane text updated. `tests/wave2/test-test-strategy-docs.sh` and the extended `test-affected-tests.sh` pin it. Baseline words stay at or below master (75,219 vs 75,225). Split out of #409; found by the field test of the release candidate — the maintainer's decision, 2026-10-16 - learn(skills): **five lab findings land as compact rows, each paid for by trimming prose in the same files, so always-loaded words do not grow (baseline 75,225 to 75,204, budget 80,000).** Enum value `New` and `AutoCreatedDate`/`AutoOwner` (detection gaps), `[%CurrentDateTime%]` not `currentDateTime()`, expression return types (Decimal from date diffs and `div`, absolute `secondsBetween`, `formatDecimal` on pages), CE2729 from the default home page and page microflow grants, Atlas selector carriers for list views, page body and timelines, `-small` spacing suffixes do not exist, `display: contents` rows paint nothing, CSS pseudo text is invisible to `innerText` — lab field runs, Refs #276 (TK-020, TK-021, TK-022, TK-024) diff --git a/README.md b/README.md index 23af2e80..560484d3 100644 --- a/README.md +++ b/README.md @@ -814,7 +814,7 @@ Then filter by Role(s). A helper dispatched with a role stub (ba, architect, mdl | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | `skills/learned-detection-gaps.md` | 5,6 | mdl, gate, review | | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | `skills/security-is-not-a-later-script.md` | 5 | mdl, gate, review | | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | `bin/bug-lookup.sh` | 5,6 | mdl, gate | -| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | 5,6 | mdl, gate | +| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, is filed only by a human with `!` (`--file PBUG-NN`, y/N, denied to agents), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | 5,6 | mdl, gate | | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | `bin/status.sh` | P,0,1,2,3,4,5,6,7 | every role | | Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first | `skills/teamserver-alignment.md` | 5,6,7 | gate (mdl, review: on demand) | diff --git a/ROUTING.md b/ROUTING.md index 8bfcac9d..2d92e651 100644 --- a/ROUTING.md +++ b/ROUTING.md @@ -264,7 +264,7 @@ picks the row up. That is the whole procedure — there is no second list to rem | A runtime test or seed reads/writes data that then is not there (the test wrote a row, the DB query shows nothing), or vice versa — three local Postgres instances can answer on this box; resolve the real port from the project's own compose file first | `skills/learned-local-db-confusion.md` | mdl,test,gate | 5,6 | ondemand | | gate-check.sh reports a Stage 0 file not found that plainly exists — ANALYSIS_BASE falls back to project root until Stage 1; move the file, do not debug the script | `skills/gate-check-file-locations.md` | ba,gate | 0 | ondemand | | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | `bin/bug-lookup.sh` | mdl,gate | 5,6 | baseline | -| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | mdl,gate | 5,6 | baseline | +| You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, is filed only by a human with `!` (`--file PBUG-NN`, y/N, denied to agents), or prints new-issue links (`--links`, `--json`) | `bin/file-bugs.sh` | mdl,gate | 5,6 | baseline | #### Reference — lookup tables, not method diff --git a/agents/gate-agent.md b/agents/gate-agent.md index 5b41d30b..0c1218bb 100644 --- a/agents/gate-agent.md +++ b/agents/gate-agent.md @@ -45,7 +45,7 @@ This table is your whole list. The baseline table in the project's CLAUDE.local. | `skills/learned-detection-gaps.md` | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | | `skills/security-is-not-a-later-script.md` | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | | `bin/bug-lookup.sh` | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | -| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) | +| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, is filed only by a human with `!` (`--file PBUG-NN`, y/N, denied to agents), or prints new-issue links (`--links`, `--json`) | | `bin/status.sh` | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | | `skills/teamserver-alignment.md` | Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first | | `skills/retesting-learned-rules.md` | Before obeying any learned-* STOP or workaround that costs a detour — probe the binary you actually have, then stamp the verdict back into the rule | diff --git a/agents/mdl-agent.md b/agents/mdl-agent.md index c58440bb..1e190fbe 100644 --- a/agents/mdl-agent.md +++ b/agents/mdl-agent.md @@ -55,7 +55,7 @@ This table is your whole list. The baseline table in the project's CLAUDE.local. | `skills/learned-detection-gaps.md` | Before trusting a green check/exec/DESCRIBE result as proof, or when a runtime symptom appears over a fully green model — the register of constructs that pass early rungs and fail later ones | | `skills/security-is-not-a-later-script.md` | Creating any entity, or calling a module security-ready — entity and grants land in one script, and ready means SHOW SECURITY MATRIX proves it | | `bin/bug-lookup.sh` | A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger | -| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) | +| `bin/file-bugs.sh` | You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, is filed only by a human with `!` (`--file PBUG-NN`, y/N, denied to agents), or prints new-issue links (`--links`, `--json`) | | `bin/status.sh` | The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory | | `skills/retesting-learned-rules.md` | Before obeying any learned-* STOP or workaround that costs a detour — probe the binary you actually have, then stamp the verdict back into the rule | | `skills/skills-over-scripts.md` | Script or skill? Before writing any .js or bash/.sh for a check, checker, linter, gate or report, or adding a rule or regex to an existing one | diff --git a/bin/file-bugs.sh b/bin/file-bugs.sh index 15d3312d..1acee6c5 100755 --- a/bin/file-bugs.sh +++ b/bin/file-bugs.sh @@ -20,7 +20,14 @@ # check-no-client-data.sh, plus /.leakguard-deny). The versions line is built from # version-shaped strings only. The same matcher then scans the result. A bug that still looks # unsafe is shown by the dry run only: no duplicate search, no URL, no --links/--json text, no post. -# Posting needs --file/--all AND y/N read from /dev/tty (never stdin). The id is project-local +# A deny list is required: without /.leakguard-deny or $LEAKGUARD_DENY the dry run shows the +# text with a note, and posting, --links and --json are refused. An invalid regex is split on | and its +# valid alternatives kept (the rest warned about). Only the repo prefix of an allowed repo URL (plus +# /issues/N or /pull/N) survives; Authorization/cookie style header values are redacted. +# Posting needs --file/--all AND y/N read from /dev/tty (never stdin). NO SCRIPT CAN TELL A HUMAN FROM +# AN AGENT DRIVING A PTY (a `script` wrapper answers y), so the shipped Claude Code permissions deny +# `file-bugs.sh --file` and `--all` (bin/install-claude-permissions.sh): a human runs posting with +# `!bin/file-bugs.sh --file PBUG-NN`. An agent uses the dry run, --links or --json and hands over. The id is project-local # (PBUG-NN, not a toolkit BUG-n); the public number is the issue. Success writes status=filed # and issue_url back, keeping the file mode. # Row fields: id date target title repro expected actual versions{mxcli,mendix,toolkit} diff --git a/bin/install-claude-permissions.sh b/bin/install-claude-permissions.sh index e2e543a7..10fe04f5 100755 --- a/bin/install-claude-permissions.sh +++ b/bin/install-claude-permissions.sh @@ -184,6 +184,8 @@ DENY=( "Bash(./mxcli exec:*)" "Bash(mxcli exec:*)" "Bash(./mxcli.exe exec:*)" + "Bash(*file-bugs.sh*--file*)" + "Bash(*file-bugs.sh*--all*)" ) ASK=() diff --git a/bin/lib/file-bugs.py b/bin/lib/file-bugs.py index 793436d5..06d120e1 100644 --- a/bin/lib/file-bugs.py +++ b/bin/lib/file-bugs.py @@ -21,6 +21,18 @@ FILE_EXT = set("mpr mdl md json jsonl sh py js ts tsx jsx css scss html htm xml yaml yml txt log sql zip mpk jar java " "exe dll png jpg jpeg gif svg pdf csv toml ini cfg conf lock tsv properties tmp bak swp map gz tar " "mjs cjs rb go rs c h cpp class so dylib bat ps1 cmd env pem key crt".split()) +# Names that are ordinary words, short, or mxcli/MDL/Mendix words: matched as whole words or identifier +# segments only, never inside other words. KW_NAMES are verbs/keywords: matched only in their exact +# spelling, so `mxcli create` or `check` in prose survive an entity called Create or Check. +KW_NAMES = set("""create check describe build show list edit view new test run init add drop alter select insert update delete +exec execute lint format validate search install uninstall marketplace catalog open save close start stop import export diff +apply rename move set get use help version doctor sync serve tunnel domain page microflow nanoflow entity module enumeration +association attribute workflow snippet layout widget security project report input output index status error warning""".split()) +COMMON_NAMES = set("""order orders home admin account accounts user users customer customers product products item items sales +shop store invoice invoices payment payments person people employee employees task tasks ticket tickets message messages +document documents request requests setting settings person address data file files group groups role roles event events +booking bookings contact contacts company department project projects client clients supplier vendor article articles +category categories price offer offers cart basket delivery shipment""".split()) CONF_SKIP = {"package.json", "package-lock.json", "bugs.jsonl", "tsconfig.json"} _CONF = {"\u0430": "a", "\u0435": "e", "\u043e": "o", "\u0440": "p", "\u0441": "c", "\u0445": "x", "\u0443": "y", @@ -39,6 +51,8 @@ r"\b(?:gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|sk-[A-Za-z0-9_-]{16,}|(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA)[0-9A-Z]{16}" r"|xox[baprs]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b" r"|\bBearer\s+[A-Za-z0-9._~+/=-]{8,}") +RE_AUTH = re.compile(r"(?i)\b((?:proxy-)?authorization)(\s*[:=]\s*)[\"']?(?:basic|bearer|digest|token|negotiate|ntlm|aws4-hmac-sha256|apikey)\s+[^\s\"',;]+") +RE_HDR = re.compile(r"(?im)^([ \t]*(?:set-cookie|cookie|www-authenticate|x-[\w-]*(?:auth|token|key|secret)[\w-]*)[ \t]*:(?![ \t]*[ \t]*$)[ \t]*)(.+)$") RE_KV = re.compile(r"(?i)([\"']?)\b((?:[a-z0-9_.-]*" + KEYW + r"[a-z0-9_.-]*|authorization))\1(\s*[:=]\s*)(\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)") RE_FLAG = re.compile(r"(?i)(--?[a-z0-9_-]*" + KEYW + r"[a-z0-9_-]*)([ \t]+|=)(\"[^\"]*\"|'[^']*'|[^\s,;]+)") RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+") @@ -77,30 +91,97 @@ def join_lb(t): return re.sub(r"-?[ \t]*\r?\n[ \t]*", "", t) +_ALLOWED = re.compile(r"(?i)^((?:https?://)?github\.com/(?:" + "|".join(re.escape(r) for r in ALLOWED_REPOS) + + r"))((?:/(?:issues|pull)/\d+)?)(?![\w.-])(.*)$", re.S) + + +def split_allowed(s): + """(kept, rest) for a URL under one of the two target repos, else None. Kept is the repo prefix + plus /issues/N or /pull/N; rest (any other path, query, fragment) is never kept.""" + m = _ALLOWED.match(s) + return (m.group(1) + m.group(2), m.group(3)) if m else None + + def url_ok(s): - """Exactly one of the two target repos (case-insensitive), then end, / ? or #.""" - s = re.sub(r"^[a-z][a-z0-9+.-]*://", "", s, flags=re.I).lower() - for repo in ALLOWED_REPOS: - pre = "github.com/" + repo - if s.startswith(pre) and (len(s) == len(pre) or s[len(pre)] in "/?#"): - return True - return False + """Exactly the repo prefix, optionally /issues/N or /pull/N; nothing after it.""" + a = split_allowed(s) + return bool(a) and a[1] == "" + + +def url_clean(s): + """What a URL becomes: the allowed prefix, a non-web scheme with its host redacted, or .""" + a = split_allowed(s) + if a: + return a[0] + m = re.match(r"(?i)^([a-z][a-z0-9+.-]*)://", s) + if m and m.group(1).lower() not in ("http", "https", "ftp"): + return m.group(1) + "://" + return "" + + +def url_safe(s): + return url_ok(s) or bool(re.match(r"(?i)^[a-z][a-z0-9+.-]*://$", s)) or s.endswith("://= 6: + names.add(stem) + except OSError: + pass + return names + + +_TK = _toolkit_names() +RE_TKFILE = re.compile(r"(?= 3: + pats.append(re.compile(re.escape(alt), re.I)) + self.warnings.append("deny list %s: invalid regex %s, kept the valid alternatives%s" % + (src, ", ".join(repr(b[:40]) for b in bad) or repr(pat[:40]), + "; invalid parts of 3+ chars are matched literally" if any(len(b) >= 3 for b in bad) else "")) env = os.environ.get("LEAKGUARD_DENY", "") files = [os.path.join(self.project, ".leakguard-deny")] @@ -168,9 +263,6 @@ def add(pat, src): ln = ln.strip() if ln and not ln.startswith("#"): add(norm(ln), f) - if not pats: - self.warnings.append("no deny list ($LEAKGUARD_DENY unset, no /.leakguard-deny): " - "client names outside the project files cannot be caught") return pats def _generic(self, w): @@ -185,7 +277,9 @@ def _users(self): if v: out.add(v) gn = sh(["git", "config", "user.name"]).stdout.strip() - out.update(w for w in re.split(r"[\s.]+", gn) if w) + out.update(w for w in re.split(r"[\s._-]+", gn) if w) + for u in list(out): + out.update(w for w in re.split(r"[._-]+", u) if w) # first.last: the whole and each part return {u for u in out if len(u) >= 3 and u.lower() not in STOP_NAMES} def _hosts(self): @@ -218,8 +312,10 @@ def _config_hosts(self): continue vals = kv.findall(txt) + re.findall(r"://(?:[^/\s@\"']*@)?([^/\s:\"'?#]+)", txt) for v in vals: - v = re.sub(r"^[a-z][a-z0-9+.-]*://", "", v, flags=re.I) + v = re.sub(r"^(?:[a-z][a-z0-9+.-]*:)+//", "", v, flags=re.I) v = re.sub(r"^[^/@]*@", "", v).split("/")[0].split(":")[0].strip("[]") + if re.match(r"(?i)^(?:jdbc|postgres(?:ql)?|mysql|mariadb|sqlserver|oracle|mongodb|redis|amqp|https?|ftp|ldaps?)$", v): + continue if (re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]{2,}$", v) and not re.match(r"^[\d.]+$", v)): out.add(v) if "." in v and len(v.split(".")[0]) >= 4: @@ -279,19 +375,16 @@ def _names(self, extra): def _compile(self): """One matcher list shared by redact() and scan(): (token, regex, kind).""" - subs = [(self.mods, "mod"), (self.ents, "ent"), (self.docs, "doc")] self.matchers = [] if self.app: - self.matchers.append((self.app, "App1", name_pat(self.app, True))) - for table, _k in subs: + self.matchers.append((self.app, "App1", name_pat(self.app))) + for table in (self.mods, self.ents, self.docs, self.attrs): for n, tok in table.items(): - self.matchers.append((n, tok, name_pat(n, True))) - for n, tok in self.attrs.items(): - self.matchers.append((n, tok, name_pat(n, False))) + self.matchers.append((n, tok, name_pat(n))) for u in self.users: - self.matchers.append((u, "", name_pat(u, False))) + self.matchers.append((u, "", name_pat(u))) for h in self.hosts: - self.matchers.append((h, "", name_pat(h, False))) + self.matchers.append((h, "", name_pat(h))) self.matchers.sort(key=lambda x: -len(x[0])) self.mrx = [(re.compile(rx), tok) for _n, tok, rx in self.matchers] @@ -306,17 +399,20 @@ def hold(s): t = norm(text) t = RE_PRIVKEY.sub("", t) t = RE_TOKENS.sub("", t) + t = RE_AUTH.sub(lambda m: m.group(1) + m.group(2) + "", t) + t = RE_HDR.sub(lambda m: m.group(1) + "", t) t = RE_KV.sub(lambda m: m.group(1) + m.group(2) + m.group(1) + m.group(3) + "", t) t = RE_FLAG.sub(lambda m: m.group(1) + m.group(2) + "", t) t = RE_HEXTOK.sub(lambda m: hold(m.group(0)) if hex_ok(t, m) else "", t) t = RE_EMAIL.sub("", t) - t = RE_URL.sub(lambda m: hold(m.group(0)) if url_ok(m.group(0)) else "", t) + t = RE_URL.sub(lambda m: hold(url_clean(m.group(0))) if url_clean(m.group(0)) != "" else "", t) t = RE_PATH.sub("", t) - t = RE_BARE.sub(lambda m: "" if host_hit(m) else (hold(m.group(0)) if url_ok(m.group(0)) else m.group(0)), t) + t = RE_BARE.sub(lambda m: "" if host_hit(m) else (hold(split_allowed(m.group(0))[0]) if split_allowed(m.group(0)) else m.group(0)), t) t = RE_IP6.sub(lambda m: "" if ip6_ok(m.group(0)) else m.group(0), t) t = RE_IP4.sub(lambda m: m.group(0) if RE_VERCTX.search(t[max(0, m.start() - 24):m.start()]) else "", t) for rx in self.deny: t = rx.sub("", t) + t = protect(t, hold) for m, pm in sorted(self.mods.items(), key=lambda x: -len(x[0])): def qual(mm, pm=pm): low = mm.group(1).lower() @@ -326,7 +422,7 @@ def qual(mm, pm=pm): return pm + "." + v self.docs[mm.group(1)] = "Doc%d" % (len(self.docs) + 1) return pm + "." + self.docs[mm.group(1)] - t = re.sub(r"(?": @@ -364,6 +464,7 @@ def scan(self, text): elif re.search(r"\d", s) and re.search(r"[A-Za-z]", s): bad.append("opaque string " + s[:12] + "...") for t in (tm, join_lb(tm)): + t = protect(t, lambda s: "\x01") for rx, tok in self.mrx: if rx.search(t): bad.append("name survives") @@ -526,11 +627,18 @@ def gh_create(repo, title, body): pass +NODENY_MSG = ("no deny list, so client names outside the project files cannot be caught. Add one: one regex per line " + "in /.leakguard-deny (the names this project must never publish), or set LEAKGUARD_DENY='Foo|Bar'.") REFUSED_NOTE = "refused: run the dry run to see why" def main(): - ap = argparse.ArgumentParser(prog="file-bugs.sh", add_help=True) + ap = argparse.ArgumentParser(prog="file-bugs.sh", add_help=True, formatter_class=argparse.RawDescriptionHelpFormatter, + description="Per-project bug log (docs/bugs.jsonl) and a redacted way to file it to mendixlabs/mxcli or the toolkit repo.", + epilog="Posting (--file, --all) is for a human: run it yourself with `!bin/file-bugs.sh --file PBUG-NN`. " + "No script can tell a person from an agent driving a pty, so the shipped Claude Code permissions " + "deny `file-bugs.sh --file` and `--all`. Dry run, --links and --json post nothing; all three, and " + "posting, need a deny list (/.leakguard-deny or $LEAKGUARD_DENY).") ap.add_argument("--project", default=".") ap.add_argument("--file", action="append", default=[], metavar="PBUG-NN") ap.add_argument("--all", action="store_true") @@ -560,13 +668,18 @@ def main(): sys.stderr.write("file-bugs: %s is not open, skipped\n" % w) posting = bool(want or a.all) and not (a.links or a.json) tty = None + r = Redactor(project, a.name) + for w in r.warnings: + sys.stderr.write("file-bugs: warning: %s\n" % w) + nodeny = not r.deny + if nodeny: + if posting or a.links or a.json: + sys.exit("file-bugs: " + NODENY_MSG + " Posting, --links and --json are refused until then; nothing posted.") + print("NOTE: " + NODENY_MSG + " Posting, --links and --json are refused until then.\n") if posting: tty = tty_open() if tty is None: sys.exit("file-bugs: posting needs an interactive terminal (/dev/tty, y/N per bug); nothing posted") - r = Redactor(project, a.name) - for w in r.warnings: - sys.stderr.write("file-bugs: warning: %s\n" % w) out = [] for _, d in todo: target = d.get("target") diff --git a/bin/lib/skill-routing.tsv b/bin/lib/skill-routing.tsv index 7aa17509..ce902102 100644 --- a/bin/lib/skill-routing.tsv +++ b/bin/lib/skill-routing.tsv @@ -302,7 +302,7 @@ preview-over-hub-tunnel skills/preview-over-hub-tunnel.md Exposing a container-r run-hub bin/run-hub.sh Starting a hub-tunnelled preview with the flags outbound calls actually need — wraps mxcli run --hub with db-name and the runtime proxy settings from preview-over-hub-tunnel.md test 5,6 ondemand verify walking-skeleton skills/walking-skeleton.md Stage 5 start, before the first module of any entry mode — one entity, flow, page, nav, demo user, journey and screenshot proven in the running app, so build/run/look/test are known to work before a module depends on them mdl,gate,test 5 ondemand build bug-lookup bin/bug-lookup.sh A CE error (mx check or mxbuild: CE0066, CE0117…), a cited BUG-n, or behavior that looks like a known mxcli quirk rather than a modeling mistake — `bin/bug-lookup.sh CE0117` / `BUG-102` / "keyword" / the whole pasted error message (ranked top 3) prints the matching ledger entries, so the session reads one entry, not the whole ledger mdl,gate 5,6 baseline diagnose -file-bugs bin/file-bugs.sh You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, files after a y/N (`--file PBUG-NN`), or prints new-issue links (`--links`, `--json`) mdl,gate 5,6 baseline diagnose +file-bugs bin/file-bugs.sh You hit, or worked around, a bug in mxcli or the toolkit — `bin/file-bugs.sh` (the `--add` line is in learned-detection-gaps.md) keeps docs/bugs.jsonl, redacts, shows duplicates, is filed only by a human with `!` (`--file PBUG-NN`, y/N, denied to agents), or prints new-issue links (`--links`, `--json`) mdl,gate 5,6 baseline diagnose status bin/status.sh The first command of every session, and any time someone asks "where are we" or "what next" — one screen: stage, done/overdue, the ONE next action, from the instruments, never from memory all P,0,1,2,3,4,5,6,7 baseline spine platform-link skills/platform-link.md At project birth (before the first build script) and any time a model needs a platform home: creating the Team Server app, adopting an existing GitHub-born model into it without rewriting history, or deploying; also when the Platform SDK returns 403, git rejects the PAT, a deploy cannot be triggered from a PAT, or the app turns out to be a Free App all P,5,6,7 ondemand build teamserver-alignment skills/teamserver-alignment.md Any session that will push a model to Mendix Team Server, and BEFORE telling the user a Team Server push is blocked — which remote is authoritative, settle-then-push order, and the four checks that have to fail first mdl?,gate,review? 5,6,7 baseline build diff --git a/skills/learned-detection-gaps.md b/skills/learned-detection-gaps.md index 626f70e1..b32a727b 100644 --- a/skills/learned-detection-gaps.md +++ b/skills/learned-detection-gaps.md @@ -7,7 +7,7 @@ and when a runtime symptom appears over a green model. Every row was found in the field: **a green result only certifies what its rung can see.** -**Hit or worked around a tool bug?** Append a row now: `bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A` (`docs/bugs.jsonl`, local id `PBUG-NN`); the same script later redacts and files it. +**Hit or worked around a tool bug?** Append a row now: `bin/file-bugs.sh --add --target mxcli|toolkit --title T --repro R --expected E --actual A` (`docs/bugs.jsonl`, local id `PBUG-NN`); the same script later redacts it; only a human files it (`!bin/file-bugs.sh --file PBUG-NN`, denied to agents). ## The ladder diff --git a/tests/wave2/test-file-bugs.sh b/tests/wave2/test-file-bugs.sh index 8e0357eb..f8cabb6c 100755 --- a/tests/wave2/test-file-bugs.sh +++ b/tests/wave2/test-file-bugs.sh @@ -19,6 +19,11 @@ # H secrets: key=value, JSON, --flag value, hex, ASIA, paths, IPv6, any-TLD hosts, config hosts # I deny list: regex value, project file, both, invalid line warned, none warned # J versions line and the dotted-quad exemption; exact allowed-URL match +# K ordinary-word names (Order, Check, Home, Create): whole words only, mxcli verbs and toolkit +# file names survive; jdbc:/postgres:// keep the scheme, redact the host +# L Authorization/cookie headers, allowed repo URLs cut to the prefix, no deny list refuses, +# invalid deny regex keeps its valid alternatives, header and --help say who posts, deny rule shipped +# M first.last users, attribute followed by digits or letters set -uo pipefail TOOLKIT="$(cd "$(dirname "$0")/../.." && pwd)" SUBJ="${1:-$TOOLKIT/bin/file-bugs.sh}" @@ -110,6 +115,8 @@ nrun() { "$PY" "$W/ptyrun.py" notty "$SUBJ" --project "$P" "$@"; } bodyof() { local P2="$W/p2"; rm -rf "$P2"; mkdir -p "$P2/docs" "$P2/mdlsource" "$P2/config" cp "$P/Zorbix.mpr" "$P2/"; cp "$P/mdlsource/"* "$P2/mdlsource/"; cp "$P/config/"* "$P2/config/" + [ -n "${P2MDL:-}" ] && printf '%s\n' "$P2MDL" > "$P2/mdlsource/02-extra.mdl" + [ -n "${P2CONF:-}" ] && printf '%s\n' "$P2CONF" > "$P2/config/extra.yaml" [ -n "${P2DENY:-}" ] && printf '%s\n' "$P2DENY" > "$P2/.leakguard-deny" FB_TEXT="$1" FB_TITLE="${2:-t}" FB_VERS="${3:-$EMPTYJ}" "$PY" -I - "$P2/docs/bugs.jsonl" <<'PY' import json, os, sys @@ -117,7 +124,12 @@ open(sys.argv[1], "w").write(json.dumps({"id": "PBUG-01", "target": "mxcli", "ti "repro": os.environ["FB_TEXT"], "expected": "e", "actual": "a", "versions": json.loads(os.environ["FB_VERS"]), "status": "open"}) + "\n") PY - "$SUBJ" --project "$P2" --json "$W/p2.err" | "$PY" -I -c 'import json,sys; x=json.load(sys.stdin)[0]; print("REFUSED" if x["refused"] else x["body"])' + "$SUBJ" --project "$P2" --json "$W/p2.err" | "$PY" -I -c 'import json,sys +try: + x=json.load(sys.stdin)[0] +except ValueError: + print("NOJSON"); sys.exit() +print("REFUSED" if x["refused"] else x["body"])' } scanof() { # scanof 'text' -> reasons the shared scan gives (empty = clean) "$PY" -I - "$TOOLKIT/bin/lib/file-bugs.py" "$P" "$1" <<'PY' @@ -244,7 +256,18 @@ chk "env as a file path and the project file are both used" '! echo "$G" | grep G="$(P2DENY='Foo(bar' bodyof 'saw Foo(bar here')" chk "an invalid regex line is matched literally and warned about" '! echo "$G" | grep -q "Foo(bar" && grep -q "invalid regex" "$W/p2.err"' G="$(P2DENY= LEAKGUARD_DENY= bodyof 'no list at all')" -chk "no deny list: warning, not failure" 'echo "$G" | grep -q "no list at all" && grep -q "no deny list" "$W/p2.err"' +chk "no deny list: --json is refused, nothing leaks" '! echo "$G" | grep -q "no list at all"' +NOD="$W/nod"; rm -rf "$NOD"; mkdir -p "$NOD/docs" +printf '%s\n' '{"id":"PBUG-01","target":"mxcli","title":"Plain","repro":"r","expected":"e","actual":"a","versions":{},"status":"open"}' > "$NOD/docs/bugs.jsonl" +: > "$GHLOG" +OUT="$(LEAKGUARD_DENY= "$SUBJ" --project "$NOD" &1)" +chk "no deny list: dry run still shows the bug and says how to add a list" 'echo "$OUT" | grep -q "TITLE: Plain" && echo "$OUT" | grep -q "leakguard-deny" && echo "$OUT" | grep -q "refused until then"' +OUT="$(LEAKGUARD_DENY= "$SUBJ" --project "$NOD" --links &1)"; RC=$? +chk "no deny list: --links refused" '[ $RC -ne 0 ] && echo "$OUT" | grep -q "no deny list" && ! echo "$OUT" | grep -q "issues/new"' +OUT="$(LEAKGUARD_DENY= ANS=y "$PY" "$W/ptyrun.py" tty "$SUBJ" --project "$NOD" --file PBUG-01 2>&1)" +chk "no deny list: posting refused before any gh call" 'echo "$OUT" | grep -q "no deny list" && ! grep -q "issue create" "$GHLOG"' +G="$(P2DENY= LEAKGUARD_DENY='Hooli|(' bodyof 'Hooli and ( here')" +chk "invalid \$LEAKGUARD_DENY 'Hooli|(' keeps the valid alternative and warns" '! echo "$G" | grep -qi "hooli" && grep -q "invalid regex" "$W/p2.err"' echo "J. versions line and exact URLs" G="$(bodyof 'Mendix 11.15.0.100 and Studio Pro 10.18.2.55 and mxcli 0.27.0.1 and version 1.2.3.4 but host 10.20.30.40' t '{"mendix":"11.15.0.100","mxcli":"v0.27.0","toolkit":"v2026.10.16"}')" @@ -264,6 +287,42 @@ PY ANS=y prun --file PBUG-10 >/dev/null 2>&1 chk "a title that looks like a flag is passed as --title=..., repo stays the target" 'grep -q "issue create --repo=mendixlabs/mxcli --title=--repo=evil/x crash --body-file=" "$GHLOG" && ! grep -q "evil/x --" "$GHLOG"' +echo "K. ordinary-word names and mxcli verbs" +KMDL='CREATE MODULE Shop; +CREATE PERSISTENT ENTITY Shop.Order (Name: String(10)); +CREATE PERSISTENT ENTITY Shop.Check (Name: String(10)); +CREATE PERSISTENT ENTITY Shop.Home (Name: String(10)); +CREATE PERSISTENT ENTITY Shop.Create (Name: String(10));' +G="$(P2MDL="$KMDL" bodyof 'ran mxcli create, mxcli check, mxcli describe and mxcli build; gate-check.sh and bin/gate-check.sh ok; ordering and shopping and homework survive; an Order is wrong; Home broke; ACT_Order_Save; Create failed')" +chk "mxcli create/check/describe/build survive" 'echo "$G" | grep -q "mxcli create, mxcli check, mxcli describe and mxcli build"' +chk "toolkit script name gate-check.sh survives" 'echo "$G" | grep -q "gate-check.sh and bin/gate-check.sh ok"' +chk "ordering, shopping, homework survive (no substring match)" 'echo "$G" | grep -q "ordering and shopping and homework survive"' +chk "Order and Home as standalone references are replaced" '! echo "$G" | grep -qw "Order\|Home" && ! echo "$G" | grep -q "ACT_Order_Save"' +chk "the keyword-named entity Create is replaced when written as the entity" '! echo "$G" | grep -q "Create failed"' +G="$(P2MDL="$KMDL" bodyof 'Shop.Order, Shop.Check and Shop.Home')" +chk "qualified Shop.Order / Shop.Check / Shop.Home are replaced" '! echo "$G" | grep -q "Shop\|Order\|Check\|Home"' +G="$(P2CONF='url: jdbc:postgresql://db.zonkco/app' bodyof 'conn jdbc:postgresql://db.zonkco/app and postgres://db.zonkco:5432/x and a jdbc driver')" +chk "jdbc:/postgres:// keep the scheme, redact the host" 'echo "$G" | grep -q "jdbc:postgresql://" && echo "$G" | grep -q "postgres://" && ! echo "$G" | grep -qi "zonkco"' +chk "jdbc is not redacted as a host elsewhere" 'echo "$G" | grep -q "a jdbc driver"' + +echo "L. headers, repo URLs, deny list, who posts" +G="$(bodyof 'curl -H "Authorization: Basic dXNlcjpwYXNz" and Authorization: Bearer abcdefghij1234 and authorization=Digest xyz123abc')" +chk "Authorization Basic/Bearer/Digest values redacted" '! echo "$G" | grep -q "dXNlcjpwYXNz\|abcdefghij1234\|xyz123abc\|Basic dX"' +G="$(bodyof "$(printf 'Cookie: sid=abc123; user=bob\nX-Auth-Token: tok999\nX-Api-Key: k1k2k3')")" +chk "Cookie and X-*-Token/Key header values redacted" '! echo "$G" | grep -q "abc123\|bob\|tok999\|k1k2k3"' +G="$(bodyof 'see https://github.com/mendixlabs/mxcli/issues?q=Zorbix+Quuxmod and https://github.com/mendixlabs/mxcli/blob/main/Kundennummer and https://github.com/mendixlabs/mxcli/issues/12#Widgetron and https://github.com/MendixMau/mxcli-project-toolkit/pull/7?x=Zorbix')" +chk "repo URLs keep only the prefix and /issues/N or /pull/N" '! echo "$G" | grep -qi "zorbix\|quuxmod\|kundennummer\|widgetron\|blob\|q=" && echo "$G" | grep -q "github.com/mendixlabs/mxcli/issues/12" && echo "$G" | grep -q "MendixMau/mxcli-project-toolkit/pull/7"' +chk "scan flags a header value and a non-prefix repo URL" '[ -n "$(scanof "Authorization: Basic dXNlcjpwYXNz")" ] && [ -n "$(scanof "https://github.com/mendixlabs/mxcli/blob/main/x")" ] && [ -n "$(scanof "https://github.com/mendixlabs/mxcli/issues?q=a")" ] && [ -z "$(scanof "https://github.com/mendixlabs/mxcli/issues/12")" ]' +chk "the script header says no script can tell a human from an agent on a pty" 'grep -q "NO SCRIPT CAN TELL A HUMAN FROM" "$TOOLKIT/bin/file-bugs.sh"' +chk "--help tells agents to hand posting to a human" '"$SUBJ" --help | grep -q "run it yourself" && "$SUBJ" --help | tr "\n" " " | grep -q "deny.*file-bugs.sh --file"' +chk "the shipped permissions deny file-bugs.sh --file and --all" 'grep -q "Bash(\*file-bugs.sh\*--file\*)" "$TOOLKIT/bin/install-claude-permissions.sh" && grep -q "Bash(\*file-bugs.sh\*--all\*)" "$TOOLKIT/bin/install-claude-permissions.sh"' +chk "the skill line says a human files it" 'grep -q "only a human files" "$TOOLKIT/skills/learned-detection-gaps.md"' + +echo "M. dotted users, attribute boundaries" +G="$(USER=maurits.visser bodyof 'by maurits.visser, maurits and visser; Kundennummer2 and kundennummerold and Kundennummer_x')" +chk "first.last redacts whole and each part" '! echo "$G" | grep -qi "maurits\|visser"' +chk "attribute followed by digits or letters is redacted" '! echo "$G" | grep -qi "kundennummer"' + echo "Z. no real issue was ever created" chk "every gh call went through the stub (log exists, no network tool used)" '[ -s "$GHLOG" ]' diff --git a/tests/wave2/test-install-claude-permissions.sh b/tests/wave2/test-install-claude-permissions.sh index d656705e..c95c58d0 100755 --- a/tests/wave2/test-install-claude-permissions.sh +++ b/tests/wave2/test-install-claude-permissions.sh @@ -79,6 +79,8 @@ EXPECTED_DENY=( "Bash(./mxcli exec:*)" "Bash(mxcli exec:*)" "Bash(./mxcli.exe exec:*)" + "Bash(*file-bugs.sh*--file*)" + "Bash(*file-bugs.sh*--all*)" ) SESSION_HOOK="bash bin/session-check.sh || true"