@codyswann/lisa 3.5.4 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +3 -2
  2. package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +773 -0
  3. package/dist/codex/hooks-installer.d.ts.map +1 -1
  4. package/dist/codex/hooks-installer.js +8 -0
  5. package/dist/codex/hooks-installer.js.map +1 -1
  6. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.js +36 -16
  8. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  9. package/dist/opencode/plugin-catalog.d.ts.map +1 -1
  10. package/dist/opencode/plugin-catalog.js +5 -0
  11. package/dist/opencode/plugin-catalog.js.map +1 -1
  12. package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +210 -0
  13. package/package.json +1 -1
  14. package/plugins/lisa/.claude-plugin/plugin.json +5 -1
  15. package/plugins/lisa/.codex-plugin/hooks.json +4 -0
  16. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa/.codex-plugin/skills/lisa-github-validate-issue/SKILL.md +12 -6
  18. package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +12 -2
  19. package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +1 -1
  20. package/plugins/lisa/.codex-plugin/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
  21. package/plugins/lisa/.codex-plugin/skills/lisa-jira-write-ticket/SKILL.md +12 -2
  22. package/plugins/lisa/.codex-plugin/skills/lisa-linear-validate-issue/SKILL.md +12 -6
  23. package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +15 -2
  24. package/plugins/lisa/hooks/block-direct-issue-create.agy.sh +59 -0
  25. package/plugins/lisa/hooks/block-direct-issue-create.sh +773 -0
  26. package/plugins/lisa/rules/eager/derived-branch-plan.md +3 -1
  27. package/plugins/lisa/rules/eager/ready-role-filing.md +4 -0
  28. package/plugins/lisa/rules/reference/derived-branch-plan.md +34 -0
  29. package/plugins/lisa/rules/reference/ready-role-filing.md +27 -0
  30. package/plugins/lisa/skills/lisa-github-validate-issue/SKILL.md +12 -6
  31. package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +12 -2
  32. package/plugins/lisa/skills/lisa-implement/SKILL.md +1 -1
  33. package/plugins/lisa/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
  34. package/plugins/lisa/skills/lisa-jira-write-ticket/SKILL.md +12 -2
  35. package/plugins/lisa/skills/lisa-linear-validate-issue/SKILL.md +12 -6
  36. package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +15 -2
  37. package/plugins/lisa-agy/hooks/block-direct-issue-create.agy.sh +59 -0
  38. package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +773 -0
  39. package/plugins/lisa-agy/hooks.json +13 -0
  40. package/plugins/lisa-agy/plugin.json +1 -1
  41. package/plugins/lisa-agy/skills/lisa-github-validate-issue/SKILL.md +12 -6
  42. package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +12 -2
  43. package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +1 -1
  44. package/plugins/lisa-agy/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
  45. package/plugins/lisa-agy/skills/lisa-jira-write-ticket/SKILL.md +12 -2
  46. package/plugins/lisa-agy/skills/lisa-linear-validate-issue/SKILL.md +12 -6
  47. package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +15 -2
  48. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  51. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-copilot/.claude-plugin/plugin.json +5 -1
  54. package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +773 -0
  55. package/plugins/lisa-copilot/rules/eager/derived-branch-plan.md +3 -1
  56. package/plugins/lisa-copilot/rules/eager/ready-role-filing.md +4 -0
  57. package/plugins/lisa-copilot/rules/reference/derived-branch-plan.md +34 -0
  58. package/plugins/lisa-copilot/rules/reference/ready-role-filing.md +27 -0
  59. package/plugins/lisa-copilot/skills/lisa-github-validate-issue/SKILL.md +12 -6
  60. package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +12 -2
  61. package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +1 -1
  62. package/plugins/lisa-copilot/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
  63. package/plugins/lisa-copilot/skills/lisa-jira-write-ticket/SKILL.md +12 -2
  64. package/plugins/lisa-copilot/skills/lisa-linear-validate-issue/SKILL.md +12 -6
  65. package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +15 -2
  66. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +773 -0
  68. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  69. package/plugins/lisa-cursor/rules/derived-branch-plan-reference.mdc +34 -0
  70. package/plugins/lisa-cursor/rules/derived-branch-plan.mdc +3 -1
  71. package/plugins/lisa-cursor/rules/ready-role-filing-reference.mdc +27 -0
  72. package/plugins/lisa-cursor/rules/ready-role-filing.mdc +4 -0
  73. package/plugins/lisa-cursor/skills/lisa-github-validate-issue/SKILL.md +12 -6
  74. package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +12 -2
  75. package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +1 -1
  76. package/plugins/lisa-cursor/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
  77. package/plugins/lisa-cursor/skills/lisa-jira-write-ticket/SKILL.md +12 -2
  78. package/plugins/lisa-cursor/skills/lisa-linear-validate-issue/SKILL.md +12 -6
  79. package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +15 -2
  80. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-expo-agy/plugin.json +1 -1
  83. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  86. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  87. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  88. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  91. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  92. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  93. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  96. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  97. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  98. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  100. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  102. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  103. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  105. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  106. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  107. package/plugins/lisa-rails-agy/plugin.json +1 -1
  108. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  110. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  111. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  112. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  113. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  115. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  117. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  118. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  120. package/plugins/src/base/.claude-plugin/plugin.json +4 -0
  121. package/plugins/src/base/hooks/block-direct-issue-create.agy.sh +59 -0
  122. package/plugins/src/base/hooks/block-direct-issue-create.sh +773 -0
  123. package/plugins/src/base/rules/eager/derived-branch-plan.md +3 -1
  124. package/plugins/src/base/rules/eager/ready-role-filing.md +4 -0
  125. package/plugins/src/base/rules/reference/derived-branch-plan.md +34 -0
  126. package/plugins/src/base/rules/reference/ready-role-filing.md +27 -0
  127. package/plugins/src/base/skills/lisa-github-validate-issue/SKILL.md +12 -6
  128. package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +12 -2
  129. package/plugins/src/base/skills/lisa-implement/SKILL.md +1 -1
  130. package/plugins/src/base/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
  131. package/plugins/src/base/skills/lisa-jira-write-ticket/SKILL.md +12 -2
  132. package/plugins/src/base/skills/lisa-linear-validate-issue/SKILL.md +12 -6
  133. package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +15 -2
  134. package/scripts/build-plugins.sh +1 -1
  135. package/scripts/generate-agy-plugin-artifacts.mjs +10 -0
  136. package/scripts/lib/per-agent-hook-filter.mjs +10 -0
  137. package/scripts/lisa-enforcement-fallback.sh +3 -2
