@codyswann/lisa 4.27.5 → 4.27.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +27 -4
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +507 -11
  3. package/dist/cli/doctor-enforcement-coverage.d.ts +14 -0
  4. package/dist/cli/doctor-enforcement-coverage.d.ts.map +1 -0
  5. package/dist/cli/doctor-enforcement-coverage.js +103 -0
  6. package/dist/cli/doctor-enforcement-coverage.js.map +1 -0
  7. package/dist/cli/doctor.d.ts.map +1 -1
  8. package/dist/cli/doctor.js +9 -0
  9. package/dist/cli/doctor.js.map +1 -1
  10. package/dist/core/enforcement-census-report.d.ts +19 -0
  11. package/dist/core/enforcement-census-report.d.ts.map +1 -0
  12. package/dist/core/enforcement-census-report.js +198 -0
  13. package/dist/core/enforcement-census-report.js.map +1 -0
  14. package/dist/core/enforcement-census.d.ts +177 -0
  15. package/dist/core/enforcement-census.d.ts.map +1 -0
  16. package/dist/core/enforcement-census.js +252 -0
  17. package/dist/core/enforcement-census.js.map +1 -0
  18. package/dist/core/enforcement-coverage.d.ts +168 -0
  19. package/dist/core/enforcement-coverage.d.ts.map +1 -0
  20. package/dist/core/enforcement-coverage.js +342 -0
  21. package/dist/core/enforcement-coverage.js.map +1 -0
  22. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  23. package/dist/core/lisa-owned-hash-ledger.js +8 -0
  24. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  25. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  26. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  27. package/dist/core/upstream-evidence-manifest.js +21 -5
  28. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  29. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +180 -19
  30. package/package.json +4 -3
  31. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa/.codex-plugin/skills/lisa-setup-automations/SKILL.md +23 -0
  34. package/plugins/lisa/hooks/block-direct-issue-create.sh +507 -11
  35. package/plugins/lisa/skills/lisa-setup-automations/SKILL.md +23 -0
  36. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +507 -11
  37. package/plugins/lisa-agy/plugin.json +1 -1
  38. package/plugins/lisa-agy/skills/lisa-setup-automations/SKILL.md +23 -0
  39. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  41. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  42. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +507 -11
  46. package/plugins/lisa-copilot/skills/lisa-setup-automations/SKILL.md +23 -0
  47. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +507 -11
  49. package/plugins/lisa-cursor/skills/lisa-setup-automations/SKILL.md +23 -0
  50. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-expo-agy/plugin.json +1 -1
  53. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  63. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  68. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  73. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-rails-agy/plugin.json +1 -1
  78. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  83. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  88. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/src/base/hooks/block-direct-issue-create.sh +507 -11
  91. package/plugins/src/base/skills/lisa-setup-automations/SKILL.md +23 -0
  92. package/scripts/lisa-enforcement-census.mjs +168 -0
  93. package/scripts/lisa-enforcement-fallback.sh +27 -4
@@ -407,10 +407,33 @@ fi
407
407
  # - `main` cut 80 releases in 24 hours, median gap 10 minutes. "Behind the
408
408
  # newest Lisa on this disk" is the DEFAULT state of a checkout within
409
409
  # minutes of being cut, through nothing anyone did wrong.
410
- # - Across the 27 host checkouts in this fleet, 12 resolve guards at all, and
411
- # ALL TWELVE are behind or undateable. None is current. Eight carry no
412
- # apply receipt; four are behind by a whole MAJOR (3.23.0, 3.23.1, 3.46.3,
413
- # 3.54.4 against 4.17.2).
410
+ # - Measured once across the host checkouts on one fleet: of those that
411
+ # resolved guards at all, every one was behind or undateable and none was
412
+ # current, several by a whole MAJOR — while a larger number resolved NO
413
+ # guard at all.
414
+ #
415
+ # That second reading used to be written here as a set of counts, and that was
416
+ # the wrong place for it. A number in a comment is a measurement, not a monitor:
417
+ # it was true on the day it was taken and nothing re-took it, so the drift after
418
+ # that date was unobserved — which is the same defect the number documents. The
419
+ # counts are therefore not restated here. Re-derive them:
420
+ #
421
+ # Run these FROM A LISA MONOREPO CHECKOUT — the census and the fleet roster are
422
+ # Lisa-monorepo artifacts, and this comment travels into host projects where
423
+ # neither exists. The script wrapper builds `dist/` first, which a fresh clone
424
+ # does not have:
425
+ #
426
+ # bun run lisa:enforcement-census # roster on this machine
427
+ # bun run lisa:enforcement-census -- --redact # safe to quote publicly
428
+ #
429
+ # In a host project the local half of the same question is `lisa doctor`, which
430
+ # reports what THIS checkout resolves and needs no roster.
431
+ #
432
+ # The census reports and never gates, and it keeps "resolves NO guard" apart
433
+ # from "resolves something old" — different failures, different remedies, and
434
+ # folding them is what made the unenforced checkouts easy to miss
435
+ # (CodySwannGT/lisa#3490). `lisa doctor` carries the same finding for the single
436
+ # checkout it is run in.
414
437
  #
