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