@@ -0,0 +1,773 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for Bash: refuse a direct tracker-creation command that
3
+ # declares no readiness.
4
+ #
5
+ # WHY THIS IS A HOOK AND NOT A RULE
6
+ #
7
+ # The `ready-role-filing` rule says every filing declares either
8
+ # `build_ready: true` or a `human_gate:` reason, and that filings go through
9
+ # `lisa-track` / `lisa-tracker-write`. A conformance audit of the ~13 issues
10
+ # filed during one working session found 13/13 bypassed it, with zero
11
+ # `lisa-track` invocations — eight of them filed AFTER the rule merged, several
12
+ # by the agent that wrote the rule. Over the same window `Co-Authored-By`
13
+ # compliance was 50/50, because a husky `commit-msg` hook enforces it.
14
+ #
15
+ # Prose at the EAGER-RULE rung did not bind even its own author; the executable
16
+ # control was never once violated. Lisa's `learnings-ladder` rule says
17
+ # machine-checkable knowledge belongs at EXECUTABLE-CONTROL. This is that
18
+ # promotion.
19
+ #
20
+ # WHAT IT CHECKS, AND WHY THAT AND NOT "DID YOU USE THE SKILL"
21
+ #
22
+ # A Bash-level hook cannot observe call provenance. Any provenance signal an
23
+ # agent could carry — a flag, a marker, an env var — is settable by the very
24
+ # agent being governed, so a guard built on one is theatre. What the hook CAN
25
+ # observe is the artifact: whether the command about to run produces a
26
+ # correctly declared work item. So it enforces the checkable half.
27
+ #
28
+ # A creation command is refused unless it carries a readiness declaration:
29
+ # - the project's configured build-ready role (GitHub label, JIRA/Linear
30
+ # workflow state) resolved from `.lisa.config.json`, never hard-coded; or
31
+ # - an explicit `[lisa-human-gate]` marker, inline or in the `--body-file`
32
+ # the create is about to submit.
33
+ #
34
+ # That is exactly the machine-checkable content of `ready-role-filing`, and it
35
+ # lets `lisa-github-write-issue` / `lisa-jira-write-ticket` /
36
+ # `lisa-linear-write-issue` through by construction, because those writers
37
+ # always stamp one. A blanket refusal would have blocked Lisa's own writers and
38
+ # left the factory unable to file anything.
39
+ #
40
+ # Creation signatures, per tracker CLI and the two ways around each:
41
+ # gh issue create · gh api POST to .../issues · gh api graphql createIssue
42
+ # linear issue create · jira issue create · acli … workitem/issue create
43
+ # curl/http POST to api.github.com/…/issues, api.linear.app/graphql with
44
+ # issueCreate, or …atlassian.net/rest/api/…/issue
45
+ # Reads never fire: `gh issue list`, `gh issue view`, `gh issue edit`,
46
+ # `gh pr create`, `gh label create`, a bare `gh api …/issues` GET, and a prose
47
+ # mention inside a quoted string are all allowed.
48
+ #
49
+ # STANDING DOWN
50
+ #
51
+ # - No tracker configured (`.lisa.config.json` absent, or carrying no
52
+ # `tracker`). There is no `lisa-tracker-write` to route through, so the
53
+ # guard has nothing to redirect to. This is the bootstrapping case, and it
54
+ # is DETECTED rather than asserted — the operator does not have to remember
55
+ # an env var to bring up a new repo.
56
+ # - `LISA_ALLOW_DIRECT_ISSUE_CREATE` non-empty in the hook's inherited
57
+ # environment. This is the human operator's override, mirroring
58
+ # `LISA_ALLOW_INSTRUCTION_FILE_WRITE`.
59
+ #
60
+ # The override is honored ONLY from the ambient environment, and is refused
61
+ # outright when it appears as an inline assignment in the intercepted command.
62
+ # That distinction is the whole point: a tool-call shell is fresh every time
63
+ # and its exports do not reach this hook's environment, so the ambient variable
64
+ # can only have been set by a human before the session started (shell profile,
65
+ # settings env block, CI config). An escape the governed agent reaches by
66
+ # typing one more token in front of the command it was just refused is not an
67
+ # escape hatch — it is the prose problem with extra steps.
68
+ set -euo pipefail
69
+
70
+ input="$(cat)"
71
+
72
+ # Probe both interpreters before use and announce a missing one rather than
73
+ # swallowing it. Under `set -e` an absent jq aborts with 127, and Claude Code
74
+ # treats any non-2 exit as a NON-BLOCKING hook error — so the guard would
75
+ # silently permit exactly what it exists to stop. Degrading to "allow" is
76
+ # right (a hook that cannot parse its input cannot tell a filing from a read),
77
+ # but doing it quietly is not: a guard that is silently absent reads exactly
78
+ # like a guard that is passing. Same reasoning as block-no-verify.sh.
79
+ for required in jq python3; do
80
+ if ! command -v "$required" >/dev/null 2>&1; then
81
+ printf 'block-direct-issue-create: %s not found; ready-role filing enforcement is NOT active\n' \
82
+ "$required" >&2
83
+ exit 0
84
+ fi
85
+ done
86
+
87
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
88
+ if [ "$tool_name" != "Bash" ]; then
89
+ exit 0
90
+ fi
91
+
92
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
93
+ if [ -z "$command_str" ]; then
94
+ exit 0
95
+ fi
96
+
97
+ project_dir="${CLAUDE_PROJECT_DIR:-}"
98
+ if [ -z "$project_dir" ]; then
99
+ project_dir="$PWD"
100
+ fi
101
+
102
+ # Merged config, local overlay over base — the same precedence
103
+ # `lisa-tracker-read` / `lisa-tracker-write` resolve with, so the guard can
104
+ # never disagree with the writer about which tracker a project has.
105
+ read_config_value() {
106
+ local filter="$1"
107
+ local value=""
108
+ local file
109
+ for file in "$project_dir/.lisa.config.json" "$project_dir/.lisa.config.local.json"; do
110
+ [ -f "$file" ] || continue
111
+ local candidate
112
+ candidate="$(jq -r "$filter // empty" "$file" 2>/dev/null || true)"
113
+ [ -n "$candidate" ] && value="$candidate"
114
+ done
115
+ printf '%s' "$value"
116
+ }
117
+
118
+ tracker="$(read_config_value '.tracker')"
119
+ if [ -z "$tracker" ]; then
120
+ exit 0
121
+ fi
122
+
123
+ # The build-ready role is read from config, never hard-coded: a project that
124
+ # renamed its ready lane must still be able to satisfy the guard, and the
125
+ # refusal has to name the token that project actually uses.
126
+ case "$tracker" in
127
+ github) ready_role="$(read_config_value '.github.labels.build.ready')" ;;
128
+ jira) ready_role="$(read_config_value '.jira.workflow.ready')" ;;
129
+ linear) ready_role="$(read_config_value '.linear.workflow.ready')" ;;
130
+ *) ready_role="" ;;
131
+ esac
132
+ if [ -z "$ready_role" ]; then
133
+ ready_role="status:ready"
134
+ fi
135
+
136
+ # Ambient-only override. Deliberately read here, from the hook process's own
137
+ # environment, and never from the command being inspected.
138
+ ambient_override="${LISA_ALLOW_DIRECT_ISSUE_CREATE:-}"
139
+
140
+ refuse() {
141
+ local signature="$1"
142
+ cat >&2 <<EOF
143
+ BLOCKED: refusing \`$signature\` — this filing declares no readiness.
144
+
145
+ WHY: a work item filed without the build-ready role is an incomplete handoff.
146
+ Build-intake scans the ready lane and nothing else, so nothing will ever pick
147
+ it up: the write succeeds and the work still dies. An audit of one working
148
+ session found 13 of 13 issues filed this way, none of them through Lisa's
149
+ filing path. The rule saying not to do this already existed — it did not bind,
150
+ so it is now enforced here.
151
+
152
+ FILE IT THE SANCTIONED WAY — one of these two, always explicit:
153
+
154
+ 1. The item is complete enough to build. Use the filing flow, not the CLI:
155
+
156
+ /lisa:track "<what needs building>"
157
+
158
+ which resolves or creates exactly one live leaf through
159
+ \`lisa-tracker-write\` with \`build_ready: true\`, validates it before the
160
+ write, and claims it. Complete means the \`work-item-definition-of-ready\`
161
+ bar — reproduction, observed-versus-expected, Gherkin acceptance criteria.
162
+
163
+ 2. A human product call is genuinely pending. Route the same way but pass
164
+ \`human_gate: "<why a human must judge this first>"\`, which stamps the hold
165
+ so it is auditable rather than indistinguishable from an accident:
166
+
167
+ Held for a human product call: <reason>.
168
+ <!-- [lisa-human-gate] reason=<short-slug> -->
169
+
170
+ Filed, not ready, and no \`human_gate\` is the incomplete-handoff case, and
171
+ \`build_ready: false\` with no reason is the same omission with a value
172
+ attached. See the \`ready-role-filing\` rule for the full contract.
173
+
174
+ If you must run the CLI directly, the command has to carry one of the two
175
+ declarations itself: the configured build-ready role \`$ready_role\` as the
176
+ value of a \`--label\` / \`--status\` / \`--state\` flag (not in the title or
177
+ body — a role named in prose is not a role applied), or a \`[lisa-human-gate]\`
178
+ marker in the body it submits.
179
+
180
+ OPERATOR ESCAPE: a human can export \`LISA_ALLOW_DIRECT_ISSUE_CREATE=1\` in the
181
+ environment before starting the session. It is deliberately not reachable by
182
+ setting it inline on this command — an inline assignment is refused.
183
+ EOF
184
+ exit 2
185
+ }
186
+
187
+ # The classifier is read into a variable with a top-level here-document rather
188
+ # than piped straight in from inside `$( … )`. bash 3.2 — which is what macOS
189
+ # still ships as /bin/bash, and therefore what this fleet's hooks run under —
190
+ # mis-parses a here-document nested in a command substitution, scanning the
191
+ # document body for quotes and parentheses it should be treating as literal.
192
+ # The whole script then fails to parse, which is the worst possible failure for
193
+ # a guard: a syntax error exits non-zero, and Claude Code reads every non-2
194
+ # exit as a non-blocking hook error, so the command runs unchecked.
195
+ classifier=""
196
+ read -r -d '' classifier <<'PY' || true
197
+ import os
198
+ import re
199
+ import shlex
200
+ import sys
201
+
202
+ command = os.environ.get("LISA_GUARD_COMMAND", "")
203
+ ready_role = os.environ.get("LISA_GUARD_READY_ROLE", "")
204
+ ambient_override = os.environ.get("LISA_GUARD_AMBIENT_OVERRIDE", "")
205
+
206
+ OVERRIDE_NAME = "LISA_ALLOW_DIRECT_ISSUE_CREATE"
207
+ HUMAN_GATE_MARKER = "[lisa-human-gate]"
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # WHY THIS IS A TOKEN SCAN AND NOT A PROGRAM RESOLVER
211
+ #
212
+ # The first version of this classifier asked "what program is being invoked?"
213
+ # and answered it by stripping a fixed allowlist of wrapper programs. That
214
+ # question is unbounded, so the allowlist could never be complete, and every
215
+ # gap failed OPEN: an unrecognised prefix became the resolved program,
216
+ # classification returned "not a creation", and the guard allowed. A
217
+ # branch-enumerating probe found 36 fail-opens, including `nice -n 10 gh issue
218
+ # create` — and `nice` is POSIX, present on every host, so one prepended token
219
+ # defeated the whole control end to end.
220
+ #
221
+ # Enumerating command SHAPES had scored 21/21 against that same code. The
222
+ # lesson is the method, not the list: probe the parser's decision points
223
+ # (tokenise, split, locate, classify, declare), not the attacks you can think
224
+ # of.
225
+ #
226
+ # So the question is inverted. Instead of "what program is this?" — unbounded —
227
+ # the classifier asks "does this command line invoke a tracker CLI in a
228
+ # creation mode?", which is bounded by a tracker list we already maintain.
229
+ # Every token is examined; anything before the CLI token is irrelevant by
230
+ # construction. `nice`, `stdbuf`, `caffeinate`, `xcrun`, `arch`, `script`,
231
+ # `timeout`, `env -i`, `sudo -u`, `xargs -I{}`, absolute paths, and every
232
+ # wrapper nobody has thought of yet all collapse into one code path.
233
+ #
234
+ # THE DEFAULT ANSWER TO "I DO NOT UNDERSTAND THIS" IS NOW REFUSE.
235
+ # Tokenisation failure, a nesting bound, an unparsed operand: each refuses with
236
+ # an explanation rather than passing. An operator who hits a false refusal says
237
+ # so and the recogniser widens; an agent who hits a false allow says nothing,
238
+ # which is the entire problem.
239
+ # ---------------------------------------------------------------------------
240
+
241
+ # Tracker CLIs. Bounded and reviewable, unlike the wrapper space.
242
+ TRACKER_CLIS = {"gh", "jira", "linear", "acli"}
243
+ HTTP_CLIS = {"curl", "http", "httpie", "wget"}
244
+
245
+ # Text that looks like a tracker creation even when it cannot be tokenised.
246
+ # Used ONLY on the tokenise-failure path, where the choice is between refusing
247
+ # and permitting something we demonstrably cannot read. Deliberately narrower
248
+ # than "mentions a tracker CLI" so an ordinary unparseable command
249
+ # (`echo 'it's fine`) still passes.
250
+ UNPARSEABLE_CREATION = re.compile(
251
+ r"\b(?:gh|jira|linear)\b[^\n]*?\bissue\b[^\n]*?\bcreate\b"
252
+ r"|\bacli\b[^\n]*?\b(?:workitem|issue)s?\b[^\n]*?\bcreate\b"
253
+ r"|\bcreateIssue\b|\bissueCreate\b"
254
+ r"|repos/[^/\s]+/[^/\s]+/issues"
255
+ r"|atlassian\.net/rest/api/[^/\s]+/issue",
256
+ re.IGNORECASE,
257
+ )
258
+
259
+ BODY_FILE_FLAGS = {"--body-file", "-F", "--input", "--data-binary"}
260
+ LABEL_FLAGS = {"--label", "--labels", "--add-label", "--status", "--state"}
261
+ POST_METHOD_FLAGS = {"-X", "--request", "--method"}
262
+ POST_PAYLOAD_FLAGS = {
263
+ "-d", "--data", "--data-raw", "--data-binary",
264
+ "-f", "-F", "--raw-field", "--field", "--input",
265
+ }
266
+ # Flags whose VALUE is a payload field. `-f path=repos/o/r/issues` must not be
267
+ # read as an endpoint: it is data being sent, not the address being posted to.
268
+ PAYLOAD_VALUE_FLAGS = {"-f", "-F", "--raw-field", "--field"}
269
+
270
+ GITHUB_ISSUES_PATH = re.compile(r"repos/[^/\s]+/[^/\s]+/issues/?$")
271
+ GITHUB_ISSUES_URL = re.compile(r"api\.github\.com/repos/[^/\s]+/[^/\s]+/issues")
272
+ JIRA_ISSUE_URL = re.compile(r"atlassian\.net/rest/api/[^/\s]+/issue")
273
+ GRAPHQL_CREATE = re.compile(r"createIssue|issueCreate")
274
+
275
+ MAX_NESTING_DEPTH = 3
276
+ # Operators that can be GLUED to an adjacent word (`true&&gh issue create`),
277
+ # so they must be split out of a token. Braces and parentheses are deliberately
278
+ # absent: shlex collapses a quoted argument into one token, so a GraphQL
279
+ # payload arrives as `query=mutation{issueCreate(input:{})…}` and splitting on
280
+ # braces tore it into fragments — silently un-refusing every GraphQL creation.
281
+ # Standalone grouping punctuation is handled at segment and basename level
282
+ # instead, where it cannot reach into a payload's contents.
283
+ GLUED_OPERATORS = ("&&", "||", ";;", ";", "|", "&")
284
+ SEGMENT_BOUNDARIES = set(GLUED_OPERATORS) | {"(", ")", "{", "}"}
285
+
286
+
287
+ def strip_heredocs(text):
288
+ """Drop heredoc bodies so quoted prose cannot be read as argv.
289
+
290
+ Args:
291
+ text: The raw command string.
292
+
293
+ Returns:
294
+ The command with heredoc bodies removed.
295
+ """
296
+ lines = text.splitlines()
297
+ output = []
298
+ pending = []
299
+ marker_pattern = re.compile(
300
+ r"<<-?\s*(?:'([^']+)'|\"([^\"]+)\"|([A-Za-z_][A-Za-z0-9_]*))"
301
+ )
302
+ index = 0
303
+ while index < len(lines):
304
+ line = lines[index]
305
+ output.append(line)
306
+ pending.extend(
307
+ next(group for group in match.groups() if group)
308
+ for match in marker_pattern.finditer(line)
309
+ )
310
+ index += 1
311
+ while pending and index < len(lines):
312
+ if lines[index].strip() == pending[0]:
313
+ output.append(lines[index])
314
+ pending.pop(0)
315
+ index += 1
316
+ break
317
+ index += 1
318
+ return "\n".join(output)
319
+
320
+
321
+ def explode_operators(tokens):
322
+ """Split shell control operators glued to adjacent words.
323
+
324
+ `true&&gh issue create` tokenises as one word `true&&gh`, whose basename is
325
+ not `gh`, so the creation hid behind the operator. Splitting them out means
326
+ an operator can never be load-bearing punctuation inside a token.
327
+
328
+ Args:
329
+ tokens: Tokens from shlex.
330
+
331
+ Returns:
332
+ Tokens with operators separated out.
333
+ """
334
+ pattern = re.compile(
335
+ "(" + "|".join(re.escape(op) for op in GLUED_OPERATORS) + ")"
336
+ )
337
+ exploded = []
338
+ for token in tokens:
339
+ for piece in pattern.split(token):
340
+ if piece:
341
+ exploded.append(piece)
342
+ return exploded
343
+
344
+
345
+ def segment(tokens):
346
+ """Split a token stream into individual commands at shell operators.
347
+
348
+ Args:
349
+ tokens: Exploded tokens.
350
+
351
+ Returns:
352
+ A list of argv lists.
353
+ """
354
+ segments = []
355
+ current = []
356
+ for token in tokens:
357
+ if token in SEGMENT_BOUNDARIES:
358
+ segments.append(current)
359
+ current = []
360
+ continue
361
+ current.append(token)
362
+ segments.append(current)
363
+ return [item for item in segments if item]
364
+
365
+
366
+ def basename(token):
367
+ """The final path component of a token, quotes stripped.
368
+
369
+ Args:
370
+ token: A shell token.
371
+
372
+ Returns:
373
+ The basename.
374
+ """
375
+ return token.strip("'\"").strip("(){}").rsplit("/", 1)[-1]
376
+
377
+
378
+ def is_flag_value(args, index):
379
+ """Whether the token at `index` is the value of the preceding flag.
380
+
381
+ This is the single position question the classifier keeps having to ask,
382
+ and getting it wrong is what produced three separate bypasses: the role
383
+ read from a `--title`, the role read past `--`, and a subcommand read as a
384
+ flag's value. It is answered in exactly one place now.
385
+
386
+ Args:
387
+ args: A command's arguments.
388
+ index: The position to test.
389
+
390
+ Returns:
391
+ True when the previous token is a flag that carries no `=`.
392
+ """
393
+ if index == 0:
394
+ return False
395
+ previous = args[index - 1]
396
+ return previous.startswith("-") and previous != "-" and "=" not in previous
397
+
398
+
399
+ def bare_index(args, word, start=0):
400
+ """Index of `word` appearing as itself rather than as a flag's value.
401
+
402
+ Args:
403
+ args: A command's arguments.
404
+ word: The word to locate.
405
+ start: Index to search from.
406
+
407
+ Returns:
408
+ The index, or -1.
409
+ """
410
+ for index in range(start, len(args)):
411
+ if args[index] == word and not is_flag_value(args, index):
412
+ return index
413
+ return -1
414
+
415
+
416
+ def invokes_verb(args, groups, verb):
417
+ """Whether a group word is followed later by a bare verb.
418
+
419
+ Deliberately tolerant of anything between them, because a flag may sit
420
+ between the group and the verb — `gh issue --repo o/r create` is accepted
421
+ by cobra, which strips persistent flags before resolving the subcommand.
422
+ Tolerance is safe here only because the verb itself must be bare: that is
423
+ what keeps `gh issue list --search create` from reading as a creation.
424
+
425
+ Args:
426
+ args: A command's arguments.
427
+ groups: Acceptable group words, e.g. {"issue", "workitem"}.
428
+ verb: The verb, e.g. "create".
429
+
430
+ Returns:
431
+ True when the invocation names the verb.
432
+ """
433
+ # The bare-token filter is applied to the VERB only, never to the group
434
+ # word, and the asymmetry is the point. `--verbose` is boolean, so treating
435
+ # the token after any flag as that flag's value swallowed `api` in
436
+ # `gh --verbose api …` and `issue` in `gh --verbose issue create`. Being
437
+ # permissive about the group costs nothing, because the verb still has to
438
+ # match; being permissive about the VERB is what would read
439
+ # `gh issue list --search create` as a creation. Over-include where the
440
+ # consequence is another check, filter where the consequence is a refusal.
441
+ for group in groups:
442
+ if group not in args:
443
+ continue
444
+ group_at = args.index(group)
445
+ if bare_index(args, verb, group_at + 1) >= 0:
446
+ return True
447
+ return False
448
+
449
+
450
+ def is_write_request(args):
451
+ """Whether the arguments describe an HTTP write rather than a read.
452
+
453
+ Args:
454
+ args: A command's arguments.
455
+
456
+ Returns:
457
+ True if a POST method or a payload-bearing flag is present.
458
+ """
459
+ for index, token in enumerate(args):
460
+ if token in POST_METHOD_FLAGS:
461
+ if index + 1 < len(args) and args[index + 1].upper() == "POST":
462
+ return True
463
+ if "=" in token:
464
+ head, value = token.split("=", 1)
465
+ if head in POST_METHOD_FLAGS and value.upper() == "POST":
466
+ return True
467
+ if token.upper() == "-XPOST":
468
+ return True
469
+ if token in POST_PAYLOAD_FLAGS:
470
+ return True
471
+ return False
472
+
473
+
474
+ def endpoint_tokens(args, pattern):
475
+ """Tokens naming an API endpoint, excluding payload values.
476
+
477
+ Scans every token rather than a filtered positional list, because a BOOLEAN
478
+ flag has no value to skip and filtering swallowed the endpoint behind one:
479
+ `gh api -X POST --silent repos/o/r/issues -f title=x` hid the endpoint
480
+ behind `--silent`. Payload values are excluded the other way, so
481
+ `-f path=repos/o/r/issues` is not mistaken for the address being posted to.
482
+
483
+ Args:
484
+ args: A command's arguments.
485
+ pattern: The endpoint regex.
486
+
487
+ Returns:
488
+ Matching endpoint tokens.
489
+ """
490
+ found = []
491
+ for index, token in enumerate(args):
492
+ if "=" in token:
493
+ continue
494
+ if index > 0 and args[index - 1] in PAYLOAD_VALUE_FLAGS:
495
+ continue
496
+ if pattern.search(token):
497
+ found.append(token)
498
+ return found
499
+
500
+
501
+ def creation_signature(name, args):
502
+ """Classify a tracker CLI invocation as a creation.
503
+
504
+ Args:
505
+ name: The CLI basename.
506
+ args: Every token after it in this segment.
507
+
508
+ Returns:
509
+ A short human-readable signature, or None.
510
+ """
511
+ if "--help" in args or "-h" in args:
512
+ return None
513
+ joined = " ".join(args)
514
+
515
+ if name == "gh":
516
+ if invokes_verb(args, {"issue"}, "create"):
517
+ return "gh issue create"
518
+ # Same reasoning: `api` is located without the flag-value filter, since
519
+ # an endpoint match and a write method must both also hold.
520
+ if "api" in args:
521
+ if GRAPHQL_CREATE.search(joined):
522
+ return "gh api graphql issue creation"
523
+ if endpoint_tokens(args, GITHUB_ISSUES_PATH) and is_write_request(args):
524
+ return "gh api POST .../issues"
525
+ return None
526
+
527
+ if name in {"linear", "jira"}:
528
+ if invokes_verb(args, {"issue", "issues"}, "create"):
529
+ return "%s issue create" % name
530
+ return None
531
+
532
+ if name == "acli":
533
+ if invokes_verb(args, {"workitem", "workitems", "issue", "issues"}, "create"):
534
+ return "acli … create"
535
+ return None
536
+
537
+ if name in HTTP_CLIS:
538
+ if not is_write_request(args):
539
+ return None
540
+ for token in args:
541
+ if GITHUB_ISSUES_URL.search(token):
542
+ return "%s POST api.github.com/…/issues" % name
543
+ if JIRA_ISSUE_URL.search(token):
544
+ return "%s POST …/rest/api/…/issue" % name
545
+ if "api.linear.app/graphql" in token and GRAPHQL_CREATE.search(joined):
546
+ return "%s POST api.linear.app/graphql issueCreate" % name
547
+ return None
548
+
549
+ return None
550
+
551
+
552
+ def before_end_of_options(args):
553
+ """The arguments up to a bare `--`.
554
+
555
+ Everything after `--` is an operand, not a flag, so it cannot reach the
556
+ created item — crediting a declaration from there is the same mistake as
557
+ reading the role out of a title, one position over.
558
+
559
+ The two CLIs available for testing disagree about it, which is why the
560
+ guard cannot lean on any of them being strict: gh 2.96.0 rejects a
561
+ post-`--` flag outright, while `acli` parses straight past it and proceeds
562
+ to create the work item with the trailing `--status` silently unapplied.
563
+ That made it a live bypass on the JIRA path, verified by running it.
564
+
565
+ Args:
566
+ args: A command's arguments.
567
+
568
+ Returns:
569
+ The arguments preceding the first bare `--`.
570
+ """
571
+ return args[: args.index("--")] if "--" in args else args
572
+
573
+
574
+ def body_file_paths(args):
575
+ """Paths the command will submit as the item body.
576
+
577
+ Args:
578
+ args: A command's arguments.
579
+
580
+ Returns:
581
+ Candidate file paths, unverified.
582
+ """
583
+ paths = []
584
+ for index, token in enumerate(args):
585
+ if token in BODY_FILE_FLAGS and index + 1 < len(args):
586
+ paths.append(args[index + 1])
587
+ if "=" in token:
588
+ head, value = token.split("=", 1)
589
+ if head in BODY_FILE_FLAGS:
590
+ paths.append(value)
591
+ if token.startswith("@") and len(token) > 1:
592
+ paths.append(token[1:])
593
+ return paths
594
+
595
+
596
+ def declares_readiness(raw_args):
597
+ """Whether the create carries one of the two required declarations.
598
+
599
+ Args:
600
+ raw_args: The creating command's arguments.
601
+
602
+ Returns:
603
+ True when the build-ready role or a human-gate marker is present.
604
+ """
605
+ args = before_end_of_options(raw_args)
606
+ if ready_role:
607
+ for raw in flag_values(args, LABEL_FLAGS):
608
+ candidates = [part.strip().strip("'\"") for part in raw.split(",")]
609
+ if ready_role in candidates:
610
+ return True
611
+ # The human-gate marker is matched anywhere, and that asymmetry is
612
+ # deliberate: it is a marker with no other meaning, so its presence in the
613
+ # title or body IS the declaration. The build-ready role is an ordinary
614
+ # string that appears in prose about the queue all the time.
615
+ if HUMAN_GATE_MARKER in " ".join(args):
616
+ return True
617
+ for path in body_file_paths(args):
618
+ try:
619
+ with open(path, encoding="utf-8", errors="replace") as handle:
620
+ if HUMAN_GATE_MARKER in handle.read():
621
+ return True
622
+ except OSError:
623
+ continue
624
+ return False
625
+
626
+
627
+ def flag_values(args, names):
628
+ """Every value assigned to one of the named flags.
629
+
630
+ Args:
631
+ args: A command's arguments.
632
+ names: The flag spellings to collect.
633
+
634
+ Returns:
635
+ The raw values, unsplit and unquoted.
636
+ """
637
+ values = []
638
+ for index, token in enumerate(args):
639
+ if token in names and index + 1 < len(args):
640
+ values.append(args[index + 1])
641
+ if "=" in token:
642
+ head, value = token.split("=", 1)
643
+ if head in names:
644
+ values.append(value)
645
+ return values
646
+
647
+
648
+ def nested_operands(argv):
649
+ """Command strings this argv hands to another interpreter.
650
+
651
+ Position-scoped rather than shell-allowlisted: the operand after `-c` (or
652
+ after `eval`) is a command by the calling convention itself, whoever the
653
+ program is. That covers `bash -c`, `sh -c`, `zsh -c`, `python -c`, and the
654
+ POSIX builtin `eval`, without an allowlist to keep complete.
655
+
656
+ Recursing into arbitrary trailing quoted operands was considered and
657
+ rejected: it re-refuses `git commit -m "the gh issue create guard"`, which
658
+ is an ordinary and correct command. `ssh host '…'` is therefore NOT
659
+ intercepted — a documented limit, since that runs against another host's
660
+ tracker config and needs that host's own guard.
661
+
662
+ Args:
663
+ argv: One command's tokens.
664
+
665
+ Returns:
666
+ Nested command strings.
667
+ """
668
+ operands = []
669
+ for index, token in enumerate(argv):
670
+ if index + 1 >= len(argv):
671
+ continue
672
+ if token == "-c" or token.endswith("-c") and token.startswith("-"):
673
+ operands.append(argv[index + 1])
674
+ elif basename(token) == "eval":
675
+ operands.append(argv[index + 1])
676
+ return operands
677
+
678
+
679
+ def scan(text, depth):
680
+ """Find the first undeclared tracker creation in a command string.
681
+
682
+ Args:
683
+ text: A shell command.
684
+ depth: Current nesting depth.
685
+
686
+ Returns:
687
+ A refusal signature, or None when nothing creation-shaped was found.
688
+ """
689
+ try:
690
+ tokens = explode_operators(
691
+ shlex.split(strip_heredocs(text), posix=True)
692
+ )
693
+ except ValueError:
694
+ # Bash's grammar is not shlex's. `gh issue create --title x #'` is a
695
+ # comment to bash, which strips it and RUNS the create, while shlex
696
+ # raises on the unbalanced quote. Two appended characters, no binary
697
+ # required. "I could not parse it" must never mean "it is fine".
698
+ if UNPARSEABLE_CREATION.search(text):
699
+ return "an unparseable command that reads as a tracker creation"
700
+ return None
701
+
702
+ for argv in segment(tokens):
703
+ for index, token in enumerate(argv):
704
+ name = basename(token)
705
+ if name not in TRACKER_CLIS and name not in HTTP_CLIS:
706
+ continue
707
+ args = argv[index + 1 :]
708
+ signature = creation_signature(name, args)
709
+ if signature is None:
710
+ continue
711
+ # The ambient override is the human operator's. An inline
712
+ # assignment is the agent granting itself the exemption, so it
713
+ # disqualifies the override rather than supplying it.
714
+ if ambient_override and not inline_override:
715
+ continue
716
+ if declares_readiness(args):
717
+ continue
718
+ return signature
719
+
720
+ for operand in nested_operands(argv):
721
+ if depth >= MAX_NESTING_DEPTH:
722
+ # Refuse at the bound rather than skipping past it. Skipping
723
+ # made a creation inside a 4th `bash -c` layer pass, which is
724
+ # the depth cap being used as the bypass.
725
+ if UNPARSEABLE_CREATION.search(operand):
726
+ return "a tracker creation nested past the inspection depth"
727
+ continue
728
+ nested = scan(operand, depth + 1)
729
+ if nested is not None:
730
+ return nested
731
+ return None
732
+
733
+
734
+ # The inline-override check runs over the WHOLE raw command text, not over
735
+ # parsed tokens, so it catches `X=1 gh …`, `env X=1 gh …`, `export X=1 && gh …`,
736
+ # and the same forms buried inside a nested `bash -c '…'` string alike. Any
737
+ # appearance of the assignment disqualifies the ambient override: this is the
738
+ # one place the guard deliberately over-matches, because a false positive costs
739
+ # a human one retry and a false negative costs the entire control.
740
+ inline_override = (OVERRIDE_NAME + "=") in command
741
+
742
+ found = scan(command, 0)
743
+ if found is not None:
744
+ print("REFUSE %s" % found)
745
+ sys.exit(0)
746
+
747
+ print("ALLOW")
748
+
749
+ PY
750
+
751
+ set +e
752
+ verdict="$(
753
+ printf '%s' "$classifier" |
754
+ LISA_GUARD_COMMAND="$command_str" \
755
+ LISA_GUARD_READY_ROLE="$ready_role" \
756
+ LISA_GUARD_AMBIENT_OVERRIDE="$ambient_override" \
757
+ python3 -
758
+ )"
759
+ python_status=$?
760
+ set -e
761
+
762
+ # A crashed classifier must not be read as "allow" without saying so.
763
+ if [ "$python_status" -ne 0 ]; then
764
+ printf 'block-direct-issue-create: classifier failed (exit %s); enforcement is NOT active for this call\n' \
765
+ "$python_status" >&2
766
+ exit 0
767
+ fi
768
+
769
+ case "$verdict" in
770
+ REFUSE*) refuse "${verdict#REFUSE }" ;;
771
+ esac
772
+
773
+ exit 0