415
438
  # So no distance threshold rescues it — even the strictest version predicate is
416
439
  # permanently true across the fleet. The noise is the REPETITION, not the
@@ -30,10 +30,24 @@
30
30
  #
31
31
  # A creation command is refused unless it carries a readiness declaration:
32
32
  # - the project's configured build-ready role (GitHub label, JIRA/Linear
33
- # workflow state) resolved from `.lisa.config.json`, never hard-coded; or
33
+ # workflow state) resolved from `.lisa.config.json`, never hard-coded;
34
+ # - on a tracker whose ready role is a STATE rather than a label, the
35
+ # `lifecycle_role: ready` declaration the access layer resolves that state
36
+ # from — because no flag on the mandated client can carry a state, and a
37
+ # guard with no satisfiable declaration is a guard that teaches lying; or
34
38
  # - an explicit `[lisa-human-gate]` marker, inline or in the `--body-file`
35
39
  # the create is about to submit.
36
40
  #
41
+ # WHERE IT LOOKS
42
+ #
43
+ # argv, the request payload (inline, in a `--data-binary @file`, or piped in
44
+ # over stdin from the same pipeline), and the contents of any file the command
45
+ # names. The last of those is what CodySwannGT/lisa#3484 was: the guard
46
+ # inspected argv and nothing else, so `bash /path/create.sh` showed it two
47
+ # tokens and the creation was one file away. Lisa's own `parity-safety-net.sh`
48
+ # tells agents to write payloads to a file and execute the file, so complying
49
+ # with Lisa's guidance produced the bypass.
50
+ #
37
51
  # That is exactly the machine-checkable content of `ready-role-filing`, and it
38
52
  # lets `lisa-github-write-issue` / `lisa-jira-write-ticket` /
39
53
  # `lisa-linear-write-issue` through by construction, because those writers
@@ -197,6 +211,38 @@ fi
197
211
  # environment, and never from the command being inspected.
198
212
  ambient_override="${LISA_ALLOW_DIRECT_ISSUE_CREATE:-}"
199
213
 
