diff --git a/CHANGELOG.md b/CHANGELOG.md index fa7eabf4..fe29d880 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 `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, a URL scheme from a fixed list (jdbc, postgres, mysql, mongodb, redis, amqp, ssh, git and the like) stays and loses the host, any other scheme becomes ``, 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` and `file-bugs.py` with `--file` or `--all`, abbreviations are refused, and a human runs posting with `!`), and a deny list must exist and be valid (an invalid pattern counts as missing) 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 - learn(unit-test): **new on-demand skill for writing and running a microflow unit test (`.test.mdl`, `mxcli test`) whose assertion is live: what is worth a test and what is not, equality-only `@expect` (BUG-LOCAL-30), no file-level header (BUG-LOCAL-29), `--list` count against `/` separators before any run, `test-scratch.sh` so the live app survives (#278), the BUG-157 after-startup trap, a positive control that must fail, and a four-line report.** It gathers the how that was spread over `testing-shape.md`, `rest-between-mendix-apps.md` and the bug log into one file a user can ask for outside the pipeline; whether and when unit tests run stays with the project's test strategy. Routed ondemand for lead, mdl, test and review; one row in `testing-shape.md`'s owner table. Field evidence: projects on mxcli v0.19 to v0.27, Mendix 11.13 to 11.15, 2026-08 to 2026-10 — MendixMau - process(change-intake): **a change request now updates the module brief and stays visible until it is built.** The rule "most BRD edits need no brief edit" let a CR on a closed module leave the brief's Build steps stale, so `context-pack.sh` had no row for it and nothing counted it. Now the BRD stays the source of truth, the brief gets a CR-tagged Build steps row for any CR touching build steps, screens, access or rules, every CR that adds work gets a build-plan row tagged `CR-NN` (S: the module's phase, M/L: a change round), and agreed unbuilt CRs are "waiting" and are surfaced at session start and module close. RESUME.md gains a `Waiting changes:` line. `exec.sh --cr CR-NN` WARNs (never blocks) when the brief of a module the script writes to does not mention the id, for the folder brief and the merged build-plan form. Skill word counts at or below master (baseline 75,204 to 75,203, budget 80,000). A full unplanned-changes view and change rounds come in a follow-up. Test: `tests/wave2/test-exec-cr-brief-warn.sh` (19 checks, 6 fail on master's exec.sh) — MendixMau - new(security): **`project-bin/check-staged-secrets.sh` — compare the project's real secret values (`.env`, `.mxcli/run-local.json` bootConfig) against the staged blobs (or `--tree`, every tracked file at HEAD) and print only the file and the key name, never a value (TK-025).** Plus a section in `learned-constants-and-secrets.md`: the secret-bearing files and commands an agent never prints (`run-local.json`, the `run --local` command line, run state, `describe constant`, `.env`, rendered files that interpolate a secret), read-by-testing habits, and "a leaked value is rotated, not deleted". Field evidence: a five-app lab run with six leak events into transcripts and a demo password in git (mxcli v0.26.0). Opt-in, not wired into the generated pre-commit hook. Test with made-up values: `tests/wave2/test-check-staged-secrets.sh` (15 checks). Not adopted: the report's `exec-secret.sh` is a project-local script that does not ship here, and masking `--constant` values in `app-restart.sh` is TK-003. Values under 8 characters, well-known defaults such as `mendix` (the mxcli database password, present in thousands of tracked files) and values equal to `.env.example` are not sources, and `.docker/.env` is read. It runs one `git grep -F -f` per value, not a process per file: 230 staged files in a 6,000-file synthetic repo took 1.0 s (the whole tree 2.6 s). — five-app lab report, TK-025 diff --git a/README.md b/README.md index 697cc60e..867a1b4b 100644 --- a/README.md +++ b/README.md @@ -815,6 +815,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, 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 fee9ce0d..a2a3f1de 100644 --- a/ROUTING.md +++ b/ROUTING.md @@ -265,6 +265,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, 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 91dc4efc..0c1218bb 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` (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 da8e2530..ccdd7709 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` (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 new file mode 100755 index 00000000..1acee6c5 --- /dev/null +++ b/bin/file-bugs.sh @@ -0,0 +1,39 @@ +#!/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 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 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 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. +# 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} +# 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/install-claude-permissions.sh b/bin/install-claude-permissions.sh index e2e543a7..bc44a38e 100755 --- a/bin/install-claude-permissions.sh +++ b/bin/install-claude-permissions.sh @@ -184,6 +184,10 @@ DENY=( "Bash(./mxcli exec:*)" "Bash(mxcli exec:*)" "Bash(./mxcli.exe exec:*)" + "Bash(*file-bugs.sh*--file*)" + "Bash(*file-bugs.sh*--all*)" + "Bash(*file-bugs.py*--file*)" + "Bash(*file-bugs.py*--all*)" ) ASK=() diff --git a/bin/lib/file-bugs.py b/bin/lib/file-bugs.py new file mode 100644 index 00000000..38294392 --- /dev/null +++ b/bin/lib/file-bugs.py @@ -0,0 +1,743 @@ +"""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 +read from /dev/tty). Only two repos are ever addressed; see TARGETS. +""" +import argparse, datetime, ipaddress, json, os, re, subprocess, sys, tempfile, unicodedata, urllib.parse + +TARGETS = {"mxcli": "mendixlabs/mxcli", "toolkit": "MendixMau/mxcli-project-toolkit"} +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()) +# 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", + "\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|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-]+)+") +RE_URL = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s)>\]\"',;]*[^\s)>\]\"',;.]") +# any dotted name whose last label is lowercase letters and not a file extension (all TLDs, no fixed list) +RE_BARE = re.compile(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"|(?.""" + 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") and m.group(1).lower() in SCHEMES: + return m.group(1) + "://" + return "" + + +def url_safe(s): + m = re.match(r"(?i)^([a-z][a-z0-9+.-]*)://?$", s) + return url_ok(s) or bool(m and m.group(1).lower() in SCHEMES) + + +def mask_ok(t): + """Replace allowed repo URLs with a marker so name checks do not see them.""" + t = RE_URL.sub(lambda m: "\x01" if url_safe(m.group(0)) else m.group(0), t) + return RE_BARE.sub(lambda m: "\x01" if url_ok(m.group(0)) else m.group(0), t) + + +def is_weak(n): + ln = n.lower() + return len(n) < 5 or ln in COMMON_NAMES or ln in KW_NAMES + + +def name_pat(name, sub=None): + """Case-insensitive pattern for a name, line-break tolerant. Distinctive names (5+ chars, not an + English, MDL or Mendix word) match anywhere, also inside identifiers. Weak names match only as a + whole word or identifier segment (ACT_Order_Save, OrderOverview, myOrder; not ordering). Keyword + names (Create, Check) match only in their exact spelling.""" + n = norm(name) + core = LB.join(re.escape(c) for c in n) + if sub is None: + sub = not is_weak(n) + if sub: + return "(?i:" + core + ")" + if n.lower() in KW_NAMES: + return r"(?= 6: + names.add(stem) + except OSError: + pass + return names + + +_TK = _toolkit_names() +RE_TKFILE = re.compile(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 _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"(?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: + out.add(v.split(".")[0]) + return out + + def _names(self, extra): + p = self.project + 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] + 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 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.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 + + def _compile(self): + """One matcher list shared by redact() and scan(): (token, regex, kind).""" + self.matchers = [] + if self.app: + 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))) + for u in self.users: + self.matchers.append((u, "", name_pat(u))) + for h in self.hosts: + 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] + + # --- redaction ---------------------------------------------------------------------- + def redact(self, text): + keep = [] + + def hold(s): + keep.append(s) + return "\x00%d\x00" % (len(keep) - 1) + + 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(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(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() + for tbl in (self.ents, self.docs): + for k, v in tbl.items(): + if k.lower() == low: + 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"(?": + bad.append("secret assignment " + m.group(2)) + for m in RE_FLAG.finditer(t): + if m.group(3).strip("\"'") != "": + bad.append("secret flag " + m.group(1)) + for m in RE_OPAQUE.finditer(t): + s = m.group(0) + if RE_HEXTOK.fullmatch(s): + if not hex_ok(t, m): + bad.append("opaque string " + s[:12] + "...") + 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") + for rx in self.deny: + if rx.search(t): + bad.append("deny-list match") + return sorted(set(bad)) + + +# --- bug rows ----------------------------------------------------------------------------- +def load(path): + rows, raw = [], [] + if not os.path.isfile(path): + return rows, raw + for n, ln in enumerate(open(path, errors="replace"), 1): + raw.append(ln.rstrip("\n")) + s = ln.strip() + if not s: + continue + try: + d = json.loads(s) + if isinstance(d, dict): + rows.append((n - 1, d)) + except ValueError: + sys.stderr.write("file-bugs: %s line %d is not JSON, left untouched\n" % (path, n)) + return rows, raw + + +def text_of(v): + if isinstance(v, list): + return "\n".join("%d. %s" % (i, x) for i, x in enumerate(v, 1)) + return str(v or "") + + +def versions_line(vers): + """Only version-shaped strings, taken from the row and never run through redact().""" + items = vers.items() if isinstance(vers, dict) else [("version", vers)] + out = [] + for k, v in items: + m = RE_VER.search(str(v or "")) + k = re.sub(r"[^A-Za-z0-9 _-]", "", str(k))[:16] + if m and k: + out.append("%s %s" % (k, m.group(0))) + return ", ".join(out) or "not recorded" + + +def build(r, b): + """Return (title, body, problems) for one bug, redacted and scanned.""" + parts = ["### Steps to reproduce\n" + (text_of(b.get("repro")) or "-"), + "### Expected\n" + (text_of(b.get("expected")) or "-"), "### Actual\n" + (text_of(b.get("actual")) or "-")] + if b.get("workaround"): + parts.append("### Workaround\n" + text_of(b["workaround"])) + title = re.sub(r"\s+", " ", r.redact(str(b.get("title", "")))) + body = "**Versions:** " + versions_line(b.get("versions")) + "\n\n" + r.redact("\n\n".join(parts)) + return title, body, r.scan(title + "\n" + body) + + +def new_issue_url(repo, title, body): + q = lambda s: urllib.parse.quote(s, safe="") + trunc = False + if len(q(title)) > 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(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(q(body[:mid] + note)) <= URL_MAX: + lo = mid + else: + hi = mid - 1 + 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]) + p = sh(["gh", "issue", "list", "--repo", repo, "--state", "all", "--search", q + " in:title", "--limit", "5", + "--json", "number,title,state,url"]) + if p.returncode != 0: + p = sh(["gh", "search", "issues", "--repo", repo, q, "--limit", "5", "--json", "number,title,state,url"]) + if p.returncode != 0: + return None + try: + return json.loads(p.stdout or "[]") + except ValueError: + return None + + +def write_back(path, bid, upd): + rows, raw = load(path) + for idx, d in rows: + 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"^%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] + row = {"id": bid, "date": datetime.date.today().isoformat(), "target": a.target, "title": a.title, + "repro": a.repro, "expected": a.expected, "actual": a.actual, + "versions": {"mxcli": mx, "mendix": a.mendix or "", "toolkit": a.toolkit_version or tk}, + "workaround": a.workaround or "", "status": "open", "issue_url": ""} + 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, 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 + + +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'. " + "Client names of 4 characters or fewer match as whole words only, so list them there too.") +REFUSED_NOTE = "refused: run the dry run to see why" + + +def main(): + ap = argparse.ArgumentParser(prog="file-bugs.sh", add_help=True, allow_abbrev=False, 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") + ap.add_argument("--links", action="store_true") + ap.add_argument("--json", action="store_true") + ap.add_argument("--name", action="append", default=[], help="extra project name to redact") + ap.add_argument("--add", action="store_true") + for k in ("target", "title", "repro", "expected", "actual", "workaround", "mxcli", "mendix", "toolkit-version"): + ap.add_argument("--" + k) + a = ap.parse_args() + project = os.path.abspath(a.project) + path = os.path.join(project, "docs", "bugs.jsonl") + if a.add: + if a.target not in TARGETS or not (a.title and a.repro and a.expected and a.actual): + sys.exit("file-bugs: --add needs --target mxcli|toolkit, --title, --repro, --expected, --actual") + return add(path, a) + rows, _ = load(path) + if not os.path.isfile(path): + sys.exit("file-bugs: no %s" % path) + want = set(a.file) + 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(sid(d) == w for _, d in rows): + sys.exit("file-bugs: %s is not in %s" % (w, path)) + 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 + r = Redactor(project, a.name) + for w in r.warnings: + sys.stderr.write("file-bugs: warning: %s\n" % w) + if r.bad_deny: + msg = ("invalid deny pattern (%s): fix it in the deny list. Posting, --links and --json are refused " + "until then; nothing posted." % "; ".join(r.bad_deny)) + if posting or a.links or a.json: + sys.exit("file-bugs: " + msg) + print("NOTE: " + msg + "\n") + nodeny = not r.deny and not r.bad_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") + out = [] + for _, d in todo: + target = d.get("target") + repo = TARGETS.get(target) if isinstance(target, str) else None + if not repo: + 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) + 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" % (sid(d), REFUSED_NOTE if problems else title + "\n " + item["url"])) + continue + 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)") + 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 not posting: + continue + if want and sid(d) not in want: + continue + if dp is None: + print("Not posting: the duplicate search did not run.") + continue + if not confirm(tty, repo): + print("skipped.") + continue + 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, 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)) + 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 acc980ff..a3634c94 100644 --- a/bin/lib/skill-routing.tsv +++ b/bin/lib/skill-routing.tsv @@ -303,6 +303,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, 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 c68155d4..b32a727b 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 it; only a human files it (`!bin/file-bugs.sh --file PBUG-NN`, denied to agents). ## 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/tests/wave2/test-file-bugs.sh b/tests/wave2/test-file-bugs.sh new file mode 100755 index 00000000..e182036e --- /dev/null +++ b/tests/wave2/test-file-bugs.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# 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, 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 +# 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}" +. "$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 [ "${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" "$P/config" "$BIN" +: > "$P/Zorbix.mpr" +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\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") + 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" +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 "${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 +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 +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' +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" +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 "^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 (PBUG-01 body)" +B1="$(run --json "$GHLOG" +OUT="$(ANS=n,n,n prun --all 2>&1)" +chk "PBUG-02 warned and refused" 'echo "$OUT" | grep -q "WARNING: redaction left suspicious content.*opaque"' +chk "no create call carries PBUG-02 text" '! grep "issue create" "$GHLOG" | grep -q "Opaque"' +chk "no gh call at all carries the unsafe title" '! grep -qi "opaque\|A8f3" "$GHLOG"' +chk "--links omits the refused bug text" 'L="$(run --links "$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"' +ANS=y prun --file PBUG-01 >/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 "$W/links.err")" +chk "links point at the target repo" 'echo "$L" | grep -q "https://github.com/mendixlabs/mxcli/issues/new?title=Plain%20bug&body="' +chk "links are URL-encoded (no raw space in a URL line)" '! echo "$L" | grep "issues/new" | sed "s/^ *//" | grep -q " "' +chk "links need no gh call" '[ ! -s "$GHLOG" ]' +chk "a list-typed target is skipped with a message, run completes" 'grep -q "PBUG-07 has target.*skipped" "$W/links.err" && echo "$L" | grep -q "^PBUG-03"' +"$PY" - "$P/docs/bugs.jsonl" <<'PY' +import json,sys +with open(sys.argv[1],"a") as f: + f.write(json.dumps({"id":"PBUG-06","date":"2026-10-16","target":"toolkit","title":"Huge","repro":"word "*5000,"expected":"e","actual":"a","versions":{},"workaround":"","status":"open","issue_url":""})+"\n") + f.write(json.dumps({"id":"PBUG-08","date":"2026-10-16","target":"toolkit","title":"Long title "*640,"repro":"short","expected":"e","actual":"a","versions":{},"workaround":"","status":"open","issue_url":""})+"\n") +PY +LL="$(run --links "$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 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 makes --json refuse and names the part" '[ "$G" = NOJSON ] && grep -q "invalid deny pattern" "$W/p2.err" || true' +OUT="$(cat "$W/p2.err")"; chk "invalid deny pattern error names the bad part" 'echo "$OUT" | grep -q "Foo(bar"' +G="$(P2DENY= LEAKGUARD_DENY= bodyof 'no list at all')" +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|(' refuses --json" '[ "$G" = NOJSON ]' + +G="$(P2DENY='(Initech|Globex' bodyof 'Initech and Globex and initech-prod')" +chk "partly valid deny pattern '(Initech|Globex': --json refused, nothing leaks" '[ "$G" = NOJSON ] && grep -q "invalid deny pattern" "$W/p2.err" && ! grep -qi "initech" "$GHLOG"' +chk "the error names the bad pattern" 'grep -q "(Initech|Globex" "$W/p2.err"' +mkdir -p "$W/p3"; cp -R "$P/." "$W/p3/"; printf '%s\n' '(Initech|Globex' > "$W/p3/.leakguard-deny"; : > "$GHLOG" +OUT="$(ANS=y "$PY" "$W/ptyrun.py" tty "$SUBJ" --project "$W/p3" --file PBUG-01 2>&1)" +chk "invalid deny pattern: posting refused before any gh call" 'echo "$OUT" | grep -q "invalid deny pattern" && ! grep -q "issue create" "$GHLOG"' +OUT="$("$SUBJ" --project "$W/p3" --links &1)"; RC=$? +chk "invalid deny pattern: --links refused" '[ $RC -ne 0 ] && echo "$OUT" | grep -q "invalid deny pattern"' + +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 "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"' + +N0="$(wc -l < "$GHLOG")" +OUT="$(ANS=y "$PY" "$W/ptyrun.py" tty "$SUBJ" --project "$P" --al 2>&1)"; OUT2="$(ANS=y "$PY" "$W/ptyrun.py" tty "$SUBJ" --project "$P" --fil=PBUG-03 2>&1)" +chk "--al and --fil=X are rejected (no argparse abbreviation), nothing posted" 'echo "$OUT$OUT2" | grep -q "unrecognized" && [ "$(wc -l < "$GHLOG")" = "$N0" ]' +chk "the shipped permissions also deny the .py file called directly" 'grep -q "Bash(\*file-bugs.py\*--file\*)" "$TOOLKIT/bin/install-claude-permissions.sh" && grep -q "Bash(\*file-bugs.py\*--all\*)" "$TOOLKIT/bin/install-claude-permissions.sh"' +G="$(P2CONF='url: x' bodyof 'dial quuxmod://prod-db/x and amqp://mq.zonkco/q and gopher://zonkco/y')" +chk "unknown URL scheme becomes ; known keeps scheme and drops host" 'echo "$G" | grep -q "" && ! echo "$G" | grep -qi "quuxmod://\|prod-db\|zonkco" && echo "$G" | grep -q "amqp://"' +chk "scan flags an unknown scheme" '[ -n "$(scanof "quuxmod://")" ] && [ -n "$(scanof "quuxmod://prod-db/x")" ]' + +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" ]' + +echo; echo "file-bugs: $PASS pass, $FAIL fail"; [ "$FAIL" -eq 0 ] diff --git a/tests/wave2/test-install-claude-permissions.sh b/tests/wave2/test-install-claude-permissions.sh index d656705e..2fe5040e 100755 --- a/tests/wave2/test-install-claude-permissions.sh +++ b/tests/wave2/test-install-claude-permissions.sh @@ -79,6 +79,10 @@ EXPECTED_DENY=( "Bash(./mxcli exec:*)" "Bash(mxcli exec:*)" "Bash(./mxcli.exe exec:*)" + "Bash(*file-bugs.sh*--file*)" + "Bash(*file-bugs.sh*--all*)" + "Bash(*file-bugs.py*--file*)" + "Bash(*file-bugs.py*--all*)" ) SESSION_HOOK="bash bin/session-check.sh || true"