@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.
- package/all/copy-overwrite/scripts/lisa-enforcement-fallback.sh +3 -2
- package/all/copy-overwrite/scripts/lisa-hooks/block-direct-issue-create.sh +773 -0
- package/dist/codex/hooks-installer.d.ts.map +1 -1
- package/dist/codex/hooks-installer.js +8 -0
- package/dist/codex/hooks-installer.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +36 -16
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-catalog.d.ts.map +1 -1
- package/dist/opencode/plugin-catalog.js +5 -0
- package/dist/opencode/plugin-catalog.js.map +1 -1
- package/dist/opencode/plugin-templates/lisa-block-direct-issue-create.ts +210 -0
- package/package.json +1 -1
- package/plugins/lisa/.claude-plugin/plugin.json +5 -1
- package/plugins/lisa/.codex-plugin/hooks.json +4 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-github-validate-issue/SKILL.md +12 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-github-write-issue/SKILL.md +12 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-implement/SKILL.md +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-jira-write-ticket/SKILL.md +12 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-validate-issue/SKILL.md +12 -6
- package/plugins/lisa/.codex-plugin/skills/lisa-linear-write-issue/SKILL.md +15 -2
- package/plugins/lisa/hooks/block-direct-issue-create.agy.sh +59 -0
- package/plugins/lisa/hooks/block-direct-issue-create.sh +773 -0
- package/plugins/lisa/rules/eager/derived-branch-plan.md +3 -1
- package/plugins/lisa/rules/eager/ready-role-filing.md +4 -0
- package/plugins/lisa/rules/reference/derived-branch-plan.md +34 -0
- package/plugins/lisa/rules/reference/ready-role-filing.md +27 -0
- package/plugins/lisa/skills/lisa-github-validate-issue/SKILL.md +12 -6
- package/plugins/lisa/skills/lisa-github-write-issue/SKILL.md +12 -2
- package/plugins/lisa/skills/lisa-implement/SKILL.md +1 -1
- package/plugins/lisa/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
- package/plugins/lisa/skills/lisa-jira-write-ticket/SKILL.md +12 -2
- package/plugins/lisa/skills/lisa-linear-validate-issue/SKILL.md +12 -6
- package/plugins/lisa/skills/lisa-linear-write-issue/SKILL.md +15 -2
- package/plugins/lisa-agy/hooks/block-direct-issue-create.agy.sh +59 -0
- package/plugins/lisa-agy/hooks/block-direct-issue-create.sh +773 -0
- package/plugins/lisa-agy/hooks.json +13 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-github-validate-issue/SKILL.md +12 -6
- package/plugins/lisa-agy/skills/lisa-github-write-issue/SKILL.md +12 -2
- package/plugins/lisa-agy/skills/lisa-implement/SKILL.md +1 -1
- package/plugins/lisa-agy/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
- package/plugins/lisa-agy/skills/lisa-jira-write-ticket/SKILL.md +12 -2
- package/plugins/lisa-agy/skills/lisa-linear-validate-issue/SKILL.md +12 -6
- package/plugins/lisa-agy/skills/lisa-linear-write-issue/SKILL.md +15 -2
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +5 -1
- package/plugins/lisa-copilot/hooks/block-direct-issue-create.sh +773 -0
- package/plugins/lisa-copilot/rules/eager/derived-branch-plan.md +3 -1
- package/plugins/lisa-copilot/rules/eager/ready-role-filing.md +4 -0
- package/plugins/lisa-copilot/rules/reference/derived-branch-plan.md +34 -0
- package/plugins/lisa-copilot/rules/reference/ready-role-filing.md +27 -0
- package/plugins/lisa-copilot/skills/lisa-github-validate-issue/SKILL.md +12 -6
- package/plugins/lisa-copilot/skills/lisa-github-write-issue/SKILL.md +12 -2
- package/plugins/lisa-copilot/skills/lisa-implement/SKILL.md +1 -1
- package/plugins/lisa-copilot/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
- package/plugins/lisa-copilot/skills/lisa-jira-write-ticket/SKILL.md +12 -2
- package/plugins/lisa-copilot/skills/lisa-linear-validate-issue/SKILL.md +12 -6
- package/plugins/lisa-copilot/skills/lisa-linear-write-issue/SKILL.md +15 -2
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-direct-issue-create.sh +773 -0
- package/plugins/lisa-cursor/hooks/hooks.json +4 -0
- package/plugins/lisa-cursor/rules/derived-branch-plan-reference.mdc +34 -0
- package/plugins/lisa-cursor/rules/derived-branch-plan.mdc +3 -1
- package/plugins/lisa-cursor/rules/ready-role-filing-reference.mdc +27 -0
- package/plugins/lisa-cursor/rules/ready-role-filing.mdc +4 -0
- package/plugins/lisa-cursor/skills/lisa-github-validate-issue/SKILL.md +12 -6
- package/plugins/lisa-cursor/skills/lisa-github-write-issue/SKILL.md +12 -2
- package/plugins/lisa-cursor/skills/lisa-implement/SKILL.md +1 -1
- package/plugins/lisa-cursor/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
- package/plugins/lisa-cursor/skills/lisa-jira-write-ticket/SKILL.md +12 -2
- package/plugins/lisa-cursor/skills/lisa-linear-validate-issue/SKILL.md +12 -6
- package/plugins/lisa-cursor/skills/lisa-linear-write-issue/SKILL.md +15 -2
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +4 -0
- package/plugins/src/base/hooks/block-direct-issue-create.agy.sh +59 -0
- package/plugins/src/base/hooks/block-direct-issue-create.sh +773 -0
- package/plugins/src/base/rules/eager/derived-branch-plan.md +3 -1
- package/plugins/src/base/rules/eager/ready-role-filing.md +4 -0
- package/plugins/src/base/rules/reference/derived-branch-plan.md +34 -0
- package/plugins/src/base/rules/reference/ready-role-filing.md +27 -0
- package/plugins/src/base/skills/lisa-github-validate-issue/SKILL.md +12 -6
- package/plugins/src/base/skills/lisa-github-write-issue/SKILL.md +12 -2
- package/plugins/src/base/skills/lisa-implement/SKILL.md +1 -1
- package/plugins/src/base/skills/lisa-jira-validate-ticket/SKILL.md +12 -6
- package/plugins/src/base/skills/lisa-jira-write-ticket/SKILL.md +12 -2
- package/plugins/src/base/skills/lisa-linear-validate-issue/SKILL.md +12 -6
- package/plugins/src/base/skills/lisa-linear-write-issue/SKILL.md +15 -2
- package/scripts/build-plugins.sh +1 -1
- package/scripts/generate-agy-plugin-artifacts.mjs +10 -0
- package/scripts/lib/per-agent-hook-filter.mjs +10 -0
- 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
|