214
+ # How to write the declaration down on THIS project's tracker. The answer is
215
+ # not the same everywhere, and printing the GitHub answer at a Linear operator
216
+ # is what made this guard unsatisfiable: it named a `--label` flag that the
217
+ # mandated client does not have, on a role that is not a label.
218
+ declaration_hint() {
219
+ case "$tracker" in
220
+ jira | linear)
221
+ cat <<EOF
222
+ Your build-ready role \`$ready_role\` is a workflow STATE, not a label, and
223
+ the mandated client is \`curl\` — which has no flag that carries a state. So
224
+ declare the LIFECYCLE ROLE the access layer resolves the state from, and let
225
+ it do the resolving:
226
+
227
+ LIFECYCLE_ROLE=ready curl -sS -X POST <the tracker endpoint> …
228
+
229
+ or \`lifecycle_role:ready\` in the request payload, or a \`--state\` /
230
+ \`--status\` flag where the CLI has one. The \`$tracker\` access layer takes
231
+ that role, resolves it against the tracker's own state catalog, and fails
232
+ CLOSED if it cannot — so the token is the input that decides the lane, not a
233
+ decoration.
234
+ EOF
235
+ ;;
236
+ *)
237
+ cat <<EOF
238
+ The command has to carry the configured build-ready role \`$ready_role\` as
239
+ the value of a \`--label\` / \`--status\` / \`--state\` flag — not in the
240
+ title or body, because a role named in prose is not a role applied.
241
+ EOF
242
+ ;;
243
+ esac
244
+ }
245
+
200
246
  refuse() {
201
247
  local signature="$1"
202
248
  local roles="$2"
@@ -237,10 +283,17 @@ Filed, not ready, and no \`human_gate\` is the incomplete-handoff case, and
237
283
  attached. See the \`ready-role-filing\` rule for the full contract.
238
284
 
239
285
  If you must run the CLI directly, the command has to carry one of the two
240
- declarations itself: the configured build-ready role \`$ready_role\` as the
241
- value of a \`--label\` / \`--status\` / \`--state\` flag (not in the title or
242
- body — a role named in prose is not a role applied), or a \`[lisa-human-gate]\`
243
- marker in the body it submits.
286
+ declarations itself:
287
+
288
+ $(declaration_hint)
289
+
290
+ Or a \`[lisa-human-gate]\` marker in the body it submits, when a human
291
+ product call really is pending.
292
+
293
+ WHERE THE DECLARATION IS READ FROM: argv, the request payload — inline, in a
294
+ \`--data-binary @file\`, or piped in over stdin — and the contents of a script
295
+ this command runs. Moving the create into a file no longer moves it out of
296
+ sight, so the declaration can live wherever the create does.
244
297
 
245
298
  OPERATOR ESCAPE: a human can export \`LISA_ALLOW_DIRECT_ISSUE_CREATE=1\` in the
246
299
  environment before starting the session. It is deliberately not reachable by
@@ -327,6 +380,8 @@ own_repo = os.environ.get("LISA_GUARD_OWN_REPO", "").strip().lower()
327
380
  upstream_repo = os.environ.get("LISA_GUARD_UPSTREAM_REPO", "").strip().lower()
328
381
  upstream_ready_role = os.environ.get("LISA_GUARD_UPSTREAM_READY_ROLE", "")
329
382
  caller_is_github = os.environ.get("LISA_GUARD_CALLER_IS_GITHUB", "") == "1"
383
+ tracker = os.environ.get("LISA_GUARD_TRACKER", "").strip().lower()
384
+ project_dir = os.environ.get("LISA_GUARD_PROJECT_DIR", "")
330
385
 
331
386
  OVERRIDE_NAME = "LISA_ALLOW_DIRECT_ISSUE_CREATE"
332
387
  HUMAN_GATE_MARKER = "[lisa-human-gate]"
@@ -413,6 +468,115 @@ GITHUB_ISSUES_URL_REPO = re.compile(
413
468
  JIRA_ISSUE_URL = re.compile(r"atlassian\.net/rest/api/[^/\s]+/issue")
414
469
  GRAPHQL_CREATE = re.compile(r"createIssue|issueCreate")
415
470
 
471
+ # ---------------------------------------------------------------------------
472
+ # REACH: THE COMMAND IS NOT THE ONLY TEXT THAT RUNS
473
+ #
474
+ # The classifier above asks the right question of the wrong text. It examines
475
+ # argv, and argv is not where a creation has to live:
476
+ #
477
+ # bash /tmp/create.sh # URL and `issueCreate` both inside the script
478
+ # node wrapper.mjs --state x # same, in JavaScript
479
+ # curl … --data-binary @p.json # URL in argv, mutation in the file
480
+ #
481
+ # All three ALLOWED, verified by driving the guard with synthetic payloads
482
+ # (CodySwannGT/lisa#3484). The detection is a CONJUNCTION — an endpoint token
483
+ # AND a creation verb in the SAME inspected command — so moving either half one
484
+ # file away means the conjunction never forms. `bash <path>` shows the
485
+ # classifier two tokens, neither of which is a tracker CLI.
486
+ #
487
+ # It is not an oversight that an operator had to be clever to find. Lisa's OWN
488
+ # guards instruct agents into this shape: `parity-safety-net.sh` refuses
489
+ # heredocs and says "write the payload to a file with the Write tool, then
490
+ # execute that file directly", and `block-shell-json-parsing.sh` pushes JSON
491
+ # construction into `jq` scripts. An agent complying perfectly with Lisa's
492
+ # guidance lands in the uninspected path BY DEFAULT. The guards were
493
+ # individually reasonable and jointly self-defeating.
494
+ #
495
+ # The fix follows this file's own inverted method. Enumerating interpreters —
496
+ # bash, sh, zsh, ksh, dash, python3, node, bun, deno, ruby, perl, `./script`,
497
+ # `source`, `env bash` — is the unbounded question all over again, and every
498
+ # gap in it fails OPEN. So the question is inverted the same way the wrapper
499
+ # question was: instead of "which programs execute their operands?", the
500
+ # classifier asks "does this command name a readable file whose CONTENTS read
501
+ # as an undeclared tracker creation?" — bounded by the tokens actually present.
502
+ #
503
+ # THE COST IS STATED, NOT HIDDEN. `cat create.sh` and `git add create.sh` are
504
+ # refused too, because proving that a program does NOT execute its operand
505
+ # requires exactly the allowlist this file already refuses to keep. That is the
506
+ # documented direction of failure: an operator who hits a false refusal says
507
+ # so, an agent who hits a false allow says nothing.
508
+ FILE_OPERAND_MAX_BYTES = 262144
509
+ FILE_OPERANDS_PER_SEGMENT = 8
510
+ # Payload sources. A creation's mutation text may arrive inline, from a file,
511
+ # or over stdin from an earlier stage of the same pipeline.
512
+ PAYLOAD_SOURCE_FLAGS = {
513
+ "-d", "--data", "--data-raw", "--data-binary", "--data-ascii",
514
+ "--data-urlencode", "--input", "-F", "--form", "--upload-file", "-T",
515
+ }
516
+ STDIN_PAYLOAD_TOKENS = {"-", "@-"}
517
+ # A tracker endpoint appearing anywhere in a file's text. Paired with
518
+ # GRAPHQL_CREATE as a CONJUNCTION, so an ordinary file that merely mentions
519
+ # `issueCreate` in prose — a changelog, a test name, a code comment — does not
520
+ # read as a creation on its own.
521
+ TRACKER_ENDPOINT = re.compile(
522
+ r"api\.linear\.app/graphql"
523
+ r"|api\.github\.com"
524
+ r"|atlassian\.net/rest/api"
525
+ r"|repos/[^/\s?#'\"]+/[^/\s?#'\"]+/issues",
526
+ re.IGNORECASE,
527
+ )
528
+
529
+ # ---------------------------------------------------------------------------
530
+ # DECLARING BUILD-READY WHEN THE ROLE IS A STATE AND NOT A LABEL
531
+ #
532
+ # `declares_readiness` accepted the build-ready role only as the value of a
533
+ # LABEL_FLAGS flag in argv. That is well-formed for a LABEL-based tracker and
534
+ # structurally impossible for a STATE-based one:
535
+ #
536
+ # - GitHub's ready role is a label, labels are argv-native (`--label`), so
537
+ # `flag_values` finds it and an honest command exists.
538
+ # - JIRA's and Linear's ready roles are workflow STATES. The mandated access
539
+ # path is raw `curl` to a GraphQL/REST endpoint, `curl` has no `--state`
540
+ # flag, and the state lives in the request payload as an ID the guard
541
+ # cannot resolve without a network round-trip it refuses to make.
542
+ #
543
+ # So on a Linear-tracked project the only declaration left that passed was
544
+ # `[lisa-human-gate]`, which this file's own comments correctly forbid for a
545
+ # build-ready item: it stamps the item as held for a human product call, and
546
+ # build-intake scans the ready role and nothing else. An honest operator had NO
547
+ # compliant command and exactly one dishonest one — a guard failing in the
548
+ # harmful direction, where complying is worse than not.
549
+ #
550
+ # The declaration accepted here for state-based trackers is the LIFECYCLE ROLE
551
+ # the access layer already consumes. `lisa-linear-access` refuses to accept a
552
+ # caller-supplied `stateId`; it takes `lifecycle_role:<ROLE>`, resolves it
553
+ # against config and the team's own state catalog through
554
+ # `linear-state-write-target.mjs`, and fails CLOSED when it cannot. So the role
555
+ # token is not decoration: it is the input that decides which lane the item
556
+ # lands in, and a command carrying it either places the item in the ready lane
557
+ # or refuses. That is the same epistemic standing as `--label status:ready`,
558
+ # whose effect also happens one layer down inside `gh`.
559
+ #
560
+ # Scoped deliberately: accepted ONLY when the configured tracker's ready role
561
+ # is a state, and ONLY for a filing addressed at this project's own tracker. A
562
+ # GitHub filing — same-repo or cross-repo — still has to carry the label,
563
+ # because for GitHub the label IS expressible and a second, weaker spelling
564
+ # would be a hole rather than a remedy.
565
+ STATE_ROLE_TRACKERS = {"jira", "linear"}
566
+ #
567
+ # The optional quote AFTER the key name is load-bearing, not decoration. Both
568
+ # refusal messages tell the operator to put the declaration in the request
569
+ # payload, and a JSON payload quotes its keys — so a pattern demanding `[:=]`
570
+ # immediately after a bare key name refuses `"lifecycle_role": "ready"`, which
571
+ # is the exact spelling it just asked for. Caught by review before it shipped.
572
+ LIFECYCLE_ROLE_READY = re.compile(
573
+ r"(?:^|[^\w.-])(?:lifecycle_role|LIFECYCLE_ROLE)[\"']?\s*[:=]\s*[\"']?ready\b"
574
+ r"|(?:^|[^\w.-])--role[=\s]+[\"']?ready\b",
575
+ )
576
+ # Built per role at the point of use, because the role is project data. Kept
577
+ # as a template rather than a compiled pattern so the role is always escaped.
578
+ LABEL_FLAG_TEXT = r"--(?:label|labels|add-label|status|state)[=\s]+[\"']?%s(?![\w:.-])"
579
+
416
580
  MAX_NESTING_DEPTH = 3
417
581
  # Operators that can be GLUED to an adjacent word (`true&&gh issue create`),
418
582
  # so they must be split out of a token. Braces and parentheses are deliberately
@@ -733,12 +897,208 @@ def endpoint_paths(args, pattern):
733
897
  return found
734
898
 
735
899
 
736
- def creation_signature(name, args):
900
+ def resolve_operand(token):
901
+ """A readable regular file named by one token, or None.
902
+
903
+ Bounded on purpose. The size cap keeps a PreToolUse hook off a multi-
904
+ megabyte read on every intercepted command, and a file too large to
905
+ inspect is skipped rather than half-read: a truncated scan reports a
906
+ confident ALLOW about text it never saw.
907
+
908
+ Args:
909
+ token: One argument, possibly quoted or `@`-prefixed for curl.
910
+
911
+ Returns:
912
+ An existing path, or None.
913
+ """
914
+ text = token.strip().strip("'\"")
915
+ # `-d@payload.json` names a file just as plainly as `@payload.json` does.
916
+ attached = attached_value(text)
917
+ if attached is not None:
918
+ text = attached
919
+ if text.startswith("@"):
920
+ text = text[1:]
921
+ if not text or text in STDIN_PAYLOAD_TOKENS:
922
+ return None
923
+ candidates = [text]
924
+ if project_dir and not os.path.isabs(text):
925
+ candidates.append(os.path.join(project_dir, text))
926
+ for candidate in candidates:
927
+ try:
928
+ if not os.path.isfile(candidate):
929
+ continue
930
+ if os.path.getsize(candidate) > FILE_OPERAND_MAX_BYTES:
931
+ continue
932
+ except OSError:
933
+ continue
934
+ return candidate
935
+ return None
936
+
937
+
938
+ def read_operand(path):
939
+ """The text of a file the command names.
940
+
941
+ Args:
942
+ path: A path from `resolve_operand`.
943
+
944
+ Returns:
945
+ The contents, or an empty string when unreadable.
946
+ """
947
+ try:
948
+ with open(path, encoding="utf-8", errors="replace") as handle:
949
+ return handle.read(FILE_OPERAND_MAX_BYTES)
950
+ except OSError:
951
+ return ""
952
+
953
+
954
+ def attached_value(token):
955
+ """A payload flag's value when it is GLUED to the flag.
956
+
957
+ `curl -d@payload.json` and `curl -d'{"query":…}'` are one token each, so a
958
+ parser that only looks at `args[i + 1]` and at `flag=value` sees neither —
959
+ and `-d@file` is the ordinary spelling, not an exotic one. Caught by review
960
+ before it shipped, and it was a real bypass: the mutation stayed in the
961
+ file, the conjunction never formed, and an undeclared creation passed.
962
+
963
+ Longest flag first, so `--data-binary@f` is not read as `--data` with a
964
+ `-binary@f` value. A remainder starting with `-` is rejected for the same
965
+ reason: it is another option, not this one's value.
966
+
967
+ Args:
968
+ token: One argument.
969
+
970
+ Returns:
971
+ The attached value, or None.
972
+ """
973
+ for flag in sorted(PAYLOAD_SOURCE_FLAGS, key=len, reverse=True):
974
+ if not token.startswith(flag) or len(token) == len(flag):
975
+ continue
976
+ rest = token[len(flag) :]
977
+ if rest.startswith("="):
978
+ rest = rest[1:]
979
+ if not rest or rest.startswith("-"):
980
+ continue
981
+ return rest
982
+ return None
983
+
984
+
985
+ def payload_text(args, whole_command):
986
+ """The body this command will submit, wherever it is coming from.
987
+
988
+ Three sources, because a request body has three places to live and the
989
+ guard was reading only the first: inline after `-d`, in a file after
990
+ `--data-binary @path` / `--input path`, or on stdin from an earlier stage
991
+ of the same pipeline.
992
+
993
+ The stdin case takes the WHOLE command text rather than the segment,
994
+ because `jq -n … | curl … --data-binary @-` is one logical command that
995
+ `segment` has already split in two — the payload literally is the other
996
+ half. That is the shape `lisa-linear-access` mandates, so it has to be
997
+ readable rather than invisible.
998
+
999
+ Args:
1000
+ args: A command's arguments.
1001
+ whole_command: The full intercepted command string.
1002
+
1003
+ Returns:
1004
+ The payload text, possibly empty.
1005
+ """
1006
+ parts = []
1007
+ for index, token in enumerate(args):
1008
+ value = None
1009
+ if token in PAYLOAD_SOURCE_FLAGS and index + 1 < len(args):
1010
+ value = args[index + 1]
1011
+ elif "=" in token:
1012
+ head, rhs = token.split("=", 1)
1013
+ if head in PAYLOAD_SOURCE_FLAGS:
1014
+ value = rhs
1015
+ if value is None:
1016
+ value = attached_value(token)
1017
+ if value is None:
1018
+ continue
1019
+ stripped = value.strip().strip("'\"")
1020
+ if stripped in STDIN_PAYLOAD_TOKENS:
1021
+ parts.append(whole_command)
1022
+ continue
1023
+ parts.append(stripped)
1024
+ path = resolve_operand(stripped)
1025
+ if path is not None:
1026
+ parts.append(read_operand(path))
1027
+ return "\n".join(parts)
1028
+
1029
+
1030
+ def text_declares_readiness(text):
1031
+ """Whether a file's or payload's own text carries a declaration.
1032
+
1033
+ The token-position machinery above cannot be reused here: a file is not
1034
+ argv, and a payload is JSON. What is checked is deliberately the SAME
1035
+ three declarations, matched textually and never loosened into "the role
1036
+ string appears somewhere" — a state-based ready role is an ordinary word
1037
+ like `Ready`, and accepting a bare occurrence of it would let prose in a
1038
+ description declare readiness the item does not have.
1039
+
1040
+ Args:
1041
+ text: File or payload contents.
1042
+
1043
+ Returns:
1044
+ True when the text declares build-ready or a human gate.
1045
+ """
1046
+ if HUMAN_GATE_MARKER in text:
1047
+ return True
1048
+ for role in (ready_role, upstream_ready_role, default_ready_role):
1049
+ if not role:
1050
+ continue
1051
+ if re.search(LABEL_FLAG_TEXT % re.escape(role), text):
1052
+ return True
1053
+ if tracker in STATE_ROLE_TRACKERS:
1054
+ if LIFECYCLE_ROLE_READY.search(text):
1055
+ return True
1056
+ if ready_role and re.search(
1057
+ r"\"(?:state|status|stateName|statusName|state_name|name|transition)\""
1058
+ r"\s*:\s*\"%s\"" % re.escape(ready_role),
1059
+ text,
1060
+ re.IGNORECASE,
1061
+ ):
1062
+ return True
1063
+ return False
1064
+
1065
+
1066
+ def scope_declaration(text, state_role_ok):
1067
+ """Whether a whole text declares readiness for everything inside it.
1068
+
1069
+ Only the two MARKERS qualify, never the `--label` flag, and that asymmetry
1070
+ is the same one the argv check already makes. A marker has no other
1071
+ meaning, so its presence anywhere in a script IS the declaration for that
1072
+ script. A label flag is positional and belongs to one create; letting it
1073
+ vouch for a second, unlabelled create further down the same file would be a
1074
+ hole rather than a convenience.
1075
+
1076
+ `state_role_ok` carries the SAME scoping the argv path applies, and it has
1077
+ to be threaded here rather than recomputed from the tracker alone: a Linear
1078
+ project whose script files `gh issue create --repo <other>` is a cross-repo
1079
+ GitHub filing, and this project's workflow role does not answer for another
1080
+ repository's queue. Checking only `tracker in STATE_ROLE_TRACKERS` waved
1081
+ exactly that through. Caught by review before it shipped.
1082
+
1083
+ Args:
1084
+ text: A script's contents, or one command segment.
1085
+ state_role_ok: Whether a lifecycle-role declaration answers here.
1086
+
1087
+ Returns:
1088
+ True when the text carries a whole-scope declaration.
1089
+ """
1090
+ if HUMAN_GATE_MARKER in text:
1091
+ return True
1092
+ return bool(state_role_ok and LIFECYCLE_ROLE_READY.search(text))
1093
+
1094
+
1095
+ def creation_signature(name, args, extra=""):
737
1096
  """Classify a tracker CLI invocation as a creation.
738
1097
 
739
1098
  Args:
740
1099
  name: The CLI basename.
741
1100
  args: Every token after it in this segment.
1101
+ extra: Payload text this command submits, from a file or stdin.
742
1102
 
743
1103
  Returns:
744
1104
  A short human-readable signature, or None.
@@ -746,6 +1106,8 @@ def creation_signature(name, args):
746
1106
  if "--help" in args or "-h" in args:
747
1107
  return None
748
1108
  joined = " ".join(args)
1109
+ if extra:
1110
+ joined = joined + "\n" + extra
749
1111
 
750
1112
  if name == "gh":
751
1113
  if invokes_verb(args, {"issue"}, "create"):
@@ -933,12 +1295,16 @@ def roles_for(target):
933
1295
  return [ready_role, role], None
934
1296
 
935
1297
 
936
- def declares_readiness(raw_args, roles):
937
- """Whether the create carries one of the two required declarations.
1298
+ def declares_readiness(raw_args, roles, extra="", state_role_ok=False):
1299
+ """Whether the create carries one of the required declarations.
938
1300
 
939
1301
  Args:
940
1302
  raw_args: The creating command's arguments.
941
1303
  roles: The build-ready role tokens that satisfy this filing.
1304
+ extra: Payload text this command submits, from a file or stdin.
1305
+ state_role_ok: Whether a lifecycle-role declaration answers here. True
1306
+ only for a state-based tracker filing into its own tracker, where
1307
+ no argv flag on the mandated client can carry the state.
942
1308
 
943
1309
  Returns:
944
1310
  True when a build-ready role or a human-gate marker is present.
@@ -964,6 +1330,16 @@ def declares_readiness(raw_args, roles):
964
1330
  return True
965
1331
  except OSError:
966
1332
  continue
1333
+ if extra and HUMAN_GATE_MARKER in extra:
1334
+ return True
1335
+ # The state-based path. Scoped by `state_role_ok` rather than checked
1336
+ # unconditionally, so this adds a compliant command where none existed and
1337
+ # takes none away where one already did.
1338
+ if state_role_ok:
1339
+ if LIFECYCLE_ROLE_READY.search(" ".join(args)):
1340
+ return True
1341
+ if extra and text_declares_readiness(extra):
1342
+ return True
967
1343
  return False
968
1344
 
969
1345
 
@@ -1019,12 +1395,81 @@ def nested_operands(argv):
1019
1395
  return operands
1020
1396
 
1021
1397
 
1022
- def scan(text, depth):
1398
+ inspected_files = set()
1399
+
1400
+
1401
+ def file_operands(argv):
1402
+ """Files this command names, in the order they appear.
1403
+
1404
+ Every token is offered, and the ones that name a readable regular file are
1405
+ kept. Deciding WHICH programs execute their operands is the unbounded
1406
+ question this file already refuses to ask, so it is not asked here either.
1407
+
1408
+ Args:
1409
+ argv: One command's tokens.
1410
+
1411
+ Returns:
1412
+ Existing paths, deduplicated across the whole scan and capped.
1413
+ """
1414
+ paths = []
1415
+ for token in argv:
1416
+ if len(paths) >= FILE_OPERANDS_PER_SEGMENT:
1417
+ break
1418
+ path = resolve_operand(token)
1419
+ if path is None:
1420
+ continue
1421
+ try:
1422
+ key = os.path.realpath(path)
1423
+ except OSError:
1424
+ key = path
1425
+ if key in inspected_files:
1426
+ continue
1427
+ inspected_files.add(key)
1428
+ paths.append(path)
1429
+ return paths
1430
+
1431
+
1432
+ def file_creation(text, depth):
1433
+ """An undeclared tracker creation inside a file's contents, or None.
1434
+
1435
+ Two recognisers, because a creation inside a file is not always shell.
1436
+ The shell path handles `bash create.sh`; the CONJUNCTION path handles
1437
+ `node wrapper.mjs`, a Python client, or anything else that speaks HTTP
1438
+ directly — it needs a tracker endpoint AND a creation verb in the same
1439
+ file, which is what keeps a changelog that merely mentions `issueCreate`
1440
+ from reading as a creation.
1441
+
1442
+ Args:
1443
+ text: The file's contents.
1444
+ depth: Current nesting depth.
1445
+
1446
+ Returns:
1447
+ A (signature, roles, cross_repo_target) triple, or None.
1448
+ """
1449
+ nested = scan(text, depth + 1, from_file=True)
1450
+ if nested is not None:
1451
+ return nested
1452
+ # The coarse path answers to a coarse declaration check, and the precise
1453
+ # path above answers to the precise one. Reversing that — screening the
1454
+ # whole file first — would let a declaration on one create in a script
1455
+ # vouch for a different, undeclared one further down.
1456
+ if (
1457
+ GRAPHQL_CREATE.search(text)
1458
+ and TRACKER_ENDPOINT.search(text)
1459
+ and not text_declares_readiness(text)
1460
+ ):
1461
+ return "a tracker creation", [ready_role], None
1462
+ return None
1463
+
1464
+
1465
+ def scan(text, depth, from_file=False):
1023
1466
  """Find the first undeclared tracker creation in a command string.
1024
1467
 
1025
1468
  Args:
1026
1469
  text: A shell command.
1027
1470
  depth: Current nesting depth.
1471
+ from_file: Whether `text` is a file's contents rather than a typed
1472
+ command.
1028
1473
 
1029
1474
  Returns:
1030
1475
  A (signature, roles, cross_repo_target) triple, or None when nothing
@@ -1038,6 +1483,13 @@ def scan(text, depth):
1038
1483
  # comment to bash, which strips it and RUNS the create, while shlex
1039
1484
  # raises on the unbalanced quote. Two appended characters, no binary
1040
1485
  # required. "I could not parse it" must never mean "it is fine".
1486
+ #
1487
+ # A FILE that does not lex is judged by the same recogniser rather than
1488
+ # waved through: an unbalanced quote inside a script is the identical
1489
+ # two-character trick moved one file away, and skipping it would hand
1490
+ # the bypass straight back.
1491
+ if from_file and text_declares_readiness(text):
1492
+ return None
1041
1493
  if UNPARSEABLE_CREATION.search(text):
1042
1494
  return (
1043
1495
  "an unparseable command that reads as a tracker creation",
@@ -1052,7 +1504,8 @@ def scan(text, depth):
1052
1504
  if name not in TRACKER_CLIS and name not in HTTP_CLIS:
1053
1505
  continue
1054
1506
  args = argv[index + 1 :]
1055
- signature = creation_signature(name, args)
1507
+ submitted = payload_text(args, text)
1508
+ signature = creation_signature(name, args, submitted)
1056
1509
  if signature is None:
1057
1510
  continue
1058
1511
  # The ambient override is the human operator's. An inline
@@ -1061,7 +1514,18 @@ def scan(text, depth):
1061
1514
  if ambient_override and not inline_override:
1062
1515
  continue
1063
1516
  roles, target = roles_for(target_repository(args))
1064
- if declares_readiness(args, roles):
1517
+ state_role_ok = tracker in STATE_ROLE_TRACKERS and target is None
1518
+ if declares_readiness(args, roles, submitted, state_role_ok):
1519
+ continue
1520
+ # `LIFECYCLE_ROLE=ready curl …` puts the declaration BEFORE the
1521
+ # client, which is where an inline assignment has to go, so the
1522
+ # role is read from the whole segment rather than from the client's
1523
+ # own arguments. Scoped to the segment and not the command, so a
1524
+ # declaration cannot be shouted from an unrelated pipeline stage.
1525
+ if state_role_ok and LIFECYCLE_ROLE_READY.search(" ".join(argv)):
1526
+ continue
1527
+ # A script declares once, for itself. See `scope_declaration`.
1528
+ if from_file and scope_declaration(text, state_role_ok):
1065
1529
  continue
1066
1530
  return signature, roles, target
1067
1531
 
@@ -1080,6 +1544,36 @@ def scan(text, depth):
1080
1544
  nested = scan(operand, depth + 1)
1081
1545
  if nested is not None:
1082
1546
  return nested
1547
+
1548
+ # The locate step, which used to stop at `bash` and never reach the
1549
+ # operand. Deliberately last: an inline creation is the cheaper and
1550
+ # more precise finding, so it is reported before a file is opened.
1551
+ # The operator's ambient override is checked BEFORE the depth bound,
1552
+ # not after it. The other order meant a creation reached past the cap
1553
+ # was refused even with `LISA_ALLOW_DIRECT_ISSUE_CREATE=1` exported —
1554
+ # while the refusal text advertised that escape. An escape hatch the
1555
+ # refusal names and the code ignores is worse than none. Caught by
1556
+ # review before it shipped.
1557
+ if ambient_override and not inline_override:
1558
+ continue
1559
+ for path in file_operands(argv):
1560
+ contents = read_operand(path)
1561
+ if not contents:
1562
+ continue
1563
+ if depth >= MAX_NESTING_DEPTH:
1564
+ if GRAPHQL_CREATE.search(contents) or UNPARSEABLE_CREATION.search(
1565
+ contents
1566
+ ):
1567
+ return (
1568
+ "a tracker creation inside %s, nested past the "
1569
+ "inspection depth" % path,
1570
+ [ready_role],
1571
+ None,
1572
+ )
1573
+ continue
1574
+ found = file_creation(contents, depth)
1575
+ if found is not None:
1576
+ return ("%s inside %s" % (found[0], path), found[1], found[2])
1083
1577
  return None
1084
1578
 
1085
1579
 
@@ -1118,6 +1612,8 @@ verdict="$(
1118
1612
  LISA_GUARD_UPSTREAM_REPO="$upstream_repo" \
1119
1613
  LISA_GUARD_UPSTREAM_READY_ROLE="$upstream_ready_role" \
1120
1614
  LISA_GUARD_CALLER_IS_GITHUB="$caller_is_github" \
1615
+ LISA_GUARD_TRACKER="$tracker" \
1616
+ LISA_GUARD_PROJECT_DIR="$project_dir" \
1121
1617
  python3 -
1122
1618
  )"
1123
1619
  python_status=$?
@@ -0,0 +1,14 @@
1
+ /** One doctor check result, structurally identical to `DoctorCheck`. */
2
+ interface CoverageCheck {
3
+ name: string;
4
+ status: "ok" | "warn" | "fail";
5
+ detail: string;
6
+ }
7
+ /**
8
+ * Report what this checkout resolves.
9
+ * @param targetPath - Project path
10
+ * @returns The check result
11
+ */
12
+ export declare function checkEnforcementCoverage(targetPath: string): Promise<CoverageCheck>;
13
+ export {};
14
+ //# sourceMappingURL=doctor-enforcement-coverage.d.ts.map